Files
RyukGram/src/ActionButton/SCIMediaActions.m
T
faroukbmiled 2977873932 [release] RyukGram v1.2.2
- Profile Analyzer (beta) — follower/following scans with mutuals, non-followbacks, new/lost trackers, and profile change history; searchable lists with batch follow/unfollow
- Theme settings — force dark mode, Full OLED, OLED chat theme, and keyboard theme picker
- Confirm story like
- Confirm story emoji reaction
- Swipe down to dismiss media viewer
- Manually add users to story/chat exclusion lists by username
- Keep stories visually seen locally
- Auto-scroll reels mode
- Quality picker: audio-only and raw photo download rows
- Clear cache button with optional auto-clear interval
- Spanish, Russian, Korean, Arabic, and Chinese (Traditional) translations
- About page with version, credits, and links
- Release notes popup on first launch of a new version
- Anonymous live viewing
- Toggle live comments
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle
- Hide RyukGram UI on screenshots, screen recordings, and mirroring
- Open link from clipboard — long-press the search tab
- Messages-only mode: optional "Hide tab bar" sub-toggle
- Fake profile stats — verified badge and follower/following/post counts on your own profile
- Language switcher + import/export localization from Debug
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting
- Force legacy Quiz sticker back into the story composer tray
- Advanced experimental features menu — toggle hidden IG experiments (QuickSnap, Homecoming, Prism, Direct Notes reply types) with apply-on-restart batching and a crash-loop auto-reset
- Shortcut to Advanced experimental features from the General experimental features section

- Push notifications render with rich previews on sideload again
- IG 426 compatibility across story audio toggle, like confirmation, seen-on-like, live comments, notes audio download
- Call confirm split into separate voice-call and video-call toggles
- Messages-only mode: tab swiping disabled
- Settings quick-access broken in non-English languages
- Story seen-receipt block restored on IG v426
- Block selected mode no longer marks listed stories as seen
- Hide explore posts grid works again on recent IG versions
- Hide suggested stories no longer breaks profile highlights
- Hide trending searches now also hides the category chip bar
- Story eye long-press menu opens next to the button
- Disable video autoplay: tap-to-play now works on videos inside carousels
- Disable vanish mode swipe fixed on IG 426

- "Confirm shh mode" renamed to "Confirm vanish mode" across all languages
- Confirm sticker interaction split into separate story and highlight toggles
- Shared link embed presets: added eeinstagram.com and vxinstagram.com
- Downloaded media filenames follow `@username_context_timestamp`
- Reels pause mode: optional tap-to-mute on photo reels
- Backup & Restore — scope picker with live preview for Settings / Excluded lists / Analyzer data
- Profile Analyzer: filter by Not verified
- Settings header: tap to open a sheet with GitHub and Telegram channel links

- Thanks to Furamako for the Spanish translation
- Thanks to [ZomkaDEV](https://github.com/ZomkaDEV) for the Russian translation
- Thanks to [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) for the Korean translation
- Thanks to [@bruuhim](https://github.com/bruuhim) for the Arabic translation
- Thanks to [@jaydenjcpy](https://github.com/jaydenjcpy) for the Chinese (Traditional) translation
- Thanks to [@darthplagueiswise](https://github.com/darthplagueiswise) (Radan) for the experimental flag feature set
- Thanks to [@asdfzxcvbn](https://github.com/asdfzxcvbn) for [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject) and [ipapatch](https://github.com/asdfzxcvbn/ipapatch)

- 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)
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures
2026-04-24 02:50:30 +01:00

1426 lines
63 KiB
Objective-C

#import "SCIMediaActions.h"
#import "SCIMediaViewer.h"
#import "SCIRepostSheet.h"
#import "../SCIDashParser.h"
#import "../SCIFFmpeg.h"
#import "../SCIQualityPicker.h"
#import "../Utils.h"
#import "../Downloader/Download.h"
#import "../PhotoAlbum.h"
#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <Photos/Photos.h>
#import <AVFoundation/AVFoundation.h>
// Retain the active download delegate so ARC doesn't kill it mid-download.
// Replaced on each new download — one active download at a time.
static SCIDownloadDelegate *sciActiveDownloadDelegate = nil;
// Story audio toggle — defined in StoryAudioToggle.xm (extern "C")
extern void sciToggleStoryAudio(void);
extern BOOL sciIsStoryAudioEnabled(void);
// MARK: - Filename naming
static NSString *sciCurrentFilenameStem = nil;
static NSString *sciSanitizeFilenameComponent(NSString *s) {
if (!s.length) return @"";
NSMutableCharacterSet *bad = [NSMutableCharacterSet alphanumericCharacterSet];
[bad addCharactersInString:@"._-"];
NSCharacterSet *drop = bad.invertedSet;
NSArray *parts = [s componentsSeparatedByCharactersInSet:drop];
NSString *out = [parts componentsJoinedByString:@""];
if (out.length > 30) out = [out substringToIndex:30];
return out;
}
// IGAPIStorableObject's backing dict.
static NSDictionary *sciMediaFieldCache(id obj) {
if (!obj) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
id v = object_getIvar(obj, fcIvar);
return [v isKindOfClass:[NSDictionary class]] ? v : nil;
}
static NSString *sciUsernameForMedia(id media) {
if (!media) return nil;
@try {
id user = nil;
@try { user = [media valueForKey:@"user"]; } @catch (__unused id e) {}
if (!user) {
NSDictionary *fc = sciMediaFieldCache(media);
user = fc[@"user"];
}
if (!user) return nil;
NSString *u = nil;
@try { u = [user valueForKey:@"username"]; } @catch (__unused id e) {}
if (![u isKindOfClass:[NSString class]] || !u.length) {
NSDictionary *ufc = sciMediaFieldCache(user);
id v = ufc[@"username"];
if ([v isKindOfClass:[NSString class]]) u = v;
else if ([user isKindOfClass:[NSDictionary class]]) u = ((NSDictionary *)user)[@"username"];
}
return [u isKindOfClass:[NSString class]] ? u : nil;
} @catch (__unused id e) { return nil; }
}
// Match keys used in the settings-entry title map for openSettingsForContext:
static NSString *sciSettingsTitleForContext(SCIActionContext ctx) {
switch (ctx) {
case SCIActionContextFeed: return SCILocalized(@"Feed");
case SCIActionContextReels: return SCILocalized(@"Reels");
case SCIActionContextStories: return SCILocalized(@"Stories");
}
return @"General";
}
// Pull an ivar by name. Returns nil on miss. Safe for any class.
static id sciIvar(id obj, const char *name) {
if (!obj || !name) return nil;
Ivar i = class_getInstanceVariable(object_getClass(obj), name);
if (!i) return nil;
@try { return object_getIvar(obj, i); } @catch (__unused id e) { return nil; }
}
// Read from IGAPIStorableObject._fieldCache (KVC returns NSNull for many keys).
static id sciFieldCache(id obj, NSString *key) {
if (!obj || !key) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
id fc = nil;
@try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; }
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
id val = ((NSDictionary *)fc)[key];
if (!val || [val isKindOfClass:[NSNull class]]) return nil;
return val;
}
// Fresh download delegate (one active download at a time).
static SCIDownloadDelegate *sciMakeDownloader(DownloadAction action, BOOL progress) {
return [[SCIDownloadDelegate alloc] initWithAction:action showProgress:progress];
}
// Route a download through the confirm dialog if the pref is on.
static void sciConfirmThen(NSString *title, void(^block)(void)) {
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
[SCIUtils showConfirmation:block title:title];
} else {
block();
}
}
@implementation SCIMediaActions
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx {
switch (ctx) {
case SCIActionContextFeed: return @"feed";
case SCIActionContextReels: return @"reels";
case SCIActionContextStories: return @"stories";
}
return @"media";
}
+ (NSString *)filenameStemForMedia:(id)media contextLabel:(NSString *)ctxLabel {
@try {
NSString *user = sciSanitizeFilenameComponent(sciUsernameForMedia(media));
NSString *userPart = user.length ? [@"@" stringByAppendingString:user] : @"media";
NSString *ctxPart = sciSanitizeFilenameComponent(ctxLabel);
if (!ctxPart.length) ctxPart = @"media";
static NSDateFormatter *fmt = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
fmt = [NSDateFormatter new];
fmt.dateFormat = @"yyyyMMdd_HHmmss";
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
});
NSString *ts = [fmt stringFromDate:[NSDate date]];
return [NSString stringWithFormat:@"%@_%@_%@", userPart, ctxPart, ts];
} @catch (__unused id e) {
return [[NSUUID UUID] UUIDString];
}
}
+ (NSString *)currentFilenameStem { return sciCurrentFilenameStem; }
+ (void)setCurrentFilenameStem:(NSString *)stem { sciCurrentFilenameStem = [stem copy]; }
// MARK: - Media extraction
+ (NSString *)captionForMedia:(id)media {
if (!media) return nil;
// Try known selectors
for (NSString *sel in @[@"fullCaptionString", @"captionString", @"caption",
@"captionText", @"text"]) {
SEL s = NSSelectorFromString(sel);
if ([media respondsToSelector:s]) {
@try {
id result = ((id(*)(id, SEL))objc_msgSend)(media, s);
if ([result isKindOfClass:[NSString class]] && [(NSString *)result length]) {
return result;
}
// Wrapper objects (IGAPICommentDict, etc.) — try all text accessors
if (result && ![result isKindOfClass:[NSString class]]) {
for (NSString *textSel in @[@"text", @"string", @"commentText",
@"attributedString", @"rawText"]) {
if ([result respondsToSelector:NSSelectorFromString(textSel)]) {
@try {
id text = ((id(*)(id,SEL))objc_msgSend)(result, NSSelectorFromString(textSel));
// NSAttributedString → .string
if ([text respondsToSelector:@selector(string)] && ![text isKindOfClass:[NSString class]])
text = ((id(*)(id,SEL))objc_msgSend)(text, @selector(string));
if ([text isKindOfClass:[NSString class]] && [(NSString *)text length])
return text;
} @catch (__unused id e) {}
}
}
// Also try reading fieldCache on the wrapper (Pando dict)
id fcText = sciFieldCache(result, @"text");
if ([fcText isKindOfClass:[NSString class]] && [(NSString *)fcText length])
return fcText;
}
} @catch (__unused id e) {}
}
}
// Fieldcache: `caption` → dict with `text`, or direct string
id capObj = sciFieldCache(media, @"caption");
if ([capObj isKindOfClass:[NSDictionary class]]) {
id text = ((NSDictionary *)capObj)[@"text"];
if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) return text;
} else if ([capObj isKindOfClass:[NSString class]] && [(NSString *)capObj length]) {
return capObj;
}
// Fieldcache: try the caption wrapper object's text
if (capObj && [capObj respondsToSelector:@selector(text)]) {
@try {
id text = ((id(*)(id, SEL))objc_msgSend)(capObj, @selector(text));
if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) return text;
} @catch (__unused id e) {}
}
// Deep scan: check ivars named _caption* on the media object
unsigned int count = 0;
Ivar *ivars = class_copyIvarList(object_getClass(media), &count);
for (unsigned int i = 0; i < count; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *ivarName = [[NSString stringWithUTF8String:name] lowercaseString];
if (![ivarName containsString:@"caption"]) continue;
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(media, ivars[i]);
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length]) {
free(ivars); return val;
}
if (val && [val respondsToSelector:@selector(text)]) {
id text = ((id(*)(id, SEL))objc_msgSend)(val, @selector(text));
if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) {
free(ivars); return text;
}
}
if (val && [val respondsToSelector:@selector(string)]) {
id str = ((id(*)(id, SEL))objc_msgSend)(val, @selector(string));
if ([str isKindOfClass:[NSString class]] && [(NSString *)str length]) {
free(ivars); return str;
}
}
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
return nil;
}
+ (BOOL)isCarouselMedia:(id)media {
if (!media) return NO;
if ([media respondsToSelector:@selector(isCarousel)]) {
@try {
BOOL r = ((BOOL(*)(id, SEL))objc_msgSend)(media, @selector(isCarousel));
if (r) return YES;
} @catch (__unused id e) {}
}
if ([media respondsToSelector:@selector(mediaType)]) {
@try {
NSInteger t = ((NSInteger(*)(id, SEL))objc_msgSend)(media, @selector(mediaType));
if (t == 8) return YES;
} @catch (__unused id e) {}
}
return [self carouselChildrenForMedia:media].count > 0;
}
+ (NSArray *)carouselChildrenForMedia:(id)media {
if (!media) return @[];
for (NSString *sel in @[@"carouselMedia", @"carouselChildren", @"children"]) {
SEL s = NSSelectorFromString(sel);
if ([media respondsToSelector:s]) {
@try {
id val = ((id(*)(id, SEL))objc_msgSend)(media, s);
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return val;
} @catch (__unused id e) {}
}
}
static const char * const kCarouselIvars[] = { "_carouselMedia", "_carouselChildren" };
for (size_t i = 0; i < sizeof(kCarouselIvars)/sizeof(kCarouselIvars[0]); i++) {
id val = sciIvar(media, kCarouselIvars[i]);
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return val;
}
id fc = sciFieldCache(media, @"carousel_media");
if ([fc isKindOfClass:[NSArray class]]) return fc;
return @[];
}
+ (BOOL)mediaHasAudio:(id)media {
if (!media) return NO;
// fieldCache on media (old IG path).
id v = sciFieldCache(media, @"has_audio");
if ([v respondsToSelector:@selector(boolValue)] && [v boolValue]) return YES;
// IGVideo.isAudioDetected — positive signal only; NO often means "IG
// hasn't decoded the manifest yet" for stories, not actually silent.
@try {
id video = nil;
if ([media respondsToSelector:@selector(video)])
video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video));
if (video && [video respondsToSelector:@selector(isAudioDetected)]) {
if (((BOOL(*)(id, SEL))objc_msgSend)(video, @selector(isAudioDetected))) return YES;
}
} @catch (__unused id e) {}
// Stories often carry audio but don't surface it in fieldCache. If any
// of these music/audio hints are present, treat as audio-bearing.
for (NSString *key in @[@"music_metadata", @"story_music_stickers",
@"is_story_image_with_music", @"story_sound_on",
@"spotify_stickers", @"story_music_lyric_stickers"]) {
id val = sciFieldCache(media, key);
if (val && ![val isKindOfClass:[NSNull class]]) {
if ([val respondsToSelector:@selector(boolValue)] && [val boolValue]) return YES;
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return YES;
if ([val isKindOfClass:[NSDictionary class]] && [(NSDictionary *)val count]) return YES;
}
}
// Last resort: if a DASH manifest exists, assume audio is present.
return [SCIDashParser dashManifestForMedia:media].length > 0;
}
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action {
NSURL *url = [self hdPhotoURLForMedia:media];
if (!url) url = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
if (!url) url = [self fieldCachePhotoURLForMedia:media];
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; }
NSString *ext = [[url lastPathComponent] pathExtension];
if (!ext.length) ext = @"jpg";
sciActiveDownloadDelegate = sciMakeDownloader(action, NO);
[sciActiveDownloadDelegate downloadFileWithURL:url fileExtension:ext hudLabel:nil];
}
// Photos library can't hold audio — save action falls back to share sheet.
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action {
NSString *manifest = [SCIDashParser dashManifestForMedia:media];
if (!manifest.length) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio stream available")];
return;
}
NSArray *reps = [SCIDashParser parseManifest:manifest];
SCIDashRepresentation *audio = [SCIDashParser bestAudioFromRepresentations:reps];
if (!audio.url) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio track found")];
return;
}
if (![SCIFFmpeg isAvailable]) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"FFmpeg not available")];
return;
}
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
NSString *ticket = [pill beginTicketWithTitle:SCILocalized(@"Downloading audio...")
onCancel:^{ [SCIFFmpeg cancelAll]; }];
NSString *audioStem = [self currentFilenameStem] ?: [[NSUUID UUID] UUIDString];
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.m4a", audioStem]];
NSString *cmd = [NSString stringWithFormat:@"-i \"%@\" -vn -c:a copy -y \"%@\"",
audio.url.absoluteString, outPath];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (!success) {
[pill finishTicket:ticket errorMessage:SCILocalized(@"Audio extract failed")];
return;
}
[pill finishTicket:ticket successMessage:SCILocalized(@"Audio ready")];
NSURL *fileURL = [NSURL fileURLWithPath:outPath];
switch (action) {
case quickLook: [SCIUtils showQuickLookVC:@[fileURL]]; break;
case share:
case saveToPhotos:
default: [SCIUtils showShareVC:fileURL]; break;
}
});
}];
}
+ (NSURL *)bestURLForMedia:(id)media {
if (!media) return nil;
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)media];
if (v) return v;
BOOL hdPhotos = [[SCIUtils getStringPref:@"default_photo_quality"] isEqualToString:@"high"];
if (hdPhotos) {
NSURL *hd = [self hdPhotoURLForMedia:media];
if (hd) return hd;
}
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
if (p) return p;
// Carousel children: fieldCache fallback
return [self fieldCachePhotoURLForMedia:media];
}
+ (NSURL *)hdPhotoURLForMedia:(id)media {
// fieldCache image_versions2.candidates has multiple sizes — pick largest
id candidates = nil;
id iv2 = sciFieldCache(media, @"image_versions2");
if ([iv2 isKindOfClass:[NSDictionary class]])
candidates = ((NSDictionary *)iv2)[@"candidates"];
if (!candidates)
candidates = sciFieldCache(media, @"candidates");
if ([candidates isKindOfClass:[NSArray class]] && [(NSArray *)candidates count]) {
NSDictionary *best = nil;
NSInteger bestW = 0;
for (id c in (NSArray *)candidates) {
if (![c isKindOfClass:[NSDictionary class]]) continue;
NSInteger w = [((NSDictionary *)c)[@"width"] integerValue];
if (w > bestW) { bestW = w; best = c; }
}
NSString *urlStr = best[@"url"];
if (urlStr.length) return [NSURL URLWithString:urlStr];
}
// Try .photo sub-object imageVersions
id photo = nil;
if ([media respondsToSelector:@selector(photo)])
photo = ((id(*)(id, SEL))objc_msgSend)(media, @selector(photo));
// _originalImageVersions on IGPhoto — array of IGImageURL objects
if (photo) {
Ivar oivIvar = class_getInstanceVariable([photo class], "_originalImageVersions");
if (oivIvar) {
id oiv = object_getIvar(photo, oivIvar);
if ([oiv isKindOfClass:[NSArray class]] && [(NSArray *)oiv count]) {
NSURL *best = nil;
NSInteger bestW = 0;
for (id item in (NSArray *)oiv) {
NSURL *u = nil;
NSInteger w = 0;
if ([item isKindOfClass:[NSDictionary class]]) {
NSString *s = ((NSDictionary *)item)[@"url"];
if (s.length) u = [NSURL URLWithString:s];
w = [((NSDictionary *)item)[@"width"] integerValue];
} else {
if ([item respondsToSelector:@selector(url)])
u = [item valueForKey:@"url"];
if ([item respondsToSelector:@selector(width)])
w = [[item valueForKey:@"width"] integerValue];
}
if (u && w > bestW) { bestW = w; best = u; }
}
if (best) return best;
}
}
}
return nil;
}
+ (NSURL *)fieldCachePhotoURLForMedia:(id)media {
id candidates = nil;
id iv2 = sciFieldCache(media, @"image_versions2");
if ([iv2 isKindOfClass:[NSDictionary class]])
candidates = ((NSDictionary *)iv2)[@"candidates"];
if (!candidates)
candidates = sciFieldCache(media, @"candidates");
if ([candidates isKindOfClass:[NSArray class]] && [(NSArray *)candidates count]) {
NSDictionary *best = nil;
NSInteger bestW = 0;
for (id c in (NSArray *)candidates) {
if (![c isKindOfClass:[NSDictionary class]]) continue;
NSInteger w = [((NSDictionary *)c)[@"width"] integerValue];
if (w > bestW) { bestW = w; best = c; }
}
NSString *urlStr = best[@"url"];
if (urlStr.length) return [NSURL URLWithString:urlStr];
}
return nil;
}
// MARK: - Enhanced HD download
+ (void)downloadHDMedia:(id)media action:(DownloadAction)action fromView:(UIView *)sourceView {
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media")]; return; }
BOOL isVideo = ([SCIUtils getVideoUrlForMedia:(IGMedia *)media] != nil);
// Photos: always use best candidates URL (no FFmpeg needed)
if (!isVideo) {
NSURL *url = [self bestURLForMedia:media];
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; }
sciActiveDownloadDelegate = sciMakeDownloader(action, NO);
[sciActiveDownloadDelegate downloadFileWithURL:url
fileExtension:[[url lastPathComponent] pathExtension]
hudLabel:nil];
return;
}
// Try enhanced HD path via reusable quality picker
BOOL handled = [SCIQualityPicker pickQualityForMedia:media
fromView:sourceView
action:action
picked:^(SCIDashRepresentation *video, SCIDashRepresentation *audio) {
[self downloadDASHVideo:video audio:audio action:action];
}
fallback:^{
// No DASH or FFmpeg unavailable — use progressive URL
NSURL *url = [self bestURLForMedia:media];
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video URL")]; return; }
sciActiveDownloadDelegate = sciMakeDownloader(action, YES);
[sciActiveDownloadDelegate downloadFileWithURL:url
fileExtension:[[url lastPathComponent] pathExtension]
hudLabel:nil];
}];
if (!handled) {
// pickQualityForMedia returned NO and already called fallback
}
}
+ (void)downloadDASHVideo:(SCIDashRepresentation *)videoRep
audio:(SCIDashRepresentation *)audioRep
action:(DownloadAction)action {
if (!videoRep.url) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No video URL")];
return;
}
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
__block void (^muxCancel)(void) = nil;
NSString *ticket = [pill beginTicketWithTitle:[NSString stringWithFormat:SCILocalized(@"Downloading %@..."), videoRep.qualityLabel ?: @"HD"]
onCancel:^{ if (muxCancel) muxCancel(); }];
NSString *encPreset = [SCIUtils getStringPref:@"ffmpeg_encoding_speed"];
if (!encPreset.length) encPreset = @"ultrafast";
[SCIFFmpeg muxVideoURL:videoRep.url audioURL:audioRep.url preset:encPreset
progress:^(float progress, NSString *stage) {
[pill updateTicket:ticket progress:progress];
[pill updateTicket:ticket text:stage];
} completion:^(NSURL *outputURL, NSError *error) {
if (error && error.code == NSUserCancelledError) {
[pill finishTicket:ticket cancelled:@"Cancelled"];
if (outputURL) [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil];
return;
}
if (error || !outputURL) {
[pill finishTicket:ticket errorMessage:error.localizedDescription ?: @"Mux failed"];
return;
}
// saveToPhotos finishes the ticket after the PH completion fires.
if (action != saveToPhotos) {
[pill finishTicket:ticket successMessage:SCILocalized(@"HD download complete")];
}
switch (action) {
case share:
[SCIUtils showShareVC:outputURL];
break;
case quickLook:
[SCIUtils showQuickLookVC:@[outputURL]];
break;
case saveToPhotos: {
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
if (status != PHAuthorizationStatusAuthorized) {
dispatch_async(dispatch_get_main_queue(), ^{
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")];
});
return;
}
BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"];
void (^onDone)(BOOL, NSError *) = ^(BOOL ok, NSError *e) {
dispatch_async(dispatch_get_main_queue(), ^{
if (ok) [pill finishTicket:ticket successMessage:useAlbum ? SCILocalized(@"Saved to RyukGram") : SCILocalized(@"Saved to Photos")];
else [pill finishTicket:ticket errorMessage:e.localizedDescription ?: @"Failed to save"];
});
};
if (useAlbum) {
[SCIPhotoAlbum saveFileToAlbum:outputURL completion:^(BOOL ok, NSError *e) {
[[NSFileManager defaultManager] removeItemAtPath:outputURL.path error:nil];
onDone(ok, e);
}];
} else {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new];
opts.shouldMoveFile = YES;
[req addResourceWithType:PHAssetResourceTypeVideo
fileURL:outputURL options:opts];
} completionHandler:onDone];
}
}];
break;
}
}
} cancelOut:^(void (^cb)(void)) {
muxCancel = cb;
}];
}
+ (NSURL *)coverURLForMedia:(id)media {
if (!media) return nil;
// For a reel/video, `media.photo` exposes the poster frame URL.
return [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
}
// MARK: - Primary actions
+ (void)expandMedia:(id)media fromView:(UIView *)sourceView caption:(NSString *)caption {
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to expand")]; return; }
NSString *cap = caption ?: [self captionForMedia:media];
// Check if this is a carousel — show all items with swiping
if ([self isCarouselMedia:media]) {
NSArray *children = [self carouselChildrenForMedia:media];
NSMutableArray<SCIMediaViewerItem *> *items = [NSMutableArray array];
for (id child in children) {
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)child];
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)child];
if (v || p) {
[items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:cap]];
}
}
if (items.count) {
[SCIMediaViewer showItems:items startIndex:0];
return;
}
}
// Single item
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:(IGMedia *)media];
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
if (!videoUrl && !photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract media URL")]; return; }
[SCIMediaViewer showWithVideoURL:videoUrl photoURL:photoUrl caption:cap];
}
+ (void)downloadAndShareMedia:(id)media {
[self downloadAndShareMedia:media fromView:nil];
}
+ (void)downloadAndShareMedia:(id)media fromView:(UIView *)sourceView {
sciConfirmThen(SCILocalized(@"Download and share?"), ^{
[self downloadHDMedia:media action:share fromView:sourceView];
});
}
+ (void)downloadAndSaveMedia:(id)media {
[self downloadAndSaveMedia:media fromView:nil];
}
+ (void)downloadAndSaveMedia:(id)media fromView:(UIView *)sourceView {
sciConfirmThen(SCILocalized(@"Save to Photos?"), ^{
[self downloadHDMedia:media action:saveToPhotos fromView:sourceView];
});
}
+ (void)copyURLForMedia:(id)media {
NSURL *url = [self bestURLForMedia:media];
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract media URL")]; return; }
[[UIPasteboard generalPasteboard] setString:url.absoluteString];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Copied download URL")];
}
+ (void)copyCaptionForMedia:(id)media {
NSString *caption = [self captionForMedia:media];
if (!caption.length) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No caption on this post")]; return; }
[[UIPasteboard generalPasteboard] setString:caption];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Copied caption")];
}
// BFS search for a view of a given class within a subtree (bounded depth).
static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxDepth) {
Class cls = NSClassFromString(className);
if (!cls || !root) return nil;
NSMutableArray *queue = [NSMutableArray arrayWithObject:root];
int processed = 0;
while (queue.count && processed < 200) {
UIView *v = queue.firstObject; [queue removeObjectAtIndex:0];
if ([v isKindOfClass:cls]) return v;
if (processed < maxDepth * 50) {
for (UIView *sub in v.subviews) [queue addObject:sub];
}
processed++;
}
return nil;
}
+ (void)triggerRepostForContext:(SCIActionContext)ctx sourceView:(UIView *)sourceView {
if (ctx == SCIActionContextReels) {
// Walk up to video cell, then BFS for the UFI bar.
Class cellCls = NSClassFromString(@"IGSundialViewerVideoCell");
if (!cellCls) cellCls = NSClassFromString(@"IGSundialViewerPhotoView");
UIView *v = sourceView;
while (v && cellCls && ![v isKindOfClass:cellCls]) v = v.superview;
UIView *ufi = v ? sciFindSubviewOfClass(v, @"IGSundialViewerVerticalUFI", 8) : nil;
if (ufi) {
SEL noArg = NSSelectorFromString(@"_didTapRepostButton");
if ([ufi respondsToSelector:noArg]) {
((void(*)(id, SEL))objc_msgSend)(ufi, noArg);
return;
}
// Fallback: try the 1-arg variant (older IG?)
SEL oneArg = @selector(_didTapRepostButton:);
if ([ufi respondsToSelector:oneArg]) {
((void(*)(id, SEL, id))objc_msgSend)(ufi, oneArg, nil);
return;
}
}
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Repost unavailable")];
return;
}
// Feed: walk responder chain for IGFeedItemUFICell.
UIResponder *r = sourceView;
Class feedCell = NSClassFromString(@"IGFeedItemUFICell");
while (r) {
if (feedCell && [r isKindOfClass:feedCell]) break;
r = [r nextResponder];
}
if (r) {
@try {
SEL s = @selector(UFIButtonBarDidTapOnRepost:);
if ([r respondsToSelector:s]) {
((void(*)(id, SEL, id))objc_msgSend)(r, s, nil);
return;
}
} @catch (__unused id e) {}
}
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Repost unavailable")];
}
+ (void)openSettingsForContext:(SCIActionContext)ctx fromView:(UIView *)sourceView {
UIWindow *win = sourceView.window;
if (!win) {
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { win = w; break; }
}
}
if (!win) {
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
if ([scene isKindOfClass:[UIWindowScene class]]) {
for (UIWindow *w in ((UIWindowScene *)scene).windows) { win = w; break; }
}
if (win) break;
}
}
if (!win) return;
[SCIUtils showSettingsVC:win atTopLevelEntry:sciSettingsTitleForContext(ctx)];
}
// MARK: - Carousel bulk actions
// Download all carousel children in parallel, call `done` when finished.
+ (void)downloadAllChildrenOfMedia:(id)media
progressTitle:(NSString *)title
done:(void(^)(NSArray<NSURL *> *fileURLs))done {
NSArray *children = [self carouselChildrenForMedia:media];
if (!children.count) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No carousel children")];
return;
}
// Collect URLs first
NSMutableArray<NSURL *> *urls = [NSMutableArray array];
for (id child in children) {
NSURL *u = [self bestURLForMedia:child];
if (u) [urls addObject:u];
}
if (!urls.count) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract any URLs")];
return;
}
sciConfirmThen(title, ^{
// Show the shared pill with bulk progress
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
[pill resetState];
[pill showBulkProgress:0 total:urls.count];
UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
if (hostView) [pill showInView:hostView];
__block BOOL cancelled = NO;
pill.onCancel = ^{ cancelled = YES; };
dispatch_group_t group = dispatch_group_create();
NSMutableArray<NSURL *> *files = [NSMutableArray array];
NSLock *lock = [NSLock new];
__block NSUInteger completed = 0;
NSString *bulkStem = [self currentFilenameStem];
NSUInteger __idx = 0;
for (NSURL *url in urls) {
if (cancelled) break;
dispatch_group_enter(group);
NSString *ext = [[url lastPathComponent] pathExtension];
NSString *name = bulkStem
? [NSString stringWithFormat:@"%@_%lu", bulkStem, (unsigned long)(++__idx)]
: [[NSUUID UUID] UUIDString];
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@", name,
ext.length ? ext : @"jpg"]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
if (!err && loc && !cancelled) {
NSError *mv = nil;
[[NSFileManager defaultManager] moveItemAtURL:loc
toURL:[NSURL fileURLWithPath:tmp]
error:&mv];
if (!mv) {
[lock lock];
[files addObject:[NSURL fileURLWithPath:tmp]];
[lock unlock];
}
}
[lock lock];
completed++;
NSUInteger c = completed;
NSUInteger t = urls.count;
[lock unlock];
dispatch_async(dispatch_get_main_queue(), ^{
[pill showBulkProgress:c total:t];
});
dispatch_group_leave(group);
}];
[task resume];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
if (cancelled) {
[pill showError:SCILocalized(@"Cancelled")];
[pill dismissAfterDelay:1.0];
} else if (files.count) {
[pill showSuccess:[NSString stringWithFormat:SCILocalized(@"Downloaded %lu items"), (unsigned long)files.count]];
[pill dismissAfterDelay:1.5];
if (done) done([files copy]);
} else {
[pill showError:SCILocalized(@"No files downloaded")];
[pill dismissAfterDelay:2.0];
}
});
});
}
+ (void)downloadAllAndShareMedia:(id)carouselMedia {
[self downloadAllChildrenOfMedia:carouselMedia
progressTitle:@"Download all and share?"
done:^(NSArray<NSURL *> *files) {
if (!files.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Nothing to share")]; return; }
UIViewController *top = topMostController();
UIActivityViewController *vc = [[UIActivityViewController alloc]
initWithActivityItems:files applicationActivities:nil];
if (is_iPad()) {
vc.popoverPresentationController.sourceView = top.view;
vc.popoverPresentationController.sourceRect =
CGRectMake(top.view.bounds.size.width/2.0, top.view.bounds.size.height/2.0, 1, 1);
}
if ([SCIUtils getBoolPref:@"save_to_ryukgram_album"]) {
[SCIPhotoAlbum watchForNextSavedAsset];
}
[top presentViewController:vc animated:YES completion:nil];
}];
}
+ (void)downloadAllAndSaveMedia:(id)carouselMedia {
[self downloadAllChildrenOfMedia:carouselMedia
progressTitle:@"Save all to Photos?"
done:^(NSArray<NSURL *> *files) {
if (!files.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Nothing to save")]; return; }
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
if (status != PHAuthorizationStatusAuthorized) {
dispatch_async(dispatch_get_main_queue(), ^{
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")];
});
return;
}
BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"];
__block NSUInteger saved = 0;
__block NSUInteger idx = 0;
// Save sequentially (Photos API doesn't like parallel writes)
__block void (^saveNext)(void) = ^{
if (idx >= files.count) {
dispatch_async(dispatch_get_main_queue(), ^{
[SCIUtils showToastForDuration:2.0
title:[NSString stringWithFormat:SCILocalized(@"Saved %lu items"), (unsigned long)saved]];
});
saveNext = nil; // break retain cycle
return;
}
NSURL *f = files[idx];
idx++;
void (^step)(BOOL, NSError *) = ^(BOOL ok, NSError *e) {
if (ok) saved++;
if (saveNext) saveNext();
};
if (useAlbum) {
[SCIPhotoAlbum saveFileToAlbum:f completion:step];
} else {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
NSString *ext = [[f pathExtension] lowercaseString];
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
opts.shouldMoveFile = YES;
[req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto)
fileURL:f options:opts];
} completionHandler:step];
}
};
saveNext();
}];
}];
}
+ (void)copyAllURLsForMedia:(id)carouselMedia {
NSArray *children = [self carouselChildrenForMedia:carouselMedia];
if (!children.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Not a carousel")]; return; }
NSMutableArray<NSString *> *urls = [NSMutableArray array];
for (id child in children) {
NSURL *u = [self bestURLForMedia:child];
if (u) [urls addObject:u.absoluteString];
}
if (!urls.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No URLs found")]; return; }
[[UIPasteboard generalPasteboard] setString:[urls componentsJoinedByString:@"\n"]];
[SCIUtils showToastForDuration:1.5 title:[NSString stringWithFormat:SCILocalized(@"Copied %lu URLs"), (unsigned long)urls.count]];
}
// MARK: - Menu builder
+ (NSArray<SCIAction *> *)actionsForContext:(SCIActionContext)ctx
media:(id)media
fromView:(UIView *)sourceView {
NSMutableArray<SCIAction *> *out = [NSMutableArray array];
NSString *ctxLabel = [self contextLabelForContext:ctx];
// Stamp the filename stem before a download fires.
void (^stampStemForMedia)(id) = ^(id m) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:m contextLabel:ctxLabel]];
};
// Resolve parent media for carousel detection + bulk actions.
id parentMedia = media;
if (media && ![self isCarouselMedia:media]) {
// Path 1: _mediaPassthrough ivar (reels)
UIView *v = sourceView;
while (v) {
Ivar mpi = class_getInstanceVariable([v class], "_mediaPassthrough");
if (mpi) {
id pm = object_getIvar(v, mpi);
if (pm && [self isCarouselMedia:pm]) { parentMedia = pm; break; }
}
v = v.superview;
}
// Path 2: sibling IGFeedItemPageCell in the collection view (feed)
if (parentMedia == media) {
v = sourceView;
UICollectionViewCell *ufiCell = nil;
UICollectionView *cv = nil;
while (v) {
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]])
ufiCell = (UICollectionViewCell *)v;
if ([v isKindOfClass:[UICollectionView class]]) { cv = (UICollectionView *)v; break; }
v = v.superview;
}
if (ufiCell && cv) {
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
if (ufiPath) {
Class mc = NSClassFromString(@"IGMedia");
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *p = [cv indexPathForCell:cell];
if (!p || p.section != ufiPath.section || cell == ufiCell) continue;
if (![NSStringFromClass([cell class]) containsString:@"Page"]) continue;
Ivar mi = class_getInstanceVariable(object_getClass(cell), "_media");
if (!mi) continue;
@try {
id pm = object_getIvar(cell, mi);
if (pm && mc && [pm isKindOfClass:mc] && [self isCarouselMedia:pm]) {
parentMedia = pm;
break;
}
} @catch (__unused id e) {}
}
}
}
}
}
NSString *caption = parentMedia ? [self captionForMedia:parentMedia] : nil;
BOOL isCarousel = parentMedia ? [self isCarouselMedia:parentMedia] : NO;
__weak UIView *weakSource = sourceView;
// --- Section 1: navigation ---
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Expand")
icon:@"arrow.up.left.and.arrow.down.right"
handler:^{
if (isCarousel) {
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
NSMutableArray *items = [NSMutableArray array];
for (id child in children) {
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)child];
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)child];
if (!v && !p) p = [SCIMediaActions bestURLForMedia:child];
if (v || p) {
[items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:caption]];
}
}
// Find current page index to start there
NSUInteger startIdx = 0;
if (media != parentMedia) {
NSUInteger idx = [children indexOfObjectIdenticalTo:media];
if (idx != NSNotFound) startIdx = idx;
}
if (items.count) {
[SCIMediaViewer showItems:items startIndex:startIdx];
} else {
[SCIMediaActions expandMedia:media fromView:weakSource caption:caption];
}
} else {
[SCIMediaActions expandMedia:media fromView:weakSource caption:caption];
}
}]];
if (ctx == SCIActionContextReels || (ctx == SCIActionContextFeed && [SCIUtils getVideoUrlForMedia:(IGMedia *)media])) {
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"View cover")
icon:@"photo"
handler:^{
NSURL *cover = [SCIMediaActions coverURLForMedia:media];
if (!cover) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No cover image")]; return; }
[SCIMediaViewer showWithVideoURL:nil photoURL:cover caption:nil];
}]];
}
// Repost = save to Photos → open IG's native creation flow
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Repost")
icon:@"arrow.2.squarepath"
handler:^{
NSURL *vidURL = [SCIUtils getVideoUrlForMedia:(IGMedia *)media];
NSURL *imgURL = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
[SCIRepostSheet repostWithVideoURL:vidURL photoURL:imgURL];
}]];
if (ctx == SCIActionContextStories) {
if ([SCIUtils getBoolPref:@"view_story_mentions"]) {
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"View mentions")
icon:@"at"
handler:^{
UIView *v = weakSource;
UIViewController *host = [SCIUtils nearestViewControllerForView:v];
extern void sciShowStoryMentions(UIViewController *, UIView *);
if (!host) return;
sciShowStoryMentions(host, v);
}]];
}
// Mute / unmute story audio
if ([SCIUtils getBoolPref:@"story_audio_toggle"]) {
BOOL audioOn = sciIsStoryAudioEnabled();
NSString *audioTitle = audioOn ? SCILocalized(@"Mute audio") : SCILocalized(@"Unmute audio");
NSString *audioIcon = audioOn ? @"speaker.wave.2" : @"speaker.slash";
[out addObject:[SCIAction actionWithTitle:audioTitle
icon:audioIcon
handler:^{ sciToggleStoryAudio(); }]];
}
}
// Story user list management (add/remove from exclusion list).
if (ctx == SCIActionContextStories && [SCIUtils getBoolPref:@"enable_story_user_exclusions"]) {
extern NSDictionary *sciOwnerInfoForView(UIView *);
extern void sciRefreshAllVisibleOverlays(UIViewController *);
extern __weak UIViewController *sciActiveStoryViewerVC;
NSDictionary *ownerInfo = sourceView ? sciOwnerInfoForView(sourceView) : nil;
NSString *ownerPK = ownerInfo[@"pk"];
if (ownerPK.length) {
BOOL inList = [SCIExcludedStoryUsers isInList:ownerPK];
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
NSString *addLabel = bs ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude from seen");
NSString *removeLabel = bs ? SCILocalized(@"Remove from block list") : SCILocalized(@"Remove from exclude list");
NSString *title = inList ? removeLabel : addLabel;
NSString *icon = inList ? @"eye.fill" : @"eye.slash";
NSString *capturedPK = [ownerPK copy];
NSString *capturedUser = [ownerInfo[@"username"] ?: @"" copy];
NSString *capturedName = [ownerInfo[@"fullName"] ?: @"" copy];
[out addObject:[SCIAction actionWithTitle:title icon:icon handler:^{
if (inList) {
[SCIExcludedStoryUsers removePK:capturedPK];
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Unblocked") : SCILocalized(@"Removed from list")];
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{@"pk": capturedPK, @"username": capturedUser, @"fullName": capturedName}];
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Added to block list") : SCILocalized(@"Added to exclude list")];
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
}
}
if (ctx != SCIActionContextStories) {
// Caption lives on the parent media (not on carousel children).
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Copy caption")
icon:@"text.quote"
handler:^{
[SCIMediaActions copyCaptionForMedia:parentMedia];
}]];
}
NSString *settingsTitle = [NSString stringWithFormat:SCILocalized(@"%@ settings"),
sciSettingsTitleForContext(ctx)];
[out addObject:[SCIAction actionWithTitle:settingsTitle
icon:@"gearshape"
handler:^{
[SCIMediaActions openSettingsForContext:ctx fromView:weakSource];
}]];
// Section 2 — bulk download (carousels or multi-story reels)
if (isCarousel) {
// Bulk actions use the PARENT media (all children), not the current page
id bulkMedia = parentMedia;
[out addObject:[SCIAction separator]];
NSArray<SCIAction *> *bulkChildren = @[
[SCIAction actionWithTitle:SCILocalized(@"Copy all URLs") icon:@"doc.on.doc" handler:^{
[SCIMediaActions copyAllURLsForMedia:bulkMedia];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{
stampStemForMedia(bulkMedia);
[SCIMediaActions downloadAllAndShareMedia:bulkMedia];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{
stampStemForMedia(bulkMedia);
[SCIMediaActions downloadAllAndSaveMedia:bulkMedia];
}],
];
NSUInteger childCount = [self carouselChildrenForMedia:bulkMedia].count;
NSString *bulkTitle = childCount > 0
? [NSString stringWithFormat:SCILocalized(@"Download all (%lu)"), (unsigned long)childCount]
: @"Download all";
[out addObject:[SCIAction actionWithTitle:bulkTitle
icon:@"square.stack.3d.down.right"
children:bulkChildren]];
}
// Multi-story reel bulk actions
if (ctx == SCIActionContextStories && !isCarousel) {
// Read reel items from the story VC
NSArray *reelItems = nil;
UIViewController *storyVC = [SCIUtils nearestViewControllerForView:sourceView];
if (!storyVC) {
UIResponder *r = sourceView;
while (r) {
if ([NSStringFromClass([r class]) containsString:@"StoryViewer"]) {
storyVC = (UIViewController *)r; break;
}
r = [r nextResponder];
}
}
if (storyVC) {
// Walk to IGStoryViewerViewController
UIResponder *r = storyVC;
Class svCls = NSClassFromString(@"IGStoryViewerViewController");
while (r && !(svCls && [r isKindOfClass:svCls])) r = [r nextResponder];
if (!r) r = (UIResponder *)storyVC;
id vm = nil;
if ([r respondsToSelector:@selector(currentViewModel)])
vm = ((id(*)(id,SEL))objc_msgSend)(r, @selector(currentViewModel));
if (vm) {
// Try selectors
for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) {
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
@try {
id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel));
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
reelItems = val;
break;
}
} @catch (__unused id e) {}
}
}
// Scan vm ivars for arrays
if (!reelItems) {
Class mc = NSClassFromString(@"IGMedia");
unsigned int cnt = 0;
Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt);
for (unsigned int i = 0; i < cnt; i++) {
const char *type = ivar_getTypeEncoding(ivs[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(vm, ivs[i]);
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
id first = [(NSArray *)val firstObject];
if ((mc && [first isKindOfClass:mc]) ||
(first && [first respondsToSelector:@selector(media)])) {
reelItems = val;
break;
}
}
} @catch (__unused id e) {}
}
if (ivs) free(ivs);
}
}
}
if (reelItems.count > 1) {
// Extract IGMedia from each item (may be wrapped)
NSMutableArray *storyMedias = [NSMutableArray array];
Class mc = NSClassFromString(@"IGMedia");
for (id item in reelItems) {
if (mc && [item isKindOfClass:mc]) {
[storyMedias addObject:item];
} else {
// Try to extract
for (NSString *sel in @[@"media", @"storyItem", @"item", @"mediaItem"]) {
if ([item respondsToSelector:NSSelectorFromString(sel)]) {
@try {
id m = ((id(*)(id,SEL))objc_msgSend)(item, NSSelectorFromString(sel));
if (m && mc && [m isKindOfClass:mc]) { [storyMedias addObject:m]; break; }
} @catch (__unused id e) {}
}
}
}
}
if (storyMedias.count > 1) {
[out addObject:[SCIAction separator]];
NSArray *capturedMedias = [storyMedias copy];
NSArray<SCIAction *> *storyBulk = @[
[SCIAction actionWithTitle:SCILocalized(@"Copy all URLs") icon:@"doc.on.doc" handler:^{
NSMutableArray *urls = [NSMutableArray array];
for (id m in capturedMedias) {
NSURL *u = [SCIMediaActions bestURLForMedia:m];
if (u) [urls addObject:u.absoluteString];
}
if (urls.count) {
[[UIPasteboard generalPasteboard] setString:[urls componentsJoinedByString:@"\n"]];
[SCIUtils showToastForDuration:1.5 title:[NSString stringWithFormat:SCILocalized(@"Copied %lu URLs"), (unsigned long)urls.count]];
}
}],
[SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{
NSMutableArray *urls = [NSMutableArray array];
for (id m in capturedMedias) {
NSURL *u = [SCIMediaActions bestURLForMedia:m];
if (u) [urls addObject:u];
}
if (!urls.count) return;
stampStemForMedia(capturedMedias.firstObject);
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Download all stories and share?") done:^(NSArray<NSURL *> *files) {
if (!files.count) return;
UIViewController *top = topMostController();
UIActivityViewController *vc = [[UIActivityViewController alloc]
initWithActivityItems:files applicationActivities:nil];
[top presentViewController:vc animated:YES completion:nil];
}];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{
NSMutableArray *urls = [NSMutableArray array];
for (id m in capturedMedias) {
NSURL *u = [SCIMediaActions bestURLForMedia:m];
if (u) [urls addObject:u];
}
if (!urls.count) return;
stampStemForMedia(capturedMedias.firstObject);
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Save all stories to Photos?") done:^(NSArray<NSURL *> *files) {
[SCIMediaActions bulkSaveFiles:files];
}];
}],
];
[out addObject:[SCIAction actionWithTitle:[NSString stringWithFormat:SCILocalized(@"Download all (%lu)"), (unsigned long)storyMedias.count]
icon:@"square.stack.3d.down.right"
children:storyBulk]];
}
}
}
// --- Section 3: current media actions ---
[out addObject:[SCIAction separator]];
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Copy download URL")
icon:@"link"
handler:^{
[SCIMediaActions copyURLForMedia:media];
}]];
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download and share")
icon:@"square.and.arrow.up"
handler:^{
stampStemForMedia(media);
[SCIMediaActions downloadAndShareMedia:media];
}]];
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download to Photos")
icon:@"square.and.arrow.down"
handler:^{
stampStemForMedia(media);
[SCIMediaActions downloadAndSaveMedia:media];
}]];
return [out copy];
}
// MARK: - Bulk URL download helpers (used by story reel + carousel)
+ (void)bulkDownloadURLs:(NSArray<NSURL *> *)urls
title:(NSString *)title
done:(void(^)(NSArray<NSURL *> *fileURLs))done {
if (!urls.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No URLs")]; return; }
sciConfirmThen(title, ^{
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
[pill resetState];
[pill showBulkProgress:0 total:urls.count];
UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
if (hostView) [pill showInView:hostView];
__block BOOL cancelled = NO;
pill.onCancel = ^{ cancelled = YES; };
dispatch_group_t group = dispatch_group_create();
NSMutableArray<NSURL *> *files = [NSMutableArray array];
NSLock *lock = [NSLock new];
__block NSUInteger completed = 0;
NSString *bulkStem2 = [self currentFilenameStem];
NSUInteger __idx2 = 0;
for (NSURL *url in urls) {
if (cancelled) break;
dispatch_group_enter(group);
NSString *ext = [[url lastPathComponent] pathExtension];
NSString *name = bulkStem2
? [NSString stringWithFormat:@"%@_%lu", bulkStem2, (unsigned long)(++__idx2)]
: [[NSUUID UUID] UUIDString];
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@", name,
ext.length ? ext : @"jpg"]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
if (!err && loc && !cancelled) {
NSError *mv = nil;
[[NSFileManager defaultManager] moveItemAtURL:loc
toURL:[NSURL fileURLWithPath:tmp]
error:&mv];
if (!mv) {
[lock lock]; [files addObject:[NSURL fileURLWithPath:tmp]]; [lock unlock];
}
}
[lock lock]; completed++; NSUInteger c = completed; [lock unlock];
dispatch_async(dispatch_get_main_queue(), ^{
[pill showBulkProgress:c total:urls.count];
});
dispatch_group_leave(group);
}];
[task resume];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
if (cancelled) {
[pill showError:SCILocalized(@"Cancelled")];
[pill dismissAfterDelay:1.0];
} else if (files.count) {
[pill showSuccess:[NSString stringWithFormat:SCILocalized(@"Downloaded %lu items"), (unsigned long)files.count]];
[pill dismissAfterDelay:1.5];
if (done) done([files copy]);
} else {
[pill showError:SCILocalized(@"No files downloaded")];
[pill dismissAfterDelay:2.0];
}
});
});
}
+ (void)bulkSaveFiles:(NSArray<NSURL *> *)files {
if (!files.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Nothing to save")]; return; }
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
if (status != PHAuthorizationStatusAuthorized) {
dispatch_async(dispatch_get_main_queue(), ^{
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")];
});
return;
}
BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"];
__block NSUInteger saved = 0;
__block NSUInteger idx = 0;
__block void (^saveNext)(void) = ^{
if (idx >= files.count) {
dispatch_async(dispatch_get_main_queue(), ^{
[SCIUtils showToastForDuration:2.0
title:[NSString stringWithFormat:SCILocalized(@"Saved %lu items"), (unsigned long)saved]];
});
saveNext = nil;
return;
}
NSURL *f = files[idx]; idx++;
void (^step)(BOOL, NSError *) = ^(BOOL ok, NSError *e) {
if (ok) saved++;
if (saveNext) saveNext();
};
if (useAlbum) {
[SCIPhotoAlbum saveFileToAlbum:f completion:step];
} else {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
NSString *ext = [[f pathExtension] lowercaseString];
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new];
opts.shouldMoveFile = YES;
[req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto)
fileURL:f options:opts];
} completionHandler:step];
}
};
saveNext();
}];
}
@end