mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-06-06 07:23:53 +02:00
feat: Long-press menu on the DM seen button for quick actions
Fix: Prevent play/pause patch from triggering Instagram’s “Reel has no sound” message when forcing audio on silent Reels imp: Un-exclude button in excluded chats (toggleable) imp: Show error for unsupported audio files that can't be processed - we can add ffmpeg at some point chore: Remove qr code text from import/export buttons
This commit is contained in:
@@ -84,6 +84,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Hide trailing action buttons on preserved messages
|
||||
- Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\***
|
||||
- Manually mark messages as seen (button or toggle mode) **\***
|
||||
- Long-press the seen button for quick actions **\***
|
||||
- Auto mark seen on send (marks messages as read when you send any message) **\***
|
||||
- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\***
|
||||
- Mark seen on story like **\***
|
||||
@@ -131,8 +132,8 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location
|
||||
|
||||
### Backup & Restore **\***
|
||||
- Export RyukGram settings as a JSON file or scannable QR code
|
||||
- Import settings from a file in Files or a QR code from your photo library
|
||||
- Export RyukGram settings as a JSON file
|
||||
- Import settings from a JSON file
|
||||
- Searchable, collapsible, editable preview before saving or applying
|
||||
|
||||
### Optimization
|
||||
|
||||
@@ -112,31 +112,60 @@ static void sciSetPlayViewOpacity(id cell, CGFloat opacity) {
|
||||
}
|
||||
}
|
||||
|
||||
// Force unmute by calling _didTapSoundButton on the section controller
|
||||
// Swallow IG's "no sound" toast and remember the media so we don't retry it.
|
||||
static NSString * const SCINoSoundToastText = @"This reel has no sound.";
|
||||
static BOOL sciSuppressNoSoundToast = NO;
|
||||
static BOOL sciSawNoSoundDuringUnmute = NO;
|
||||
static NSMutableSet<NSString *> *sciNoAudioMediaIds = nil;
|
||||
|
||||
static NSString *sciMediaIdFor(id media) {
|
||||
if (!media) return nil;
|
||||
for (NSString *k in @[@"pk", @"mediaPk", @"mediaID", @"mpk"]) {
|
||||
@try {
|
||||
id v = [media valueForKey:k];
|
||||
if (v) return [NSString stringWithFormat:@"%@", v];
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciForceUnmuteCell(id videoCell) {
|
||||
if (!videoCell) return;
|
||||
Ivar delegateIvar = class_getInstanceVariable([videoCell class], "_delegate");
|
||||
if (!delegateIvar) return;
|
||||
id sectionCtrl = object_getIvar(videoCell, delegateIvar);
|
||||
if (!sectionCtrl) return;
|
||||
|
||||
Ivar mediaIvar = class_getInstanceVariable([sectionCtrl class], "_media");
|
||||
id media = mediaIvar ? object_getIvar(sectionCtrl, mediaIvar) : nil;
|
||||
NSString *mediaId = sciMediaIdFor(media);
|
||||
if (mediaId && [sciNoAudioMediaIds containsObject:mediaId]) return;
|
||||
|
||||
SEL isAudioSel = NSSelectorFromString(@"isAudioEnabled");
|
||||
if (![sectionCtrl respondsToSelector:isAudioSel]) return;
|
||||
BOOL audioOn = ((BOOL(*)(id,SEL))objc_msgSend)(sectionCtrl, isAudioSel);
|
||||
if (audioOn) return;
|
||||
|
||||
SEL tapSel = NSSelectorFromString(@"_didTapSoundButton");
|
||||
if ([sectionCtrl respondsToSelector:tapSel]) {
|
||||
((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel);
|
||||
if (![sectionCtrl respondsToSelector:tapSel]) return;
|
||||
|
||||
sciSuppressNoSoundToast = YES;
|
||||
sciSawNoSoundDuringUnmute = NO;
|
||||
((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel);
|
||||
sciSuppressNoSoundToast = NO;
|
||||
|
||||
if (sciSawNoSoundDuringUnmute && mediaId) {
|
||||
if (!sciNoAudioMediaIds) sciNoAudioMediaIds = [NSMutableSet new];
|
||||
[sciNoAudioMediaIds addObject:mediaId];
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGSundialViewerVideoCell
|
||||
// Video playing/unpausing — use hidden (IG sets hidden=NO on next pause)
|
||||
// hidden=YES on play; IG resets it on the next pause.
|
||||
- (void)sundialVideoPlaybackViewDidStartPlaying:(id)view {
|
||||
%orig;
|
||||
if (sciIsPausePlayMode()) {
|
||||
sciHidePlayView(self);
|
||||
// Force unmute if in reels tab — this fires when the video ACTUALLY starts
|
||||
// playing, guaranteed to have a ready section controller
|
||||
if (sciIsInReelsTab) sciForceUnmuteCell(self);
|
||||
}
|
||||
}
|
||||
@@ -226,7 +255,7 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciIsInReelsTab = YES;
|
||||
// Force unmute first reel — retry until the cell is ready
|
||||
// Retry-until-ready: the first reel's cell may not be wired up yet.
|
||||
if (sciIsPausePlayMode()) {
|
||||
id feedVC = self;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
@@ -256,6 +285,30 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UILabel
|
||||
- (void)setText:(NSString *)text {
|
||||
if (sciSuppressNoSoundToast && [text isEqualToString:SCINoSoundToastText]) {
|
||||
sciSawNoSoundDuringUnmute = YES;
|
||||
%orig(@"");
|
||||
self.hidden = YES;
|
||||
// Container view is attached to a window after we return — detach the
|
||||
// topmost non-window ancestor on the next tick to remove the outline.
|
||||
__weak UILabel *weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UILabel *s = weakSelf;
|
||||
if (!s) return;
|
||||
UIView *top = s;
|
||||
while (top.superview && ![top.superview isKindOfClass:[UIWindow class]]) {
|
||||
top = top.superview;
|
||||
}
|
||||
[top removeFromSuperview];
|
||||
});
|
||||
return;
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Download voice messages from DMs
|
||||
// Hooks IGDirectMessageMenuConfiguration to detect audio messages, then injects a "Download"
|
||||
// item into the IGDSPrismMenuView long-press menu. Downloads the audio via the playbackURL
|
||||
// on IGAudio (accessed through vm -> audio -> _server_audio), converts mp4 to m4a, and
|
||||
// presents a share sheet.
|
||||
// Download voice messages from DMs. Detects audio messages via the
|
||||
// menuConfiguration hook, then injects a Download item into the long-press
|
||||
// PrismMenu. Tries to convert to .m4a; falls back to the source extension
|
||||
// (e.g. .ogg from web users) if AVFoundation can't decode the format.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
@@ -17,12 +17,9 @@ static inline id sciDAF(id obj, SEL sel) {
|
||||
return ((SCIMsgSendId)objc_msgSend)(obj, sel);
|
||||
}
|
||||
|
||||
// Flag set by menuConfig hook when a voice_media menu is about to be created
|
||||
static BOOL sciAudioMenuPending = NO;
|
||||
static id sciLastAudioViewModel = nil;
|
||||
|
||||
#pragma mark - Detect audio message long-press
|
||||
|
||||
// Demangled: IGDirectMessageMenuConfiguration.IGDirectMessageMenuConfiguration
|
||||
%hook _TtC32IGDirectMessageMenuConfiguration32IGDirectMessageMenuConfiguration
|
||||
|
||||
@@ -47,10 +44,7 @@ static id sciLastAudioViewModel = nil;
|
||||
|
||||
%end
|
||||
|
||||
#pragma mark - Inject Download item into PrismMenu
|
||||
|
||||
// PrismMenu uses Swift classes — must use MSHookMessageEx with runtime class lookup
|
||||
// (dot-notation names like liquid glass hooks in Tweak.x)
|
||||
// PrismMenu uses Swift classes with mangled names — hook via MSHookMessageEx in %ctor.
|
||||
|
||||
static id (*orig_prismMenuView_init3)(id, SEL, NSArray *, id, BOOL);
|
||||
|
||||
@@ -74,7 +68,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
|
||||
void (^handler)(void) = ^{
|
||||
if (!capturedVM) return;
|
||||
|
||||
// Audio URL path: vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL
|
||||
// vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL
|
||||
id directAudio = nil;
|
||||
@try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {}
|
||||
if (!directAudio) {
|
||||
@@ -112,9 +106,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
|
||||
return;
|
||||
}
|
||||
|
||||
// Always try to convert to .m4a via AVFoundation. If that fails (e.g. Ogg/Opus
|
||||
// from PC/web users — iOS has no decoder), fall back to keeping the original
|
||||
// extension from the URL so the share sheet still treats it as audio.
|
||||
// Try to convert to .m4a; on failure (e.g. Ogg/Opus) keep the source extension.
|
||||
NSString *urlExt = [[playbackURL.path pathExtension] lowercaseString];
|
||||
if (urlExt.length == 0) urlExt = @"m4a";
|
||||
|
||||
@@ -150,7 +142,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
|
||||
present(m4aURL);
|
||||
return;
|
||||
}
|
||||
// Conversion failed — keep the original file with its real extension.
|
||||
// Conversion failed — keep the original with its real extension.
|
||||
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
|
||||
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"audio_%@.%@", mediaId, urlExt]];
|
||||
@@ -166,14 +158,13 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
|
||||
[task resume];
|
||||
};
|
||||
|
||||
// Build menu item via IGDSPrismMenuItemBuilder
|
||||
id builder = ((InitFn)objc_msgSend)([builderClass alloc], @selector(initWithTitle:), @"Download");
|
||||
builder = ((WithFn)objc_msgSend)(builder, @selector(withImage:), [UIImage systemImageNamed:@"arrow.down.circle"]);
|
||||
builder = ((WithFn)objc_msgSend)(builder, @selector(withHandler:), handler);
|
||||
id menuItem = ((BuildFn)objc_msgSend)(builder, @selector(build));
|
||||
if (!menuItem) return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
|
||||
|
||||
// Wrap in IGDSPrismMenuElement (copy _subtype from existing element, set _item_menuItem)
|
||||
// Wrap in IGDSPrismMenuElement: clone _subtype from a sibling, attach the menuItem.
|
||||
id templateEl = elements[0];
|
||||
id newElement = [[templateEl class] new];
|
||||
Ivar subtypeIvar = class_getInstanceVariable([templateEl class], "_subtype");
|
||||
|
||||
@@ -71,13 +71,121 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
|
||||
|
||||
// ============ NAV BAR BUTTONS ============
|
||||
|
||||
// Re-runs setRightBarButtonItems with the live items. The hook tags its own
|
||||
// buttons so they get stripped and rebuilt against the new exclusion state.
|
||||
static void sciRefreshNavBarItems(UIView *anchor) {
|
||||
if (!anchor || ![anchor respondsToSelector:@selector(setRightBarButtonItems:)]) return;
|
||||
NSArray *cur = [(id)anchor performSelector:@selector(rightBarButtonItems)];
|
||||
[(id)anchor performSelector:@selector(setRightBarButtonItems:) withObject:cur];
|
||||
}
|
||||
|
||||
// Long-press menu shared by the seen button and the un-exclude button.
|
||||
static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIWindow *window) {
|
||||
BOOL excluded = threadId && [SCIExcludedThreads isThreadIdExcluded:threadId];
|
||||
BOOL seenFeatureOn = [SCIUtils getBoolPref:@"remove_lastseen"];
|
||||
|
||||
NSMutableArray<UIMenuElement *> *items = [NSMutableArray array];
|
||||
|
||||
if (seenFeatureOn && !excluded) {
|
||||
BOOL toggleMode = sciIsSeenToggleMode();
|
||||
NSString *title;
|
||||
UIImage *img;
|
||||
if (toggleMode) {
|
||||
title = dmSeenToggleEnabled ? @"Disable read receipts" : @"Enable read receipts";
|
||||
img = [UIImage systemImageNamed:dmSeenToggleEnabled ? @"eye.slash" : @"eye"];
|
||||
} else {
|
||||
title = @"Mark messages as seen";
|
||||
img = [UIImage systemImageNamed:@"eye"];
|
||||
}
|
||||
UIAction *seenAction = [UIAction actionWithTitle:title image:img identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
|
||||
if (![nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) return;
|
||||
if (toggleMode) {
|
||||
dmSeenToggleEnabled = !dmSeenToggleEnabled;
|
||||
if (dmSeenToggleEnabled) {
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Read receipts enabled"];
|
||||
} else {
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Read receipts disabled"];
|
||||
}
|
||||
} else {
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Marked messages as seen"];
|
||||
}
|
||||
}];
|
||||
[items addObject:seenAction];
|
||||
}
|
||||
|
||||
NSString *toggleTitle = excluded ? @"Remove from exclusion" : @"Add to exclusion";
|
||||
UIImage *toggleImg = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"];
|
||||
__weak UIView *weakAnchor = anchor;
|
||||
UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
if (!threadId) return;
|
||||
if (excluded) {
|
||||
[SCIExcludedThreads removeThreadId:threadId];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"];
|
||||
} else {
|
||||
[SCIExcludedThreads addOrUpdateEntry:@{ @"threadId": threadId,
|
||||
@"threadName": @"",
|
||||
@"isGroup": @NO,
|
||||
@"users": @[] }];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Added to exclusion"];
|
||||
}
|
||||
sciRefreshNavBarItems(weakAnchor);
|
||||
}];
|
||||
if (excluded) toggle.attributes = UIMenuElementAttributesDestructive;
|
||||
[items addObject:toggle];
|
||||
|
||||
UIAction *openSettings = [UIAction actionWithTitle:@"Messages settings"
|
||||
image:[UIImage systemImageNamed:@"gear"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
UIWindow *win = window;
|
||||
if (!win) {
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
}
|
||||
[SCIUtils showSettingsVC:win atTopLevelEntry:@"Messages"];
|
||||
}];
|
||||
[items addObject:openSettings];
|
||||
|
||||
return [UIMenu menuWithTitle:@"" children:items];
|
||||
}
|
||||
|
||||
%hook IGTallNavigationBarView
|
||||
|
||||
%new - (void)sciUnexcludeButtonHandler:(UIBarButtonItem *)sender {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
NSString *tid = sciThreadIdForVC(nearestVC);
|
||||
if (!tid) return;
|
||||
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:@"Remove from exclusion?"
|
||||
message:@"This chat will resume normal read-receipt behavior."
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
|
||||
[SCIExcludedThreads removeThreadId:tid];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"];
|
||||
sciRefreshNavBarItems(weakSelf);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
[nearestVC presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
- (void)setRightBarButtonItems:(NSArray <UIBarButtonItem *> *)items {
|
||||
// Strip our own injected buttons so re-running this hook doesn't dupe them.
|
||||
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
|
||||
[NSPredicate predicateWithBlock:^BOOL(UIView *value, NSDictionary *_) {
|
||||
[NSPredicate predicateWithBlock:^BOOL(UIBarButtonItem *value, NSDictionary *_) {
|
||||
NSString *aid = value.accessibilityIdentifier;
|
||||
if ([aid isEqualToString:@"sci-seen-btn"] ||
|
||||
[aid isEqualToString:@"sci-unex-btn"] ||
|
||||
[aid isEqualToString:@"sci-visual-btn"]) return NO;
|
||||
if ([SCIUtils getBoolPref:@"hide_reels_blend"])
|
||||
return ![value.accessibilityIdentifier isEqualToString:@"blend-button"];
|
||||
return true;
|
||||
return ![aid isEqualToString:@"blend-button"];
|
||||
return YES;
|
||||
}]
|
||||
] mutableCopy];
|
||||
|
||||
@@ -88,14 +196,31 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
|
||||
BOOL navExcluded = navThreadId && [SCIExcludedThreads isThreadIdExcluded:navThreadId];
|
||||
|
||||
if ([SCIUtils getBoolPref:@"remove_lastseen"] && !navExcluded) {
|
||||
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.message"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
|
||||
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"eye"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
|
||||
seenButton.accessibilityIdentifier = @"sci-seen-btn";
|
||||
if (sciIsSeenToggleMode())
|
||||
[seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
|
||||
seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
[new_items addObject:seenButton];
|
||||
}
|
||||
|
||||
// Excluded chats hide the seen button — surface an un-exclude affordance instead.
|
||||
if ([SCIUtils getBoolPref:@"remove_lastseen"] && navExcluded &&
|
||||
[SCIUtils getBoolPref:@"unexclude_inbox_button"]) {
|
||||
UIBarButtonItem *unexBtn = [[UIBarButtonItem alloc]
|
||||
initWithImage:[UIImage systemImageNamed:@"eye.slash.fill"]
|
||||
style:UIBarButtonItemStylePlain
|
||||
target:self
|
||||
action:@selector(sciUnexcludeButtonHandler:)];
|
||||
unexBtn.accessibilityIdentifier = @"sci-unex-btn";
|
||||
unexBtn.tintColor = SCIUtils.SCIColor_Primary;
|
||||
unexBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
[new_items addObject:unexBtn];
|
||||
}
|
||||
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) {
|
||||
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
|
||||
dmVisualMsgsViewedButton.accessibilityIdentifier = @"sci-visual-btn";
|
||||
[new_items addObject:dmVisualMsgsViewedButton];
|
||||
[dmVisualMsgsViewedButton setTintColor:dmVisualMsgsViewedButtonEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Send audio file as voice message in DMs
|
||||
// Injects native "Upload Audio" item into the DM plus menu via IGDSMenuItem,
|
||||
// presents file/video picker with trim support, converts to AAC, sends through IG's voice pipeline.
|
||||
// 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 <objc/runtime.h>
|
||||
@@ -18,13 +20,13 @@ 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);
|
||||
if (duration <= 0) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Invalid audio duration"];
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
@@ -33,7 +35,6 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
|
||||
voiceRecordVC = vrIvar ? object_getIvar(voiceController, vrIvar) : nil;
|
||||
}
|
||||
|
||||
// generate waveform
|
||||
id waveform = nil;
|
||||
Class wfClass = NSClassFromString(@"IGDirectAudioWaveform");
|
||||
NSMutableArray *fallbackArr = [NSMutableArray array];
|
||||
@@ -101,19 +102,60 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
|
||||
|
||||
#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:@"Send anyway" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
|
||||
sciSendAudioFile(url, weakVC);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"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];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Allowlisted formats skip AVFoundation entirely; trim is ignored since
|
||||
// AVFoundation can't read their timelines anyway.
|
||||
NSString *ext = [[url pathExtension] lowercaseString];
|
||||
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
|
||||
sciSendAudioFile(url, threadVC);
|
||||
return;
|
||||
}
|
||||
|
||||
[SCIUtils showToastForDuration:1.5 title:isVideo ? @"Extracting audio..." : @"Converting..."];
|
||||
|
||||
dispatch_async(dispatch_get_global_queue(0, 0), ^{
|
||||
AVAsset *asset = [AVAsset assetWithURL:url];
|
||||
|
||||
// build composition — extract audio track (works for both audio-only and video files)
|
||||
AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
|
||||
if (!audioTrack) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ [SCIUtils showErrorHUDWithDescription:@"No audio track found"]; });
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sciShowUnsupportedAlert(url, @"no audio track could be read", threadVC);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,7 +166,9 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide
|
||||
NSError *insertErr = nil;
|
||||
[ct insertTimeRange:sourceRange ofTrack:audioTrack atTime:kCMTimeZero error:&insertErr];
|
||||
if (insertErr) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ [SCIUtils showErrorHUDWithDescription:@"Failed to process audio"]; });
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sciShowUnsupportedAlert(url, insertErr.localizedDescription, threadVC);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,19 +185,28 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide
|
||||
if (exp.status == AVAssetExportSessionStatusCompleted) {
|
||||
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
|
||||
} else {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Export failed: %@",
|
||||
exp.error.localizedDescription ?: @"unknown"]];
|
||||
sciShowUnsupportedAlert(url, exp.error.localizedDescription, threadVC);
|
||||
}
|
||||
});
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
// convenience: no trim
|
||||
// Extensions IG accepts as voice messages without conversion. Append after testing.
|
||||
// m4a/aac — native iOS recording format
|
||||
// ogg/opus — what web/desktop IG sends
|
||||
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 audio file already in the right format and no trim needed, send directly
|
||||
if (!isVideo && ([ext isEqualToString:@"m4a"] || [ext isEqualToString:@"aac"])) {
|
||||
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
|
||||
sciSendAudioFile(url, threadVC);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
[SCISetting menuCellWithTitle:@"Read receipt mode" subtitle:@"How the seen button behaves" menu:[self menus][@"seen_mode"]],
|
||||
[SCISetting switchCellWithTitle:@"Auto mark seen on interact" subtitle:@"Locally marks messages as seen when you send any message" defaultsKey:@"seen_auto_on_interact"],
|
||||
[SCISetting switchCellWithTitle:@"Auto mark seen on typing" subtitle:@"Marks messages as seen the moment you start typing in a DM (works even when typing status is hidden)" defaultsKey:@"seen_auto_on_typing"],
|
||||
[SCISetting switchCellWithTitle:@"Un-exclude button in excluded chats" subtitle:@"Show a small eye button in excluded chats to remove them from the exclusion list (with confirmation). Long-press it for more options." defaultsKey:@"unexclude_inbox_button"],
|
||||
]
|
||||
}]
|
||||
],
|
||||
@@ -295,15 +296,15 @@
|
||||
icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"]
|
||||
navSections:@[@{
|
||||
@"header": @"",
|
||||
@"footer": @"Export your RyukGram settings to a JSON file or QR code, and import them later from Files or a photo. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.",
|
||||
@"footer": @"Export your RyukGram settings to a JSON file and import them later. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.",
|
||||
@"rows": @[
|
||||
[SCISetting buttonCellWithTitle:@"Export settings"
|
||||
subtitle:@"Save as a file or scannable QR code"
|
||||
subtitle:@"Save settings as a JSON file"
|
||||
icon:[SCISymbol symbolWithName:@"square.and.arrow.up"]
|
||||
action:^(void) { [SCISettingsBackup presentExport]; }
|
||||
],
|
||||
[SCISetting buttonCellWithTitle:@"Import settings"
|
||||
subtitle:@"From a file or QR code in your library"
|
||||
subtitle:@"Load settings from a JSON file"
|
||||
icon:[SCISymbol symbolWithName:@"square.and.arrow.down"]
|
||||
action:^(void) { [SCISettingsBackup presentImport]; }
|
||||
]
|
||||
|
||||
+2
-1
@@ -60,7 +60,8 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
|
||||
@"unsent_message_toast": @(NO),
|
||||
@"warn_refresh_clears_preserved": @(NO),
|
||||
@"enable_chat_exclusions": @(YES),
|
||||
@"exclusions_default_keep_deleted": @(NO)
|
||||
@"exclusions_default_keep_deleted": @(NO),
|
||||
@"unexclude_inbox_button": @(YES)
|
||||
};
|
||||
[[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
+ (void)showQuickLookVC:(NSArray<id> *)items;
|
||||
+ (void)showShareVC:(id)item;
|
||||
+ (void)showSettingsVC:(UIWindow *)window;
|
||||
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle;
|
||||
|
||||
// Colours
|
||||
+ (UIColor *)SCIColor_Primary;
|
||||
|
||||
+29
-1
@@ -1,5 +1,6 @@
|
||||
#import "Utils.h"
|
||||
#import "PhotoAlbum.h"
|
||||
#import "Settings/TweakSettings.h"
|
||||
|
||||
@implementation SCIUtils
|
||||
|
||||
@@ -113,10 +114,37 @@
|
||||
UIViewController *rootController = [window rootViewController];
|
||||
SCISettingsViewController *settingsViewController = [SCISettingsViewController new];
|
||||
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:settingsViewController];
|
||||
|
||||
|
||||
[rootController presentViewController:navigationController animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// Open settings and push straight into a named top-level entry (e.g. "Messages").
|
||||
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle {
|
||||
UIViewController *rootController = [window rootViewController];
|
||||
SCISettingsViewController *root = [SCISettingsViewController new];
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root];
|
||||
|
||||
NSArray *targetNavSections = nil;
|
||||
for (NSDictionary *section in [SCITweakSettings sections]) {
|
||||
for (SCISetting *row in section[@"rows"]) {
|
||||
if (row.type == SCITableCellNavigation && [row.title isEqualToString:entryTitle]) {
|
||||
targetNavSections = row.navSections;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetNavSections) break;
|
||||
}
|
||||
|
||||
if (targetNavSections) {
|
||||
SCISettingsViewController *child = [[SCISettingsViewController alloc]
|
||||
initWithTitle:entryTitle sections:targetNavSections reduceMargin:NO];
|
||||
child.title = entryTitle;
|
||||
[nav pushViewController:child animated:NO];
|
||||
}
|
||||
|
||||
[rootController presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// Colours
|
||||
+ (UIColor *)SCIColor_Primary {
|
||||
return [UIColor colorWithRed:0/255.0 green:152/255.0 blue:254/255.0 alpha:1];
|
||||
|
||||
Reference in New Issue
Block a user