mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-04-29 23:57:59 +02:00
2977873932
- 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
1426 lines
63 KiB
Objective-C
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
|