mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-06-08 08:23:54 +02:00
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user