mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-06-08 08:23:54 +02:00
[release] RyukGram v1.2.0
### Features - **Open Instagram links in app (Safari extension)** — bundled Safari web extension (sideload IPA only). Enable in Safari → Extensions; instagram.com links open in the app. - **Localization** — every user-facing string flows through a central translation layer. Globe button in Settings; missing keys fall back to English. Ships English only — see the "Translating RyukGram" section in the README to add more. - **Action buttons** — context-aware menus on feed, reels, and stories (expand, repost, download, copy caption, etc.) with per-context default tap action and carousel/multi-story bulk download - **Enhanced HD downloads** — up to 1080p via DASH + FFmpegKit with quality picker, preview playback, encoding-speed options, and 720p fallback - **Repost**, **media viewer**, **media zoom** (long-press), **download pill** (frosted glass, stacks concurrent downloads) - **Fake location** — overrides CoreLocation app-wide, map picker + saved presets, optional quick-toggle button on the Friends Map - **Messages-only mode** — strips every tab except DM inbox + profile - **Launch tab** — pick which tab the app opens to - Full last active date in DMs — show full date instead of "Active 2h ago" - Custom date format — 12 formats with per-surface toggles (feed, notes/comments/stories, DMs) - Send files in DMs (experimental) - View story mentions - Hide suggested stories - Story tray long-press actions — view HD profile picture from the tray menu - Advance on story reply — auto-skip to next story after sending a reply or reaction - Mark story as seen on reply or emoji reaction - Hide metrics (likes, comments, shares counts) - Hide messages tab - Hide voice/video call buttons in DM thread header (independent toggles) - Disable app haptics - Disable reels tab refresh - Disable disappearing messages mode in DMs - Follow indicator — shows whether the profile user follows you - Copy note text on long press - Zoom profile photo — long press opens full-screen viewer - Notes actions — copy text, download GIF/audio from notes long-press menu - Confirm unfollow - Feed refresh controls — disable background refresh, home button refresh, and home button scroll ### Improvements - Default tap action: added copy URL, repost, and view mentions options; dynamic menu generation per context - Settings pages reordered: General → Feed → Stories → Reels → Messages → Profile → Navigation → Saving → Confirmations - Fake location picker: native Apple Maps-style UI (search, long-press to drop pin, current location) - Liquid glass floating tab bar + dynamic sizing - Upload audio: FFmpegKit re-encode + trim for any audio/video input - Settings reorganized with per-context action button config; new Profile page - Highlight cover: full-screen viewer replaces direct download - Switched HD encoder to `h264_videotoolbox` (hardware) — no GPL FFmpegKit required - Legacy long-press download deprecated (off by default), replaced by action buttons ### Fixes - Hide suggested stories no longer removes followed users' stories on scroll - Settings search bar transparency with liquid glass off; auto-deactivates on push - HD download cancel: tapping pill aborts in-flight downloads + FFmpeg sessions cleanly - Download pill stuck state on background/foreground, progress reset per download - Disappearing messages mode confirmation not firing on swipe - Detailed color picker not working on story draw `†` - DM seen toggle menu not updating after tap - Reel refresh confirmation appearing on first app launch `†` - Reels action button displacing profile pictures on photo reels - Disappearing DM media download (expand, share, save to Photos with progress pill) - Carousel "Download all" not showing item count in feed - Encoding speed setting being ignored for HD downloads - Various upstream SCInsta merges (Meta AI hiding, suggested chats hiding, notes tray) — marked `†` > `†` Merged from upstream [SCInsta](https://github.com/SoCuul/SCInsta) by SoCuul ### Credits - Thanks to [@erupts0](https://github.com/erupts0) (John) for testing and feature suggestions - Thanks to [@euoradan](https://t.me/euoradan) (Radan) for experimental Instagram feature flag research - Safari extension forked/cleaned from [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension) ### Known Issues - 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)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
// SCIActionButton — wires a UIButton to the RyukGram action menu system.
|
||||
// Tap fires the default action; long-press opens the full context menu.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "SCIMediaActions.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef id _Nullable (^SCIActionMediaProvider)(UIView *sourceView);
|
||||
|
||||
@interface SCIActionButton : NSObject
|
||||
|
||||
/// Key for an optional dismiss callback block (void(^)(void)) stored on
|
||||
/// the button via objc_setAssociatedObject. Called when the context menu
|
||||
/// or UIMenu dismisses. Used by stories to resume playback.
|
||||
extern const void *kSCIDismissKey;
|
||||
|
||||
/// Configure an existing UIButton with RyukGram action-menu behavior.
|
||||
///
|
||||
/// `prefKey` is the NSUserDefaults key storing the default-tap choice
|
||||
/// (one of `menu`, `expand`, `download_share`, `download_photos`).
|
||||
+ (void)configureButton:(UIButton *)button
|
||||
context:(SCIActionContext)ctx
|
||||
prefKey:(NSString *)prefKey
|
||||
mediaProvider:(SCIActionMediaProvider)provider;
|
||||
|
||||
/// Build the deferred UIMenu for a given context + provider. Exposed so
|
||||
/// callers that already have their own UIButton wiring can reuse just the
|
||||
/// menu construction.
|
||||
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
|
||||
fromView:(UIView *)sourceView
|
||||
mediaProvider:(SCIActionMediaProvider)provider;
|
||||
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,165 @@
|
||||
#import "SCIActionButton.h"
|
||||
#import "SCIActionMenu.h"
|
||||
#import "../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Associated-object keys for per-button config.
|
||||
static const void *kSCICtxKey = &kSCICtxKey;
|
||||
static const void *kSCIProviderKey = &kSCIProviderKey;
|
||||
static const void *kSCIPrefKey = &kSCIPrefKey;
|
||||
const void *kSCIDismissKey = &kSCIDismissKey;
|
||||
|
||||
|
||||
@interface SCIActionButton () <UIContextMenuInteractionDelegate>
|
||||
@end
|
||||
|
||||
@implementation SCIActionButton
|
||||
|
||||
// Singleton delegate for UIContextMenuInteraction.
|
||||
+ (instancetype)shared {
|
||||
static SCIActionButton *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [SCIActionButton new]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
|
||||
fromView:(UIView *)sourceView
|
||||
mediaProvider:(SCIActionMediaProvider)provider {
|
||||
__weak UIView *weakSource = sourceView;
|
||||
SCIActionMediaProvider capturedProvider = [provider copy];
|
||||
|
||||
UIDeferredMenuElement *deferred = [UIDeferredMenuElement
|
||||
elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
|
||||
UIView *view = weakSource;
|
||||
id media = (view && capturedProvider) ? capturedProvider(view) : nil;
|
||||
NSArray *actions = [SCIMediaActions actionsForContext:ctx
|
||||
media:media
|
||||
fromView:view];
|
||||
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
|
||||
completion(built.children);
|
||||
}];
|
||||
|
||||
return [UIMenu menuWithTitle:@""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:0
|
||||
children:@[deferred]];
|
||||
}
|
||||
|
||||
+ (void)configureButton:(UIButton *)button
|
||||
context:(SCIActionContext)ctx
|
||||
prefKey:(NSString *)prefKey
|
||||
mediaProvider:(SCIActionMediaProvider)provider {
|
||||
if (!button) return;
|
||||
|
||||
// Stash config on the button.
|
||||
objc_setAssociatedObject(button, kSCICtxKey, @(ctx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
objc_setAssociatedObject(button, kSCIProviderKey, [provider copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
objc_setAssociatedObject(button, kSCIPrefKey, [prefKey copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
|
||||
// Read default tap mode fresh.
|
||||
NSString *defaultTap = [SCIUtils getStringPref:prefKey];
|
||||
if (!defaultTap.length) defaultTap = @"menu";
|
||||
|
||||
// Remove previous wiring to stay idempotent.
|
||||
[button removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
|
||||
for (id<UIInteraction> it in [button.interactions copy]) {
|
||||
if ([(id)it isKindOfClass:[UIContextMenuInteraction class]]) {
|
||||
[button removeInteraction:it];
|
||||
}
|
||||
}
|
||||
|
||||
if ([defaultTap isEqualToString:@"menu"]) {
|
||||
// Tap opens menu natively.
|
||||
button.menu = [self deferredMenuForContext:ctx fromView:button mediaProvider:provider];
|
||||
button.showsMenuAsPrimaryAction = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tap fires dedicated action; long-press opens menu.
|
||||
button.showsMenuAsPrimaryAction = NO;
|
||||
button.menu = nil;
|
||||
[button addTarget:[self shared]
|
||||
action:@selector(sciTapHandler:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
UIContextMenuInteraction *interaction =
|
||||
[[UIContextMenuInteraction alloc] initWithDelegate:[self shared]];
|
||||
[button addInteraction:interaction];
|
||||
}
|
||||
|
||||
// Haptic + scale-bounce feedback.
|
||||
+ (void)bounceButton:(UIView *)view {
|
||||
UIImpactFeedbackGenerator *haptic =
|
||||
[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1
|
||||
animations:^{ view.transform = CGAffineTransformMakeScale(0.82, 0.82); }
|
||||
completion:^(BOOL _) {
|
||||
[UIView animateWithDuration:0.1 animations:^{
|
||||
view.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
// Default-tap handler.
|
||||
- (void)sciTapHandler:(UIButton *)sender {
|
||||
[SCIActionButton bounceButton:sender];
|
||||
|
||||
NSNumber *ctxNum = objc_getAssociatedObject(sender, kSCICtxKey);
|
||||
SCIActionMediaProvider provider = objc_getAssociatedObject(sender, kSCIProviderKey);
|
||||
NSString *prefKey = objc_getAssociatedObject(sender, kSCIPrefKey);
|
||||
if (!ctxNum || !provider) return;
|
||||
|
||||
NSString *tap = [SCIUtils getStringPref:prefKey];
|
||||
if (!tap.length) tap = @"menu";
|
||||
id media = provider(sender);
|
||||
if (media == (id)kCFNull) return;
|
||||
|
||||
if ([tap isEqualToString:@"expand"]) {
|
||||
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
|
||||
} else if ([tap isEqualToString:@"download_share"]) {
|
||||
[SCIMediaActions downloadAndShareMedia:media];
|
||||
} else if ([tap isEqualToString:@"download_photos"]) {
|
||||
[SCIMediaActions downloadAndSaveMedia:media];
|
||||
} else {
|
||||
// Fallback: user can long-press for menu.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIContextMenuInteractionDelegate
|
||||
|
||||
- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
configurationForMenuAtLocation:(CGPoint)location {
|
||||
UIView *view = interaction.view;
|
||||
NSNumber *ctxNum = objc_getAssociatedObject(view, kSCICtxKey);
|
||||
SCIActionMediaProvider provider = objc_getAssociatedObject(view, kSCIProviderKey);
|
||||
if (!ctxNum || !provider) return nil;
|
||||
SCIActionContext ctx = (SCIActionContext)ctxNum.integerValue;
|
||||
|
||||
return [UIContextMenuConfiguration
|
||||
configurationWithIdentifier:nil
|
||||
previewProvider:nil
|
||||
actionProvider:^UIMenu * _Nullable(NSArray<UIMenuElement *> * _Nonnull suggested) {
|
||||
return [SCIActionButton deferredMenuForContext:ctx
|
||||
fromView:view
|
||||
mediaProvider:provider];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
|
||||
animator:(id<UIContextMenuInteractionAnimating>)animator {
|
||||
UIView *view = interaction.view;
|
||||
void (^dismiss)(void) = objc_getAssociatedObject(view, kSCIDismissKey);
|
||||
if (dismiss) {
|
||||
if (animator) {
|
||||
[animator addCompletion:^{ dismiss(); }];
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,48 @@
|
||||
// SCIActionMenu — reusable action menu model + UIMenu builder.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// One menu entry. Either a leaf (has handler) or a submenu (has children).
|
||||
@interface SCIAction : NSObject
|
||||
@property (nonatomic, copy, readonly) NSString *title;
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *subtitle;
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *systemIconName;
|
||||
@property (nonatomic, copy, readonly, nullable) void (^handler)(void);
|
||||
@property (nonatomic, copy, readonly, nullable) NSArray<SCIAction *> *children;
|
||||
@property (nonatomic, assign, readonly) BOOL destructive;
|
||||
@property (nonatomic, assign, readonly) BOOL isSeparator;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(nullable NSString *)icon
|
||||
handler:(void(^)(void))handler;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
subtitle:(nullable NSString *)subtitle
|
||||
icon:(nullable NSString *)icon
|
||||
destructive:(BOOL)destructive
|
||||
handler:(void(^)(void))handler;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(nullable NSString *)icon
|
||||
children:(NSArray<SCIAction *> *)children;
|
||||
|
||||
/// A visual group break. Rendered as an inline submenu divider in UIMenu.
|
||||
+ (instancetype)separator;
|
||||
@end
|
||||
|
||||
|
||||
@interface SCIActionMenu : NSObject
|
||||
|
||||
/// Build a UIMenu from an array of SCIAction. Consecutive actions between
|
||||
/// `separator` markers are grouped into inline submenus so they render as
|
||||
/// divided sections (standard iOS menu aesthetic).
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions;
|
||||
|
||||
/// Build a UIMenu with a header title shown at the top of the menu.
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(nullable NSString *)title;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,132 @@
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
#pragma mark - SCIAction
|
||||
|
||||
@interface SCIAction ()
|
||||
@property (nonatomic, copy, readwrite) NSString *title;
|
||||
@property (nonatomic, copy, readwrite, nullable) NSString *subtitle;
|
||||
@property (nonatomic, copy, readwrite, nullable) NSString *systemIconName;
|
||||
@property (nonatomic, copy, readwrite, nullable) void (^handler)(void);
|
||||
@property (nonatomic, copy, readwrite, nullable) NSArray<SCIAction *> *children;
|
||||
@property (nonatomic, assign, readwrite) BOOL destructive;
|
||||
@property (nonatomic, assign, readwrite) BOOL isSeparator;
|
||||
@end
|
||||
|
||||
@implementation SCIAction
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(NSString *)icon
|
||||
handler:(void(^)(void))handler {
|
||||
return [self actionWithTitle:title subtitle:nil icon:icon destructive:NO handler:handler];
|
||||
}
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
subtitle:(NSString *)subtitle
|
||||
icon:(NSString *)icon
|
||||
destructive:(BOOL)destructive
|
||||
handler:(void(^)(void))handler {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.title = title ?: @"";
|
||||
a.subtitle = subtitle;
|
||||
a.systemIconName = icon;
|
||||
a.handler = handler;
|
||||
a.destructive = destructive;
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(NSString *)icon
|
||||
children:(NSArray<SCIAction *> *)children {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.title = title ?: @"";
|
||||
a.systemIconName = icon;
|
||||
a.children = [children copy];
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (instancetype)separator {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.isSeparator = YES;
|
||||
return a;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - SCIActionMenu
|
||||
|
||||
@implementation SCIActionMenu
|
||||
|
||||
+ (UIImage *)imageForIcon:(NSString *)name {
|
||||
if (!name.length) return nil;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightRegular];
|
||||
return [UIImage systemImageNamed:name withConfiguration:cfg];
|
||||
}
|
||||
|
||||
// Convert SCIAction to UIMenuElement.
|
||||
+ (UIMenuElement *)elementForAction:(SCIAction *)action {
|
||||
if (action.children.count) {
|
||||
NSMutableArray<UIMenuElement *> *kids = [NSMutableArray arrayWithCapacity:action.children.count];
|
||||
for (SCIAction *child in action.children) {
|
||||
UIMenuElement *el = [self elementForAction:child];
|
||||
if (el) [kids addObject:el];
|
||||
}
|
||||
return [UIMenu menuWithTitle:action.title
|
||||
image:[self imageForIcon:action.systemIconName]
|
||||
identifier:nil
|
||||
options:0
|
||||
children:kids];
|
||||
}
|
||||
|
||||
UIAction *ua = [UIAction actionWithTitle:action.title
|
||||
image:[self imageForIcon:action.systemIconName]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction * _Nonnull a) {
|
||||
if (action.handler) action.handler();
|
||||
}];
|
||||
|
||||
if (@available(iOS 15.0, *)) {
|
||||
if (action.subtitle.length) ua.subtitle = action.subtitle;
|
||||
}
|
||||
if (action.destructive) ua.attributes = UIMenuElementAttributesDestructive;
|
||||
return ua;
|
||||
}
|
||||
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions {
|
||||
return [self buildMenuWithActions:actions title:nil];
|
||||
}
|
||||
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(NSString *)title {
|
||||
// Group actions between separators into inline submenus.
|
||||
NSMutableArray<UIMenuElement *> *top = [NSMutableArray array];
|
||||
NSMutableArray<UIMenuElement *> *currentGroup = [NSMutableArray array];
|
||||
|
||||
void (^flush)(void) = ^{
|
||||
if (currentGroup.count == 0) return;
|
||||
UIMenu *group = [UIMenu menuWithTitle:@""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:UIMenuOptionsDisplayInline
|
||||
children:[currentGroup copy]];
|
||||
[top addObject:group];
|
||||
[currentGroup removeAllObjects];
|
||||
};
|
||||
|
||||
for (SCIAction *a in actions) {
|
||||
if (a.isSeparator) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
UIMenuElement *el = [self elementForAction:a];
|
||||
if (el) [currentGroup addObject:el];
|
||||
}
|
||||
flush();
|
||||
|
||||
return [UIMenu menuWithTitle:title ?: @""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:0
|
||||
children:[top copy]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,98 @@
|
||||
// SCIMediaActions — shared media extraction + action handlers for the action menu.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "../InstagramHeaders.h"
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// Where the action is being invoked from. Used to target settings entries
|
||||
/// and to pick context-specific language in HUDs.
|
||||
typedef NS_ENUM(NSInteger, SCIActionContext) {
|
||||
SCIActionContextFeed,
|
||||
SCIActionContextReels,
|
||||
SCIActionContextStories,
|
||||
};
|
||||
|
||||
@interface SCIMediaActions : NSObject
|
||||
|
||||
// MARK: - Media extraction
|
||||
|
||||
/// Return the post's caption string. Tries selectors first, falls back to
|
||||
/// reading `_fieldCache[@"caption"][@"text"]`.
|
||||
+ (nullable NSString *)captionForMedia:(id)media;
|
||||
|
||||
/// YES if the media is a carousel (multi-photo/video sidecar).
|
||||
+ (BOOL)isCarouselMedia:(id)media;
|
||||
|
||||
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
|
||||
+ (NSArray *)carouselChildrenForMedia:(id)media;
|
||||
|
||||
/// Best URL for a single (non-carousel) media item. Prefers video URL, falls
|
||||
/// back to photo URL. Returns nil if nothing extractable.
|
||||
+ (nullable NSURL *)bestURLForMedia:(id)media;
|
||||
|
||||
/// Cover/poster image URL for a video-type media (first frame). Works for
|
||||
/// reels, feed videos, and story videos.
|
||||
+ (nullable NSURL *)coverURLForMedia:(id)media;
|
||||
|
||||
// MARK: - Primary actions (each directly triggerable from a menu entry)
|
||||
|
||||
/// Present the media in the native QLPreview UI. Video URLs download first,
|
||||
/// images preview directly. Optional caption is shown as a subtitle.
|
||||
+ (void)expandMedia:(id)media
|
||||
fromView:(UIView *)sourceView
|
||||
caption:(nullable NSString *)caption;
|
||||
|
||||
/// Download the best URL for the media and hand off via share sheet.
|
||||
+ (void)downloadAndShareMedia:(id)media;
|
||||
|
||||
/// Download the best URL for the media and save to Photos (respects album pref).
|
||||
+ (void)downloadAndSaveMedia:(id)media;
|
||||
|
||||
/// Copy the direct CDN URL for the media to the clipboard.
|
||||
+ (void)copyURLForMedia:(id)media;
|
||||
|
||||
/// Copy the post caption to the clipboard.
|
||||
+ (void)copyCaptionForMedia:(id)media;
|
||||
|
||||
/// Trigger Instagram's native repost flow for the given context's currently
|
||||
/// visible UFI bar. Uses the existing button ivars to avoid reimplementing.
|
||||
+ (void)triggerRepostForContext:(SCIActionContext)ctx sourceView:(UIView *)sourceView;
|
||||
|
||||
/// Open the RyukGram settings page for the given context.
|
||||
+ (void)openSettingsForContext:(SCIActionContext)ctx fromView:(UIView *)sourceView;
|
||||
|
||||
// MARK: - Carousel bulk actions
|
||||
|
||||
/// Download every child of a carousel and share as a batch.
|
||||
+ (void)downloadAllAndShareMedia:(id)carouselMedia;
|
||||
|
||||
/// Download every child of a carousel and save to Photos.
|
||||
+ (void)downloadAllAndSaveMedia:(id)carouselMedia;
|
||||
|
||||
/// Copy newline-joined CDN URLs for every child of a carousel.
|
||||
+ (void)copyAllURLsForMedia:(id)carouselMedia;
|
||||
|
||||
// MARK: - Menu builders
|
||||
|
||||
// MARK: - Bulk URL download helpers
|
||||
|
||||
/// Download an array of URLs in parallel, show pill, call done with file URLs.
|
||||
+ (void)bulkDownloadURLs:(NSArray<NSURL *> *)urls
|
||||
title:(NSString *)title
|
||||
done:(void(^)(NSArray<NSURL *> *fileURLs))done;
|
||||
|
||||
/// Save an array of local file URLs to Photos (sequential, respects album pref).
|
||||
+ (void)bulkSaveFiles:(NSArray<NSURL *> *)files;
|
||||
|
||||
/// Build the full action menu for the given context + media + default tap.
|
||||
/// If `defaultTap` is provided and non-menu, the builder may reorder or skip
|
||||
/// its matching leaf so it's visible in the full menu.
|
||||
+ (NSArray<SCIAction *> *)actionsForContext:(SCIActionContext)ctx
|
||||
media:(nullable id)media
|
||||
fromView:(UIView *)sourceView;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
// SCIMediaViewer — full-screen media viewer. Supports single items and carousels.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
/// One media item to display.
|
||||
@interface SCIMediaViewerItem : NSObject
|
||||
@property (nonatomic, strong) NSURL *videoURL; // nil for photos
|
||||
@property (nonatomic, strong) NSURL *photoURL; // nil for videos
|
||||
@property (nonatomic, copy) NSString *caption;
|
||||
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
|
||||
@end
|
||||
|
||||
@interface SCIMediaViewer : NSObject
|
||||
|
||||
/// Show a single media item.
|
||||
+ (void)showItem:(SCIMediaViewerItem *)item;
|
||||
|
||||
/// Show multiple items (carousel). Starts at the given index.
|
||||
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index;
|
||||
|
||||
/// Convenience: auto-detect video vs photo for a single item.
|
||||
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,437 @@
|
||||
#import "SCIMediaViewer.h"
|
||||
#import "../Utils.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <AVKit/AVKit.h>
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Data model
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@implementation SCIMediaViewerItem
|
||||
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
|
||||
SCIMediaViewerItem *i = [SCIMediaViewerItem new];
|
||||
i.videoURL = videoURL;
|
||||
i.photoURL = photoURL;
|
||||
i.caption = caption;
|
||||
return i;
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Single photo page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIPhotoPageVC : UIViewController <UIScrollViewDelegate>
|
||||
@property (nonatomic, strong) NSURL *photoURL;
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UIImageView *imageView;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
|
||||
@end
|
||||
|
||||
@implementation _SCIPhotoPageVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
|
||||
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
|
||||
self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.scrollView.delegate = self;
|
||||
self.scrollView.minimumZoomScale = 1.0;
|
||||
self.scrollView.maximumZoomScale = 5.0;
|
||||
self.scrollView.showsVerticalScrollIndicator = NO;
|
||||
self.scrollView.showsHorizontalScrollIndicator = NO;
|
||||
[self.view addSubview:self.scrollView];
|
||||
|
||||
self.imageView = [[UIImageView alloc] initWithFrame:self.scrollView.bounds];
|
||||
self.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
self.imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.scrollView addSubview:self.imageView];
|
||||
|
||||
self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
self.spinner.color = [UIColor whiteColor];
|
||||
self.spinner.center = self.view.center;
|
||||
self.spinner.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin
|
||||
| UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
|
||||
[self.view addSubview:self.spinner];
|
||||
[self.spinner startAnimating];
|
||||
|
||||
NSURL *url = [self.photoURL copy];
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSData *data = [NSData dataWithContentsOfURL:url];
|
||||
UIImage *img = data ? [UIImage imageWithData:data] : nil;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.spinner stopAnimating];
|
||||
if (img) self.imageView.image = img;
|
||||
});
|
||||
});
|
||||
|
||||
// Double-tap to zoom
|
||||
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
|
||||
doubleTap.numberOfTapsRequired = 2;
|
||||
[self.scrollView addGestureRecognizer:doubleTap];
|
||||
}
|
||||
|
||||
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)sv { return self.imageView; }
|
||||
|
||||
- (void)handleDoubleTap:(UITapGestureRecognizer *)gr {
|
||||
if (self.scrollView.zoomScale > 1.0) {
|
||||
[self.scrollView setZoomScale:1.0 animated:YES];
|
||||
} else {
|
||||
CGPoint pt = [gr locationInView:self.imageView];
|
||||
CGRect rect = CGRectMake(pt.x - 50, pt.y - 50, 100, 100);
|
||||
[self.scrollView zoomToRect:rect animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIImage *)currentImage { return self.imageView.image; }
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Single video page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIVideoPageVC : UIViewController
|
||||
@property (nonatomic, strong) NSURL *videoURL;
|
||||
@property (nonatomic, strong) AVPlayerViewController *playerVC;
|
||||
@end
|
||||
|
||||
@implementation _SCIVideoPageVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
|
||||
AVPlayer *player = [AVPlayer playerWithURL:self.videoURL];
|
||||
self.playerVC = [[AVPlayerViewController alloc] init];
|
||||
self.playerVC.player = player;
|
||||
self.playerVC.showsPlaybackControls = YES;
|
||||
|
||||
[self addChildViewController:self.playerVC];
|
||||
self.playerVC.view.frame = self.view.bounds;
|
||||
self.playerVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.playerVC.view];
|
||||
[self.playerVC didMoveToParentViewController:self];
|
||||
|
||||
[player play];
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
[self.playerVC.player pause];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Container VC (PageViewController-based)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIMediaViewerContainerVC : UIViewController <UIPageViewControllerDataSource, UIPageViewControllerDelegate>
|
||||
@property (nonatomic, strong) NSArray<SCIMediaViewerItem *> *items;
|
||||
@property (nonatomic, assign) NSUInteger currentIndex;
|
||||
@property (nonatomic, strong) UIPageViewController *pageVC;
|
||||
@property (nonatomic, strong) UIView *topBar;
|
||||
@property (nonatomic, strong) UIButton *closeBtn;
|
||||
@property (nonatomic, strong) UILabel *counterLabel;
|
||||
@property (nonatomic, strong) UIButton *shareBtn;
|
||||
@property (nonatomic, strong) UIView *bottomBar;
|
||||
@property (nonatomic, strong) UILabel *captionLabel;
|
||||
@property (nonatomic, assign) BOOL chromeVisible;
|
||||
@property (nonatomic, assign) BOOL captionExpanded;
|
||||
@end
|
||||
|
||||
@implementation _SCIMediaViewerContainerVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
self.chromeVisible = YES;
|
||||
|
||||
// Page view controller
|
||||
self.pageVC = [[UIPageViewController alloc]
|
||||
initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
|
||||
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
|
||||
options:nil];
|
||||
self.pageVC.dataSource = self.items.count > 1 ? self : nil;
|
||||
self.pageVC.delegate = self;
|
||||
|
||||
UIViewController *firstPage = [self viewControllerForIndex:self.currentIndex];
|
||||
if (firstPage) [self.pageVC setViewControllers:@[firstPage] direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
|
||||
|
||||
[self addChildViewController:self.pageVC];
|
||||
self.pageVC.view.frame = self.view.bounds;
|
||||
self.pageVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.pageVC.view];
|
||||
[self.pageVC didMoveToParentViewController:self];
|
||||
|
||||
// Top bar
|
||||
self.topBar = [[UIView alloc] init];
|
||||
self.topBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.topBar];
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:17 weight:UIImageSymbolWeightSemibold];
|
||||
|
||||
self.closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[self.closeBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
self.closeBtn.tintColor = [UIColor whiteColor];
|
||||
self.closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.topBar addSubview:self.closeBtn];
|
||||
|
||||
self.shareBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[self.shareBtn setImage:[UIImage systemImageNamed:@"square.and.arrow.up" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
self.shareBtn.tintColor = [UIColor whiteColor];
|
||||
self.shareBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.shareBtn addTarget:self action:@selector(shareTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.topBar addSubview:self.shareBtn];
|
||||
|
||||
self.counterLabel = [[UILabel alloc] init];
|
||||
self.counterLabel.textColor = [UIColor whiteColor];
|
||||
self.counterLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
self.counterLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.counterLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.topBar addSubview:self.counterLabel];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.topBar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
|
||||
[self.topBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.topBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.topBar.heightAnchor constraintEqualToConstant:44],
|
||||
[self.closeBtn.leadingAnchor constraintEqualToAnchor:self.topBar.leadingAnchor constant:16],
|
||||
[self.closeBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
[self.shareBtn.trailingAnchor constraintEqualToAnchor:self.topBar.trailingAnchor constant:-16],
|
||||
[self.shareBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
[self.counterLabel.centerXAnchor constraintEqualToAnchor:self.topBar.centerXAnchor],
|
||||
[self.counterLabel.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
]];
|
||||
|
||||
// Bottom bar (caption — tap to expand/collapse)
|
||||
self.bottomBar = [[UIView alloc] init];
|
||||
self.bottomBar.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6];
|
||||
self.bottomBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.bottomBar];
|
||||
|
||||
self.captionLabel = [[UILabel alloc] init];
|
||||
self.captionLabel.textColor = [UIColor whiteColor];
|
||||
self.captionLabel.font = [UIFont systemFontOfSize:14];
|
||||
self.captionLabel.numberOfLines = 3; // collapsed
|
||||
self.captionLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.captionLabel.userInteractionEnabled = YES;
|
||||
[self.bottomBar addSubview:self.captionLabel];
|
||||
|
||||
UITapGestureRecognizer *captionTap = [[UITapGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(toggleCaption)];
|
||||
[self.captionLabel addGestureRecognizer:captionTap];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.bottomBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.bottomBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.bottomBar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
|
||||
[self.captionLabel.topAnchor constraintEqualToAnchor:self.bottomBar.topAnchor constant:12],
|
||||
[self.captionLabel.leadingAnchor constraintEqualToAnchor:self.bottomBar.leadingAnchor constant:16],
|
||||
[self.captionLabel.trailingAnchor constraintEqualToAnchor:self.bottomBar.trailingAnchor constant:-16],
|
||||
[self.captionLabel.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-8],
|
||||
]];
|
||||
|
||||
// Single tap toggles chrome
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleChrome)];
|
||||
tap.cancelsTouchesInView = NO;
|
||||
[self.pageVC.view addGestureRecognizer:tap];
|
||||
|
||||
// For photos, let double-tap zoom work without triggering single-tap
|
||||
for (UIGestureRecognizer *gr in self.pageVC.view.gestureRecognizers) {
|
||||
if ([gr isKindOfClass:[UITapGestureRecognizer class]] && ((UITapGestureRecognizer *)gr).numberOfTapsRequired == 1) {
|
||||
// Already have our tap
|
||||
}
|
||||
}
|
||||
|
||||
[self updateChrome];
|
||||
}
|
||||
|
||||
- (void)updateChrome {
|
||||
SCIMediaViewerItem *item = self.items[self.currentIndex];
|
||||
|
||||
// Counter (hide for single items)
|
||||
if (self.items.count > 1) {
|
||||
self.counterLabel.text = [NSString stringWithFormat:@"%lu / %lu", (unsigned long)(self.currentIndex + 1), (unsigned long)self.items.count];
|
||||
self.counterLabel.hidden = NO;
|
||||
} else {
|
||||
self.counterLabel.hidden = YES;
|
||||
}
|
||||
|
||||
// Caption
|
||||
if (item.caption.length) {
|
||||
self.captionLabel.text = item.caption;
|
||||
self.bottomBar.hidden = NO;
|
||||
} else {
|
||||
self.bottomBar.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleChrome {
|
||||
self.chromeVisible = !self.chromeVisible;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
CGFloat a = self.chromeVisible ? 1.0 : 0.0;
|
||||
self.topBar.alpha = a;
|
||||
self.bottomBar.alpha = a;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)toggleCaption {
|
||||
self.captionExpanded = !self.captionExpanded;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.captionLabel.numberOfLines = self.captionExpanded ? 0 : 3;
|
||||
[self.view layoutIfNeeded];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)closeTapped {
|
||||
// Pause any playing video
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[(((_SCIVideoPageVC *)current).playerVC.player) pause];
|
||||
}
|
||||
[self dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)shareTapped {
|
||||
SCIMediaViewerItem *item = self.items[self.currentIndex];
|
||||
NSMutableArray *shareItems = [NSMutableArray array];
|
||||
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
if ([current isKindOfClass:[_SCIPhotoPageVC class]]) {
|
||||
UIImage *img = [(_SCIPhotoPageVC *)current currentImage];
|
||||
if (img) [shareItems addObject:img];
|
||||
}
|
||||
|
||||
// For videos or if no image loaded, share the URL
|
||||
if (!shareItems.count) {
|
||||
NSURL *url = item.videoURL ?: item.photoURL;
|
||||
if (url) [shareItems addObject:url];
|
||||
}
|
||||
|
||||
if (!shareItems.count) return;
|
||||
|
||||
UIActivityViewController *vc = [[UIActivityViewController alloc] initWithActivityItems:shareItems applicationActivities:nil];
|
||||
vc.popoverPresentationController.sourceView = self.shareBtn;
|
||||
[self presentViewController:vc animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// ─── Page data source ───
|
||||
|
||||
- (UIViewController *)viewControllerForIndex:(NSUInteger)idx {
|
||||
if (idx >= self.items.count) return nil;
|
||||
SCIMediaViewerItem *item = self.items[idx];
|
||||
|
||||
if (item.videoURL) {
|
||||
_SCIVideoPageVC *vc = [[_SCIVideoPageVC alloc] init];
|
||||
vc.videoURL = item.videoURL;
|
||||
vc.view.tag = (NSInteger)idx;
|
||||
return vc;
|
||||
} else if (item.photoURL) {
|
||||
_SCIPhotoPageVC *vc = [[_SCIPhotoPageVC alloc] init];
|
||||
vc.photoURL = item.photoURL;
|
||||
vc.view.tag = (NSInteger)idx;
|
||||
return vc;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerBeforeViewController:(UIViewController *)vc {
|
||||
NSInteger idx = vc.view.tag;
|
||||
if (idx <= 0) return nil;
|
||||
return [self viewControllerForIndex:idx - 1];
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerAfterViewController:(UIViewController *)vc {
|
||||
NSInteger idx = vc.view.tag;
|
||||
if (idx + 1 >= (NSInteger)self.items.count) return nil;
|
||||
return [self viewControllerForIndex:idx + 1];
|
||||
}
|
||||
|
||||
- (void)pageViewController:(UIPageViewController *)pvc didFinishAnimating:(BOOL)finished
|
||||
previousViewControllers:(NSArray<UIViewController *> *)prev transitionCompleted:(BOOL)completed {
|
||||
if (!completed) return;
|
||||
UIViewController *current = pvc.viewControllers.firstObject;
|
||||
self.currentIndex = (NSUInteger)current.view.tag;
|
||||
|
||||
// Pause previous video
|
||||
for (UIViewController *p in prev) {
|
||||
if ([p isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[((_SCIVideoPageVC *)p).playerVC.player pause];
|
||||
}
|
||||
}
|
||||
// Play new video
|
||||
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[((_SCIVideoPageVC *)current).playerVC.player play];
|
||||
}
|
||||
|
||||
[self updateChrome];
|
||||
}
|
||||
|
||||
- (BOOL)prefersStatusBarHidden { return YES; }
|
||||
- (BOOL)prefersHomeIndicatorAutoHidden { return YES; }
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Public API
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@implementation SCIMediaViewer
|
||||
|
||||
+ (void)presentNativeVideoPlayer:(NSURL *)url {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
AVPlayerViewController *playerVC = [[AVPlayerViewController alloc] init];
|
||||
playerVC.player = [AVPlayer playerWithURL:url];
|
||||
playerVC.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
[topMostController() presentViewController:playerVC animated:YES completion:^{
|
||||
[playerVC.player play];
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)showItem:(SCIMediaViewerItem *)item {
|
||||
if (!item) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
|
||||
|
||||
// Single video → native AVPlayerViewController directly (no wrapper)
|
||||
if (item.videoURL) {
|
||||
[self presentNativeVideoPlayer:item.videoURL];
|
||||
return;
|
||||
}
|
||||
|
||||
// Single photo → use our photo viewer container
|
||||
[self showItems:@[item] startIndex:0];
|
||||
}
|
||||
|
||||
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index {
|
||||
if (!items.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
|
||||
if (index >= items.count) index = 0;
|
||||
|
||||
// Single video item → native player
|
||||
if (items.count == 1 && items[0].videoURL) {
|
||||
[self presentNativeVideoPlayer:items[0].videoURL];
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
_SCIMediaViewerContainerVC *vc = [[_SCIMediaViewerContainerVC alloc] init];
|
||||
vc.items = items;
|
||||
vc.currentIndex = index;
|
||||
vc.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
|
||||
[topMostController() presentViewController:vc animated:YES completion:nil];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
|
||||
[self showItem:[SCIMediaViewerItem itemWithVideoURL:videoURL photoURL:photoURL caption:caption]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,10 @@
|
||||
// SCIRepostSheet — download media, save to Photos, open IG's creation flow.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIRepostSheet : NSObject
|
||||
|
||||
/// Download media, save to Photos, open IG's creation flow.
|
||||
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,109 @@
|
||||
#import "SCIRepostSheet.h"
|
||||
#import "../Utils.h"
|
||||
#import "../Downloader/Download.h"
|
||||
#import "../PhotoAlbum.h"
|
||||
#import <Photos/Photos.h>
|
||||
|
||||
@implementation SCIRepostSheet
|
||||
|
||||
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL {
|
||||
NSURL *url = videoURL ?: photoURL;
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media URL")]; return; }
|
||||
|
||||
// Show pill
|
||||
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
|
||||
[pill resetState];
|
||||
[pill setText:SCILocalized(@"Preparing repost...")];
|
||||
[pill setSubtitle:nil];
|
||||
UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
|
||||
if (hostView) [pill showInView:hostView];
|
||||
|
||||
// Download to temp file
|
||||
NSString *ext = [[url lastPathComponent] pathExtension];
|
||||
if (!ext.length) ext = videoURL ? @"mp4" : @"jpg";
|
||||
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"repost_%@.%@", [[NSUUID UUID] UUIDString], ext]];
|
||||
|
||||
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
|
||||
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
|
||||
if (err || !loc) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Download failed")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *mv = nil;
|
||||
NSURL *fileURL = [NSURL fileURLWithPath:tmp];
|
||||
[[NSFileManager defaultManager] moveItemAtURL:loc toURL:fileURL error:&mv];
|
||||
if (mv) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Save failed")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to Photos and get the localIdentifier
|
||||
[self saveToPhotosAndOpenCreation:fileURL isVideo:(videoURL != nil) pill:pill];
|
||||
}];
|
||||
[task resume];
|
||||
}
|
||||
|
||||
+ (void)saveToPhotosAndOpenCreation:(NSURL *)fileURL isVideo:(BOOL)isVideo pill:(SCIDownloadPillView *)pill {
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
|
||||
if (status != PHAuthorizationStatusAuthorized) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Photos access denied")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
__block NSString *localId = nil;
|
||||
|
||||
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
||||
PHAssetCreationRequest *req;
|
||||
if (isVideo) {
|
||||
req = [PHAssetCreationRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
|
||||
} else {
|
||||
UIImage *img = [UIImage imageWithContentsOfFile:fileURL.path];
|
||||
if (img) {
|
||||
req = [PHAssetCreationRequest creationRequestForAssetFromImage:img];
|
||||
} else {
|
||||
req = [PHAssetCreationRequest creationRequestForAsset];
|
||||
PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new];
|
||||
opts.shouldMoveFile = YES;
|
||||
[req addResourceWithType:PHAssetResourceTypePhoto fileURL:fileURL options:opts];
|
||||
}
|
||||
}
|
||||
localId = req.placeholderForCreatedAsset.localIdentifier;
|
||||
} completionHandler:^(BOOL success, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (!success || !localId.length) {
|
||||
[pill showError:SCILocalized(@"Failed to save")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
return;
|
||||
}
|
||||
|
||||
[pill showSuccess:SCILocalized(@"Opening creator...")];
|
||||
[pill dismissAfterDelay:1.0];
|
||||
|
||||
// Open IG's native creation flow with the saved asset
|
||||
NSString *urlStr = [NSString stringWithFormat:@"instagram://library?LocalIdentifier=%@",
|
||||
[localId stringByAddingPercentEncodingWithAllowedCharacters:
|
||||
[NSCharacterSet URLQueryAllowedCharacterSet]]];
|
||||
NSURL *igURL = [NSURL URLWithString:urlStr];
|
||||
if ([[UIApplication sharedApplication] canOpenURL:igURL]) {
|
||||
[[UIApplication sharedApplication] openURL:igURL options:@{} completionHandler:nil];
|
||||
} else {
|
||||
// Fallback: show share sheet
|
||||
[SCIUtils showShareVC:fileURL];
|
||||
}
|
||||
});
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user