Files
RyukGram/src/ActionButton/SCIMediaViewer.m
T
faroukbmiled 86eaa95019 [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)
2026-04-16 03:03:30 +01:00

438 lines
19 KiB
Objective-C

#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