[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:
faroukbmiled
2026-04-16 03:03:30 +01:00
parent 9b2c7dc202
commit 86eaa95019
124 changed files with 11523 additions and 1393 deletions
+37
View File
@@ -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
+165
View File
@@ -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
+48
View File
@@ -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
+132
View File
@@ -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
+98
View File
@@ -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
+24
View File
@@ -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
+437
View File
@@ -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
+10
View File
@@ -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
+109
View File
@@ -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