Files
RyukGram/src/Features/StoriesAndMessages/SendAudioAsFile.xm
T
faroukbmiled 86eaa95019 [release] RyukGram v1.2.0
### Features
- **Open Instagram links in app (Safari extension)** — bundled Safari web extension (sideload IPA only). Enable in Safari → Extensions; instagram.com links open in the app.
- **Localization** — every user-facing string flows through a central translation layer. Globe button in Settings; missing keys fall back to English. Ships English only — see the "Translating RyukGram" section in the README to add more.
- **Action buttons** — context-aware menus on feed, reels, and stories (expand, repost, download, copy caption, etc.) with per-context default tap action and carousel/multi-story bulk download
- **Enhanced HD downloads** — up to 1080p via DASH + FFmpegKit with quality picker, preview playback, encoding-speed options, and 720p fallback
- **Repost**, **media viewer**, **media zoom** (long-press), **download pill** (frosted glass, stacks concurrent downloads)
- **Fake location** — overrides CoreLocation app-wide, map picker + saved presets, optional quick-toggle button on the Friends Map
- **Messages-only mode** — strips every tab except DM inbox + profile
- **Launch tab** — pick which tab the app opens to
- Full last active date in DMs — show full date instead of "Active 2h ago"
- Custom date format — 12 formats with per-surface toggles (feed, notes/comments/stories, DMs)
- Send files in DMs (experimental)
- View story mentions
- Hide suggested stories
- Story tray long-press actions — view HD profile picture from the tray menu
- Advance on story reply — auto-skip to next story after sending a reply or reaction
- Mark story as seen on reply or emoji reaction
- Hide metrics (likes, comments, shares counts)
- Hide messages tab
- Hide voice/video call buttons in DM thread header (independent toggles)
- Disable app haptics
- Disable reels tab refresh
- Disable disappearing messages mode in DMs
- Follow indicator — shows whether the profile user follows you
- Copy note text on long press
- Zoom profile photo — long press opens full-screen viewer
- Notes actions — copy text, download GIF/audio from notes long-press menu
- Confirm unfollow
- Feed refresh controls — disable background refresh, home button refresh, and home button scroll

### Improvements
- Default tap action: added copy URL, repost, and view mentions options; dynamic menu generation per context
- Settings pages reordered: General → Feed → Stories → Reels → Messages → Profile → Navigation → Saving → Confirmations
- Fake location picker: native Apple Maps-style UI (search, long-press to drop pin, current location)
- Liquid glass floating tab bar + dynamic sizing
- Upload audio: FFmpegKit re-encode + trim for any audio/video input
- Settings reorganized with per-context action button config; new Profile page
- Highlight cover: full-screen viewer replaces direct download
- Switched HD encoder to `h264_videotoolbox` (hardware) — no GPL FFmpegKit required
- Legacy long-press download deprecated (off by default), replaced by action buttons

### Fixes
- Hide suggested stories no longer removes followed users' stories on scroll
- Settings search bar transparency with liquid glass off; auto-deactivates on push
- HD download cancel: tapping pill aborts in-flight downloads + FFmpeg sessions cleanly
- Download pill stuck state on background/foreground, progress reset per download
- Disappearing messages mode confirmation not firing on swipe
- Detailed color picker not working on story draw `†`
- DM seen toggle menu not updating after tap
- Reel refresh confirmation appearing on first app launch `†`
- Reels action button displacing profile pictures on photo reels
- Disappearing DM media download (expand, share, save to Photos with progress pill)
- Carousel "Download all" not showing item count in feed
- Encoding speed setting being ignored for HD downloads
- Various upstream SCInsta merges (Meta AI hiding, suggested chats hiding, notes tray) — marked `†`

> `†` Merged from upstream [SCInsta](https://github.com/SoCuul/SCInsta) by SoCuul

### Credits
- Thanks to [@erupts0](https://github.com/erupts0) (John) for testing and feature suggestions
- Thanks to [@euoradan](https://t.me/euoradan) (Radan) for experimental Instagram feature flag research
- Safari extension forked/cleaned from [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension)

### Known Issues
- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
2026-04-16 03:03:30 +01:00

773 lines
34 KiB
Plaintext

// Send audio/video files as voice messages in DMs.
// Injects an Upload Audio item into the DM plus menu, runs the file through a
// trim UI, transcodes to AAC m4a (or passes formats IG accepts as-is), then
// hands the URL to IG's native voice pipeline.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIFFmpeg.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <AVFoundation/AVFoundation.h>
typedef id (*SCIMsgSend)(id, SEL);
static inline id sciAF(id obj, SEL sel) {
if (!obj || ![obj respondsToSelector:sel]) return nil;
return ((SCIMsgSend)objc_msgSend)(obj, sel);
}
static __weak UIViewController *sciAudioThreadVC = nil;
static BOOL sciDMMenuPending = NO;
#pragma mark - Send audio through IG pipeline
static NSSet<NSString *> *sciPassthroughAudioExts(void);
static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
AVAsset *asset = [AVAsset assetWithURL:audioURL];
double duration = CMTimeGetSeconds(asset.duration);
// AVFoundation returns 0/NaN for containers it can't parse (e.g. Ogg).
if (duration <= 0 || isnan(duration)) duration = 1.0;
id voiceController = sciAF(threadVC, @selector(voiceController));
id voiceRecordVC = nil;
if (voiceController) {
Ivar vrIvar = class_getInstanceVariable([voiceController class], "_voiceRecordViewController");
voiceRecordVC = vrIvar ? object_getIvar(voiceController, vrIvar) : nil;
}
id waveform = nil;
Class wfClass = NSClassFromString(@"IGDirectAudioWaveform");
NSMutableArray *fallbackArr = [NSMutableArray array];
for (int i = 0; i < MAX(10, MIN((int)(duration * 10), 300)); i++)
[fallbackArr addObject:@(0.1 + arc4random_uniform(80) / 100.0)];
if (wfClass) {
NSArray *rawData = nil;
SEL genSel = @selector(generateWaveformDataFromAudioFile:maxLength:);
if ([wfClass respondsToSelector:genSel]) {
typedef id (*GenFn)(id, SEL, id, NSInteger);
rawData = ((GenFn)objc_msgSend)(wfClass, genSel, audioURL, (NSInteger)(duration * 10));
}
if (!rawData) rawData = fallbackArr;
SEL scaleSel = @selector(scaledArrayOfNumbers:);
if ([wfClass respondsToSelector:scaleSel]) {
typedef id (*ScaleFn)(id, SEL, id);
NSArray *scaled = ((ScaleFn)objc_msgSend)(wfClass, scaleSel, rawData);
if (scaled) rawData = scaled;
}
SEL initWF = @selector(initWithVolumeRecordingInterval:averageVolume:);
if ([wfClass instancesRespondToSelector:initWF]) {
typedef id (*InitFn)(id, SEL, double, id);
waveform = ((InitFn)objc_msgSend)([wfClass alloc], initWF, 0.1, rawData);
}
if (!waveform) {
waveform = [[wfClass alloc] init];
for (NSString *n in @[@"_averageVolume", @"_waveformData", @"_data", @"_volumes"]) {
Ivar iv = class_getInstanceVariable(wfClass, [n UTF8String]);
if (iv) { object_setIvar(waveform, iv, rawData); break; }
}
}
}
if (!waveform) waveform = fallbackArr;
@try {
SEL vmSel = @selector(visualMessageViewerPresentationManagerDidRecordAudioClipWithURL:waveform:duration:entryPoint:toReplyToMessageWithID:);
if ([threadVC respondsToSelector:vmSel]) {
typedef void (*Fn)(id, SEL, id, id, double, NSInteger, id);
((Fn)objc_msgSend)(threadVC, vmSel, audioURL, waveform, duration, (NSInteger)2, nil);
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
SEL s7 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:aiVoiceEffectApplied:sendButtonTypeTapped:);
if ([threadVC respondsToSelector:s7]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger, id, id);
((Fn)objc_msgSend)(threadVC, s7, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2, nil, nil);
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
SEL s5 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:);
if ([threadVC respondsToSelector:s5]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger);
((Fn)objc_msgSend)(threadVC, s5, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2);
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No voice send method found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Send failed: %@"), e.reason]];
}
}
#pragma mark - Audio conversion with optional trim
// Unified failure alert: explains why, lets the user try sending raw, and links
// to the GitHub issues page for format requests.
static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewController *threadVC) {
NSString *fileExt = [[url pathExtension] lowercaseString];
NSString *displayExt = (fileExt.length > 0) ? [NSString stringWithFormat:@".%@", fileExt] : @"This file";
NSString *title = [NSString stringWithFormat:@"%@ can't be converted", displayExt];
NSString *msg = [NSString stringWithFormat:
@"iOS audio APIs couldn't process this file%@%@\n\n"
"You can try sending it to Instagram as-is — IG's server may accept it "
"(e.g. Opus/Ogg from web users), or it may silently fail.\n\n"
"If you'd like RyukGram to support this format natively, open an issue:\n"
"https://github.com/faroukbmiled/RyukGram/issues",
reason.length > 0 ? @":\n" : @".",
reason.length > 0 ? reason : @""];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Send anyway") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
sciSendAudioFile(url, weakVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Open GitHub") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[[UIApplication sharedApplication]
openURL:[NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram/issues"]
options:@{} completionHandler:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
UIViewController *presenter = threadVC ?: [UIApplication sharedApplication].keyWindow.rootViewController;
[presenter presentViewController:alert animated:YES completion:nil];
}
// FFmpeg path: any format → AAC M4A, with optional trim
static void sciFFmpegConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_ffaudio_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
NSMutableString *cmd = [NSMutableString stringWithFormat:@"-y -i \"%@\"", url.path];
if (hasTrim) {
double ss = CMTimeGetSeconds(trimRange.start);
double dur = CMTimeGetSeconds(trimRange.duration);
[cmd appendFormat:@" -ss %.3f -t %.3f", ss, dur];
}
[cmd appendFormat:@" -vn -c:a aac -b:a 128k -ar 44100 -ac 1 \"%@\"", out];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
} else {
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
}
});
}];
}
// AVFoundation fallback for iOS-native formats
static void sciAVFoundationConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
AVAsset *asset = [AVAsset assetWithURL:url];
AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
if (!audioTrack) {
dispatch_async(dispatch_get_main_queue(), ^{
sciShowUnsupportedAlert(url, @"no audio track could be read", threadVC);
});
return;
}
AVMutableComposition *comp = [AVMutableComposition composition];
AVMutableCompositionTrack *ct = [comp addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
CMTimeRange sourceRange = hasTrim ? trimRange : CMTimeRangeMake(kCMTimeZero, asset.duration);
NSError *insertErr = nil;
[ct insertTimeRange:sourceRange ofTrack:audioTrack atTime:kCMTimeZero error:&insertErr];
if (insertErr) {
dispatch_async(dispatch_get_main_queue(), ^{
sciShowUnsupportedAlert(url, insertErr.localizedDescription, threadVC);
});
return;
}
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_exp_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
AVAssetExportSession *exp = [AVAssetExportSession exportSessionWithAsset:comp presetName:AVAssetExportPresetAppleM4A];
exp.outputURL = [NSURL fileURLWithPath:out];
exp.outputFileType = AVFileTypeAppleM4A;
[exp exportAsynchronouslyWithCompletionHandler:^{
dispatch_async(dispatch_get_main_queue(), ^{
if (exp.status == AVAssetExportSessionStatusCompleted) {
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
} else {
sciShowUnsupportedAlert(url, exp.error.localizedDescription, threadVC);
}
});
}];
});
}
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
// Passthrough formats IG accepts directly (no conversion needed, trim ignored)
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && !hasTrim && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
[SCIUtils showToastForDuration:1.5 title:isVideo ? SCILocalized(@"Extracting audio...") : SCILocalized(@"Converting...")];
// FFmpeg handles any format + video→audio extraction
if ([SCIFFmpeg isAvailable]) {
sciFFmpegConvertAndSend(url, threadVC, trimRange);
return;
}
// Passthrough without trim when no FFmpeg
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
// AVFoundation fallback
sciAVFoundationConvertAndSend(url, threadVC, trimRange);
}
// Formats IG accepts as-is (no conversion needed)
static NSSet<NSString *> *sciPassthroughAudioExts(void) {
static NSSet *set;
static dispatch_once_t once;
dispatch_once(&once, ^{
set = [NSSet setWithArray:@[@"m4a", @"aac", @"ogg", @"opus"]];
});
return set;
}
static void sciConvertAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo) {
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
sciExportAndSend(url, threadVC, isVideo, kCMTimeRangeInvalid);
}
#pragma mark - Audio/Video trim VC
@interface SCITrimViewController : UIViewController
@property (nonatomic, strong) NSURL *mediaURL;
@property (nonatomic, assign) BOOL isVideo;
@property (nonatomic, strong) AVPlayer *player;
@property (nonatomic, strong) UILabel *durationLabel;
@property (nonatomic, strong) UILabel *rangeLabel;
@property (nonatomic, strong) UIView *trackView;
@property (nonatomic, strong) UIView *selectedRange;
@property (nonatomic, strong) UIView *leftHandle;
@property (nonatomic, strong) UIView *rightHandle;
@property (nonatomic, strong) UIView *playhead;
@property (nonatomic, strong) UIButton *playBtn;
@property (nonatomic, assign) double totalDuration;
@property (nonatomic, assign) double startTime;
@property (nonatomic, assign) double endTime;
@property (nonatomic, assign) BOOL isPlaying;
@property (nonatomic, strong) id timeObserver;
@property (nonatomic, weak) UIViewController *threadVC;
@end
static const CGFloat kTrackH = 56.0;
static const CGFloat kHandleW = 16.0;
static const CGFloat kHandleHitW = 48.0; // wide touch target
static const CGFloat kTrackMargin = 24.0;
@implementation SCITrimViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor colorWithRed:0.06 green:0.06 blue:0.08 alpha:1.0];
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
AVAsset *asset = [AVAsset assetWithURL:self.mediaURL];
self.totalDuration = CMTimeGetSeconds(asset.duration);
self.startTime = 0;
self.endTime = self.totalDuration;
CGFloat w = self.view.bounds.size.width;
CGFloat safeBottom = 34; // approximate safe area
CGFloat bottomY = self.view.bounds.size.height - safeBottom;
// ── send button (bottom, full width, thumb-reachable) ──
UIButton *sendBtn = [UIButton buttonWithType:UIButtonTypeSystem];
sendBtn.frame = CGRectMake(kTrackMargin, bottomY - 56, w - kTrackMargin * 2, 50);
sendBtn.backgroundColor = [UIColor systemBlueColor];
sendBtn.layer.cornerRadius = 14;
[sendBtn setTitle:SCILocalized(@"Send Audio") forState:UIControlStateNormal];
[sendBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
sendBtn.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
[sendBtn addTarget:self action:@selector(sendTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:sendBtn];
// ── play/pause button ──
CGFloat playY = sendBtn.frame.origin.y - 64;
self.playBtn = [UIButton buttonWithType:UIButtonTypeCustom];
self.playBtn.frame = CGRectMake(w / 2 - 28, playY, 56, 56);
self.playBtn.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.1];
self.playBtn.layer.cornerRadius = 28;
UIImageSymbolConfiguration *playCfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
[self.playBtn setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:playCfg] forState:UIControlStateNormal];
self.playBtn.tintColor = [UIColor whiteColor];
[self.playBtn addTarget:self action:@selector(playPauseTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:self.playBtn];
// ── range label (above play button) ──
self.rangeLabel = [[UILabel alloc] initWithFrame:CGRectMake(kTrackMargin, playY - 36, w - kTrackMargin * 2, 24)];
self.rangeLabel.textColor = [UIColor whiteColor];
self.rangeLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightMedium];
self.rangeLabel.textAlignment = NSTextAlignmentCenter;
[self.view addSubview:self.rangeLabel];
// ── track (range selector) ──
CGFloat trackY = self.rangeLabel.frame.origin.y - kTrackH - 20;
// track background
self.trackView = [[UIView alloc] initWithFrame:CGRectMake(kTrackMargin, trackY, w - kTrackMargin * 2, kTrackH)];
self.trackView.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08];
self.trackView.layer.cornerRadius = 10;
self.trackView.clipsToBounds = YES;
[self.view addSubview:self.trackView];
// generate waveform bars
[self generateWaveformBars];
// selected range overlay
self.selectedRange = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.trackView.bounds.size.width, kTrackH)];
self.selectedRange.backgroundColor = [UIColor colorWithRed:0.35 green:0.5 blue:1.0 alpha:0.25];
self.selectedRange.userInteractionEnabled = NO;
self.selectedRange.layer.cornerRadius = 10;
[self.trackView addSubview:self.selectedRange];
// left handle — wide invisible hit area with narrow visual handle inside
self.leftHandle = [[UIView alloc] initWithFrame:CGRectMake(-kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20)];
self.leftHandle.backgroundColor = [UIColor clearColor];
self.leftHandle.userInteractionEnabled = YES;
UIView *leftVisual = [self createHandleVisual];
leftVisual.frame = CGRectMake((kHandleHitW - kHandleW) / 2, 10, kHandleW, kTrackH);
leftVisual.layer.maskedCorners = kCALayerMinXMinYCorner | kCALayerMinXMaxYCorner;
leftVisual.tag = 7001;
[self.leftHandle addSubview:leftVisual];
[self.trackView addSubview:self.leftHandle];
UIPanGestureRecognizer *leftPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(leftHandlePan:)];
[self.leftHandle addGestureRecognizer:leftPan];
// right handle
CGFloat trackW = self.trackView.bounds.size.width;
self.rightHandle = [[UIView alloc] initWithFrame:CGRectMake(trackW - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20)];
self.rightHandle.backgroundColor = [UIColor clearColor];
self.rightHandle.userInteractionEnabled = YES;
UIView *rightVisual = [self createHandleVisual];
rightVisual.frame = CGRectMake((kHandleHitW - kHandleW) / 2, 10, kHandleW, kTrackH);
rightVisual.layer.maskedCorners = kCALayerMaxXMinYCorner | kCALayerMaxXMaxYCorner;
rightVisual.tag = 7001;
[self.rightHandle addSubview:rightVisual];
[self.trackView addSubview:self.rightHandle];
UIPanGestureRecognizer *rightPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(rightHandlePan:)];
[self.rightHandle addGestureRecognizer:rightPan];
// playhead
self.playhead = [[UIView alloc] initWithFrame:CGRectMake(0, 2, 2.5, kTrackH - 4)];
self.playhead.backgroundColor = [UIColor whiteColor];
self.playhead.layer.cornerRadius = 1.25;
self.playhead.hidden = YES;
[self.trackView addSubview:self.playhead];
// ── top area: icon + file info ──
CGFloat topAreaY = 70;
UIImageSymbolConfiguration *iconCfg = [UIImageSymbolConfiguration configurationWithPointSize:36 weight:UIImageSymbolWeightLight];
UIImageView *icon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:self.isVideo ? @"video.fill" : @"waveform"
withConfiguration:iconCfg]];
icon.tintColor = [UIColor colorWithWhite:1.0 alpha:0.5];
icon.contentMode = UIViewContentModeScaleAspectFit;
icon.frame = CGRectMake(w / 2 - 24, topAreaY, 48, 48);
[self.view addSubview:icon];
UILabel *nameLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, topAreaY + 56, w - 40, 20)];
nameLabel.text = [self.mediaURL lastPathComponent];
nameLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.4];
nameLabel.font = [UIFont systemFontOfSize:13];
nameLabel.textAlignment = NSTextAlignmentCenter;
nameLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
[self.view addSubview:nameLabel];
self.durationLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, topAreaY + 78, w - 40, 20)];
self.durationLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.3];
self.durationLabel.font = [UIFont systemFontOfSize:12];
self.durationLabel.textAlignment = NSTextAlignmentCenter;
self.durationLabel.text = [NSString stringWithFormat:SCILocalized(@"Total: %@"), [self formatTime:self.totalDuration]];
[self.view addSubview:self.durationLabel];
// ── cancel X button (top-left) ──
UIButton *cancelBtn = [UIButton buttonWithType:UIButtonTypeCustom];
cancelBtn.frame = CGRectMake(12, 50, 36, 36);
UIImageSymbolConfiguration *xCfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightMedium];
[cancelBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:xCfg] forState:UIControlStateNormal];
cancelBtn.tintColor = [UIColor colorWithWhite:1.0 alpha:0.6];
cancelBtn.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08];
cancelBtn.layer.cornerRadius = 18;
[cancelBtn addTarget:self action:@selector(cancelTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:cancelBtn];
[self updateRangeUI];
}
- (void)generateWaveformBars {
CGFloat trackW = self.trackView.bounds.size.width;
int barCount = (int)(trackW / 4);
CGFloat barW = 2.0;
CGFloat gap = (trackW - barCount * barW) / (barCount - 1);
for (int i = 0; i < barCount; i++) {
CGFloat h = 8 + arc4random_uniform((unsigned int)(kTrackH - 16));
CGFloat x = i * (barW + gap);
UIView *bar = [[UIView alloc] initWithFrame:CGRectMake(x, (kTrackH - h) / 2, barW, h)];
bar.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.15];
bar.layer.cornerRadius = 1;
bar.tag = 8000 + i;
[self.trackView insertSubview:bar atIndex:0];
}
}
- (UIView *)createHandleVisual {
UIView *handle = [[UIView alloc] init];
handle.backgroundColor = [UIColor systemBlueColor];
handle.layer.cornerRadius = 4;
handle.userInteractionEnabled = NO;
UIView *grip = [[UIView alloc] initWithFrame:CGRectMake(5, kTrackH / 2 - 8, 6, 16)];
grip.userInteractionEnabled = NO;
for (int i = 0; i < 2; i++) {
UIView *line = [[UIView alloc] initWithFrame:CGRectMake(i * 4, 0, 1.5, 16)];
line.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.7];
line.layer.cornerRadius = 0.75;
[grip addSubview:line];
}
[handle addSubview:grip];
return handle;
}
- (CGFloat)timeToX:(double)time {
CGFloat trackW = self.trackView.bounds.size.width;
return (time / self.totalDuration) * trackW;
}
- (double)xToTime:(CGFloat)x {
CGFloat trackW = self.trackView.bounds.size.width;
double t = (x / trackW) * self.totalDuration;
return MAX(0, MIN(t, self.totalDuration));
}
- (void)leftHandlePan:(UIPanGestureRecognizer *)pan {
CGPoint translation = [pan translationInView:self.trackView];
[pan setTranslation:CGPointZero inView:self.trackView];
CGFloat centerX = CGRectGetMidX(self.leftHandle.frame) + translation.x;
double newTime = [self xToTime:centerX];
newTime = MAX(0, MIN(newTime, self.endTime - 0.5));
self.startTime = newTime;
[self updateRangeUI];
}
- (void)rightHandlePan:(UIPanGestureRecognizer *)pan {
CGPoint translation = [pan translationInView:self.trackView];
[pan setTranslation:CGPointZero inView:self.trackView];
CGFloat centerX = CGRectGetMidX(self.rightHandle.frame) + translation.x;
double newTime = [self xToTime:centerX];
newTime = MIN(self.totalDuration, MAX(newTime, self.startTime + 0.5));
self.endTime = newTime;
[self updateRangeUI];
}
- (void)updateRangeUI {
CGFloat leftX = [self timeToX:self.startTime];
CGFloat rightX = [self timeToX:self.endTime];
self.leftHandle.frame = CGRectMake(leftX - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20);
self.rightHandle.frame = CGRectMake(rightX - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20);
self.selectedRange.frame = CGRectMake(leftX, 0, rightX - leftX, kTrackH);
double sel = self.endTime - self.startTime;
self.rangeLabel.text = [NSString stringWithFormat:@"%@ — %@ (%@)",
[self formatTime:self.startTime], [self formatTime:self.endTime], [self formatDuration:sel]];
}
- (NSString *)formatTime:(double)secs {
int m = (int)secs / 60;
int s = (int)secs % 60;
return [NSString stringWithFormat:@"%d:%02d", m, s];
}
- (NSString *)formatDuration:(double)secs {
if (secs < 60) return [NSString stringWithFormat:@"%.1fs", secs];
int m = (int)secs / 60;
double s = secs - m * 60;
return [NSString stringWithFormat:@"%dm %.0fs", m, s];
}
- (void)playPauseTapped {
if (self.isPlaying) {
[self stopPlayback];
} else {
[self startPlayback];
}
}
- (void)startPlayback {
[self stopPlayback];
self.player = [AVPlayer playerWithURL:self.mediaURL];
[self.player seekToTime:CMTimeMakeWithSeconds(self.startTime, 600) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
self.playhead.hidden = NO;
self.isPlaying = YES;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
[self.playBtn setImage:[UIImage systemImageNamed:@"pause.fill" withConfiguration:cfg] forState:UIControlStateNormal];
__weak SCITrimViewController *weakSelf = self;
self.timeObserver = [self.player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(0.05, 600) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
SCITrimViewController *s = weakSelf;
if (!s) return;
double current = CMTimeGetSeconds(time);
if (current >= s.endTime) {
[s stopPlayback];
return;
}
CGFloat x = [s timeToX:current];
s.playhead.frame = CGRectMake(x - 1.25, 2, 2.5, kTrackH - 4);
}];
[self.player play];
}
- (void)stopPlayback {
if (self.timeObserver && self.player) {
[self.player removeTimeObserver:self.timeObserver];
}
self.timeObserver = nil;
[self.player pause];
self.player = nil;
self.isPlaying = NO;
self.playhead.hidden = YES;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
[self.playBtn setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:cfg] forState:UIControlStateNormal];
}
- (void)cancelTapped {
[self stopPlayback];
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)sendTapped {
[self stopPlayback];
double dur = self.endTime - self.startTime;
if (dur < 0.5) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Selection too short (min 0.5s)")];
return;
}
UIViewController *tvc = self.threadVC;
NSURL *url = self.mediaURL;
BOOL video = self.isVideo;
CMTimeRange trimRange = CMTimeRangeMake(CMTimeMakeWithSeconds(self.startTime, 600), CMTimeMakeWithSeconds(dur, 600));
[self dismissViewControllerAnimated:YES completion:^{
if (tvc) sciExportAndSend(url, tvc, video, trimRange);
}];
}
- (UIStatusBarStyle)preferredStatusBarStyle { return UIStatusBarStyleLightContent; }
@end
static void sciShowTrimVC(NSURL *url, BOOL isVideo, UIViewController *threadVC) {
SCITrimViewController *trimVC = [[SCITrimViewController alloc] init];
trimVC.mediaURL = url;
trimVC.isVideo = isVideo;
trimVC.threadVC = threadVC;
trimVC.modalPresentationStyle = UIModalPresentationFullScreen;
[threadVC presentViewController:trimVC animated:YES completion:nil];
}
#pragma mark - Show picker options
static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciAudioThreadVC = threadVC;
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Upload Audio")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Audio/Video from Files") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSArray *types = [SCIFFmpeg isAvailable]
? @[@"public.audio", @"public.audiovisual-content"]
: @[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
@"public.aiff-audio", @"com.apple.m4a-audio",
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"];
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
initWithDocumentTypes:types inMode:UIDocumentPickerModeImport];
#pragma clang diagnostic pop
picker.delegate = (id<UIDocumentPickerDelegate>)vc;
[vc presentViewController:picker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Video from Library") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
UIImagePickerController *imgPicker = [[UIImagePickerController alloc] init];
imgPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
imgPicker.mediaTypes = @[@"public.movie"];
imgPicker.delegate = (id<UINavigationControllerDelegate, UIImagePickerControllerDelegate>)vc;
imgPicker.videoExportPreset = AVAssetExportPresetPassthrough;
imgPicker.allowsEditing = YES; // enables built-in video trimming
[vc presentViewController:imgPicker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[threadVC presentViewController:alert animated:YES completion:nil];
}
#pragma mark - Hook IGDSMenu to inject native menu item
%hook IGDSMenu
- (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header {
if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return %orig;
// Only inject into DM plus menus — sciDMMenuPending is set right before
// this menu is created by the composer overflow button callback
if (!sciDMMenuPending) return %orig;
sciDMMenuPending = NO;
for (id item in items) {
id title = sciAF(item, @selector(title));
if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Upload Audio"]) return %orig;
}
Class itemClass = NSClassFromString(@"IGDSMenuItem");
if (!itemClass) return %orig;
UIImage *img = [[UIImage systemImageNamed:@"waveform"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
void (^handler)(void) = ^{
UIViewController *threadVC = sciAudioThreadVC;
if (threadVC) sciShowUploadAudioOptions(threadVC);
};
SEL initSel = @selector(initWithTitle:image:handler:);
if (![itemClass instancesRespondToSelector:initSel]) return %orig;
typedef id (*InitFn)(id, SEL, id, id, id);
id audioItem = ((InitFn)objc_msgSend)([itemClass alloc], initSel, @"Upload Audio", img, handler);
if (!audioItem) return %orig;
NSMutableArray *newItems = [NSMutableArray arrayWithObject:audioItem];
[newItems addObjectsFromArray:items];
return %orig(newItems, edr, header);
}
%end
#pragma mark - Hook IGDirectThreadViewController
%hook IGDirectThreadViewController
- (void)composerOverflowButtonMenuWillPrepareExpandWithPlusButton:(id)plusButton {
%orig;
if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return;
sciAudioThreadVC = self;
sciDMMenuPending = YES;
}
// Convert unsupported formats to M4A before showing trim UI
static void sciPrepareAndShowTrim(NSURL *url, UIViewController *threadVC) {
AVAsset *asset = [AVAsset assetWithURL:url];
double dur = CMTimeGetSeconds(asset.duration);
BOOL avCanRead = dur > 0 && !isnan(dur);
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
if (avCanRead) {
sciShowTrimVC(url, isVideo, threadVC);
return;
}
// AVFoundation can't read it — pre-convert with FFmpeg
if ([SCIFFmpeg isAvailable]) {
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Converting...")];
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_pre_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
NSString *cmd = [NSString stringWithFormat:@"-y -i \"%@\" -vn -c:a aac -b:a 128k -ar 44100 \"%@\"", url.path, out];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
sciShowTrimVC([NSURL fileURLWithPath:out], NO, threadVC);
} else {
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
}
});
}];
return;
}
// No FFmpeg, can't read — unsupported
sciShowUnsupportedAlert(url, @"Format not supported without FFmpegKit", threadVC);
}
// File picker delegate
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url) return;
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
if (!url) return;
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {}
// video picker delegate — UIImagePickerController with allowsEditing handles trimming
%new - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {
[picker dismissViewControllerAnimated:YES completion:nil];
NSURL *videoURL = info[UIImagePickerControllerMediaURL];
if (!videoURL) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get video URL")];
return;
}
// UIImagePickerController with allowsEditing already trimmed the video for us
sciConvertAndSend(videoURL, self, YES);
}
%new - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
[picker dismissViewControllerAnimated:YES completion:nil];
}
%end