feat: Story audio mute/unmute toggle — button on story overlay and 3-dot menu

feat: Multi-select in excluded chats/story users lists with batch actions
feat: Dynamic count refresh on manage list buttons
imp: Opening tweak settings pauses any playing video/audio (toggleable in advanced settings)
imp: Tweak settings quick-access (hold feed tab) now on by default
imp: Disable auto-unmuting reels now off by default
imp: Excluding a chat or story now immediately marks as seen
This commit is contained in:
faroukbmiled
2026-04-10 01:15:04 +01:00
parent ceb89e65d2
commit f2f310fce5
9 changed files with 292 additions and 38 deletions
+3
View File
@@ -101,6 +101,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\***
- Long-press the story seen button for quick actions **\***
- Per-user story seen-receipt exclusions — exclude specific users so their stories behave normally. Manage via 3-dot menu, eye button long-press, or settings list **\***
- Story audio mute/unmute toggle — button on the story overlay and 3-dot menu to toggle audio **\***
- Stop story auto-advance — stories won't auto-skip when the timer ends **\***
- Story download button — download directly from the story overlay **\***
- Download disappearing DM media (photos + videos) **\***
@@ -132,6 +133,8 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
### Tweak settings **\***
- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location
- Pause playback when opening settings (toggleable) **\***
- Quick-access via long-press on feed tab **\***
### Backup & Restore **\***
- Export RyukGram settings as a JSON file
+4 -3
View File
@@ -67,15 +67,16 @@
}
}
- (void)_muteSwitchStateChanged:(id)changed {
if (![SCIUtils getBoolPref:@"disable_auto_unmuting_reels"]) {
extern BOOL sciStoryAudioBypass;
if (sciStoryAudioBypass || ![SCIUtils getBoolPref:@"disable_auto_unmuting_reels"]) {
%orig(changed);
}
}
// Block the announcer from broadcasting "audio enabled" state changes
- (void)_announceForDeviceStateChangesIfNeededForAudioEnabled:(BOOL)enabled reason:(NSInteger)reason {
// When pause/play mode is on, allow unmute (our force-unmute needs this path)
extern BOOL sciStoryAudioBypass;
BOOL pausePlayMode = [[SCIUtils getStringPref:@"reels_tap_control"] isEqualToString:@"pause"];
if ([SCIUtils getBoolPref:@"disable_auto_unmuting_reels"] && enabled && !pausePlayMode) {
if ([SCIUtils getBoolPref:@"disable_auto_unmuting_reels"] && enabled && !pausePlayMode && !sciStoryAudioBypass) {
return;
}
%orig;
@@ -174,11 +174,15 @@ void sciRefreshAllVisibleOverlays(UIViewController *storyVC) {
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
if (!overlayCls) return;
SEL refreshSel = @selector(sciRefreshSeenButton);
SEL audioSel = @selector(sciRefreshAudioButton);
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([v isKindOfClass:overlayCls] && [v respondsToSelector:refreshSel]) {
((void(*)(id, SEL))objc_msgSend)(v, refreshSel);
if ([v isKindOfClass:overlayCls]) {
if ([v respondsToSelector:refreshSel])
((void(*)(id, SEL))objc_msgSend)(v, refreshSel);
if ([v respondsToSelector:audioSel])
((void(*)(id, SEL))objc_msgSend)(v, audioSel);
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
@@ -14,6 +14,10 @@ extern "C" BOOL sciStorySeenToggleEnabled;
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC);
extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC);
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
extern "C" void sciToggleStoryAudio(void);
extern "C" BOOL sciIsStoryAudioEnabled(void);
extern "C" void sciInitStoryAudioState(void);
extern "C" void sciResetStoryAudioState(void);
static SCIDownloadDelegate *sciStoryVideoDl = nil;
static SCIDownloadDelegate *sciStoryImageDl = nil;
@@ -131,6 +135,29 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
]];
}
// Audio toggle button (left side, small)
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1341;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
[btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 14;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
[btn.widthAnchor constraintEqualToConstant:28],
[btn.heightAnchor constraintEqualToConstant:28]
]];
}
// Seen button — deferred so the responder chain is wired up
__weak UIView *weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
@@ -141,6 +168,15 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ============ Seen button lifecycle ============
// Refresh the audio toggle icon (tag 1341) to match current state.
%new - (void)sciRefreshAudioButton {
UIButton *btn = (UIButton *)[self viewWithTag:1341];
if (!btn) return;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
[btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// Rebuilds the eye button (tag 1339) based on current owner + prefs. Idempotent.
%new - (void)sciRefreshSeenButton {
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
@@ -192,11 +228,11 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
lp.minimumPressDuration = 0.4;
[btn addGestureRecognizer:lp];
[self addSubview:btn];
UIView *dlBtn = [self viewWithTag:1340];
if (dlBtn) {
UIView *anchor = [self viewWithTag:1340];
if (anchor) {
[NSLayoutConstraint activateConstraints:@[
[btn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor],
[btn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10],
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
@@ -210,12 +246,26 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
}
}
// Refresh when story owner changes (overlay reuse across reels)
// Refresh when story owner changes or audio state changes
- (void)layoutSubviews {
%orig;
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
static char kLastPKKey;
static char kLastExclKey;
static char kLastAudioKey;
// Audio button: check if state changed
UIButton *audioBtn = (UIButton *)[self viewWithTag:1341];
if (audioBtn) {
BOOL audioOn = sciIsStoryAudioEnabled();
NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey);
if (!prevAudio || [prevAudio boolValue] != audioOn) {
objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshAudioButton));
}
}
// Seen button: check if owner/exclusion changed
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
NSDictionary *info = sciOwnerInfoForView(self);
NSString *pk = info[@"pk"] ?: @"";
BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk];
@@ -228,6 +278,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
}
// ============ Audio toggle handler ============
%new - (void)sciAudioToggleTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[haptic impactOccurred];
sciToggleStoryAudio();
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
[sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// ============ Download handler ============
%new - (void)sciDownloadTapped:(UIButton *)sender {
@@ -471,10 +532,12 @@ static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
while (cur) {
for (UIView *sib in cur.superview.subviews) {
if (![sib isKindOfClass:overlayCls]) continue;
UIView *seen = [sib viewWithTag:1339];
UIView *dl = [sib viewWithTag:1340];
if (seen) seen.alpha = alpha;
if (dl) dl.alpha = alpha;
UIView *seen = [sib viewWithTag:1339];
UIView *dl = [sib viewWithTag:1340];
UIView *audio = [sib viewWithTag:1341];
if (seen) seen.alpha = alpha;
if (dl) dl.alpha = alpha;
if (audio) audio.alpha = alpha;
return;
}
cur = cur.superview;
@@ -0,0 +1,155 @@
// Story audio mute/unmute toggle. Posts mute-switch-state-changed to toggle
// IG's audio. Reads _audioEnabled on IGAudioStatusAnnouncer for icon state.
#import <AVFoundation/AVFoundation.h>
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *);
static id sciAudioAnnouncer = nil;
static BOOL sciIGAudioEnabled(void) {
if (!sciAudioAnnouncer) return NO;
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
if (!ivar) return NO;
ptrdiff_t offset = ivar_getOffset(ivar);
return *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset);
}
// ============ Volume KVO ============
@interface _SciVolumeObserver : NSObject
@end
@implementation _SciVolumeObserver
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
change:(NSDictionary *)change context:(void *)context {
dispatch_async(dispatch_get_main_queue(), ^{
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
});
}
@end
static _SciVolumeObserver *sciVolumeObserver = nil;
// ============ Public API ============
extern "C" {
BOOL sciStoryAudioBypass = NO;
void sciToggleStoryAudio(void) {
BOOL on = sciIGAudioEnabled();
sciStoryAudioBypass = YES;
[[NSNotificationCenter defaultCenter]
postNotificationName:@"mute-switch-state-changed"
object:nil
userInfo:@{@"mute-state": @(on ? 0 : 1)}];
sciStoryAudioBypass = NO;
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}
BOOL sciIsStoryAudioEnabled(void) {
return sciIGAudioEnabled();
}
void sciInitStoryAudioState(void) {
if (!sciVolumeObserver) sciVolumeObserver = [_SciVolumeObserver new];
@try {
[[AVAudioSession sharedInstance] addObserver:sciVolumeObserver
forKeyPath:@"outputVolume"
options:NSKeyValueObservingOptionNew
context:NULL];
} @catch (__unused id e) {}
}
void sciResetStoryAudioState(void) {
@try {
[[AVAudioSession sharedInstance] removeObserver:sciVolumeObserver forKeyPath:@"outputVolume"];
} @catch (__unused id e) {}
}
} // extern "C"
// ============ Announcer hooks ============
static id (*orig_announcerInit)(id, SEL);
static id new_announcerInit(id self, SEL _cmd) {
id r = orig_announcerInit(self, _cmd);
sciAudioAnnouncer = self;
return r;
}
static void (*orig_announce)(id, SEL, BOOL, NSInteger);
static void new_announce(id self, SEL _cmd, BOOL enabled, NSInteger reason) {
orig_announce(self, _cmd, enabled, reason);
if (sciActiveStoryViewerVC) {
dispatch_async(dispatch_get_main_queue(), ^{
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
});
}
}
// ============ 3-dot menu item ============
extern "C" NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *items) {
if (!sciActiveStoryViewerVC) return items;
BOOL looksLikeStoryHeader = NO;
for (id it in items) {
@try {
NSString *t = [NSString stringWithFormat:@"%@", [it valueForKey:@"title"] ?: @""];
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
[t isEqualToString:@"Hide"]) { looksLikeStoryHeader = YES; break; }
} @catch (__unused id e) {}
}
if (!looksLikeStoryHeader) return items;
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
BOOL on = sciIGAudioEnabled();
NSString *title = on ? @"Mute story audio" : @"Unmute story audio";
void (^handler)(void) = ^{ sciToggleStoryAudio(); };
id newItem = nil;
@try {
typedef id (*Init)(id, SEL, id, id, id);
newItem = ((Init)objc_msgSend)([menuItemCls alloc],
@selector(initWithTitle:image:handler:), title, nil, handler);
} @catch (__unused id e) {}
if (!newItem) return items;
NSMutableArray *newItems = [items mutableCopy];
[newItems addObject:newItem];
return [newItems copy];
}
// ============ Ringer listener ============
static void sciRingerChanged(CFNotificationCenterRef center, void *observer,
CFNotificationName name, const void *object,
CFDictionaryRef userInfo) {
dispatch_async(dispatch_get_main_queue(), ^{
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
});
}
// ============ Init ============
__attribute__((constructor)) static void _storyAudioInit(void) {
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(), NULL,
sciRingerChanged, CFSTR("com.apple.springboard.ringerstate"),
NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
Class cls = NSClassFromString(@"IGAudioStatusAnnouncer");
if (!cls) return;
MSHookMessageEx(cls, @selector(init), (IMP)new_announcerInit, (IMP *)&orig_announcerInit);
SEL s = NSSelectorFromString(@"_announceForDeviceStateChangesIfNeededForAudioEnabled:reason:");
if (class_getInstanceMethod(cls, s))
MSHookMessageEx(cls, s, (IMP)new_announce, (IMP *)&orig_announce);
}
+8
View File
@@ -78,9 +78,17 @@ static char rowStaticRef[] = "row";
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.searchController = sc;
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemClose
target:self action:@selector(sciDismissSettings)];
}
}
- (void)sciDismissSettings {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.tableView reloadData];
+32 -21
View File
@@ -187,6 +187,12 @@
}),
]
},
@{
@"header": @"Audio",
@"rows": @[
[SCISetting switchCellWithTitle:@"Story audio toggle" subtitle:@"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" defaultsKey:@"story_audio_toggle"],
]
},
@{
@"header": @"Other",
@"rows": @[
@@ -361,33 +367,38 @@
// }
// ]
// ],
[SCISetting navigationCellWithTitle:@"Advanced"
subtitle:@""
icon:[SCISymbol symbolWithName:@"gearshape.2"]
navSections:@[@{
@"header": @"Settings",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable tweak settings quick-access" subtitle:@"Hold on the home tab to open RyukGram settings" defaultsKey:@"settings_shortcut" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Show tweak settings on app launch" subtitle:@"Automatically opens settings when the app launches" defaultsKey:@"tweak_settings_app_launch"],
[SCISetting switchCellWithTitle:@"Pause playback when opening settings" subtitle:@"Pauses any playing video/audio when settings opens" defaultsKey:@"settings_pause_playback"],
]
},
@{
@"header": @"Instagram",
@"rows": @[
[SCISetting switchCellWithTitle:@"Disable safe mode" subtitle:@"Prevents Instagram from resetting settings after crashes (at your own risk)" defaultsKey:@"disable_safe_mode"],
[SCISetting buttonCellWithTitle:@"Reset onboarding state"
subtitle:@""
icon:nil
action:^(void) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"SCInstaFirstRun"]; [SCIUtils showRestartConfirmation];}
],
]
}]
],
[SCISetting navigationCellWithTitle:@"Debug"
subtitle:@""
icon:[SCISymbol symbolWithName:@"ladybug"]
navSections:@[@{
@"header": @"FLEX",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable FLEX gesture" subtitle:@"Allows you to hold 5 fingers on the screen to open the FLEX explorer" defaultsKey:@"flex_instagram"],
[SCISetting switchCellWithTitle:@"Open FLEX on app launch" subtitle:@"Automatically opens the FLEX explorer when the app launches" defaultsKey:@"flex_app_launch"],
[SCISetting switchCellWithTitle:@"Open FLEX on app focus" subtitle:@"Automatically opens the FLEX explorer when the app is focused" defaultsKey:@"flex_app_start"]
]
},
@{
@"header": @"RyukGram",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable tweak settings quick-access" subtitle:@"Allows you to hold on the home tab to open the RyukGram settings" defaultsKey:@"settings_shortcut" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Show tweak settings on app launch" subtitle:@"Automatically opens the RyukGram settings when the app launches" defaultsKey:@"tweak_settings_app_launch"],
[SCISetting buttonCellWithTitle:@"Reset onboarding completion state"
subtitle:@""
icon:nil
action:^(void) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"SCInstaFirstRun"]; [SCIUtils showRestartConfirmation];}
],
]
},
@{
@"header": @"Instagram",
@"rows": @[
[SCISetting switchCellWithTitle:@"Disable safe mode" subtitle:@"Makes Instagram not reset settings after subsequent crashes (at your own risk)" defaultsKey:@"disable_safe_mode"]
[SCISetting switchCellWithTitle:@"Enable FLEX gesture" subtitle:@"Hold 5 fingers on the screen to open FLEX" defaultsKey:@"flex_instagram"],
[SCISetting switchCellWithTitle:@"Open FLEX on app launch" subtitle:@"Opens FLEX when the app launches" defaultsKey:@"flex_app_launch"],
[SCISetting switchCellWithTitle:@"Open FLEX on app focus" subtitle:@"Opens FLEX when the app is focused" defaultsKey:@"flex_app_start"]
]
},
@{
+7 -2
View File
@@ -44,7 +44,8 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"swipe_nav_tabs": @"default",
@"enable_notes_customization": @(YES),
@"custom_note_themes": @(YES),
@"disable_auto_unmuting_reels": @(YES),
@"disable_auto_unmuting_reels": @(NO),
@"settings_shortcut": @(YES),
@"doom_scrolling_reel_count": @(1),
@"no_seen_visual": @(YES),
@"send_audio_as_file": @(YES),
@@ -64,7 +65,9 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"unexclude_inbox_button": @(YES),
@"enable_story_user_exclusions": @(YES),
@"story_excluded_show_unexclude_eye": @(YES),
@"story_seen_mode": @"button"
@"story_seen_mode": @"button",
@"story_audio_toggle": @(NO),
@"settings_pause_playback": @(YES)
};
[[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];
@@ -615,7 +618,9 @@ shouldPersistLastBugReportId:(id)arg6
}
extern NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *);
extern NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *);
NSArray *finalObjs = sciMaybeAppendStoryExcludeMenuItem([filteredObjs copy]);
finalObjs = sciMaybeAppendStoryAudioMenuItem(finalObjs);
return %orig(finalObjs, edr, headerLabelText);
}
%end
+4
View File
@@ -114,6 +114,8 @@
UIViewController *rootController = [window rootViewController];
SCISettingsViewController *settingsViewController = [SCISettingsViewController new];
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:settingsViewController];
if ([SCIUtils getBoolPref:@"settings_pause_playback"])
navigationController.modalPresentationStyle = UIModalPresentationFullScreen;
[rootController presentViewController:navigationController animated:YES completion:nil];
}
@@ -124,6 +126,8 @@
while (rootController.presentedViewController) rootController = rootController.presentedViewController;
SCISettingsViewController *root = [SCISettingsViewController new];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root];
if ([SCIUtils getBoolPref:@"settings_pause_playback"])
nav.modalPresentationStyle = UIModalPresentationFullScreen;
NSArray *targetNavSections = nil;
for (NSDictionary *section in [SCITweakSettings sections]) {