diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fd82285e..a0436b5c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,14 +4,14 @@ on:
branches: [ "main" ]
workflow_dispatch:
inputs:
- branch:
- description: 'Branch to build from'
- required: true
- default: 'main'
repo:
description: 'Repository to build from'
required: true
default: 'Tonwalter888/YouMod'
+ branch:
+ description: 'Branch to build from'
+ required: true
+ default: 'main'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -28,8 +28,8 @@ jobs:
- name: Checkout YouMod
uses: actions/checkout@v7
with:
- repository: ${{ inputs.repo }}
ref: ${{ inputs.branch }}
+ repository: ${{ inputs.repo }}
path: YouMod
- name: Install dependencies
diff --git a/.github/workflows/ipa.yml b/.github/workflows/ipa.yml
index 41677a15..bd60e4da 100644
--- a/.github/workflows/ipa.yml
+++ b/.github/workflows/ipa.yml
@@ -54,36 +54,12 @@ on:
required: false
type: boolean
- youquality:
- description: "Integrate YouQuality"
- default: true
- required: false
- type: boolean
-
- youspeed:
- description: "Integrate YouSpeed"
- default: true
- required: false
- type: boolean
-
demc:
description: "Integrate DontEatMyContent"
default: true
required: false
type: boolean
- youmute:
- description: "Integrate YouMute"
- default: true
- required: false
- type: boolean
-
- youloop:
- description: "Integrate YouLoop"
- default: true
- required: false
- type: boolean
-
ytweaks:
description: "Integrate YTweaks"
default: true
@@ -96,36 +72,12 @@ on:
required: false
type: boolean
- ytholdspeed:
- description: "Integrate YTHoldForSpeed"
- default: true
- required: false
- type: boolean
-
- youchoose:
- description: "Integrate YouChooseQuality"
- default: true
- required: false
- type: boolean
-
- youshare:
- description: "Integrate YouShare"
- default: true
- required: false
- type: boolean
-
gonerino:
description: "Integrate Gonerino"
default: true
required: false
type: boolean
- getcap:
- description: "Integrate YouGetCaption"
- default: true
- required: false
- type: boolean
-
ytshare:
description: "Integrate youtube-native-share"
default: true
@@ -153,17 +105,19 @@ jobs:
- name: Hide Sensitive Inputs
uses: levibostian/action-hide-sensitive-inputs@v1
with:
- exclude_inputs: bundle_id,display_name,youpip,ytuhd,ryd,abconfig,youquality,youspeed,youmute,youloop,ytweaks,youslider,ytholdspeed,youchoose,youshare,gonerino,getcap,demc,ytshare,volboost
+ exclude_inputs: bundle_id,display_name,youpip,ytuhd,ryd,abconfig,ytweaks,youslider,gonerino,demc,volboost
- name: Install MEGA downloader
if: ${{ startsWith(inputs.ipa_url, 'https://mega.nz/') }}
run: brew install megatools
+
- name: Download IPA from Mega share link
if: ${{ startsWith(inputs.ipa_url, 'https://mega.nz/') }}
env:
IPA_URL: ${{ inputs.ipa_url }}
run: |
megadl --no-progress --path youtube.ipa "$IPA_URL"
+
- name: Download IPA from direct URL
if: ${{ !startsWith(inputs.ipa_url, 'https://mega.nz/') }}
env:
@@ -186,7 +140,7 @@ jobs:
echo "YT_VERSION=$(grep -A 1 'CFBundleVersion' ytextracted/Payload/YouTube.app/Info.plist | grep '' | awk -F'[><]' '{print $3}')" >> $GITHUB_ENV
- name: Install Dependencies
- run: brew install make ldid swiftlint
+ run: brew install make ldid
- name: Set PATH Environment Variables
run: |
@@ -249,10 +203,7 @@ jobs:
run: sed -i '' 's/od -c "$i" | head/od -c "$i" 2>\/dev\/null | head/g' $THEOS/bin/convert_xml_plist.sh || true
- name: Install cyan and tbd
- run: |
- pipx install https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip
- wget --quiet --no-verbose "https://github.com/inoahdev/tbd/releases/download/2.2/tbd-mac" -O /usr/local/bin/tbd
- chmod +x /usr/local/bin/tbd
+ run: pipx install https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip
- name: Checkout YouMod
uses: actions/checkout@v7
@@ -275,7 +226,7 @@ jobs:
- name: Clone YTUHD
if: ${{ inputs.ytuhd }}
- run: git clone --quiet --depth=1 https://github.com/Tonwalter888/YTUHD.git
+ run: git clone --quiet --depth=1 --recurse-submodules https://github.com/PoomSmart/YTUHD.git
- name: Clone Return-YouTube-Dislikes
if: ${{ inputs.ryd }}
@@ -285,51 +236,26 @@ jobs:
run: git clone --quiet --depth=1 https://github.com/PoomSmart/YouGroupSettings.git
- name: Clone YTVideoOverlay
- if: ${{ inputs.youpip || inputs.ytuhd || inputs.youquality || inputs.youspeed || inputs.youmute || inputs.youloop || inputs.youshare || inputs.getcap }}
+ if: ${{ inputs.youpip || inputs.ytuhd }}
run: git clone --quiet --depth=1 https://github.com/PoomSmart/YTVideoOverlay.git
- name: Clone YTABConfig
if: ${{ inputs.abconfig }}
run: git clone --quiet --depth=1 https://github.com/PoomSmart/YTABConfig.git
-
- - name: Clone YouQuality
- if: ${{ inputs.youquality }}
- run: git clone --quiet --depth=1 https://github.com/PoomSmart/YouQuality.git
- - name: Clone YouSpeed
- if: ${{ inputs.youspeed }}
- run: git clone --quiet --depth=1 https://github.com/PoomSmart/YouSpeed.git
+ - name: Clone UncappedAVPlayer
+ run: git clone --quiet --depth=1 https://github.com/PoomSmart/UncappedAVPlayer.git
- name: Clone DontEatMyContent
if: ${{ inputs.demc }}
run: git clone --quiet --depth=1 https://github.com/therealFoxster/DontEatMyContent.git
- - name: Clone YouMute
- if: ${{ inputs.youmute }}
- run: git clone --quiet --depth=1 https://github.com/PoomSmart/YouMute.git
-
- - name: Clone YouLoop
- if: ${{ inputs.youloop }}
- run: git clone --quiet --depth=1 https://github.com/bhackel/YouLoop.git
-
- name: Clone YouSlider
if: ${{ inputs.youslider }}
run: |
git clone --quiet --depth=1 https://github.com/PoomSmart/YouSlider.git
wget --no-verbose --quiet https://github.com/Tonwalter888/Tonwalter888.github.io/raw/refs/heads/main/deb/alderis.deb
- - name: Clone YTHoldForSpeed
- if: ${{ inputs.ytholdspeed }}
- run: git clone --quiet --depth=1 https://github.com/joshuaseltzer/YTHoldForSpeed.git
-
- - name: Clone YouChooseQuality
- if: ${{ inputs.youchoose }}
- run: git clone --quiet https://github.com/PoomSmart/YouChooseQuality.git
-
- - name: Clone YouShare
- if: ${{ inputs.youshare }}
- run: git clone --quiet --depth=1 https://github.com/Tonwalter888/YouShare.git
-
- name: Clone YTweaks
if: ${{ inputs.ytweaks }}
run: git clone --quiet --depth=1 https://github.com/fosterbarnes/YTweaks.git
@@ -338,10 +264,6 @@ jobs:
if: ${{ inputs.gonerino }}
run: git clone --quiet --depth=1 https://github.com/castdrian/Gonerino.git
- - name: Clone YouGetCaption
- if: ${{ inputs.getcap }}
- run: git clone --quiet --depth=1 https://github.com/PoomSmart/YouGetCaption.git
-
- name: Clone youtube-native-share
if: ${{ inputs.ytshare }}
run: git clone --quiet --depth=1 https://github.com/jkhsjdhjs/youtube-native-share.git
@@ -368,7 +290,7 @@ jobs:
if: ${{ inputs.ytuhd }}
run: |
cd YTUHD
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
+ make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless SIDELOAD=1
mv packages/*.deb ${{ github.workspace }}/ytuhd.deb
- name: Build Return-YouTube-Dislikes
@@ -385,7 +307,7 @@ jobs:
mv packages/*.deb ${{ github.workspace }}/ygs.deb
- name: Build YTVideoOverlay
- if: ${{ inputs.youpip || inputs.ytuhd || inputs.youquality || inputs.youspeed || inputs.youmute || inputs.youloop || inputs.youshare || inputs.getcap }}
+ if: ${{ inputs.youpip || inputs.ytuhd }}
run: |
cd YTVideoOverlay
make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
@@ -398,19 +320,11 @@ jobs:
make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
mv packages/*.deb ${{ github.workspace }}/ytabconfig.deb
- - name: Build YouQuality
- if: ${{ inputs.youquality }}
- run: |
- cd YouQuality
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/youquality.deb
-
- - name: Build YouSpeed
- if: ${{ inputs.youspeed }}
+ - name: Build UncappedAVPlayer
run: |
- cd YouSpeed
+ cd UncappedAVPlayer
make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/youspeed.deb
+ mv packages/*.deb ${{ github.workspace }}/uncapavplayer.deb
- name: Build DontEatMyContent
if: ${{ inputs.demc }}
@@ -419,20 +333,6 @@ jobs:
make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
mv packages/*.deb ${{ github.workspace }}/demc.deb
- - name: Build YouMute
- if: ${{ inputs.youmute }}
- run: |
- cd YouMute
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/ym.deb
-
- - name: Build YouLoop
- if: ${{ inputs.youloop }}
- run: |
- cd YouLoop
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/youloop.deb
-
- name: Build YouSlider
if: ${{ inputs.youslider }}
run: |
@@ -440,28 +340,6 @@ jobs:
make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
mv packages/*.deb ${{ github.workspace }}/youslider.deb
- - name: Build YTHoldForSpeed
- if: ${{ inputs.ytholdspeed }}
- run: |
- cd YTHoldForSpeed
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless TARGET=iphone:clang:latest:15.0 ARCHS=arm64
- mv packages/*.deb ${{ github.workspace }}/ytholdspeed.deb
-
- - name: Build YouChooseQuality
- if: ${{ inputs.youchoose }}
- run: |
- cd YouChooseQuality
- git checkout 1585a3691b2ef0b59d42c40c31639fd8b79e2cd4
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/youchoose.deb
-
- - name: Build YouShare
- if: ${{ inputs.youshare }}
- run: |
- cd YouShare
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/youshare.deb
-
- name: Build YTweaks
if: ${{ inputs.ytweaks }}
run: |
@@ -476,13 +354,6 @@ jobs:
make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
mv packages/*.deb ${{ github.workspace }}/gonerino.deb
- - name: Build YouGetCaption
- if: ${{ inputs.getcap }}
- run: |
- cd YouGetCaption
- make clean package DEBUG=0 FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless
- mv packages/*.deb ${{ github.workspace }}/ygc.deb
-
- name: Build youtube-native-share
if: ${{ inputs.ytshare }}
run: |
diff --git a/Files/Ads.x b/Files/Ads.x
index 6b19953e..49697ccd 100644
--- a/Files/Ads.x
+++ b/Files/Ads.x
@@ -9,15 +9,34 @@ static BOOL isProductList(YTICommand *command) {
return NO;
}
-NSString *getAdString(NSString *description) {
+static NSString *getPostString(NSString *description) {
+ for (NSString *str in @[
+ @"poll_post_root.eml",
+ @"options_post_root.eml",
+ @"images_post_root_slim.eml",
+ @"images_post_responsive_root.eml",
+ @"options_post_responsive_root.eml",
+ @"post_base_wrapper_slim.eml",
+ @"text_post_root_slim.eml",
+ @"text_post_responsive_root.eml",
+ @"videos_post_root.eml"
+ ])
+ if ([description containsString:str]) return str;
+ return nil;
+}
+
+static NSString *getAdString(NSString *description) {
for (NSString *str in @[
@"brand_promo",
+ @"brand_video_shelf",
+ @"brand_video_singleton",
@"carousel_footered_layout",
@"carousel_headered_layout",
@"eml.expandable_metadata",
@"feed_ad_metadata",
@"full_width_portrait_image_layout",
@"full_width_square_image_layout",
+ @"grid_ads_image_layout",
@"landscape_image_wide_button_layout",
@"post_shelf",
@"product_carousel",
@@ -51,7 +70,21 @@ static BOOL isAdRenderer(YTIElementRenderer *elementRenderer, int kind) {
static NSMutableArray *filteredArray(NSArray *array) {
NSMutableArray *newArray = [array mutableCopy];
NSIndexSet *removeIndexes = [newArray indexesOfObjectsPassingTest:^BOOL(YTIItemSectionRenderer *sectionRenderer, NSUInteger idx, BOOL *stop) {
+ // Filter shelf renderer items (ads and shorts)
if ([sectionRenderer isKindOfClass:%c(YTIShelfRenderer)]) {
+ NSString *description = [sectionRenderer description];
+ if ([description containsString:@"community-tab-chip-posts-section"]) return NO;
+ // Filter shorts
+ if (IS_ENABLED(HideShortsShelf)) {
+ if (IS_ENABLED(KeepShortsSubscript) && [description containsString:@"subscriptions-shorts-shelf-item"]) return NO;
+ if ([description containsString:@"shorts_video_cell.eml"]) return YES;
+ if ([description containsString:@"shelf_header.eml"] && [description containsString:@"youtube_shorts_24_cairo"]) return YES;
+ }
+ // Filter feed posts
+ NSString *filtered = getPostString(description);
+ if (IS_ENABLED(HideFeedPost) && filtered) {
+ return YES;
+ }
YTIShelfSupportedRenderers *content = ((YTIShelfRenderer *)sectionRenderer).content;
YTIHorizontalListRenderer *horizontalListRenderer = content.horizontalListRenderer;
NSMutableArray *itemsArray = horizontalListRenderer.itemsArray;
@@ -61,8 +94,83 @@ static NSMutableArray *filteredArray(NSArray *contentsArray = sectionRenderer.contentsArray;
+
+ NSMutableIndexSet *indexesToRemove = [NSMutableIndexSet indexSet];
+ __block NSUInteger lastCellDividerIndex = NSNotFound;
+
+ [contentsArray enumerateObjectsUsingBlock:^(YTIItemSectionSupportedRenderers *item, NSUInteger idx, BOOL *stop) {
+ NSString *desc = [item description];
+
+ if ([desc containsString:@"cell_divider.eml"]) {
+ lastCellDividerIndex = idx;
+ }
+
+ if ([desc containsString:@"UNLIMITED"] && [desc containsString:@"SPunlimited"]) {
+ [indexesToRemove addIndex:idx];
+
+ if (lastCellDividerIndex != NSNotFound) {
+ [indexesToRemove addIndex:lastCellDividerIndex];
+ lastCellDividerIndex = NSNotFound;
+ }
+ }
+ }];
+
+ [contentsArray removeObjectsAtIndexes:indexesToRemove];
+ }
+ if ([description containsString:@"community-tab-chip-posts-section"]) return NO;
+
+ // Filter shorts shelf
+ BOOL isShortsShelf = [description containsString:@"shorts_shelf.eml"];
+ BOOL isHistory = [description containsString:@"history-shorts-shelf-item"];
+ if (IS_ENABLED(HideShortsShelf) && IS_ENABLED(KeepShortsSubscript)) {
+ if (isShortsShelf && ![description containsString:@"subscriptions-shorts-shelf-item"] && !isHistory) {
+ return YES;
+ }
+ if ([description containsString:@"video_lockup_overlay"]) return YES;
+ } else if (IS_ENABLED(HideShortsShelf)) {
+ if (isShortsShelf && !isHistory) {
+ return YES;
+ }
+ if ([description containsString:@"video_lockup_overlay"]) return YES;
+ }
+
+ // Filter horizontal shelf
+ if ([description containsString:@"horizontal_shelf.eml"]) {
+ if (IS_ENABLED(HidePlayables) && [description containsString:@"FEmini_app_destination"]) return YES;
+ if (IS_ENABLED(HideHoriShelf) && ![description containsString:@"UCYfdidRxbB8Qhf0Nx7ioOYw"] && ![description containsString:@"FElibrary"] && ![description containsString:@"mini_game_card.eml"] && ![description containsString:@"FEplaylist_aggregation"]) {
+ return YES;
+ }
+ }
+
+ if (IS_ENABLED(HideCommuGuide) && ([description containsString:@"community_guidelines.eml"] || [description containsString:@"channel_guidelines_entry_banner.eml"])) {
+ return YES;
+ }
+
+ // Filter feed posts
+ NSString *filtered = getPostString(description);
+ if (IS_ENABLED(HideFeedPost) && filtered) {
+ return YES;
+ }
+
+ if (IS_ENABLED(HideGenMusicShelf) && [description containsString:@"feed_nudge.eml"]) {
+ return YES;
+ }
+
+ if (IS_ENABLED(HideSurveys) && [description containsString:@"in_feed_survey.eml"]) {
+ return YES;
+ }
+
+ if (IS_ENABLED(HideCommentsSection) && [description containsString:@"comment-item-section"] && [description containsString:@"comments-entry-point"]) {
+ return YES;
+ }
+
NSMutableArray *contentsArray = sectionRenderer.contentsArray;
if (contentsArray.count > 1) {
NSIndexSet *removeContentsArrayIndexes = [contentsArray indexesOfObjectsPassingTest:^BOOL(YTIItemSectionSupportedRenderers *sectionSupportedRenderers, NSUInteger idx2, BOOL *stop2) {
@@ -73,7 +181,10 @@ static NSMutableArray *filteredArray(NSArray *filteredArray(NSArray *filteredArray(NSArray *filteredArray(NSArray *)reels {
[reels removeObjectsAtIndexes:[reels indexesOfObjectsPassingTest:^BOOL(YTReelModel *obj, NSUInteger idx, BOOL *stop) {
- return [obj respondsToSelector:@selector(videoType)] ? obj.videoType == 3 : NO;
+ if ([obj respondsToSelector:@selector(videoType)] && obj.videoType == 3) return YES;
+ if ([obj isKindOfClass:%c(YTReelNonVideoContentModel)]) return YES;
+ if ([obj respondsToSelector:@selector(videoType)] && obj.videoType == 10 && IS_ENABLED(RemoveShortsPosts)) return YES;
+ if ([obj respondsToSelector:@selector(videoType)] && (obj.videoType == 4 || obj.videoType == 7) && IS_ENABLED(RemoveShortsLive)) return YES;
+ return NO;
}]];
%orig;
}
@@ -164,6 +313,7 @@ static NSMutableArray *filteredArray(NSArray *filteredArray(NSArray *filteredArray(NSArray
#import
@@ -8,60 +7,16 @@
#import
#import
#import
-#import
-#import
-#import
-#import
-#import
-
-@interface YTDefaultSheetController (YouModDownload)
-+ (instancetype)sheetControllerWithParentResponder:(id)parentResponder;
-- (void)addAction:(YTActionSheetAction *)action;
-- (void)presentFromView:(UIView *)view animated:(BOOL)animated completion:(void (^)(void))completion;
-- (void)presentFromViewController:(UIViewController *)vc animated:(BOOL)animated completion:(void (^)(void))completion;
-@end
-
-@interface YTPlayerViewController (YouModDownload)
-- (YTPlayerResponse *)contentPlayerResponse;
-@end
-
-@interface YTIPlayerResponse (YouModDownload)
-- (id)streamingData;
-@end
-
-@interface YTIFormatStream (YouModDownload)
-- (NSString *)mimeType;
-- (BOOL)hasContentLength;
-- (unsigned long long)contentLength;
-- (unsigned long long)approxDurationMs;
-@end
-
-@interface YTIVideoDetails (YouModDownload)
-- (NSString *)title;
-- (NSString *)author;
-- (NSString *)shortDescription;
-@end
-
-static UIImage *YouModIconImage(NSInteger iconType) {
- YTIIcon *icon = [%c(YTIIcon) new];
- icon.iconType = iconType;
- UIImage *image = [icon iconImageWithColor:[UIColor labelColor]];
- return [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
-}
@interface YouModMenuItem : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *subtitle;
@property (nonatomic, strong) UIImage *iconImage;
@property (nonatomic, copy) void (^handler)(void);
-+ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle handler:(void (^)(void))handler;
+ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle icon:(UIImage *)icon handler:(void (^)(void))handler;
@end
@implementation YouModMenuItem
-+ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle handler:(void (^)(void))handler {
- return [self itemWithTitle:title subtitle:subtitle icon:nil handler:handler];
-}
+ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle icon:(UIImage *)icon handler:(void (^)(void))handler {
YouModMenuItem *item = [YouModMenuItem new];
item.title = title;
@@ -73,36 +28,22 @@ static UIImage *YouModIconImage(NSInteger iconType) {
@end
@interface YouModMediaFormat : NSObject
-@property (nonatomic, strong) id source;
+@property (nonatomic, strong) YTIFormatStream *source;
@property (nonatomic, copy) NSString *urlString;
@property (nonatomic, copy) NSString *qualityLabel;
@property (nonatomic, copy) NSString *mimeType;
-@property (nonatomic, copy) NSDictionary *httpHeaders;
-@property (nonatomic, assign) unsigned long long contentLength;
-@property (nonatomic, assign) unsigned long long durationMs;
-@property (nonatomic, assign) NSInteger fps;
+@property (nonatomic, copy) NSString *idp;
+@property (nonatomic, assign) NSInteger contentLength;
+@property (nonatomic, assign) NSUInteger durationMs;
+@property (nonatomic, assign) int fps;
+@property (nonatomic, assign) int itag;
+@property (nonatomic, assign) int resolution;
@property (nonatomic, assign) BOOL video;
-@property (nonatomic, copy) NSString *languageCode;
-@property (nonatomic, copy) NSString *languageName;
-@property (nonatomic, assign) BOOL drcAudio;
@end
@implementation YouModMediaFormat
@end
-@interface YouModAudioOutputFormat : NSObject
-@property (nonatomic, copy) NSString *identifier;
-@property (nonatomic, copy) NSString *title;
-@property (nonatomic, copy) NSString *subtitle;
-@property (nonatomic, copy) NSString *fileExtension;
-@property (nonatomic, copy) NSArray *ffmpegArguments;
-@property (nonatomic, assign) BOOL passthroughWhenCompatible;
-@property (nonatomic, assign) BOOL supported;
-@end
-
-@implementation YouModAudioOutputFormat
-@end
-
typedef void (^YouModFileDownloadCompletion)(NSURL *fileURL, NSError *error);
typedef void (^YouModMergeCompletion)(BOOL success, NSError *error);
typedef void (^YouModRangeDownloadProgress)(unsigned long long completedBytes);
@@ -144,8 +85,8 @@ typedef void (^YouModRangeDownloadProgress)(unsigned long long completedBytes);
@property (nonatomic, strong) NSURLSessionDownloadTask *task;
@property (nonatomic, strong) NSURLSessionDataTask *metadataTask;
@property (nonatomic, strong) YouModRangeDownloader *rangeDownloader;
-@property (nonatomic, strong) UIAlertController *progressAlert;
-@property (nonatomic, strong) UIProgressView *progressView;
+@property (nonatomic, strong) AVAssetExportSession *exporter;
+@property (nonatomic, strong) YMDownloadProgressView *progressPill;
@property (nonatomic, weak) UIViewController *presenter;
@property (nonatomic, copy) YouModFileDownloadCompletion fileCompletion;
@property (nonatomic, strong) NSURL *destinationURL;
@@ -161,18 +102,15 @@ typedef void (^YouModRangeDownloadProgress)(unsigned long long completedBytes);
@property (nonatomic, assign) BOOL cancelled;
@property (nonatomic, copy) NSString *baseProgressTitle;
@property (nonatomic, assign) NSTimeInterval downloadStartTime;
+@property (nonatomic, copy) void (^downloadCompletionBlock)(NSURL *localURL, NSString *errorMsg);
+ (instancetype)sharedCoordinator;
-- (void)startVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
-- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
-- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter;
-- (void)startDirectVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
-- (void)startDirectSingleVideoDownloadWithFormat:(YouModMediaFormat *)format fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
-- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
-- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter;
+- (void)startVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID;
+- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID;
+- (void)startDirectVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID;
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID;
+- (void)trimAudioToHalfLengthAtURL:(NSURL *)inputURL toURL:(NSURL *)outputURL completion:(void (^)(NSError *error))completion;
- (void)mergeVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL fileName:(NSString *)fileName outputExtension:(NSString *)outputExtension durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter;
- (void)mergeVideoWithAVFoundationVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter fallbackError:(NSError *)fallbackError;
-- (void)trimSingleVideoURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter;
-- (void)convertAudioURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL outputFormat:(YouModAudioOutputFormat *)outputFormat durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter;
@end
static const unsigned long long YouModFastDownloadMinimumBytes = 256ULL * 1024ULL;
@@ -199,9 +137,8 @@ static NSString *YouModYouTubeCookiesString(void) {
}
static NSString *YouModNativeUserAgent(void) {
- NSString *version = @"21.18.4";
- NSString *sysVersion = [[UIDevice currentDevice].systemVersion stringByReplacingOccurrencesOfString:@"." withString:@"_"] ?: @"18_7";
- return [NSString stringWithFormat:@"com.google.ios.youtube/%@ (iPhone; CPU iPhone OS %@ like Mac OS X)", version, sysVersion];
+ NSString *device = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad ? @"iPad" : @"iPhone";
+ return [NSString stringWithFormat:@"com.google.ios.youtube/21.26.4 (%@; CPU OS 18_7 like Mac OS X)", device];
}
static void YouModApplyDownloadHeaders(NSMutableURLRequest *request, NSDictionary *headers) {
@@ -313,7 +250,7 @@ static void YouModApplyDownloadHeaders(NSMutableURLRequest *request, NSDictionar
return;
}
- unsigned long long chunkSize = self.expectedBytes / YouModFastDownloadConcurrency;
+ unsigned long long chunkSize = self.expectedBytes / 100ULL;
if (chunkSize < 256ULL * 1024ULL) chunkSize = 256ULL * 1024ULL;
if (chunkSize > YouModFastDownloadChunkBytes) chunkSize = YouModFastDownloadChunkBytes;
@@ -486,22 +423,8 @@ void YouModDownloadSetCurrentPlayer(YTPlayerViewController *player) {
YouModCurrentPlayerViewController = player;
}
-static NSString *YouModStringFromSelector(id object, SEL selector) {
- if (!object) return nil;
- id value = nil;
- if ([object respondsToSelector:selector]) {
- value = ((id (*)(id, SEL))objc_msgSend)(object, selector);
- } else {
- @try {
- value = [object valueForKey:NSStringFromSelector(selector)];
- } @catch (__unused NSException *exception) {
- value = nil;
- }
- }
- if ([value isKindOfClass:NSString.class]) return value;
- if ([value isKindOfClass:NSURL.class]) return [(NSURL *)value absoluteString];
- if ([value respondsToSelector:@selector(stringValue)]) return [value stringValue];
- return [value respondsToSelector:@selector(description)] ? [value description] : nil;
+YTPlayerViewController *YouModDownloadGetCurrentPlayer(void) {
+ return YouModCurrentPlayerViewController;
}
static id YouModObjectFromSelector(id object, SEL selector) {
@@ -516,83 +439,19 @@ static id YouModObjectFromSelector(id object, SEL selector) {
}
}
-static unsigned long long YouModUnsignedLongLongFromSelector(id object, SEL selector) {
- if (!object) return 0;
- if ([object respondsToSelector:selector]) {
- return ((unsigned long long (*)(id, SEL))objc_msgSend)(object, selector);
- }
- @try {
- id value = [object valueForKey:NSStringFromSelector(selector)];
- if ([value respondsToSelector:@selector(unsignedLongLongValue)])
- return [value unsignedLongLongValue];
- } @catch (__unused NSException *exception) {
- }
- return 0;
-}
-
-static BOOL YouModBoolFromSelector(id object, SEL selector) {
- if (!object) return NO;
- if ([object respondsToSelector:selector]) {
- return ((BOOL (*)(id, SEL))objc_msgSend)(object, selector);
- }
- @try {
- id value = [object valueForKey:NSStringFromSelector(selector)];
- if ([value respondsToSelector:@selector(boolValue)])
- return [value boolValue];
- } @catch (__unused NSException *exception) {
- }
- return NO;
-}
-
-static NSInteger YouModIntegerFromSelector(id object, SEL selector) {
- if (!object) return 0;
- if ([object respondsToSelector:selector]) {
- return ((NSInteger (*)(id, SEL))objc_msgSend)(object, selector);
- }
- @try {
- id value = [object valueForKey:NSStringFromSelector(selector)];
- if ([value respondsToSelector:@selector(integerValue)])
- return [value integerValue];
- } @catch (__unused NSException *exception) {
- }
- return 0;
+static void YouModSendToast(NSString *message) {
+ UIView *parent = sbGetNotificationParent();
+ [SBSkipNotificationView showInView:parent message:message buttonTitle:nil action:nil duration:3.0];
}
-static UIViewController *YouModTopViewController(UIViewController *root) {
- if (!root) {
- UIWindow *keyWindow = nil;
- for (UIWindow *window in UIApplication.sharedApplication.windows) {
- if (window.isKeyWindow) {
- keyWindow = window;
- break;
- }
- }
- root = keyWindow.rootViewController;
- }
- while (root.presentedViewController) root = root.presentedViewController;
- if ([root isKindOfClass:UINavigationController.class])
- return YouModTopViewController(((UINavigationController *)root).topViewController);
- if ([root isKindOfClass:UITabBarController.class])
- return YouModTopViewController(((UITabBarController *)root).selectedViewController);
- return root;
+static void YouModSendSuccess(NSString *message) {
+ UIView *parent = sbGetNotificationParent();
+ [SBSkipNotificationView showSuccessInView:parent message:message duration:3.0];
}
-static void YouModSendToast(NSString *message, id responder) {
- Class toastClass = NSClassFromString(@"YTToastResponderEvent");
- id event = [toastClass eventWithMessage:message firstResponder:responder ?: YouModTopViewController(nil)];
- if ([event respondsToSelector:@selector(send)]) {
- [event send];
- return;
- }
-
- UIViewController *presenter = YouModTopViewController([responder isKindOfClass:UIViewController.class] ? responder : nil);
- if (!presenter) return;
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:message preferredStyle:UIAlertControllerStyleAlert];
- [presenter presentViewController:alert animated:YES completion:^{
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
- [alert dismissViewControllerAnimated:YES completion:nil];
- });
- }];
+static void YouModSendError(NSString *message) {
+ UIView *parent = sbGetNotificationParent();
+ [SBSkipNotificationView showErrorInView:parent message:message duration:4.0];
}
static NSString *YouModByteCount(unsigned long long bytes) {
@@ -602,14 +461,6 @@ static NSString *YouModByteCount(unsigned long long bytes) {
return [formatter stringFromByteCount:(long long)bytes];
}
-static NSString *YouModGenerateCPN(void) {
- static NSString *const alphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
- NSMutableString *nonce = [NSMutableString stringWithCapacity:16];
- for (NSUInteger i = 0; i < 16; i++)
- [nonce appendFormat:@"%C", [alphabet characterAtIndex:arc4random_uniform((uint32_t)alphabet.length)]];
- return nonce;
-}
-
static NSString *YouModURLStringBypassingThrottle(NSString *urlString) {
if (urlString.length == 0) return urlString;
NSURLComponents *components = [NSURLComponents componentsWithString:urlString];
@@ -637,10 +488,7 @@ static NSString *YouModURLStringWithCPN(NSString *urlString) {
if (urlString.length == 0) return urlString;
urlString = YouModURLStringBypassingThrottle(urlString);
if ([urlString containsString:@"cpn="]) return urlString;
- Class ytDataUtils = NSClassFromString(@"YTDataUtils");
- NSString *cpn = ((id (*)(Class, SEL))objc_msgSend)(ytDataUtils, @selector(generateClientSideNonce));
- if (![cpn isKindOfClass:NSString.class] || cpn.length == 0)
- cpn = YouModGenerateCPN();
+ NSString *cpn = [%c(YTDataUtils) generateClientSideNonce];
NSString *separator = [urlString containsString:@"?"] ? @"&" : @"?";
return [NSString stringWithFormat:@"%@%@cpn=%@", urlString, separator, cpn];
}
@@ -658,60 +506,11 @@ static NSString *YouModSanitizedFileName(NSString *name) {
static NSURL *YouModDownloadsDirectoryURL(void) {
NSURL *documentsURL = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
- NSURL *downloadsURL = [documentsURL URLByAppendingPathComponent:@"YouMod Downloads" isDirectory:YES];
+ NSURL *downloadsURL = [documentsURL URLByAppendingPathComponent:@"YouMod_Downloads" isDirectory:YES];
[NSFileManager.defaultManager createDirectoryAtURL:downloadsURL withIntermediateDirectories:YES attributes:nil error:nil];
return downloadsURL;
}
-static NSString *YouModLastDownloadDiagnostic;
-
-static NSURL *YouModDiagnosticLogURL(void) {
- return [YouModDownloadsDirectoryURL() URLByAppendingPathComponent:@"youmod-download-diagnostics.txt"];
-}
-
-static void YouModRecordDownloadDiagnostic(NSString *context, NSString *details) {
- if (context.length == 0 && details.length == 0) return;
-
- NSDateFormatter *formatter = [NSDateFormatter new];
- formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
- formatter.dateFormat = @"yyyy-MM-dd HH:mm:ss ZZZZZ";
- NSString *timestamp = [formatter stringFromDate:NSDate.date];
- NSString *entry = [NSString stringWithFormat:@"[%@]\n%@\n%@\n\n", timestamp ?: @"", context ?: @"", details ?: @""];
- YouModLastDownloadDiagnostic = entry;
-
- NSURL *logURL = YouModDiagnosticLogURL();
- NSData *data = [entry dataUsingEncoding:NSUTF8StringEncoding];
- if (![NSFileManager.defaultManager fileExistsAtPath:logURL.path])
- [NSFileManager.defaultManager createFileAtPath:logURL.path contents:nil attributes:nil];
-
- NSFileHandle *handle = [NSFileHandle fileHandleForWritingAtPath:logURL.path];
- if (!handle) return;
- @try {
- [handle seekToEndOfFile];
- [handle writeData:data];
- [handle closeFile];
- } @catch (__unused NSException *exception) {
- }
-}
-
-static NSString *YouModDownloadDiagnosticText(void) {
- if (YouModLastDownloadDiagnostic.length) return YouModLastDownloadDiagnostic;
- NSString *log = [NSString stringWithContentsOfURL:YouModDiagnosticLogURL() encoding:NSUTF8StringEncoding error:nil];
- if (log.length == 0) return nil;
- NSUInteger maxLength = 12000;
- return log.length > maxLength ? [log substringFromIndex:log.length - maxLength] : log;
-}
-
-static void YouModCopyDownloadDiagnostics(UIViewController *presenter) {
- NSString *diagnostic = YouModDownloadDiagnosticText();
- if (diagnostic.length == 0) {
- YouModSendToast(@"No download diagnostics yet.", presenter);
- return;
- }
- UIPasteboard.generalPasteboard.string = diagnostic;
- YouModSendToast(@"Copied download diagnostics", presenter);
-}
-
static NSURL *YouModUniqueFileURL(NSString *fileName, NSString *extension) {
NSString *safeName = YouModSanitizedFileName(fileName);
NSURL *directoryURL = YouModDownloadsDirectoryURL();
@@ -729,13 +528,6 @@ static NSURL *YouModTemporaryFileURL(NSString *extension) {
return [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:name]];
}
-static NSInteger YouModResolutionFromQuality(NSString *quality);
-static NSInteger YouModFPSFromQuality(NSString *quality);
-static NSInteger YouModNormalizedFPS(NSInteger fps);
-static NSInteger YouModDisplayHeightForVideoHeight(NSInteger height);
-static NSString *YouModQualityLabel(NSInteger height, NSInteger fps, NSString *fallback);
-static BOOL YouModFFmpegKitAvailable(void);
-
static unsigned long long YouModDurationMsForURL(NSURL *url) {
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
if (!CMTIME_IS_NUMERIC(asset.duration) || !CMTIME_IS_VALID(asset.duration)) return 0;
@@ -744,10 +536,6 @@ static unsigned long long YouModDurationMsForURL(NSURL *url) {
return (unsigned long long)llround(seconds * 1000.0);
}
-static NSString *YouModDurationSecondsArgument(unsigned long long durationMs) {
- return [NSString stringWithFormat:@"%.3f", (double)durationMs / 1000.0];
-}
-
static BOOL YouModCMTimeIsUsable(CMTime time) {
if (!CMTIME_IS_VALID(time) || !CMTIME_IS_NUMERIC(time) || CMTIME_IS_INDEFINITE(time)) return NO;
Float64 seconds = CMTimeGetSeconds(time);
@@ -778,321 +566,33 @@ static CMTime YouModExportDuration(AVAsset *videoAsset, AVAsset *audioAsset, uns
return duration;
}
-static NSMutableArray *YouModFFmpegKitLoadEntries(void) {
- static NSMutableArray *entries = nil;
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- entries = [NSMutableArray array];
- });
- return entries;
-}
-
-static void YouModAppendFFmpegKitLoadEntry(NSString *format, ...) {
- if (format.length == 0) return;
-
- va_list arguments;
- va_start(arguments, format);
- NSString *entry = [[NSString alloc] initWithFormat:format arguments:arguments];
- va_end(arguments);
- if (entry.length == 0) return;
-
- NSMutableArray *entries = YouModFFmpegKitLoadEntries();
- @synchronized(entries) {
- [entries addObject:entry];
- if (entries.count > 220)
- [entries removeObjectsInRange:NSMakeRange(0, entries.count - 220)];
- }
-}
-
-static NSArray *YouModFFmpegKitSearchDirectories(void) {
- NSMutableOrderedSet *directories = [NSMutableOrderedSet orderedSet];
-
- // Path to YouMod.bundle/Frameworks inside the main app bundle
- NSString *bundlePath = [[NSBundle.mainBundle resourcePath] stringByAppendingPathComponent:@"YouMod.bundle"];
- NSString *frameworksInsideBundle = [bundlePath stringByAppendingPathComponent:@"Frameworks"];
-
- // Safety check: only add if the directory actually exists
- BOOL isDir = NO;
- if ([[NSFileManager defaultManager] fileExistsAtPath:frameworksInsideBundle isDirectory:&isDir] && isDir) {
- [directories addObject:frameworksInsideBundle];
- }
-
- return directories.array;
-}
-
-static void YouModDlopenPath(NSString *path, BOOL requireExistingFile) {
- if (path.length == 0) return;
- if (requireExistingFile && ![NSFileManager.defaultManager fileExistsAtPath:path]) {
- YouModAppendFFmpegKitLoadEntry(@"missing %@", path);
- return;
- }
-
- dlerror();
- void *handle = dlopen(path.fileSystemRepresentation, RTLD_NOW | RTLD_GLOBAL);
- const char *error = dlerror();
- if (handle) {
- YouModAppendFFmpegKitLoadEntry(@"loaded %@", path);
- } else {
- YouModAppendFFmpegKitLoadEntry(@"failed %@\n dlerror=%@", path, error ? [NSString stringWithUTF8String:error] : @"unknown");
- }
-}
-
-static void YouModDlopenPathIfPresent(NSString *path) {
- YouModDlopenPath(path, YES);
-}
-
-static void YouModLoadFrameworkBinary(NSString *directory, NSString *frameworkName, NSString *binaryName) {
- if (directory.length == 0 || frameworkName.length == 0 || binaryName.length == 0) return;
- YouModDlopenPathIfPresent([[directory stringByAppendingPathComponent:[frameworkName stringByAppendingString:@".framework"]] stringByAppendingPathComponent:binaryName]);
- YouModDlopenPathIfPresent([[directory stringByAppendingPathComponent:[frameworkName stringByAppendingString:@".framework"]] stringByAppendingPathComponent:frameworkName]);
-}
-
-static void YouModLoadFFmpegKitIfNeeded(void) {
- static BOOL attempted = NO;
- if (NSClassFromString(@"FFmpegKit")) return;
- if (attempted) return;
- attempted = YES;
-
- YouModAppendFFmpegKitLoadEntry(@"[YouMod] Starting bundled FFmpegKit load...");
-
- // Order is important: load dependencies (avutil, etc.) before the main toolkit
- NSArray *> *frameworks = @[
- @[@"libavutil", @"libavutil"],
- @[@"libswresample", @"libswresample"],
- @[@"libswscale", @"libswscale"],
- @[@"libavcodec", @"libavcodec"],
- @[@"libavformat", @"libavformat"],
- @[@"libavfilter", @"libavfilter"],
- @[@"libavdevice", @"libavdevice"],
- @[@"ffmpegkit", @"ffmpegkit"],
- @[@"FFmpegKit", @"FFmpegKit"],
- ];
-
- NSArray *searchDirs = YouModFFmpegKitSearchDirectories();
- if (searchDirs.count == 0) {
- YouModAppendFFmpegKitLoadEntry(@"[YouMod] Error: Bundled Frameworks directory not found.");
- return;
- }
-
- // Only iterate through our controlled bundle directory
- for (NSString *directory in searchDirs) {
- for (NSArray *framework in frameworks) {
- // This helper uses dlopen on the direct path within our bundle
- YouModLoadFrameworkBinary(directory, framework.firstObject, framework.lastObject);
- }
-
- if (NSClassFromString(@"FFmpegKit")) {
- YouModAppendFFmpegKitLoadEntry(@"[YouMod] Success: FFmpegKit loaded from bundle.");
- return;
- }
- }
-
- YouModAppendFFmpegKitLoadEntry(@"[YouMod] Critical: FFmpegKit could not be found in YouMod.bundle.");
-}
-
-static Class YouModFFmpegKitClass(void) {
- Class ffmpegKitClass = NSClassFromString(@"FFmpegKit");
- if (!ffmpegKitClass) {
- YouModLoadFFmpegKitIfNeeded();
- ffmpegKitClass = NSClassFromString(@"FFmpegKit");
- }
- return ffmpegKitClass;
-}
-
-static BOOL YouModFFmpegKitAvailable(void) {
- Class ffmpegKitClass = YouModFFmpegKitClass();
- return ffmpegKitClass && [ffmpegKitClass respondsToSelector:@selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:)];
-}
-
-static NSString *YouModFFmpegKitDiagnosticText(YouModAudioOutputFormat *outputFormat, YouModMediaFormat *sourceFormat, NSString *videoID) {
- YouModLoadFFmpegKitIfNeeded();
-
- Class ffmpegKitClass = NSClassFromString(@"FFmpegKit");
- SEL executeSelector = @selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:);
- NSMutableArray *lines = [NSMutableArray array];
- NSBundle *mainBundle = NSBundle.mainBundle;
- NSString *resourcePath = mainBundle.resourcePath ?: @"";
- NSString *privateFrameworksPath = mainBundle.privateFrameworksPath ?: @"";
- NSString *executablePath = mainBundle.executablePath ?: @"";
- NSString *bundlePath = [resourcePath stringByAppendingPathComponent:@"YouMod.bundle"];
- NSString *packageFrameworkPath = [resourcePath stringByAppendingPathComponent:@"YouMod.bundle/Frameworks"];
-
- [lines addObject:@"FFmpegKit lookup"];
- [lines addObject:[NSString stringWithFormat:@"videoID=%@", videoID ?: @""]];
- [lines addObject:[NSString stringWithFormat:@"requestedFormat=%@ (%@)", outputFormat.title ?: @"", outputFormat.identifier ?: @""]];
- [lines addObject:[NSString stringWithFormat:@"sourceMime=%@", sourceFormat.mimeType ?: @""]];
- [lines addObject:[NSString stringWithFormat:@"sourceQuality=%@", sourceFormat.qualityLabel ?: @""]];
- [lines addObject:[NSString stringWithFormat:@"sourceBytes=%llu", sourceFormat.contentLength]];
- [lines addObject:[NSString stringWithFormat:@"mainBundle=%@", mainBundle.bundlePath ?: @""]];
- [lines addObject:[NSString stringWithFormat:@"resourcePath=%@", resourcePath]];
- [lines addObject:[NSString stringWithFormat:@"privateFrameworksPath=%@", privateFrameworksPath]];
- [lines addObject:[NSString stringWithFormat:@"executablePath=%@", executablePath]];
- [lines addObject:[NSString stringWithFormat:@"YouMod.bundle exists=%@", [NSFileManager.defaultManager fileExistsAtPath:bundlePath] ? @"YES" : @"NO"]];
- [lines addObject:[NSString stringWithFormat:@"YouMod.bundle/Frameworks exists=%@", [NSFileManager.defaultManager fileExistsAtPath:packageFrameworkPath] ? @"YES" : @"NO"]];
- [lines addObject:[NSString stringWithFormat:@"FFmpegKit class=%@", ffmpegKitClass ? @"YES" : @"NO"]];
- [lines addObject:[NSString stringWithFormat:@"FFmpegKit execute selector=%@", [ffmpegKitClass respondsToSelector:executeSelector] ? @"YES" : @"NO"]];
- [lines addObject:[NSString stringWithFormat:@"ReturnCode class=%@", NSClassFromString(@"ReturnCode") ? @"YES" : @"NO"]];
- [lines addObject:@"searchDirectories:"];
- for (NSString *directory in YouModFFmpegKitSearchDirectories()) {
- BOOL isDirectory = NO;
- BOOL exists = [NSFileManager.defaultManager fileExistsAtPath:directory isDirectory:&isDirectory];
- [lines addObject:[NSString stringWithFormat:@" %@ exists=%@ directory=%@", directory, exists ? @"YES" : @"NO", isDirectory ? @"YES" : @"NO"]];
- }
-
- NSMutableArray *entries = YouModFFmpegKitLoadEntries();
- [lines addObject:@"dlopenAttempts:"];
- @synchronized(entries) {
- [lines addObjectsFromArray:entries];
- }
- return [lines componentsJoinedByString:@"\n"];
-}
-
-static void YouModCancelFFmpegKit(void) {
- Class ffmpegKitClass = YouModFFmpegKitClass();
- if ([ffmpegKitClass respondsToSelector:@selector(cancel)])
- ((void (*)(Class, SEL))objc_msgSend)(ffmpegKitClass, @selector(cancel));
-}
-
-static NSError *YouModFFmpegErrorFromSession(id session) {
- NSString *failure = YouModStringFromSelector(session, @selector(getFailStackTrace));
- NSString *message = failure.length ? failure : @"FFmpeg failed";
- return [NSError errorWithDomain:@"YouMod" code:7 userInfo:@{NSLocalizedDescriptionKey: message}];
-}
-
static BOOL YouModPathExtensionIsPhotosVideo(NSString *extension) {
- NSString *lower = extension.lowercaseString ?: @"";
- return [@[@"mp4", @"m4v", @"mov"] containsObject:lower];
-}
-
-static BOOL YouModStartFFmpegKitMerge(NSURL *videoURL, NSURL *audioURL, NSURL *outputURL, unsigned long long durationMs, void (^progress)(float progress), YouModMergeCompletion completion) {
- Class ffmpegKitClass = YouModFFmpegKitClass();
- SEL executeSelector = @selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:);
- if (![ffmpegKitClass respondsToSelector:executeSelector]) return NO;
-
- NSMutableArray *arguments = [@[
- @"-y",
- @"-i", videoURL.path,
- @"-i", audioURL.path,
- @"-map", @"0:v:0",
- @"-map", @"1:a:0",
- ] mutableCopy];
- if (durationMs > 0)
- [arguments addObjectsFromArray:@[@"-t", YouModDurationSecondsArgument(durationMs)]];
- [arguments addObjectsFromArray:@[
- @"-c", @"copy",
- @"-shortest",
- @"-avoid_negative_ts", @"make_zero",
- ]];
- if (YouModPathExtensionIsPhotosVideo(outputURL.pathExtension))
- [arguments addObjectsFromArray:@[@"-movflags", @"+faststart"]];
- [arguments addObject:outputURL.path];
-
- id completeBlock = [^(id session) {
- Class returnCodeClass = NSClassFromString(@"ReturnCode");
- id returnCode = YouModObjectFromSelector(session, @selector(getReturnCode));
- BOOL success = NO;
- if ([returnCodeClass respondsToSelector:@selector(isSuccess:)])
- success = ((BOOL (*)(Class, SEL, id))objc_msgSend)(returnCodeClass, @selector(isSuccess:), returnCode);
-
- NSError *error = success ? nil : YouModFFmpegErrorFromSession(session);
- dispatch_async(dispatch_get_main_queue(), ^{
- if (success && [NSFileManager.defaultManager fileExistsAtPath:outputURL.path]) {
- completion(YES, nil);
- } else {
- completion(NO, error ?: [NSError errorWithDomain:@"YouMod" code:7 userInfo:@{NSLocalizedDescriptionKey: @"Merge failed"}]);
- }
- });
- } copy];
-
- id statisticsBlock = durationMs ? [^(id statistics) {
- if (!progress || ![statistics respondsToSelector:@selector(getTime)]) return;
- double timeMs = ((double (*)(id, SEL))objc_msgSend)(statistics, @selector(getTime));
- if (!isfinite(timeMs) || timeMs <= 0.0) return;
- float mergeProgress = 0.985f + (0.01f * fminf((float)(timeMs / (double)durationMs), 1.0f));
- dispatch_async(dispatch_get_main_queue(), ^{
- progress(mergeProgress);
- });
- } copy] : nil;
-
- ((id (*)(Class, SEL, NSArray *, id, id, id))objc_msgSend)(ffmpegKitClass, executeSelector, arguments, completeBlock, nil, statisticsBlock);
- return YES;
-}
-
-static BOOL YouModStartFFmpegKitAudioConvert(NSURL *inputURL, NSURL *outputURL, YouModAudioOutputFormat *outputFormat, unsigned long long durationMs, void (^progress)(float progress), YouModMergeCompletion completion) {
- Class ffmpegKitClass = YouModFFmpegKitClass();
- SEL executeSelector = @selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:);
- if (![ffmpegKitClass respondsToSelector:executeSelector] || outputFormat.ffmpegArguments.count == 0) return NO;
-
- NSMutableArray *arguments = [@[@"-y", @"-i", inputURL.path] mutableCopy];
- [arguments addObjectsFromArray:outputFormat.ffmpegArguments];
- [arguments addObject:outputURL.path];
-
- id completeBlock = [^(id session) {
- Class returnCodeClass = NSClassFromString(@"ReturnCode");
- id returnCode = YouModObjectFromSelector(session, @selector(getReturnCode));
- BOOL success = NO;
- if ([returnCodeClass respondsToSelector:@selector(isSuccess:)])
- success = ((BOOL (*)(Class, SEL, id))objc_msgSend)(returnCodeClass, @selector(isSuccess:), returnCode);
-
- NSError *error = success ? nil : YouModFFmpegErrorFromSession(session);
- dispatch_async(dispatch_get_main_queue(), ^{
- if (success && [NSFileManager.defaultManager fileExistsAtPath:outputURL.path]) {
- completion(YES, nil);
- } else {
- completion(NO, error ?: [NSError errorWithDomain:@"YouMod" code:13 userInfo:@{NSLocalizedDescriptionKey: @"Conversion failed"}]);
- }
- });
- } copy];
-
- id statisticsBlock = durationMs ? [^(id statistics) {
- if (!progress || ![statistics respondsToSelector:@selector(getTime)]) return;
- double timeMs = ((double (*)(id, SEL))objc_msgSend)(statistics, @selector(getTime));
- if (!isfinite(timeMs) || timeMs <= 0.0) return;
- float convertProgress = 0.985f + (0.01f * fminf((float)(timeMs / (double)durationMs), 1.0f));
- dispatch_async(dispatch_get_main_queue(), ^{
- progress(convertProgress);
- });
- } copy] : nil;
-
- ((id (*)(Class, SEL, NSArray *, id, id, id))objc_msgSend)(ffmpegKitClass, executeSelector, arguments, completeBlock, nil, statisticsBlock);
- return YES;
+ NSString *lower = extension.lowercaseString;
+ return [@[@"mp4"] containsObject:lower];
}
static NSString *YouModMimeDetail(NSString *mimeType) {
- NSString *lower = mimeType.lowercaseString ?: @"";
+ NSString *lower = mimeType.lowercaseString;
if ([lower containsString:@"mp4"]) return @"MP4";
- if ([lower containsString:@"webm"]) return @"WebM";
- if ([lower containsString:@"mp3"]) return @"MP3";
- if ([lower containsString:@"aac"]) return @"AAC";
- return mimeType.length ? mimeType : @"Stream";
-}
-
-static NSString *YouModFileExtensionForFormat(YouModMediaFormat *format, NSString *fallbackExtension) {
- NSString *lower = format.mimeType.lowercaseString ?: @"";
- if ([lower containsString:@"webm"]) return @"webm";
- if ([lower containsString:@"matroska"]) return @"mkv";
- if ([lower containsString:@"quicktime"]) return @"mov";
- if ([lower containsString:@"m4a"]) return @"m4a";
- if ([lower containsString:@"mp4"]) return @"mp4";
- return fallbackExtension ?: @"mp4";
+ return mimeType;
}
-static BOOL YouModFormatLooksMP4Family(YouModMediaFormat *format) {
- NSString *mime = format.mimeType.lowercaseString ?: @"";
- NSString *extension = YouModFileExtensionForFormat(format, @"").lowercaseString ?: @"";
- return [mime containsString:@"mp4"] || [mime containsString:@"m4a"] || [mime containsString:@"quicktime"] || [@[@"mp4", @"m4a", @"m4v", @"mov"] containsObject:extension];
+static NSString *YouModFileExtensionForFormat(YouModMediaFormat *format) {
+ NSString *lower = format.mimeType.lowercaseString;
+ if ([lower containsString:@"mp4a"]) return @"m4a";
+ if ([lower containsString:@"mp4"]) return @"mp4";
+ return nil;
}
-static BOOL YouModFormatLooksWebM(YouModMediaFormat *format) {
- NSString *mime = format.mimeType.lowercaseString ?: @"";
- NSString *extension = YouModFileExtensionForFormat(format, @"").lowercaseString ?: @"";
- return [mime containsString:@"webm"] || [extension isEqualToString:@"webm"];
+static BOOL YouModFormatLooksMP4Family(YouModMediaFormat *format) {
+ NSString *mime = format.mimeType.lowercaseString;
+ NSString *extension = YouModFileExtensionForFormat(format);
+ return [mime containsString:@"mp4"] || [mime containsString:@"mp4a"] || [@[@"mp4", @"mp4a"] containsObject:extension];
}
static NSString *YouModMergedVideoOutputExtension(YouModMediaFormat *videoFormat, YouModMediaFormat *audioFormat) {
if (YouModFormatLooksMP4Family(videoFormat) && YouModFormatLooksMP4Family(audioFormat)) return @"mp4";
- if (YouModFormatLooksWebM(videoFormat) && YouModFormatLooksWebM(audioFormat)) return @"webm";
- return @"mkv";
+ return nil;
}
static BOOL YouModVideoFileCanUseAVFoundation(NSURL *fileURL) {
@@ -1103,324 +603,122 @@ static BOOL YouModVideoFileCanSaveToPhotos(NSURL *fileURL) {
return YouModPathExtensionIsPhotosVideo(fileURL.pathExtension);
}
-static YouModAudioOutputFormat *YouModAudioOutputFormatMake(NSString *identifier, NSString *title, NSString *subtitle, NSString *fileExtension, NSArray *ffmpegArguments, BOOL passthroughWhenCompatible, BOOL supported) {
- YouModAudioOutputFormat *format = [YouModAudioOutputFormat new];
- format.identifier = identifier;
- format.title = title;
- format.subtitle = subtitle;
- format.fileExtension = fileExtension;
- format.ffmpegArguments = ffmpegArguments;
- format.passthroughWhenCompatible = passthroughWhenCompatible;
- format.supported = supported;
- return format;
-}
-
-static NSArray *YouModAudioOutputFormats(void) {
- static NSArray *formats = nil;
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- formats = @[
- YouModAudioOutputFormatMake(@"m4a", @"M4A", @"AAC container, passthrough when possible", @"m4a", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"aac", @"-b:a", @"192k", @"-movflags", @"+faststart"], YES, YES),
- YouModAudioOutputFormatMake(@"aac", @"AAC", @"Lossy (192k)", @"aac", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"aac", @"-b:a", @"192k", @"-f", @"adts"], YES, YES),
- YouModAudioOutputFormatMake(@"mp3", @"MP3", @"Lossy, widely compatible", @"mp3", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"libmp3lame", @"-q:a", @"2"], NO, YES),
- YouModAudioOutputFormatMake(@"opus", @"Opus", @"Lossy, small file size", @"opus", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"libopus", @"-b:a", @"160k", @"-vbr", @"on"], NO, YES),
- YouModAudioOutputFormatMake(@"ogg", @"OGG", @"Vorbis lossy", @"ogg", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"libvorbis", @"-q:a", @"6"], NO, YES),
- YouModAudioOutputFormatMake(@"flac", @"FLAC", @"Lossless compressed", @"flac", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"flac", @"-compression_level", @"8"], NO, YES),
- YouModAudioOutputFormatMake(@"alac", @"ALAC", @"Apple lossless (M4A)", @"m4a", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"alac", @"-movflags", @"+faststart"], NO, YES),
- YouModAudioOutputFormatMake(@"wav", @"WAV", @"Uncompressed PCM", @"wav", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"pcm_s24le"], NO, YES),
- YouModAudioOutputFormatMake(@"aiff", @"AIFF", @"Apple PCM", @"aiff", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"pcm_s24be"], NO, YES),
- ];
- });
- return formats;
-}
-
-static YouModAudioOutputFormat *YouModDefaultAudioOutputFormat(void) {
- return [YouModAudioOutputFormats() firstObject];
-}
-
-static BOOL YouModAudioOutputFormatCanPassthrough(YouModAudioOutputFormat *outputFormat, YouModMediaFormat *sourceFormat) {
- if (!outputFormat.passthroughWhenCompatible) return NO;
- NSString *identifier = outputFormat.identifier.lowercaseString ?: @"";
- NSString *mime = sourceFormat.mimeType.lowercaseString ?: @"";
- NSString *extension = YouModFileExtensionForFormat(sourceFormat, @"").lowercaseString ?: @"";
- if ([identifier isEqualToString:@"m4a"] || [identifier isEqualToString:@"aac"])
- return [extension isEqualToString:@"m4a"] || [mime containsString:@"mp4"] || [mime containsString:@"m4a"];
- return NO;
-}
-
-static NSString *YouModAudioOutputFileExtension(YouModAudioOutputFormat *outputFormat, YouModMediaFormat *sourceFormat, BOOL passthrough) {
- NSString *identifier = outputFormat.identifier.lowercaseString ?: @"";
- NSString *mime = sourceFormat.mimeType.lowercaseString ?: @"";
- if (passthrough && ([identifier isEqualToString:@"m4a"] || [identifier isEqualToString:@"aac"]) && ([mime containsString:@"mp4"] || [mime containsString:@"m4a"]))
- return @"m4a";
- return outputFormat.fileExtension ?: YouModFileExtensionForFormat(sourceFormat, @"m4a");
-}
-
-static NSString *YouModAudioOutputSubtitle(YouModAudioOutputFormat *outputFormat) {
- return [NSString stringWithFormat:@"%@", outputFormat.subtitle];
-}
-
-static NSString *YouModFormatSubtitle(YouModMediaFormat *format) {
- NSMutableArray *parts = [NSMutableArray array];
- NSString *language = format.languageName.length ? format.languageName : format.languageCode;
- if (language.length) [parts addObject:language];
- if (format.drcAudio) [parts addObject:@"DRC"];
- NSString *detail = YouModMimeDetail(format.mimeType);
- if (detail.length) [parts addObject:detail];
- NSString *size = YouModByteCount(format.contentLength);
- if (size.length) [parts addObject:size];
- return [parts componentsJoinedByString:@" - "];
-}
-
-static NSString *YouModVideoIDForPlayer(YTPlayerViewController *player) {
- NSString *videoID = [player contentVideoID];
- if (videoID.length == 0)
- videoID = [player currentVideoID];
- return videoID;
+static NSString *YouModFormatSubtitle(YouModMediaFormat *format, BOOL video) {
+ if (video) {
+ NSMutableArray *parts = [NSMutableArray array];
+ NSString *detail = YouModMimeDetail(format.mimeType);
+ if (detail.length) [parts addObject:detail];
+ NSString *size = YouModByteCount(format.contentLength);
+ if (size.length) [parts addObject:size];
+ return [parts componentsJoinedByString:@" - "];
+ }
+ NSString *cut = [[format.idp componentsSeparatedByString:@"."] firstObject];
+ return cut;
}
-static NSArray *YouModPlayerResponsesForPlayer(YTPlayerViewController *player) {
- NSMutableArray *responses = [NSMutableArray array];
- id response = YouModObjectFromSelector(player, @selector(contentPlayerResponse));
- if (response) [responses addObject:response];
-
- id activeVideo = YouModObjectFromSelector(player, @selector(activeVideo));
- response = YouModObjectFromSelector(activeVideo, @selector(contentPlayerResponse));
- if (response && ![responses containsObject:response]) [responses addObject:response];
- return responses.copy;
+static YTIPlayerResponse *YouModPlayerDataForPlayer(YTPlayerViewController *player) {
+ YTPlayerResponse *response;
+ if ([player respondsToSelector:@selector(contentPlayerResponse)]) {
+ response = player.contentPlayerResponse;
+ } else {
+ response = player.playerResponse;
+ }
+ YTIPlayerResponse *playerData = response.playerData;
+ return playerData;
}
-// Where is this going to?
static NSArray *YouModCaptionTracksForPlayer(YTPlayerViewController *player) {
- for (id response in YouModPlayerResponsesForPlayer(player)) {
- id playerData = YouModObjectFromSelector(response, @selector(playerData)) ?: response;
- id captions = YouModObjectFromSelector(playerData, @selector(captions));
- id tracklistRenderer = YouModObjectFromSelector(captions, @selector(playerCaptionsTracklistRenderer));
- NSArray *tracks = YouModObjectFromSelector(tracklistRenderer, @selector(captionTracksArray));
- if (tracks.count > 0) return tracks;
- }
+ YTIPlayerResponse *playerData = YouModPlayerDataForPlayer(player);
+ YTICaptionsSupportedRenderers *captions = playerData.captions;
+ YTIPlayerCaptionsTrackListRenderer *tracklistRenderer = captions.playerCaptionsTracklistRenderer;
+ NSArray *tracks = tracklistRenderer.captionTracksArray;
+ if (tracks.count > 0) return tracks;
return nil;
}
-static id YouModPlayerDataForPlayer(YTPlayerViewController *player) {
- id response = YouModPlayerResponsesForPlayer(player).firstObject;
- id playerData = YouModObjectFromSelector(response, @selector(playerData));
- return playerData ?: response;
+static NSString *YouModAuthorForPlayer(YTPlayerViewController *player) {
+ YTIPlayerResponse *playerData = YouModPlayerDataForPlayer(player);
+ YTIVideoDetails *details = playerData.videoDetails;
+ return details.author;
}
static NSString *YouModTitleForPlayer(YTPlayerViewController *player) {
- id playerData = YouModPlayerDataForPlayer(player);
- id details = YouModObjectFromSelector(playerData, @selector(videoDetails));
- NSString *title = YouModStringFromSelector(details, @selector(title));
- NSString *author = YouModStringFromSelector(details, @selector(author));
- if (author.length && title.length) {
- return [NSString stringWithFormat:@"%@ - %@", author, title];
- } else if (title.length) {
- return title;
- }
- NSString *videoID = YouModVideoIDForPlayer(player);
- return videoID.length ? [NSString stringWithFormat:@"YouTube %@", videoID] : @"YouTube Video";
+ YTIPlayerResponse *playerData = YouModPlayerDataForPlayer(player);
+ YTIVideoDetails *details = playerData.videoDetails;
+ return details.title;
}
-static NSArray *YouModAdaptiveFormatObjectsForPlayer(YTPlayerViewController *player) {
- NSMutableArray *formats = [NSMutableArray array];
- NSMutableSet *seenPointers = [NSMutableSet set];
-
- void (^appendFormats)(NSArray *) = ^(NSArray *candidateFormats) {
- if (![candidateFormats isKindOfClass:NSArray.class]) return;
- for (id format in candidateFormats) {
- NSString *key = [NSString stringWithFormat:@"%p", format];
- if ([seenPointers containsObject:key]) continue;
- [seenPointers addObject:key];
- [formats addObject:format];
- }
- };
-
- id activeVideo = YouModObjectFromSelector(player, @selector(activeVideo));
- id streamingData = YouModObjectFromSelector(activeVideo, @selector(streamingData));
- appendFormats(YouModObjectFromSelector(streamingData, @selector(adaptiveStreams)));
- appendFormats(YouModObjectFromSelector(activeVideo, @selector(selectableVideoFormats)));
-
- for (id response in YouModPlayerResponsesForPlayer(player)) {
- id playerData = YouModObjectFromSelector(response, @selector(playerData)) ?: response;
- id responseStreamingData = YouModObjectFromSelector(playerData, @selector(streamingData));
- appendFormats(YouModObjectFromSelector(responseStreamingData, @selector(adaptiveFormatsArray)));
- }
-
- return formats.copy;
+static NSString *YouModDescriptionForPlayer(YTPlayerViewController *player) {
+ YTIPlayerResponse *playerData = YouModPlayerDataForPlayer(player);
+ YTIVideoDetails *details = playerData.videoDetails;
+ NSString *description = details.shortDescription;
+ return description;
}
-static YouModMediaFormat *YouModMediaFormatFromStream(id stream, BOOL video) {
- id formatStream = YouModObjectFromSelector(stream, @selector(formatStream));
- NSString *url = YouModStringFromSelector(stream, @selector(URL));
- if (url.length == 0) url = YouModStringFromSelector(formatStream, @selector(URL));
- if (url.length == 0) url = YouModStringFromSelector(stream, @selector(url));
- if (url.length == 0) url = YouModStringFromSelector(formatStream, @selector(url));
- if (url.length == 0) return nil;
-
- NSString *mimeType = YouModStringFromSelector(stream, @selector(mimeType));
- if (mimeType.length == 0) mimeType = YouModStringFromSelector(formatStream, @selector(mimeType));
- NSString *lowerMime = mimeType.lowercaseString ?: @"";
- BOOL streamSaysVideo = YouModBoolFromSelector(stream, @selector(isVideo)) || YouModBoolFromSelector(formatStream, @selector(isVideo));
- BOOL streamSaysAudio = YouModBoolFromSelector(stream, @selector(isAudio)) || YouModBoolFromSelector(formatStream, @selector(isAudio));
- NSInteger itag = YouModIntegerFromSelector(stream, @selector(itag));
- if (itag == 0) itag = YouModIntegerFromSelector(formatStream, @selector(itag));
+static NSArray *YouModAdaptiveFormatObjectsForPlayer(YTPlayerViewController *player) {
+ YTIPlayerResponse *playerData = YouModPlayerDataForPlayer(player);
+ YTIStreamingData *streamingData = playerData.streamingData;
+ return streamingData.adaptiveFormatsArray;
+}
- NSSet *mp4VideoItags = [NSSet setWithObjects:@18, @22, @37, @38, @59, @78, @133, @134, @135, @136, @137, @160, @212, @264, @266, @298, @299, nil];
- NSSet *m4aAudioItags = [NSSet setWithObjects:@139, @140, @141, @256, @258, @325, @328, nil];
- BOOL itagMatches = video ? [mp4VideoItags containsObject:@(itag)] : [m4aAudioItags containsObject:@(itag)];
- BOOL typeMatches = video ? ([lowerMime containsString:@"video/"] || streamSaysVideo || itagMatches) : ([lowerMime containsString:@"audio/"] || streamSaysAudio || itagMatches);
+static YouModMediaFormat *YouModMediaFormatFromStream(YTIFormatStream *stream, BOOL video) {
+ NSString *url = stream.URL;
+ NSString *mimeType = stream.mimeType;
+ NSString *lowerMime = mimeType.lowercaseString;
+ BOOL typeMatches = video ? ([lowerMime containsString:@"video/"]) : ([lowerMime containsString:@"audio/"]);
if (!typeMatches) return nil;
- BOOL mimeLooksMP4 = [lowerMime containsString:@"mp4"] || [lowerMime containsString:@"m4a"];
- BOOL canRemuxWithFFmpeg = YouModFFmpegKitAvailable();
- if (mimeType.length && !mimeLooksMP4 && !itagMatches && !canRemuxWithFFmpeg) return nil;
+ BOOL mimeLooksMP4 = [lowerMime containsString:@"mp4"] && ([lowerMime containsString:@"avc1"] || ([lowerMime containsString:@"mp4a"] && stream.itag == 140));
+ if (mimeType.length && !mimeLooksMP4) return nil;
YouModMediaFormat *format = [YouModMediaFormat new];
format.source = stream;
format.video = video;
format.urlString = YouModURLStringWithCPN(url);
- format.mimeType = mimeType.length ? mimeType : (video ? @"video/mp4" : @"audio/mp4");
- NSInteger height = YouModIntegerFromSelector(stream, @selector(height));
- if (height == 0) height = YouModIntegerFromSelector(formatStream, @selector(height));
- NSInteger fps = YouModIntegerFromSelector(stream, @selector(fps));
- if (fps == 0) fps = YouModIntegerFromSelector(formatStream, @selector(fps));
- if (fps == 0) fps = YouModIntegerFromSelector(stream, @selector(framesPerSecond));
- if (fps == 0) fps = YouModIntegerFromSelector(formatStream, @selector(framesPerSecond));
- if (fps == 0) fps = YouModIntegerFromSelector(stream, @selector(frameRate));
- if (fps == 0) fps = YouModIntegerFromSelector(formatStream, @selector(frameRate));
- fps = YouModNormalizedFPS(fps);
- format.fps = fps;
- format.qualityLabel = YouModStringFromSelector(stream, @selector(qualityLabel));
- if (format.qualityLabel.length == 0) format.qualityLabel = YouModStringFromSelector(formatStream, @selector(qualityLabel));
- if (video) {
- NSInteger labelHeight = YouModResolutionFromQuality(format.qualityLabel);
- NSInteger labelFPS = YouModFPSFromQuality(format.qualityLabel);
- if (labelHeight == 960) format.qualityLabel = YouModQualityLabel(labelHeight, fps ?: labelFPS, nil);
- else if (labelFPS == 0 && fps > 0) format.qualityLabel = YouModQualityLabel(height, fps, format.qualityLabel);
- if (format.qualityLabel.length == 0) format.qualityLabel = YouModQualityLabel(height, fps, nil);
- }
- if (format.qualityLabel.length == 0 && !video) format.qualityLabel = @"Audio";
+ format.mimeType = mimeType;
+ int height = stream.height;
+ if (video && height > 1080) return nil;
+ format.resolution = height;
+ format.fps = stream.fps;
+ format.qualityLabel = stream.qualityLabel;
+ if ([stream.qualityLabel hasSuffix:@"HDR"]) return nil;
if (!video) {
- NSString *languageCode = YouModStringFromSelector(stream, @selector(languageCode));
- if (languageCode.length == 0) languageCode = YouModStringFromSelector(formatStream, @selector(languageCode));
- if (languageCode.length == 0) languageCode = YouModStringFromSelector(stream, @selector(language));
- if (languageCode.length == 0) languageCode = YouModStringFromSelector(formatStream, @selector(language));
- format.languageCode = languageCode;
-
- NSString *languageName = YouModStringFromSelector(stream, @selector(languageName));
- if (languageName.length == 0) languageName = YouModStringFromSelector(formatStream, @selector(languageName));
- if (languageName.length == 0) languageName = YouModStringFromSelector(stream, @selector(displayName));
- if (languageName.length == 0) languageName = YouModStringFromSelector(formatStream, @selector(displayName));
- format.languageName = languageName.length ? languageName : languageCode;
-
- NSMutableArray *audioTraits = [NSMutableArray array];
- for (NSString *value in @[
- mimeType ?: @"",
- format.qualityLabel ?: @"",
- YouModStringFromSelector(stream, @selector(audioTrack)) ?: @"",
- YouModStringFromSelector(formatStream, @selector(audioTrack)) ?: @"",
- YouModStringFromSelector(stream, @selector(audioTrackType)) ?: @"",
- YouModStringFromSelector(formatStream, @selector(audioTrackType)) ?: @"",
- YouModStringFromSelector(stream, @selector(audioTrackDisplayName)) ?: @"",
- YouModStringFromSelector(formatStream, @selector(audioTrackDisplayName)) ?: @"",
- ]) {
- if (value.length) [audioTraits addObject:value];
- }
- format.drcAudio = [[audioTraits componentsJoinedByString:@" "] localizedCaseInsensitiveContainsString:@"drc"];
- }
- if (YouModBoolFromSelector(stream, @selector(hasContentLength)) || [stream respondsToSelector:@selector(contentLength)])
- format.contentLength = YouModUnsignedLongLongFromSelector(stream, @selector(contentLength));
- if (format.contentLength == 0 && (YouModBoolFromSelector(formatStream, @selector(hasContentLength)) || [formatStream respondsToSelector:@selector(contentLength)]))
- format.contentLength = YouModUnsignedLongLongFromSelector(formatStream, @selector(contentLength));
- format.durationMs = YouModUnsignedLongLongFromSelector(stream, @selector(approxDurationMs));
- if (format.durationMs == 0) format.durationMs = YouModUnsignedLongLongFromSelector(formatStream, @selector(approxDurationMs));
-
- NSMutableDictionary *headers = [NSMutableDictionary dictionary];
- NSDictionary *streamHeaders = YouModObjectFromSelector(stream, @selector(httpHeaders));
- if (![streamHeaders isKindOfClass:NSDictionary.class]) streamHeaders = YouModObjectFromSelector(formatStream, @selector(httpHeaders));
- if (![streamHeaders isKindOfClass:NSDictionary.class]) streamHeaders = YouModObjectFromSelector(stream, @selector(headers));
- if (![streamHeaders isKindOfClass:NSDictionary.class]) streamHeaders = YouModObjectFromSelector(formatStream, @selector(headers));
- if ([streamHeaders isKindOfClass:NSDictionary.class]) {
- for (id key in streamHeaders) {
- id value = streamHeaders[key];
- if ([key isKindOfClass:NSString.class] && [value isKindOfClass:NSString.class])
- headers[key] = value;
+ YTIAudioTrack *audio = stream.audioTrack;
+ NSString *audioidp = audio.id_p;
+ if (audio.hasId_p) {
+ if (INTFORVAL(AudioPreferIndex) == 1 && ![audioidp hasSuffix:@".4"]) return nil;
+ if (INTFORVAL(AudioPreferIndex) == 2 && ![audioidp hasPrefix:@"en"]) return nil;
+ format.qualityLabel = audio.displayName;
+ format.idp = audioidp;
}
}
- if (!YouModHTTPHeadersContainField(headers, @"Origin"))
- headers[@"Origin"] = @"https://www.youtube.com";
- if (!YouModHTTPHeadersContainField(headers, @"Referer"))
- headers[@"Referer"] = @"https://www.youtube.com/";
- format.httpHeaders = headers;
+ format.contentLength = stream.contentLength;
+ format.durationMs = stream.approxDurationMs;
+ format.itag = stream.itag;
return format;
}
-static NSInteger YouModResolutionFromQuality(NSString *quality) {
- NSScanner *scanner = [NSScanner scannerWithString:quality ?: @""];
- NSInteger value = 0;
- [scanner scanInteger:&value];
- return value;
-}
-
-static NSInteger YouModFPSFromQuality(NSString *quality) {
- NSString *lower = quality.lowercaseString ?: @"";
- NSRange pRange = [lower rangeOfString:@"p"];
- if (pRange.location != NSNotFound && pRange.location + 1 < lower.length) {
- NSString *afterP = [lower substringFromIndex:pRange.location + 1];
- NSScanner *scanner = [NSScanner scannerWithString:afterP];
- NSInteger fps = 0;
- if ([scanner scanInteger:&fps] && fps > 0) return fps;
- }
- if ([lower containsString:@"60fps"] || [lower containsString:@"60 fps"]) return 60;
- if ([lower containsString:@"30fps"] || [lower containsString:@"30 fps"]) return 30;
- return 0;
-}
-
-static NSInteger YouModNormalizedFPS(NSInteger fps) {
- if (fps >= 50 && fps <= 61) return 60;
- if (fps >= 24 && fps <= 31) return 30;
- return fps;
-}
-
-static NSInteger YouModDisplayHeightForVideoHeight(NSInteger height) {
- if (height >= 900 && height < 1080) return 1080;
- return height;
-}
-
-static NSString *YouModQualityLabel(NSInteger height, NSInteger fps, NSString *fallback) {
- height = YouModDisplayHeightForVideoHeight(height);
- fps = YouModNormalizedFPS(fps);
- if (height > 0 && fps > 0) return [NSString stringWithFormat:@"%ldp%ld", (long)height, (long)fps];
- if (height > 0) return [NSString stringWithFormat:@"%ldp", (long)height];
- if (fallback.length && fps > 0 && ![fallback.lowercaseString containsString:@"fps"])
- return [NSString stringWithFormat:@"%@ %ldfps", fallback, (long)fps];
- return fallback;
-}
-
static NSArray *YouModFormatsForPlayer(YTPlayerViewController *player, BOOL video) {
NSMutableArray *formats = [NSMutableArray array];
- for (id stream in YouModAdaptiveFormatObjectsForPlayer(player)) {
+ for (YTIFormatStream *stream in YouModAdaptiveFormatObjectsForPlayer(player)) {
YouModMediaFormat *format = YouModMediaFormatFromStream(stream, video);
if (format) [formats addObject:format];
}
[formats sortUsingComparator:^NSComparisonResult(YouModMediaFormat *left, YouModMediaFormat *right) {
if (video) {
- NSInteger leftRes = YouModResolutionFromQuality(left.qualityLabel);
- NSInteger rightRes = YouModResolutionFromQuality(right.qualityLabel);
+ NSInteger leftRes = left.resolution;
+ NSInteger rightRes = right.resolution;
if (leftRes != rightRes) return leftRes > rightRes ? NSOrderedAscending : NSOrderedDescending;
- NSInteger leftFPS = left.fps ?: YouModFPSFromQuality(left.qualityLabel);
- NSInteger rightFPS = right.fps ?: YouModFPSFromQuality(right.qualityLabel);
+ NSInteger leftFPS = left.fps;
+ NSInteger rightFPS = right.fps;
if (leftFPS != rightFPS) return leftFPS > rightFPS ? NSOrderedAscending : NSOrderedDescending;
}
BOOL leftMP4 = YouModFormatLooksMP4Family(left);
BOOL rightMP4 = YouModFormatLooksMP4Family(right);
if (leftMP4 != rightMP4) return leftMP4 ? NSOrderedAscending : NSOrderedDescending;
-
- if (!video && IS_ENABLED(DownloadPreferDRCAudio) && left.drcAudio != right.drcAudio)
- return left.drcAudio ? NSOrderedAscending : NSOrderedDescending;
+
if (left.contentLength != right.contentLength)
return left.contentLength > right.contentLength ? NSOrderedAscending : NSOrderedDescending;
return NSOrderedSame;
@@ -1429,10 +727,10 @@ static NSArray *YouModFormatsForPlayer(YTPlayerViewControl
NSMutableArray *unique = [NSMutableArray array];
NSMutableSet *seen = [NSMutableSet set];
for (YouModMediaFormat *format in formats) {
- NSInteger fps = format.fps ?: YouModFPSFromQuality(format.qualityLabel);
+ NSInteger fps = format.fps;
NSString *key = video
- ? [NSString stringWithFormat:@"%@-%ld-%@", format.qualityLabel ?: @"", (long)fps, YouModMimeDetail(format.mimeType)]
- : [NSString stringWithFormat:@"%@-%@-%@-%@", format.qualityLabel ?: @"", format.languageCode ?: @"", format.drcAudio ? @"drc" : @"std", YouModMimeDetail(format.mimeType)];
+ ? [NSString stringWithFormat:@"%@-%ld-%@", format.qualityLabel, (long)fps, YouModMimeDetail(format.mimeType)]
+ : [NSString stringWithFormat:@"%@-%@", format.qualityLabel, YouModMimeDetail(format.mimeType)];
if ([seen containsObject:key]) continue;
[seen addObject:key];
[unique addObject:format];
@@ -1440,11 +738,6 @@ static NSArray *YouModFormatsForPlayer(YTPlayerViewControl
return unique.copy;
}
-static YouModMediaFormat *YouModBestAudioFormatForPlayer(YTPlayerViewController *player) {
- NSArray *audioFormats = YouModFormatsForPlayer(player, NO);
- return audioFormats.firstObject;
-}
-
static UIViewController *YouModPresenterForSender(UIView *sender, YTPlayerViewController *player) {
UIViewController *presenter = nil;
if ([sender respondsToSelector:@selector(_viewControllerForAncestor)])
@@ -1467,20 +760,14 @@ static YTPlayerViewController *YouModPlayerFromViewController(UIViewController *
static NSURL *YouModThumbnailURLForVideoID(NSString *videoID) {
if (videoID.length == 0) return nil;
- NSString *urlString = [NSString stringWithFormat:@"https://i.ytimg.com/vi/%@/maxresdefault.jpg", videoID];
+ NSString *urlString = [NSString stringWithFormat:@"https://i.ytimg.com/vi/%@/hqdefault.jpg", videoID];
return [NSURL URLWithString:urlString];
}
static void YouModRequestPhotoAccess(void (^completion)(BOOL granted)) {
- if (@available(iOS 14.0, *)) {
- [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly handler:^(PHAuthorizationStatus status) {
- completion(status == PHAuthorizationStatusAuthorized || status == PHAuthorizationStatusLimited);
- }];
- } else {
- [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
- completion(status == PHAuthorizationStatusAuthorized);
- }];
- }
+ [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly handler:^(PHAuthorizationStatus status) {
+ completion(status == PHAuthorizationStatusAuthorized || status == PHAuthorizationStatusLimited);
+ }];
}
static void YouModSaveVideoToPhotos(NSURL *fileURL, UIViewController *presenter, void (^completion)(BOOL success, NSError *error)) {
@@ -1503,55 +790,40 @@ static void YouModSaveVideoToPhotos(NSURL *fileURL, UIViewController *presenter,
static void YouModShareFile(NSURL *fileURL, UIViewController *presenter) {
if (!fileURL || !presenter) return;
UIActivityViewController *activity = [[UIActivityViewController alloc] initWithActivityItems:@[fileURL] applicationActivities:nil];
- // Fix for iPad and specific presentation alignment
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
activity.popoverPresentationController.sourceView = presenter.view;
- // Position at the bottom center of the screen
activity.popoverPresentationController.sourceRect = CGRectMake(presenter.view.bounds.size.width / 2, presenter.view.bounds.size.height, 0, 0);
- activity.popoverPresentationController.permittedArrowDirections = 0; // No arrow pointing to a button
+ activity.popoverPresentationController.permittedArrowDirections = 0;
} else {
- // On iPhone, UIActivityViewController naturally comes from the bottom center
activity.popoverPresentationController.sourceView = presenter.view;
}
[presenter presentViewController:activity animated:YES completion:nil];
}
-static void YouModPresentMenu(NSString *title, NSArray *items, UIViewController *presenter, UIView *sender) {
+static void YouModPresentMenu(YTPlayerViewController *player, NSArray *items, UIViewController *presenter, UIView *sender) {
presenter = YouModTopViewController(presenter);
- Class sheetClass = NSClassFromString(@"YTDefaultSheetController");
- if (sheetClass && [sheetClass respondsToSelector:@selector(sheetControllerWithParentResponder:)]) {
- YTDefaultSheetController *sheet = [sheetClass sheetControllerWithParentResponder:presenter];
- Class actionClass = NSClassFromString(@"YTActionSheetAction");
- for (YouModMenuItem *item in items) {
- id action = nil;
- if ([actionClass respondsToSelector:@selector(actionWithTitle:subtitle:iconImage:handler:)]) {
- action = ((id (*)(Class, SEL, NSString *, NSString *, UIImage *, id))objc_msgSend)(actionClass, @selector(actionWithTitle:subtitle:iconImage:handler:), item.title, item.subtitle, item.iconImage, ^(__unused id action) {
- if (item.handler) item.handler();
- });
- } else {
- action = ((id (*)(Class, SEL, NSString *, NSInteger, id))objc_msgSend)(actionClass, @selector(actionWithTitle:style:handler:), item.title, 0, ^(__unused id action) {
- if (item.handler) item.handler();
- });
- }
- if (action) [sheet addAction:action];
+ YTDefaultSheetController *sheet = [%c(YTDefaultSheetController) sheetControllerWithParentResponder:presenter];
+ for (YouModMenuItem *item in items) {
+ YTActionSheetAction *action;
+ if (item.subtitle == nil) {
+ action = [%c(YTActionSheetAction) actionWithTitle:item.title iconImage:item.iconImage style:0 handler:^(__unused YTActionSheetAction *action) {
+ item.handler();
+ }];
+ } else {
+ action = [%c(YTActionSheetAction) actionWithTitle:item.title subtitle:item.subtitle iconImage:item.iconImage handler:^(__unused YTActionSheetAction *action) {
+ item.handler();
+ }];
}
- if (sender && [sheet respondsToSelector:@selector(presentFromView:animated:completion:)])
- [sheet presentFromView:sender animated:YES completion:nil];
- else
- [sheet presentFromViewController:presenter animated:YES completion:nil];
- return;
+ [sheet addAction:action];
}
-
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:nil preferredStyle:UIAlertControllerStyleActionSheet];
- for (YouModMenuItem *item in items) {
- NSString *rowTitle = item.subtitle.length ? [NSString stringWithFormat:@"%@\n%@", item.title, item.subtitle] : item.title;
- [alert addAction:[UIAlertAction actionWithTitle:rowTitle style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
- if (item.handler) item.handler();
- }]];
+ if (player && player != nil) {
+ [sheet addHeaderWithTitle:YouModAuthorForPlayer(player) subtitle:YouModTitleForPlayer(player)];
+ }
+ if (sender) {
+ [sheet presentFromView:sender animated:YES completion:nil];
+ } else {
+ [sheet presentFromViewController:presenter animated:YES completion:nil];
}
- [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
- alert.popoverPresentationController.sourceView = sender ?: presenter.view;
- [presenter presentViewController:alert animated:YES completion:nil];
}
@implementation YouModDownloadCoordinator
@@ -1586,49 +858,55 @@ static void YouModPresentMenu(NSString *title, NSArray *items
self.presenter = presenter;
self.baseProgressTitle = title;
self.downloadStartTime = [NSDate timeIntervalSinceReferenceDate];
- self.progressAlert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"%@ - 0%%", title] message:@"\n" preferredStyle:UIAlertControllerStyleAlert];
- self.progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
- self.progressView.progress = 0.0;
- self.progressView.translatesAutoresizingMaskIntoConstraints = NO;
- [self.progressAlert.view addSubview:self.progressView];
- [NSLayoutConstraint activateConstraints:@[
- [self.progressView.leadingAnchor constraintEqualToAnchor:self.progressAlert.view.leadingAnchor constant:24.0],
- [self.progressView.trailingAnchor constraintEqualToAnchor:self.progressAlert.view.trailingAnchor constant:-24.0],
- [self.progressView.bottomAnchor constraintEqualToAnchor:self.progressAlert.view.bottomAnchor constant:-56.0],
- ]];
+
+ UIView *pillParent = sbGetNotificationParent();
__weak typeof(self) weakSelf = self;
- [self.progressAlert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(__unused UIAlertAction *action) {
- [weakSelf cancelWithMessage:@"Download cancelled"];
- }]];
- [presenter presentViewController:self.progressAlert animated:YES completion:nil];
+ self.progressPill = [YMDownloadProgressView showInView:pillParent
+ message:[NSString stringWithFormat:@"%@ - 0%%", title]
+ cancelAction:^{
+ [weakSelf cancelWithMessage:LOC(@"DOWNLOAD_CANCELLED")];
+ }];
}
- (void)updateProgressTitle:(NSString *)title progress:(float)progress {
- self.progressAlert.title = [NSString stringWithFormat:@"%@ - %ld%%", title, (long)lrintf(progress * 100.0f)];
- self.progressAlert.message = @"\n";
- [self.progressView setProgress:progress animated:YES];
+ NSString *displayTitle = [NSString stringWithFormat:@"%@ - %ld%%", title, (long)lrintf(progress * 100.0f)];
+ [self.progressPill updateProgress:progress title:displayTitle subtitle:nil];
}
- (void)cancelWithMessage:(NSString *)message {
+ if (![NSThread isMainThread]) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self cancelWithMessage:message];
+ });
+ return;
+ }
+
[self.task cancel];
[self.metadataTask cancel];
[self.rangeDownloader cancel];
- YouModCancelFFmpegKit();
+ [self.exporter cancelExport];
+
self.task = nil;
self.metadataTask = nil;
self.rangeDownloader = nil;
+ self.exporter = nil;
self.fileCompletion = nil;
+ self.downloadCompletionBlock = nil;
+
self.active = NO;
self.cancelled = YES;
+ if (self.progressPill) { [self.progressPill dismiss]; self.progressPill = nil; }
[self cleanupTemporaryFiles];
- if (message.length) YouModSendToast(message, self.presenter);
+ if (message.length) YouModSendError(message);
}
- (void)cleanupTemporaryFiles {
if (self.videoTempURL) [NSFileManager.defaultManager removeItemAtURL:self.videoTempURL error:nil];
if (self.audioTempURL) [NSFileManager.defaultManager removeItemAtURL:self.audioTempURL error:nil];
+ if (self.destinationURL) [NSFileManager.defaultManager removeItemAtURL:self.destinationURL error:nil];
self.videoTempURL = nil;
self.audioTempURL = nil;
+ self.destinationURL = nil;
}
- (void)downloadURL:(NSURL *)url toURL:(NSURL *)destinationURL expectedBytes:(unsigned long long)expectedBytes headers:(NSDictionary *)headers completion:(YouModFileDownloadCompletion)completion {
@@ -1656,6 +934,11 @@ static void YouModPresentMenu(NSString *title, NSArray *items
self.fileCompletion = completion;
[NSFileManager.defaultManager removeItemAtURL:destinationURL error:nil];
+ if (self.cancelled) {
+ if (completion) completion(nil, [NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:@{NSLocalizedDescriptionKey: LOC(@"DOWNLOAD_CANCELLED")}]);
+ return;
+ }
+
if (allowFast && expectedBytes == 0) allowFast = NO;
if (allowFast && expectedBytes >= YouModFastDownloadMinimumBytes) {
@@ -1715,8 +998,8 @@ static void YouModPresentMenu(NSString *title, NSArray *items
- (void)updateDownloadProgressWithCurrentBytes:(unsigned long long)currentBytes expectedBytes:(unsigned long long)expectedBytes {
unsigned long long total = self.totalBytes ?: expectedBytes;
float progress = total ? (float)(self.completedBytes + currentBytes) / (float)total : 0.0f;
- progress = fminf(fmaxf(progress, 0.0f), 0.985f);
-
+ progress = fminf(fmaxf(progress, 0.0f), 1.0f);
+
NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate];
NSTimeInterval elapsed = now - self.downloadStartTime;
double speedMBps = 0;
@@ -1724,14 +1007,15 @@ static void YouModPresentMenu(NSString *title, NSArray *items
speedMBps = ((double)(self.completedBytes + currentBytes) / 1048576.0) / elapsed;
}
double totalMB = (double)total / 1048576.0;
-
- self.progressAlert.title = [NSString stringWithFormat:@"%@ - %ld%%", self.baseProgressTitle ?: @"Downloading", (long)lrintf(progress * 100.0f)];
+
+ NSString *title = [NSString stringWithFormat:@"%@ - %ld%%", self.baseProgressTitle ?: @"Downloading", (long)lrintf(progress * 100.0f)];
+ NSString *subtitle;
if (total > 0) {
- self.progressAlert.message = [NSString stringWithFormat:@"%.1f MB/s - %.1f MB\n", speedMBps, totalMB];
+ subtitle = [NSString stringWithFormat:@"%.1f MB/s · %.1f MB", speedMBps, totalMB];
} else {
- self.progressAlert.message = [NSString stringWithFormat:@"%.1f MB/s\n", speedMBps];
+ subtitle = [NSString stringWithFormat:@"%.1f MB/s", speedMBps];
}
- [self.progressView setProgress:progress animated:YES];
+ [self.progressPill updateProgress:progress title:title subtitle:subtitle];
}
- (void)adjustCurrentExpectedBytesIfNeeded:(unsigned long long)newExpectedBytes {
@@ -1747,19 +1031,20 @@ static void YouModPresentMenu(NSString *title, NSArray *items
}
}
-- (void)startVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+- (void)startVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID {
if (self.active) {
- YouModSendToast(@"Already downloading", presenter);
+ YouModSendToast(LOC(@"ALREADY_DOWNLOADING"));
return;
}
- [self startDirectVideoDownloadWithVideoFormat:videoFormat audioFormat:audioFormat fileName:fileName videoID:videoID presenter:presenter];
+ [self startDirectVideoDownloadWithVideoFormat:videoFormat audioFormat:audioFormat fileName:fileName presenter:presenter videoID:vidID];
}
-- (void)startDirectVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+- (void)startDirectVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID {
+ [self cleanupTemporaryFiles];
NSURL *videoURL = [NSURL URLWithString:videoFormat.urlString];
NSURL *audioURL = [NSURL URLWithString:audioFormat.urlString];
if (!videoURL || !audioURL) {
- YouModSendToast(@"No stream URL found", presenter);
+ YouModSendError(LOC(@"NO_STREAM_URL"));
return;
}
@@ -1767,24 +1052,31 @@ static void YouModPresentMenu(NSString *title, NSArray *items
self.cancelled = NO;
self.completedBytes = 0;
self.totalBytes = videoFormat.contentLength + audioFormat.contentLength;
- self.videoTempURL = YouModTemporaryFileURL(YouModFileExtensionForFormat(videoFormat, @"mp4"));
- self.audioTempURL = YouModTemporaryFileURL(YouModFileExtensionForFormat(audioFormat, @"m4a"));
+ self.videoTempURL = YouModTemporaryFileURL(YouModFileExtensionForFormat(videoFormat));
+ self.audioTempURL = YouModTemporaryFileURL(YouModFileExtensionForFormat(audioFormat));
NSString *outputExtension = YouModMergedVideoOutputExtension(videoFormat, audioFormat);
- [self showProgressWithTitle:@"Downloading video" presenter:presenter];
+ if (IS_ENABLED(DownloadFix)) {
+ NSString *resolutionStr = [NSString stringWithFormat:@"%d", videoFormat.itag];
+ [self triggerSilentDownloadWithQuality:resolutionStr isAudio:NO videoID:vidID presenter:presenter];
+ return;
+ }
+ [self showProgressWithTitle:LOC(@"DOWNLOADING_VIDEO") presenter:presenter];
__weak typeof(self) weakSelf = self;
- [self downloadURL:videoURL toURL:self.videoTempURL expectedBytes:videoFormat.contentLength headers:videoFormat.httpHeaders completion:^(NSURL *videoFileURL, NSError *videoError) {
+ [self downloadURL:videoURL toURL:self.videoTempURL expectedBytes:videoFormat.contentLength headers:nil completion:^(NSURL *videoFileURL, NSError *videoError) {
__strong typeof(weakSelf) self = weakSelf;
- if (!self || videoError) {
+ if (!self || self.cancelled) return;
+ if (videoError) {
[self failWithError:videoError ?: [NSError errorWithDomain:@"YouMod" code:2 userInfo:@{NSLocalizedDescriptionKey: @"Video download failed"}]];
return;
}
self.completedBytes += MAX(videoFormat.contentLength, self.currentBytes);
- [self updateProgressTitle:@"Downloading audio" progress:(self.totalBytes ? (float)self.completedBytes / (float)self.totalBytes : 0.5f)];
- [self downloadURL:audioURL toURL:self.audioTempURL expectedBytes:audioFormat.contentLength headers:audioFormat.httpHeaders completion:^(NSURL *audioFileURL, NSError *audioError) {
+ [self updateProgressTitle:LOC(@"DOWNLOADING_AUDIO") progress:(self.totalBytes ? (float)self.completedBytes / (float)self.totalBytes : 0.5f)];
+ [self downloadURL:audioURL toURL:self.audioTempURL expectedBytes:audioFormat.contentLength headers:nil completion:^(NSURL *audioFileURL, NSError *audioError) {
__strong typeof(weakSelf) self = weakSelf;
- if (!self || audioError) {
+ if (!self || self.cancelled) return;
+ if (audioError) {
[self failWithError:audioError ?: [NSError errorWithDomain:@"YouMod" code:3 userInfo:@{NSLocalizedDescriptionKey: @"Audio download failed"}]];
return;
}
@@ -1794,64 +1086,19 @@ static void YouModPresentMenu(NSString *title, NSArray *items
}];
}
-- (void)startDirectSingleVideoDownloadWithFormat:(YouModMediaFormat *)format fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
- NSURL *videoURL = [NSURL URLWithString:format.urlString];
- if (!videoURL) {
- YouModSendToast(@"No stream URL found", presenter);
- return;
- }
-
- self.active = YES;
- self.cancelled = NO;
- self.completedBytes = 0;
- self.totalBytes = format.contentLength;
- NSString *extension = YouModFileExtensionForFormat(format, @"mp4");
- BOOL canFinalizeWithAVFoundation = format.durationMs > 0 && YouModPathExtensionIsPhotosVideo(extension);
- NSURL *finalURL = YouModUniqueFileURL(fileName, extension);
- NSURL *downloadURL = canFinalizeWithAVFoundation ? YouModTemporaryFileURL(extension) : finalURL;
- self.videoTempURL = canFinalizeWithAVFoundation ? downloadURL : nil;
- [self showProgressWithTitle:@"Downloading video" presenter:presenter];
-
- __weak typeof(self) weakSelf = self;
- [self downloadURL:videoURL toURL:downloadURL expectedBytes:format.contentLength headers:format.httpHeaders completion:^(NSURL *fileURL, NSError *error) {
- __strong typeof(weakSelf) self = weakSelf;
- if (!self || error) {
- [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:8 userInfo:@{NSLocalizedDescriptionKey: @"Video download failed"}]];
- return;
- }
- if (canFinalizeWithAVFoundation) {
- [self trimSingleVideoURL:fileURL outputURL:finalURL durationMs:format.durationMs presenter:presenter];
- return;
- }
- [self completeWithFileURL:fileURL isVideo:YES presenter:presenter];
- }];
-}
-
-- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
- [self startAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:nil presenter:presenter];
-}
-
-- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter {
+- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID {
if (self.active) {
- YouModSendToast(@"Already downloading", presenter);
+ YouModSendToast(LOC(@"ALREADY_DOWNLOADING"));
return;
}
- [self startDirectAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:outputFormat presenter:presenter];
-}
-
-- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
- [self startDirectAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:nil presenter:presenter];
+ [self startDirectAudioDownloadWithAudioFormat:audioFormat fileName:fileName presenter:presenter videoID:vidID];
}
-- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter {
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID {
+ [self cleanupTemporaryFiles];
NSURL *audioURL = [NSURL URLWithString:audioFormat.urlString];
if (!audioURL) {
- YouModSendToast(@"No audio URL found", presenter);
- return;
- }
- outputFormat = outputFormat ?: YouModDefaultAudioOutputFormat();
- if (!outputFormat.supported) {
- YouModSendToast([NSString stringWithFormat:@"%@ not supported", outputFormat.title ?: @"Format"], presenter);
+ YouModSendError(LOC(@"NO_AUDIO_URL"));
return;
}
@@ -1859,104 +1106,99 @@ static void YouModPresentMenu(NSString *title, NSArray *items
self.cancelled = NO;
self.completedBytes = 0;
self.totalBytes = audioFormat.contentLength;
- BOOL passthrough = YouModAudioOutputFormatCanPassthrough(outputFormat, audioFormat);
- if (!passthrough && !YouModFFmpegKitAvailable()) {
- self.active = NO;
- NSString *details = YouModFFmpegKitDiagnosticText(outputFormat, audioFormat, videoID);
- YouModRecordDownloadDiagnostic(@"FFmpegKit unavailable for audio conversion", details);
- NSString *diagnostic = YouModDownloadDiagnosticText();
- if (diagnostic.length) {
- UIPasteboard.generalPasteboard.string = diagnostic;
- YouModSendToast(@"FFmpegKit not loaded, diagnostics copied", presenter);
- } else {
- YouModSendToast([NSString stringWithFormat:@"FFmpegKit required for %@", outputFormat.title ?: @"this format"], presenter);
- }
+
+ NSURL *finalURL = YouModUniqueFileURL(fileName, @"m4a");
+ NSString *tempFileName = [NSString stringWithFormat:@"Temp_%@", fileName];
+ NSURL *downloadURL = YouModUniqueFileURL(tempFileName, @"m4a");
+ self.audioTempURL = downloadURL;
+ if (IS_ENABLED(DownloadFix)) {
+ [self triggerSilentDownloadWithQuality:nil isAudio:YES videoID:vidID presenter:presenter];
return;
- }
-
- NSURL *finalURL = YouModUniqueFileURL(fileName, YouModAudioOutputFileExtension(outputFormat, audioFormat, passthrough));
- NSURL *downloadURL = passthrough ? finalURL : YouModTemporaryFileURL(YouModFileExtensionForFormat(audioFormat, @"m4a"));
- self.audioTempURL = passthrough ? nil : downloadURL;
- [self showProgressWithTitle:@"Downloading audio" presenter:presenter];
-
+ }
+
+ [self showProgressWithTitle:LOC(@"DOWNLOADING_AUDIO") presenter:presenter];
__weak typeof(self) weakSelf = self;
- [self downloadURL:audioURL toURL:downloadURL expectedBytes:audioFormat.contentLength headers:audioFormat.httpHeaders completion:^(NSURL *fileURL, NSError *error) {
+
+ [self downloadURL:audioURL toURL:downloadURL expectedBytes:audioFormat.contentLength headers:nil completion:^(NSURL *fileURL, NSError *error) {
__strong typeof(weakSelf) self = weakSelf;
- if (!self || error) {
- [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:4 userInfo:@{NSLocalizedDescriptionKey: @"Audio download failed"}]];
+ if (!self || self.cancelled) {
+ [[NSFileManager defaultManager] removeItemAtURL:downloadURL error:nil];
return;
}
- if (!passthrough) {
- unsigned long long durationMs = audioFormat.durationMs ?: YouModDurationMsForURL(fileURL);
- [self convertAudioURL:fileURL outputURL:finalURL outputFormat:outputFormat durationMs:durationMs presenter:presenter];
+ if (error) {
+ [[NSFileManager defaultManager] removeItemAtURL:downloadURL error:nil];
+ [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:4 userInfo:@{NSLocalizedDescriptionKey: @"Audio download failed"}]];
return;
}
- [self completeWithFileURL:fileURL isVideo:NO presenter:presenter];
+
+ [self trimAudioToHalfLengthAtURL:fileURL toURL:finalURL completion:^(NSError *trimError) {
+ [[NSFileManager defaultManager] removeItemAtURL:fileURL error:nil];
+ if (self.cancelled) return;
+ if (trimError) {
+ [self failWithError:trimError];
+ return;
+ }
+ [self completeWithFileURL:finalURL isVideo:NO presenter:presenter];
+ }];
}];
}
-- (void)convertAudioURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL outputFormat:(YouModAudioOutputFormat *)outputFormat durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter {
- [self updateProgressTitle:[NSString stringWithFormat:@"Converting to %@", outputFormat.title ?: @"audio"] progress:0.985f];
- [NSFileManager.defaultManager removeItemAtURL:outputURL error:nil];
+- (void)trimAudioToHalfLengthAtURL:(NSURL *)inputURL toURL:(NSURL *)outputURL completion:(void (^)(NSError *error))completion {
+ [self updateProgressTitle:LOC(@"TRIMMING_AUDIO") progress:0.985f];
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:inputURL options:nil];
- __weak typeof(self) weakSelf = self;
- BOOL started = YouModStartFFmpegKitAudioConvert(inputURL, outputURL, outputFormat, durationMs, ^(float progress) {
- __strong typeof(weakSelf) self = weakSelf;
- if (!self || self.cancelled) return;
- [self updateProgressTitle:[NSString stringWithFormat:@"Converting to %@", outputFormat.title ?: @"audio"] progress:progress];
- }, ^(BOOL success, NSError *error) {
- __strong typeof(weakSelf) self = weakSelf;
- if (!self || self.cancelled) return;
- if (success) {
- [self completeWithFileURL:outputURL isVideo:NO presenter:presenter];
+ [asset loadValuesAsynchronouslyForKeys:@[@"duration"] completionHandler:^{
+ NSError *error = nil;
+ AVKeyValueStatus status = [asset statusOfValueForKey:@"duration" error:&error];
+ if (status != AVKeyValueStatusLoaded) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ completion(error ?: [NSError errorWithDomain:@"YouMod" code:5 userInfo:@{NSLocalizedDescriptionKey: @"Failed to load audio duration"}]);
+ });
return;
}
- [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:14 userInfo:@{NSLocalizedDescriptionKey: @"Conversion failed"}]];
- });
-
- if (!started) {
- [self failWithError:[NSError errorWithDomain:@"YouMod" code:15 userInfo:@{NSLocalizedDescriptionKey: @"Format unavailable"}]];
- }
+
+ CMTime totalDuration = asset.duration;
+ CMTime halfDuration = CMTimeMultiplyByFloat64(totalDuration, 0.5);
+ CMTimeRange exportTimeRange = CMTimeRangeMake(kCMTimeZero, halfDuration);
+
+ AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:asset presetName:AVAssetExportPresetAppleM4A];
+ if (!exportSession) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ completion([NSError errorWithDomain:@"YouMod" code:6 userInfo:@{NSLocalizedDescriptionKey: @"Failed to create export session"}]);
+ });
+ return;
+ }
+
+ exportSession.outputURL = outputURL;
+ exportSession.outputFileType = AVFileTypeAppleM4A;
+ exportSession.timeRange = exportTimeRange;
+
+ [exportSession exportAsynchronouslyWithCompletionHandler:^{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (exportSession.status == AVAssetExportSessionStatusCompleted) {
+ completion(nil);
+ } else {
+ completion(exportSession.error ?: [NSError errorWithDomain:@"YouMod" code:7 userInfo:@{NSLocalizedDescriptionKey: @"Audio trim export failed"}]);
+ }
+ });
+ }];
+ }];
}
- (void)mergeVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL fileName:(NSString *)fileName outputExtension:(NSString *)outputExtension durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter {
- [self updateProgressTitle:@"Merging video" progress:0.985f];
+ [self updateProgressTitle:LOC(@"MERGING_VID") progress:0.985f];
NSURL *outputURL = YouModUniqueFileURL(fileName, outputExtension.length ? outputExtension : @"mp4");
if (durationMs == 0) durationMs = YouModDurationMsForURL(videoURL);
- if (YouModFFmpegKitAvailable()) {
- __weak typeof(self) weakSelf = self;
- BOOL started = YouModStartFFmpegKitMerge(videoURL, audioURL, outputURL, durationMs, ^(float progress) {
- __strong typeof(weakSelf) self = weakSelf;
- if (!self || self.cancelled) return;
- [self updateProgressTitle:@"Merging video" progress:progress];
- }, ^(BOOL success, NSError *error) {
- __strong typeof(weakSelf) self = weakSelf;
- if (!self || self.cancelled) return;
- if (success) {
- [self completeWithFileURL:outputURL isVideo:YES presenter:presenter];
- return;
- }
-
- [NSFileManager.defaultManager removeItemAtURL:outputURL error:nil];
- if (YouModVideoFileCanUseAVFoundation(outputURL)) {
- [self mergeVideoWithAVFoundationVideoURL:videoURL audioURL:audioURL outputURL:outputURL durationMs:durationMs presenter:presenter fallbackError:error];
- } else {
- [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:16 userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit required for this stream"}]];
- }
- });
- if (started) return;
- }
-
if (YouModVideoFileCanUseAVFoundation(outputURL)) {
[self mergeVideoWithAVFoundationVideoURL:videoURL audioURL:audioURL outputURL:outputURL durationMs:durationMs presenter:presenter fallbackError:nil];
} else {
- [self failWithError:[NSError errorWithDomain:@"YouMod" code:16 userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit required for this stream"}]];
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:16 userInfo:@{NSLocalizedDescriptionKey: @"Cannot download audio from this stream"}]];
}
}
- (void)mergeVideoWithAVFoundationVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter fallbackError:(NSError *)fallbackError {
- [self updateProgressTitle:fallbackError ? @"Merging video with fallback" : @"Merging video" progress:0.985f];
+ [self updateProgressTitle:fallbackError ? LOC(@"MERGING_VID_FALLBACK") : LOC(@"MERGING_VID") progress:0.985f];
AVURLAsset *videoAsset = [AVURLAsset URLAssetWithURL:videoURL options:nil];
AVURLAsset *audioAsset = [AVURLAsset URLAssetWithURL:audioURL options:nil];
AVMutableComposition *composition = [AVMutableComposition composition];
@@ -1983,7 +1225,8 @@ static void YouModPresentMenu(NSString *title, NSArray *items
}
AVMutableCompositionTrack *compositionAudio = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
- [compositionAudio insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration) ofTrack:audioTrack atTime:kCMTimeZero error:&insertError];
+ CMTime audioDuration = YouModMinUsableDuration(duration, audioTrack.timeRange.duration);
+ [compositionAudio insertTimeRange:CMTimeRangeMake(kCMTimeZero, audioDuration) ofTrack:audioTrack atTime:kCMTimeZero error:&insertError];
if (insertError) {
[self failWithError:insertError];
return;
@@ -1993,12 +1236,15 @@ static void YouModPresentMenu(NSString *title, NSArray *items
exporter.outputURL = outputURL;
exporter.outputFileType = AVFileTypeMPEG4;
exporter.shouldOptimizeForNetworkUse = YES;
+ self.exporter = exporter;
__weak typeof(self) weakSelf = self;
[exporter exportAsynchronouslyWithCompletionHandler:^{
dispatch_async(dispatch_get_main_queue(), ^{
__strong typeof(weakSelf) self = weakSelf;
if (!self) return;
+ self.exporter = nil;
+ if (self.cancelled || exporter.status == AVAssetExportSessionStatusCancelled) return;
if (exporter.status == AVAssetExportSessionStatusCompleted) {
[self completeWithFileURL:outputURL isVideo:YES presenter:presenter];
} else {
@@ -2008,95 +1254,33 @@ static void YouModPresentMenu(NSString *title, NSArray *items
}];
}
-- (void)trimSingleVideoURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter {
- [self updateProgressTitle:@"Finalizing video" progress:0.99f];
- [NSFileManager.defaultManager removeItemAtURL:outputURL error:nil];
-
- AVURLAsset *asset = [AVURLAsset URLAssetWithURL:inputURL options:nil];
- AVAssetTrack *videoTrack = [[asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
- if (!videoTrack) {
- [self failWithError:[NSError errorWithDomain:@"YouMod" code:10 userInfo:@{NSLocalizedDescriptionKey: @"Cannot finalize video"}]];
- return;
- }
-
- CMTime duration = YouModExportDuration(asset, nil, durationMs);
- if (!YouModCMTimeIsUsable(duration)) {
- [self failWithError:[NSError errorWithDomain:@"YouMod" code:11 userInfo:@{NSLocalizedDescriptionKey: @"Cannot determine duration"}]];
- return;
- }
-
- AVMutableComposition *composition = [AVMutableComposition composition];
- NSError *insertError = nil;
- AVMutableCompositionTrack *compositionVideo = [composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
- [compositionVideo insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration) ofTrack:videoTrack atTime:kCMTimeZero error:&insertError];
- compositionVideo.preferredTransform = videoTrack.preferredTransform;
- if (insertError) {
- [self failWithError:insertError];
- return;
- }
-
- AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
- if (audioTrack) {
- CMTime audioDuration = YouModMinUsableDuration(duration, audioTrack.timeRange.duration);
- AVMutableCompositionTrack *compositionAudio = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
- [compositionAudio insertTimeRange:CMTimeRangeMake(kCMTimeZero, audioDuration) ofTrack:audioTrack atTime:kCMTimeZero error:&insertError];
- if (insertError) {
- [self failWithError:insertError];
- return;
- }
- }
-
- AVAssetExportSession *exporter = [[AVAssetExportSession alloc] initWithAsset:composition presetName:AVAssetExportPresetPassthrough];
- exporter.outputURL = outputURL;
- exporter.outputFileType = AVFileTypeMPEG4;
- exporter.shouldOptimizeForNetworkUse = YES;
-
- __weak typeof(self) weakSelf = self;
- [exporter exportAsynchronouslyWithCompletionHandler:^{
- dispatch_async(dispatch_get_main_queue(), ^{
- __strong typeof(weakSelf) self = weakSelf;
- if (!self) return;
- if (exporter.status == AVAssetExportSessionStatusCompleted) {
- [self completeWithFileURL:outputURL isVideo:YES presenter:presenter];
- } else {
- [self failWithError:exporter.error ?: [NSError errorWithDomain:@"YouMod" code:12 userInfo:@{NSLocalizedDescriptionKey: @"Finalize failed"}]];
- }
- });
- }];
-}
-
- (void)completeWithFileURL:(NSURL *)fileURL isVideo:(BOOL)isVideo presenter:(UIViewController *)presenter {
+ if (self.cancelled) return;
self.active = NO;
- [self updateProgressTitle:@"Download completed" progress:1.0f];
- [self.progressAlert dismissViewControllerAnimated:YES completion:nil];
- self.progressAlert = nil;
- self.progressView = nil;
-
- BOOL canSaveToPhotos = isVideo && YouModVideoFileCanSaveToPhotos(fileURL);
- if (isVideo && IS_ENABLED(DownloadSaveToPhotos) && canSaveToPhotos) {
- [self cleanupTemporaryFiles];
+ [self updateProgressTitle:LOC(@"DOWNLOAD_COMPLETED") progress:1.0f];
+ if (self.progressPill) { [self.progressPill dismiss]; self.progressPill = nil; }
+
+ if (isVideo && IS_ENABLED(DownloadSaveToPhotos) && YouModVideoFileCanSaveToPhotos(fileURL)) {
YouModSaveVideoToPhotos(fileURL, presenter, ^(BOOL success, NSError *error) {
if (success) {
- YouModSendToast(@"Saved to Photos", presenter);
+ YouModSendSuccess(LOC(@"SAVED_TO_PHOTOS"));
} else {
- YouModSendToast(error.localizedDescription ?: @"Cannot save to Photos", presenter);
+ YouModSendError(error.localizedDescription ?: LOC(@"CANNOT_SAVE_TO_PHOTOS"));
YouModShareFile(fileURL, presenter);
}
});
} else {
- [self cleanupTemporaryFiles];
- YouModSendToast(isVideo ? @"Download completed" : @"Audio saved", presenter);
- if (!isVideo || (isVideo && !canSaveToPhotos)) YouModShareFile(fileURL, presenter);
+ YouModSendSuccess(LOC(@"DOWNLOAD_COMPLETED"));
+ YouModShareFile(fileURL, presenter);
}
}
- (void)failWithError:(NSError *)error {
+ if (self.cancelled) return;
self.active = NO;
- [self.progressAlert dismissViewControllerAnimated:YES completion:nil];
- self.progressAlert = nil;
- self.progressView = nil;
+ if (self.progressPill) { [self.progressPill dismiss]; self.progressPill = nil; }
[self cleanupTemporaryFiles];
- YouModSendToast(error.localizedDescription ?: @"Download failed", self.presenter);
+ YouModSendError(error.localizedDescription ?: LOC(@"DOWNLOAD_FAILED"));
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
@@ -2109,46 +1293,233 @@ static void YouModPresentMenu(NSString *title, NSArray *items
}
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location {
+ if (self.cancelled) return;
self.finishedCurrentFile = YES;
+
+ NSURL *destURL = self.destinationURL;
+
NSError *error = nil;
- [NSFileManager.defaultManager removeItemAtURL:self.destinationURL error:nil];
- [NSFileManager.defaultManager moveItemAtURL:location toURL:self.destinationURL error:&error];
- if (self.fileCompletion) self.fileCompletion(error ? nil : self.destinationURL, error);
+ [[NSFileManager defaultManager] removeItemAtURL:destURL error:nil];
+ [[NSFileManager defaultManager] moveItemAtURL:location toURL:destURL error:&error];
+
+ if (self.downloadCompletionBlock) {
+ self.downloadCompletionBlock(error ? nil : destURL, error ? error.localizedDescription : nil);
+ self.downloadCompletionBlock = nil;
+ } else if (self.fileCompletion) {
+ self.fileCompletion(error ? nil : destURL, error);
+ self.fileCompletion = nil;
+ }
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
- if (error && !self.finishedCurrentFile && self.fileCompletion) {
- self.fileCompletion(nil, error);
+ if (error && !self.finishedCurrentFile) {
+ if (self.downloadCompletionBlock) {
+ self.downloadCompletionBlock(nil, error.localizedDescription);
+ self.downloadCompletionBlock = nil;
+ } else if (self.fileCompletion) {
+ self.fileCompletion(nil, error);
+ self.fileCompletion = nil;
+ }
+ }
+ // We intentionally don't invalidate the shared session here if it's reused.
+}
+
+- (NSString *)serverEndpoint {
+ if (INTFORVAL(DownloadServerIndex) == 0) {
+ return @"https://appropriatenet2928.tail6a9ca7.ts.net/"; // Europe (@AppropriateNet2928)
+ } else if (INTFORVAL(DownloadServerIndex) == 1) {
+ return @"https://waterdl.freeddns.org/"; // Thailand - Asia (@Tonwalter888)
}
+ return @"";
+}
+
+- (void)triggerSilentDownloadWithQuality:(NSString *)quality isAudio:(BOOL)isAudio videoID:(NSString *)vidID presenter:(UIViewController *)presenter {
+ __weak typeof(self) weakSelf = self;
+ [self requestDownloadForVideoId:vidID isAudio:isAudio quality:quality presenter:presenter completion:^(NSURL *localURL, NSString *errorMsg) {
+ __strong typeof(weakSelf) strongSelf = weakSelf;
+ if (!strongSelf || strongSelf.cancelled) return;
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!strongSelf || strongSelf.cancelled) return;
+ if (!localURL) {
+ [strongSelf cancelWithMessage:errorMsg];
+ return;
+ }
+ [strongSelf completeWithFileURL:localURL isVideo:!isAudio presenter:presenter];
+ });
+ }];
+}
+
+- (void)requestDownloadForVideoId:(NSString *)vId isAudio:(BOOL)isAudio quality:(NSString *)quality presenter:(UIViewController *)presenter completion:(void (^)(NSURL *localURL, NSString *errorMsg))completionBlock {
+ [self showProgressWithTitle:LOC(@"CONNECTING_TO_SERVER") presenter:presenter];
+ NSString *watchURL = [NSString stringWithFormat:@"https://www.youtube.com/watch?v=%@", vId];
+ [self startYTDMDownloadWithWatchURL:watchURL format:isAudio ? @"audio" : @"video" formatId:quality presenter:presenter completion:completionBlock];
+}
+
+- (void)startYTDMDownloadWithWatchURL:(NSString *)watchURL format:(NSString *)format formatId:(NSString *)formatId presenter:(UIViewController *)presenter completion:(void (^)(NSURL *localURL, NSString *errorMsg))completionBlock {
+ if (!self || self.cancelled) return;
+ NSString *urlStr = [[self serverEndpoint] stringByAppendingString:@"/api/download"];
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlStr]];
+ [request setHTTPMethod:@"POST"];
+ [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
+
+ NSMutableDictionary *payload = [@{@"url": watchURL, @"format": format} mutableCopy];
+ if (formatId) payload[@"format_id"] = formatId;
+ request.HTTPBody = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
+
+ [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ if (!self || self.cancelled) return;
+ if (error || !data) { completionBlock(nil, @"Server unreachable."); return; }
+ NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+
+ if (json[@"job_id"]) {
+ BOOL isAudioDl = [format isEqualToString:@"audio"];
+ [self pollJobStatus:json[@"job_id"] isAudio:isAudioDl presenter:presenter completion:completionBlock];
+ }
+ else {
+ completionBlock(nil, json[@"error"] ?: @"Job init failed.");
+ }
+ }] resume];
+}
+
+- (void)pollJobStatus:(NSString *)jobId isAudio:(BOOL)isAudio presenter:(UIViewController *)presenter completion:(void (^)(NSURL *localURL, NSString *errorMsg))completionBlock {
+ if (!self || self.cancelled) return;
+
+ NSString *urlStr = [NSString stringWithFormat:@"%@/api/status/%@", [self serverEndpoint], jobId];
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:urlStr]];
+
+ [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ if (!self || self.cancelled) return;
+ if (error || !data) {
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+ [self pollJobStatus:jobId isAudio:isAudio presenter:presenter completion:completionBlock];
+ });
+ return;
+ }
+
+ NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+ NSString *status = json[@"status"];
+
+ if ([status isEqualToString:@"done"]) {
+ NSString *singleFileName = json[@"filename"];
+
+ if (!singleFileName || singleFileName.length == 0) {
+ singleFileName = isAudio ? @"downloaded_file.mp3" : @"downloaded_file.mp4";
+ }
+
+ [self downloadSingleFile:singleFileName isAudio:isAudio forJobId:jobId presenter:presenter completion:completionBlock];
+
+ } else if ([status isEqualToString:@"error"]) {
+ completionBlock(nil, json[@"error"] ?: @"Error.");
+ } else {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self updateProgressTitle:LOC(@"DOWNLOADING_TO_SERVER") progress:0.0f];
+ });
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+ [self pollJobStatus:jobId isAudio:isAudio presenter:presenter completion:completionBlock];
+ });
+ }
+ }] resume];
+}
+
+- (void)downloadSingleFile:(NSString *)filename isAudio:(BOOL)isAudio forJobId:(NSString *)jobId presenter:(UIViewController *)presenter completion:(void (^)(NSURL *localURL, NSString *errorMsg))completionBlock {
+ if (!self || self.cancelled) return;
+
+ self.downloadCompletionBlock = completionBlock;
+
+ NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:filename];
+ self.destinationURL = [NSURL fileURLWithPath:tempPath];
+
+ self.finishedCurrentFile = NO;
+ self.currentBytes = 0;
+ self.currentExpectedBytes = 0;
+ self.baseProgressTitle = isAudio ? LOC(@"DOWNLOADING_AUDIO") : LOC(@"DOWNLOADING_VIDEO");
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self updateProgressTitle:self.baseProgressTitle progress:0.0f];
+ });
+
+ NSString *urlString = [NSString stringWithFormat:@"%@/api/file/%@", [self serverEndpoint], jobId];
+
+ self.task = [self.session downloadTaskWithURL:[NSURL URLWithString:urlString]];
+ self.task.taskDescription = filename;
+
+ [self.task resume];
}
@end
+static void YouModShowThumbnailViewer(NSString *videoID, UIViewController *presenter) {
+ NSURL *thumbnailURL = YouModThumbnailURLForVideoID(videoID);
+ if (!thumbnailURL) {
+ YouModSendError(LOC(@"NO_THUMBNAIL_FOUND"));
+ return;
+ }
+
+ [[NSURLSession.sharedSession dataTaskWithURL:thumbnailURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ UIImage *image = data ? [UIImage imageWithData:data] : nil;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!image || error) {
+ YouModSendError(error.localizedDescription ?: LOC(@"THUMBNAIL_FAILED"));
+ return;
+ }
+
+ YouModThumbnailViewController *viewerVC = [[YouModThumbnailViewController alloc] init];
+ viewerVC.thumbnailImage = image;
+ viewerVC.modalPresentationStyle = UIModalPresentationOverFullScreen;
+ viewerVC.modalTransitionStyle = UIModalTransitionStyleCoverVertical;
+
+ [presenter presentViewController:viewerVC animated:YES completion:nil];
+ });
+ }] resume];
+}
+
+static void YouModCopyThumbnail(NSString *videoID, UIViewController *presenter) {
+ NSURL *thumbnailURL = YouModThumbnailURLForVideoID(videoID);
+ if (!thumbnailURL) {
+ YouModSendError(LOC(@"NO_THUMBNAIL_FOUND"));
+ return;
+ }
+
+ YouModSendToast(LOC(@"DOWNLOADING_THUMBNAIL"));
+ [[NSURLSession.sharedSession dataTaskWithURL:thumbnailURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ UIImage *image = data ? [UIImage imageWithData:data] : nil;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!image || error) {
+ YouModSendError(error.localizedDescription ?: LOC(@"THUMBNAIL_FAILED"));
+ return;
+ }
+ [[UIPasteboard generalPasteboard] setImage:image];
+ YouModSendSuccess(LOC(@"COPIED_TO_CLIPBOARD"));
+ });
+ }] resume];
+}
+
static void YouModDownloadThumbnail(NSString *videoID, UIViewController *presenter) {
NSURL *thumbnailURL = YouModThumbnailURLForVideoID(videoID);
if (!thumbnailURL) {
- YouModSendToast(@"No thumbnail found", presenter);
+ YouModSendError(LOC(@"NO_THUMBNAIL_FOUND"));
return;
}
- YouModSendToast(@"Downloading thumbnail", presenter);
+ YouModSendToast(LOC(@"DOWNLOADING_THUMBNAIL"));
[[NSURLSession.sharedSession dataTaskWithURL:thumbnailURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
UIImage *image = data ? [UIImage imageWithData:data] : nil;
dispatch_async(dispatch_get_main_queue(), ^{
if (!image || error) {
- YouModSendToast(error.localizedDescription ?: @"Thumbnail failed", presenter);
+ YouModSendError(error.localizedDescription ?: LOC(@"THUMBNAIL_FAILED"));
return;
}
YouModRequestPhotoAccess(^(BOOL granted) {
if (!granted) {
- YouModSendToast(@"Photos access denied", presenter);
+ YouModSendError(LOC(@"PHOTO_ACCESS_DENINED"));
return;
}
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
[PHAssetChangeRequest creationRequestForAssetFromImage:image];
} completionHandler:^(BOOL success, NSError *saveError) {
dispatch_async(dispatch_get_main_queue(), ^{
- YouModSendToast(success ? @"Saved to Photos" : (saveError.localizedDescription ?: @"Save failed"), presenter);
+ if (success) YouModSendSuccess(LOC(@"SAVED_TO_PHOTOS")); else YouModSendError(saveError.localizedDescription ?: LOC(@"SAVE_FAILED"));
});
}];
});
@@ -2156,188 +1527,210 @@ static void YouModDownloadThumbnail(NSString *videoID, UIViewController *present
}] resume];
}
-static void YouModCopyVideoInfo(YTPlayerViewController *player, UIViewController *presenter) {
- NSString *videoID = YouModVideoIDForPlayer(player);
- NSString *title = YouModTitleForPlayer(player);
- NSString *url = videoID.length ? [NSString stringWithFormat:@"https://youtu.be/%@", videoID] : @"";
- UIPasteboard.generalPasteboard.string = url.length ? [NSString stringWithFormat:@"%@\n%@", title, url] : title;
- YouModSendToast(@"Copied video information", presenter);
+static void YouModCopyTextToPasteboard(NSString *text, NSString *successKey) {
+ UIPasteboard.generalPasteboard.string = text;
+ YouModSendSuccess(LOC(successKey));
}
-static void YouModShowVideoQualitySheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
- NSArray *videoFormats = YouModFormatsForPlayer(player, YES);
- YouModMediaFormat *audioFormat = YouModBestAudioFormatForPlayer(player);
- NSString *title = YouModTitleForPlayer(player);
- NSString *videoID = YouModVideoIDForPlayer(player);
+static void YouModCopyImageToPasteboard(UIImage *image, NSString *successKey) {
+ UIPasteboard.generalPasteboard.image = image;
+ YouModSendSuccess(LOC(successKey));
+}
- if (videoFormats.count == 0 || !audioFormat) {
- YouModSendToast(@"No video/audio streams found", presenter);
- return;
- }
+static void YouModShowCopyVideoInfoSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSString *author = YouModAuthorForPlayer(player);
+ NSString *title = YouModTitleForPlayer(player);
+ NSString *description = YouModDescriptionForPlayer(player);
+ NSString *all = [NSString stringWithFormat:@"%@ - %@\n%@", author, title, description];
NSMutableArray *items = [NSMutableArray array];
- for (YouModMediaFormat *format in videoFormats) {
- NSString *rowTitle = format.qualityLabel.length ? format.qualityLabel : @"Video";
- NSString *subtitle = YouModFormatSubtitle(format);
- [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModIconImage(658) handler:^{
- [[YouModDownloadCoordinator sharedCoordinator] startVideoDownloadWithVideoFormat:format audioFormat:audioFormat fileName:title videoID:videoID presenter:presenter];
- }]];
- }
- YouModPresentMenu(@"Download video", items, presenter, sender);
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_ALL_VID_INFO") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModCopyTextToPasteboard(all, @"COPIED_VID_INFO");
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_AUTHOR") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModCopyTextToPasteboard(author, @"COPIED_AUTHOR");
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_TITLE") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModCopyTextToPasteboard(title, @"COPIED_TITLE");
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_DESCRIPTION") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModCopyTextToPasteboard(description, @"COPIED_DESCRIPTION");
+ }]];
+
+ YouModPresentMenu(nil, items, presenter, sender);
}
-static void YouModShowAudioSourceSheet(YTPlayerViewController *player, YouModAudioOutputFormat *outputFormat, UIViewController *presenter, UIView *sender) {
+static void YouModShowAudioTrackSelectionSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender, NSString *fileName, BOOL downloadVideo, YouModMediaFormat *videoFormat) {
NSArray *audioFormats = YouModFormatsForPlayer(player, NO);
- NSString *title = YouModTitleForPlayer(player);
- NSString *videoID = YouModVideoIDForPlayer(player);
- NSMutableArray *items = [NSMutableArray array];
-
if (audioFormats.count == 0) {
- if (items.count) {
- YouModPresentMenu(@"Download audio", items, presenter, sender);
- return;
+ YouModSendError(LOC(@"NO_AUDIO_STREAM_FOUND"));
+ return;
+ }
+
+ if (audioFormats.count == 1 || IS_ENABLED(DownloadFix)) {
+ YouModMediaFormat *selectedFormat = audioFormats.firstObject;
+ if (downloadVideo) {
+ [[YouModDownloadCoordinator sharedCoordinator] startVideoDownloadWithVideoFormat:videoFormat audioFormat:selectedFormat fileName:fileName presenter:presenter videoID:player.currentVideoID];
+ } else {
+ [[YouModDownloadCoordinator sharedCoordinator] startAudioDownloadWithAudioFormat:selectedFormat fileName:fileName presenter:presenter videoID:player.currentVideoID];
}
- YouModSendToast(@"No audio streams found", presenter);
return;
}
- NSUInteger index = 1;
+ NSMutableArray *items = [NSMutableArray array];
for (YouModMediaFormat *format in audioFormats) {
- NSString *rowTitle = audioFormats.count == 1 ? @"Audio" : [NSString stringWithFormat:@"Audio %lu", (unsigned long)index++];
- NSString *subtitle = YouModFormatSubtitle(format);
- [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModIconImage(21) handler:^{
- [[YouModDownloadCoordinator sharedCoordinator] startAudioDownloadWithAudioFormat:format fileName:title videoID:videoID outputFormat:outputFormat presenter:presenter];
+ NSString *rowTitle = format.qualityLabel;
+ NSString *subtitle = YouModFormatSubtitle(format, NO);
+ [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModYTIconImage(906, NO, nil) handler:^{
+ if (downloadVideo) {
+ [[YouModDownloadCoordinator sharedCoordinator] startVideoDownloadWithVideoFormat:videoFormat audioFormat:format fileName:fileName presenter:presenter videoID:player.currentVideoID];
+ } else {
+ [[YouModDownloadCoordinator sharedCoordinator] startAudioDownloadWithAudioFormat:format fileName:fileName presenter:presenter videoID:player.currentVideoID];
+ }
}]];
}
- NSString *menuTitle = outputFormat.title.length ? [NSString stringWithFormat:@"Download %@", outputFormat.title] : @"Download audio";
- YouModPresentMenu(menuTitle, items, presenter, sender);
+
+ YouModPresentMenu(nil, items, presenter, sender);
}
-static void YouModShowAudioSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+static void YouModShowVideoQualitySheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender, BOOL isShorts) {
+ NSArray *videoFormats = YouModFormatsForPlayer(player, YES);
+ NSString *title = YouModTitleForPlayer(player);
+
+ if (videoFormats.count == 0) {
+ YouModSendError(LOC(@"NO_VID_AUDIO_STREAM_FOUND"));
+ return;
+ }
+
NSMutableArray *items = [NSMutableArray array];
- for (YouModAudioOutputFormat *format in YouModAudioOutputFormats()) {
- [items addObject:[YouModMenuItem itemWithTitle:format.title subtitle:YouModAudioOutputSubtitle(format) icon:YouModIconImage(21) handler:^{
- if (!format.supported) {
- YouModSendToast(@"DSD export is not supported by bundled FFmpeg.", presenter);
- return;
- }
- YouModShowAudioSourceSheet(player, format, presenter, sender);
- }]];
+ for (YouModMediaFormat *format in videoFormats) {
+ NSString *rowTitle = format.qualityLabel;
+ NSString *subtitle = YouModFormatSubtitle(format, YES);
+ if (isShorts) {
+ [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModYTIconImage(769, NO, nil) handler:^{
+ YouModShowAudioTrackSelectionSheet(player, presenter, sender, title, YES, format);
+ }]];
+ } else {
+ [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModYTIconImage(658, NO, nil) handler:^{
+ YouModShowAudioTrackSelectionSheet(player, presenter, sender, title, YES, format);
+ }]];
+ }
}
- YouModPresentMenu(@"Audio format", items, presenter, sender);
+ YouModPresentMenu(nil, items, presenter, sender);
+}
+
+static void YouModStartDownloadAudio(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSString *title = YouModTitleForPlayer(player);
+ YouModShowAudioTrackSelectionSheet(player, presenter, sender, title, NO, nil);
}
static void YouModShowCaptionsSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
NSArray *tracks = YouModCaptionTracksForPlayer(player);
if (tracks.count == 0) {
- YouModSendToast(@"No captions available for this video.", presenter);
+ YouModSendError(LOC(@"NO_CAPTIONS"));
return;
}
NSMutableArray *items = [NSMutableArray array];
- for (id track in tracks) {
- NSString *baseURL = YouModStringFromSelector(track, @selector(baseURL));
+ for (YTICaptionTrackEntry *track in tracks) {
+ NSString *baseURL = track.baseURL;
if (baseURL.length == 0) continue;
- NSString *languageCode = YouModStringFromSelector(track, @selector(languageCode));
- NSString *vssId = YouModStringFromSelector(track, @selector(vssId));
- NSString *nameStr = nil;
- id nameObj = YouModObjectFromSelector(track, @selector(name));
- nameStr = YouModStringFromSelector(nameObj, @selector(simpleText));
- if (!nameStr.length) {
- NSArray *runs = YouModObjectFromSelector(nameObj, @selector(runsArray));
- if (runs.count > 0) nameStr = YouModStringFromSelector(runs.firstObject, @selector(text));
- }
- if (!nameStr.length) nameStr = languageCode;
- if (!nameStr.length) nameStr = vssId;
+ NSString *languageCode = track.languageCode;
+ YTIFormattedString *nameObj = track.name;
+ NSString *nameStr = nameObj.dropdownOptionTitle;
- [items addObject:[YouModMenuItem itemWithTitle:nameStr subtitle:languageCode icon:YouModIconImage(637) handler:^{
+ [items addObject:[YouModMenuItem itemWithTitle:nameStr subtitle:languageCode icon:YouModYTIconImage(50, NO, nil) handler:^{
NSString *vttURL = [baseURL stringByAppendingString:@"&fmt=vtt"];
NSURL *url = [NSURL URLWithString:vttURL];
if (!url) {
- YouModSendToast(@"Invalid caption URL.", presenter);
+ YouModSendError(LOC(@"NO_CAPTIONS_URL"));
return;
}
- YouModSendToast(@"Downloading captions...", presenter);
+ YouModSendToast(LOC(@"DOWNLOADING_CAPTIONS"));
[[NSURLSession.sharedSession dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ if (error || data.length == 0) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ YouModSendError(LOC(@"CAPTIONS_FAILED"));
+ });
+ return;
+ }
+
+ NSString *title = YouModTitleForPlayer(player);
+ NSString *filename = [NSString stringWithFormat:@"%@.%@", title, languageCode];
+ NSURL *tempURL = YouModUniqueFileURL(filename, @"vtt");
+
+ NSError *writeError = nil;
+ BOOL writeSuccess = [data writeToURL:tempURL options:NSDataWritingAtomic error:&writeError];
+
dispatch_async(dispatch_get_main_queue(), ^{
- if (error || data.length == 0) {
- YouModSendToast(@"Failed to download captions.", presenter);
- return;
+ if (writeSuccess) {
+ YouModSendSuccess(LOC(@"DOWNLOAD_COMPLETED"));
+ YouModShareFile(tempURL, presenter);
+ } else {
+ YouModSendError(writeError.localizedDescription ?: LOC(@"CAPTIONS_FAILED"));
}
- NSString *videoID = YouModVideoIDForPlayer(player) ?: @"video";
- NSString *filename = [NSString stringWithFormat:@"%@_%@.vtt", videoID, languageCode ?: @"captions"];
- NSURL *tempURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:filename]];
- [data writeToURL:tempURL atomically:YES];
- YouModShareFile(tempURL, presenter);
});
}] resume];
}]];
}
if (items.count == 0) {
- YouModSendToast(@"No valid caption URLs found.", presenter);
+ YouModSendError(LOC(@"NO_CAPTIONS_URL"));
return;
}
- YouModPresentMenu(@"Download captions", items, presenter, sender);
+ YouModPresentMenu(nil, items, presenter, sender);
}
-static void YouModShowDownloadManager(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
- if (!player) {
- YouModSendToast(@"Open a video before using the download manager.", presenter);
- return;
- }
+static void YouModShowThumbnailSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSString *videoID = player.currentVideoID;
- NSString *videoID = YouModVideoIDForPlayer(player);
NSMutableArray *items = [NSMutableArray array];
- [items addObject:[YouModMenuItem itemWithTitle:@"Download video" subtitle:@"Choose quality" icon:YouModIconImage(658) handler:^{
- YouModShowVideoQualitySheet(player, presenter, sender);
- }]];
- [items addObject:[YouModMenuItem itemWithTitle:@"Download audio" subtitle:@"Choose format" icon:YouModIconImage(21) handler:^{
- YouModShowAudioSheet(player, presenter, sender);
- }]];
- [items addObject:[YouModMenuItem itemWithTitle:@"Download captions" subtitle:@"Save subtitles as VTT" icon:YouModIconImage(637) handler:^{
- YouModShowCaptionsSheet(player, presenter, sender);
- }]];
- [items addObject:[YouModMenuItem itemWithTitle:@"Copy diagnostics" subtitle:@"Copy last error log" icon:YouModIconImage(870) handler:^{
- YouModCopyDownloadDiagnostics(presenter);
- }]];
- [items addObject:[YouModMenuItem itemWithTitle:@"Save thumbnail" subtitle:@"Save to Photos" icon:YouModIconImage(367) handler:^{
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SAVE_THUMBNAIL") subtitle:nil icon:YouModYTIconImage(57, NO, nil) handler:^{
YouModDownloadThumbnail(videoID, presenter);
}]];
- [items addObject:[YouModMenuItem itemWithTitle:@"Copy video information" subtitle:@"Copy title and URL" icon:YouModIconImage(250) handler:^{
- YouModCopyVideoInfo(player, presenter);
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SHOW_THUMBNAIL") subtitle:nil icon:YouModYTIconImage(208, NO, nil) handler:^{
+ YouModShowThumbnailViewer(videoID, presenter);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_THUMBNAIL") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModCopyThumbnail(videoID, presenter);
}]];
- YouModPresentMenu(@"Download manager", items, presenter, sender);
-}
-
-void YouModConfigureDownloadButton(_ASDisplayView *view) {
- if (![view.accessibilityIdentifier isEqualToString:@"id.ui.add_to.offline.button"]) return;
- if (!IS_ENABLED(DownloadManager) || IS_ENABLED(HideDownloadButton)) return;
- if (objc_getAssociatedObject(view, @selector(YouModDownloadButtonTapped:))) return;
- view.userInteractionEnabled = YES;
- UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:view action:@selector(YouModDownloadButtonTapped:)];
- tap.cancelsTouchesInView = YES;
- tap.delaysTouchesBegan = YES;
- tap.delaysTouchesEnded = YES;
- [view addGestureRecognizer:tap];
- objc_setAssociatedObject(view, @selector(YouModDownloadButtonTapped:), @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ YouModPresentMenu(nil, items, presenter, sender);
}
-%hook _ASDisplayView
+static void YouModShowDownloadManager(YTPlayerViewController *player, UIViewController *presenter, UIView *sender, BOOL isShorts) {
+ if (!player) {
+ YouModSendError(LOC(@"OPEN_VID_BEFORE"));
+ return;
+ }
+ NSMutableArray *items = [NSMutableArray array];
-%new
-- (void)YouModDownloadButtonTapped:(UITapGestureRecognizer *)sender {
- if (sender.state != UIGestureRecognizerStateEnded) return;
- UIViewController *presenter = YouModPresenterForSender(self, YouModCurrentPlayerViewController);
- YTPlayerViewController *player = YouModPlayerFromViewController(presenter);
- YouModShowDownloadManager(player, presenter, self);
+ if (isShorts) {
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"DOWNLOAD_SHORTS") subtitle:nil icon:YouModYTIconImage(769, NO, nil) handler:^{
+ YouModShowVideoQualitySheet(player, presenter, sender, YES);
+ }]];
+ } else {
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"DOWNLOAD_VIDEO") subtitle:nil icon:YouModYTIconImage(658, NO, nil) handler:^{
+ YouModShowVideoQualitySheet(player, presenter, sender, NO);
+ }]];
+ }
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"DOWNLOAD_AUDIO") subtitle:nil icon:YouModYTIconImage(21, NO, nil) handler:^{
+ YouModStartDownloadAudio(player, presenter, sender);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"DOWNLOAD_CAPTIONS") subtitle:nil icon:YouModYTIconImage(50, NO, nil) handler:^{
+ YouModShowCaptionsSheet(player, presenter, sender);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"THUMBNAIL_OPTIONS") subtitle:nil icon:YouModYTIconImage(367, NO, nil) handler:^{
+ YouModShowThumbnailSheet(player, presenter, sender);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_VID_INFO") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModShowCopyVideoInfoSheet(player, presenter, sender);
+ }]];
+ YouModPresentMenu(player, items, presenter, sender);
}
-%end
-
%hook YTPlayerViewController
- (void)viewDidAppear:(BOOL)animated {
@@ -2384,3 +1777,343 @@ NSString *YouModGlobalAuthHeader = nil;
return token;
}
%end
+
+void YouModConfigureDownloadButton(_ASDisplayView *view) {
+ if (!IS_ENABLED(DownloadManager)) return;
+ if (objc_getAssociatedObject(view, @selector(YouModDownloadButtonTapped:))) return;
+
+ if ([view.accessibilityIdentifier isEqualToString:@"id.ui.add_to.offline.button"]) {
+ view.userInteractionEnabled = YES;
+ UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:view action:@selector(YouModDownloadButtonTapped:)];
+ tap.cancelsTouchesInView = YES;
+ tap.delaysTouchesBegan = YES;
+ tap.delaysTouchesEnded = YES;
+ [view addGestureRecognizer:tap];
+ objc_setAssociatedObject(view, @selector(YouModDownloadButtonTapped:), @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ }
+}
+
+static void YouModShowTranslationDialog(NSString *text, UIViewController *presenter) {
+ if (!text || text.length == 0 || !presenter) return;
+
+ YouModTranslationViewController *vc = [[YouModTranslationViewController alloc] init];
+ vc.originalText = text;
+
+ UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
+
+ if (@available(iOS 15.0, *)) {
+ UISheetPresentationController *sheet = nav.sheetPresentationController;
+ if (sheet) {
+ sheet.detents = @[
+ [UISheetPresentationControllerDetent mediumDetent],
+ [UISheetPresentationControllerDetent largeDetent]
+ ];
+ sheet.prefersGrabberVisible = YES;
+ sheet.preferredCornerRadius = 24.0;
+ }
+ } else {
+ // Fallback for iOS 14
+ nav.modalPresentationStyle = UIModalPresentationFormSheet;
+ }
+ [presenter presentViewController:nav animated:YES completion:nil];
+}
+
+static NSString *YouModExtractCommentText(UIView *cellView) {
+ if (!cellView) return @"";
+
+ NSString *resultText = @"";
+ NSMutableArray *queue = [NSMutableArray arrayWithObject:cellView];
+ Class asDisplayClass = NSClassFromString(@"_ASDisplayView");
+ Class elmTextExClass = NSClassFromString(@"ELMExpandableTextNode");
+ Class elmTextClass = NSClassFromString(@"ELMTextNode");
+
+ while (queue.count > 0) {
+ UIView *current = queue.firstObject;
+ [queue removeObjectAtIndex:0];
+
+ if (asDisplayClass && [current isKindOfClass:asDisplayClass]) {
+ id node = [current performSelector:@selector(keepalive_node)];
+
+ BOOL isExpandableText = node && elmTextExClass && [node isKindOfClass:elmTextExClass];
+ BOOL isText = node && elmTextClass && [node isKindOfClass:elmTextClass];
+ BOOL isCommentLabel = [current.accessibilityIdentifier isEqualToString:@"id.comment.content.label"];
+
+ if (isText || isExpandableText || isCommentLabel) {
+ resultText = current.accessibilityLabel ?: @"";
+ break;
+ }
+ }
+
+ [queue addObjectsFromArray:current.subviews];
+ }
+
+ return resultText;
+}
+
+static UIImage *YouModRenderViewToImage(_ASDisplayView *view) {
+ if (!view || view.bounds.size.width <= 0 || view.bounds.size.height <= 0) return nil;
+
+ UIColor *realBgColor = view.keepalive_node.closestViewController.view.backgroundColor;
+ UIGraphicsBeginImageContextWithOptions(view.bounds.size, NO, [UIScreen mainScreen].scale);
+ CGContextRef context = UIGraphicsGetCurrentContext();
+ [realBgColor setFill];
+ CGContextFillRect(context, view.bounds);
+ [view drawViewHierarchyInRect:view.bounds afterScreenUpdates:YES];
+
+ UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
+ UIGraphicsEndImageContext();
+ return image;
+}
+
+/*
+static UIImage *YouModExtractPostImage(UIView *cellView) {
+ NSMutableArray *queue = [NSMutableArray arrayWithObject:cellView];
+ Class asDisplayClass = NSClassFromString(@"_ASDisplayView");
+ Class imageZoomNodeClass = NSClassFromString(@"YTImageZoomNode");
+ UIView *targetViewForRender = nil;
+
+ while (queue.count > 0) {
+ UIView *current = queue.firstObject;
+ [queue removeObjectAtIndex:0];
+
+ if (asDisplayClass && [current isKindOfClass:asDisplayClass]) {
+ id node = [current performSelector:@selector(keepalive_node)];
+
+ if (imageZoomNodeClass && [node isKindOfClass:imageZoomNodeClass]) {
+ if (!targetViewForRender) {
+ targetViewForRender = current;
+ }
+ }
+ }
+
+ @synchronized (current) {
+ [queue addObjectsFromArray:current.subviews];
+ }
+ }
+
+ if (targetViewForRender && targetViewForRender.bounds.size.width > 0 && targetViewForRender.bounds.size.height > 0) {
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:targetViewForRender.bounds.size];
+ return [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) {
+ [targetViewForRender.layer renderInContext:rendererContext.CGContext];
+ }];
+ }
+
+ return nil;
+}
+*/
+
+%hook _ASDisplayView
+
+- (void)layoutSubviews {
+ %orig;
+ if ([self.accessibilityIdentifier isEqualToString:@"id.ui.comment_cell"] && IS_ENABLED(DownloadComment)) {
+ BOOL hasGesture = NO;
+ for (UIGestureRecognizer *g in self.gestureRecognizers) {
+ if ([g.name isEqualToString:@"YouModCommentLongPress"]) {
+ hasGesture = YES;
+ break;
+ }
+ }
+
+ if (!hasGesture) {
+ UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(YouModHandleCommentLongPress:)];
+ longPress.name = @"YouModCommentLongPress";
+ longPress.minimumPressDuration = 0.3;
+ [self addGestureRecognizer:longPress];
+ }
+ } else if ([self.accessibilityIdentifier isEqualToString:@"id.ui.backstage.original_post"] && IS_ENABLED(DownloadPost)) {
+ BOOL hasGesture = NO;
+ for (UIGestureRecognizer *g in self.gestureRecognizers) {
+ if ([g.name isEqualToString:@"YouModPostLongPress"]) {
+ hasGesture = YES;
+ break;
+ }
+ }
+
+ if (!hasGesture) {
+ UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(YouModHandlePostLongPress:)];
+ longPress.name = @"YouModPostLongPress";
+ longPress.minimumPressDuration = 0.3;
+ [self addGestureRecognizer:longPress];
+ }
+ }
+}
+
+%new
+- (void)YouModHandleCommentLongPress:(UILongPressGestureRecognizer *)sender {
+ if (sender.state != UIGestureRecognizerStateBegan) return;
+
+ NSMutableArray *items = [NSMutableArray array];
+ NSString *commentText = YouModExtractCommentText(self);
+
+ if (commentText && commentText.length > 0) {
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"TRANSLATE_COMMENT") subtitle:nil icon:YouModYTIconImage(897, NO, nil) handler:^{
+ UIViewController *presenter = YouModPresenterForSender(self, nil);
+ YouModShowTranslationDialog(commentText, presenter);
+ }]];
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_COMMENT_TEXT") subtitle:nil icon:YouModYTIconImage(243, NO, nil) handler:^{
+ YouModCopyTextToPasteboard(commentText, @"COPIED_TO_CLIPBOARD");
+ }]];
+ }
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SAVE_COMMENT_IMAGE") subtitle:nil icon:YouModYTIconImage(367, NO, nil) handler:^{
+ UIImage *image = YouModRenderViewToImage(self);
+ if (image) {
+ UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil);
+ YouModSendSuccess(LOC(@"SAVED_TO_PHOTOS"));
+ }
+ }]];
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_COMMENT_IMAGE") subtitle:nil icon:YouModYTIconImage(208, NO, nil) handler:^{
+ UIImage *image = YouModRenderViewToImage(self);
+ if (image) {
+ YouModCopyImageToPasteboard(image, @"COPIED_TO_CLIPBOARD");
+ }
+ }]];
+
+ UIViewController *presenter = YouModPresenterForSender(self, nil);
+ if (!presenter) return;
+
+ YouModPresentMenu(nil, items, presenter, self);
+}
+
+%new
+- (void)YouModHandlePostLongPress:(UILongPressGestureRecognizer *)sender {
+ if (sender.state != UIGestureRecognizerStateBegan) return;
+
+ NSMutableArray *items = [NSMutableArray array];
+ NSString *commentText = YouModExtractCommentText(self);
+
+ if (commentText && commentText.length > 0) {
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"TRANSLATE_POST") subtitle:nil icon:YouModYTIconImage(897, NO, nil) handler:^{
+ UIViewController *presenter = YouModPresenterForSender(self, nil);
+ YouModShowTranslationDialog(commentText, presenter);
+ }]];
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_POST_TEXT") subtitle:nil icon:YouModYTIconImage(243, NO, nil) handler:^{
+ YouModCopyTextToPasteboard(commentText, @"COPIED_TO_CLIPBOARD");
+ }]];
+ }
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SAVE_POST_IMAGE") subtitle:nil icon:YouModYTIconImage(367, NO, nil) handler:^{
+ UIImage *image = YouModRenderViewToImage(self);
+ if (image) {
+ UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil);
+ YouModSendSuccess(LOC(@"SAVED_TO_PHOTOS"));
+ }
+ }]];
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_POST_IMAGE") subtitle:nil icon:YouModYTIconImage(208, NO, nil) handler:^{
+ UIImage *image = YouModRenderViewToImage(self);
+ if (image) {
+ YouModCopyImageToPasteboard(image, @"COPIED_TO_CLIPBOARD");
+ }
+ }]];
+
+ /*
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SAVE_CURRENT_IMAGE") subtitle:nil icon:YouModYTIconImage(367, YES, [UIColor systemPurpleColor]) handler:^{
+ UIImage *image = YouModExtractPostImage(self);
+ if (image) {
+ UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil);
+ YouModSendSuccess(LOC(@"SAVED_TO_PHOTOS"));
+ }
+ }]];
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_CURRENT_IMAGE") subtitle:nil icon:YouModYTIconImage(208, YES, [UIColor systemPurpleColor]) handler:^{
+ UIImage *image = YouModExtractPostImage(self);
+ if (image) {
+ YouModCopyImageToPasteboard(image, @"COPIED_TO_CLIPBOARD");
+ }
+ }]];
+ */
+
+ UIViewController *presenter = YouModPresenterForSender(self, nil);
+ if (!presenter) return;
+
+ YouModPresentMenu(nil, items, presenter, self);
+}
+
+%new
+- (void)YouModDownloadButtonTapped:(UITapGestureRecognizer *)sender {
+ if (sender.state != UIGestureRecognizerStateEnded) return;
+ UIViewController *presenter = YouModPresenterForSender(self, YouModCurrentPlayerViewController);
+ YTPlayerViewController *player = YouModPlayerFromViewController(presenter);
+ YouModShowDownloadManager(player, presenter, self, NO);
+}
+
+%end
+
+%hook YTReelWatchPlaybackOverlayView
+
+- (void)layoutSubviews {
+ %orig;
+ if (!IS_ENABLED(DownloadManager) || !IS_ENABLED(AddDownloadToShorts)) return;
+ UIView *likeButtonView = nil;
+ for (UIView *subview in self.subviews) {
+ if ([subview isKindOfClass:%c(YTReelPlayerButton)]) {
+ likeButtonView = subview;
+ break;
+ }
+ }
+
+ if (!likeButtonView) return;
+
+ YTQTMButton *downloadBtn = (YTQTMButton *)[self viewWithTag:1501];
+ if (!downloadBtn) {
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:20 weight:UIImageSymbolWeightMedium];
+ UIImage *icon = [[UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:config] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
+ downloadBtn = [%c(YTQTMButton) iconButton];
+ [downloadBtn setImage:icon forState:UIControlStateNormal];
+ downloadBtn.tintColor = [UIColor whiteColor];
+ downloadBtn.exclusiveTouch = YES;
+ downloadBtn.tag = 1501;
+
+ [downloadBtn addTarget:self action:@selector(didTapYouModShortsDownload:) forControlEvents:UIControlEventTouchUpInside];
+ [self addSubview:downloadBtn];
+ }
+
+ CGFloat btnWidth = 64.0;
+ CGFloat btnHeight = 60.0;
+
+ CGFloat pX = likeButtonView.frame.origin.x;
+ CGFloat pY = likeButtonView.frame.origin.y + 85.0;
+
+ downloadBtn.frame = CGRectMake(pX, pY, btnWidth, btnHeight);
+
+ [self bringSubviewToFront:downloadBtn];
+}
+
+%new
+- (void)didTapYouModShortsDownload:(YTQTMButton *)button {
+ UIResponder *responder = self.nextResponder;
+ while (responder && ![responder isKindOfClass:%c(YTShortsPlayerViewController)]) {
+ responder = responder.nextResponder;
+ }
+ if (responder) {
+ YTShortsPlayerViewController *shortsPlayerView = (YTShortsPlayerViewController *)responder;
+ YTPlayerViewController *player = (YTPlayerViewController *)shortsPlayerView.childViewControllers[0];
+ UIViewController *presenter = YouModPresenterForSender(button, player);
+ YouModShowDownloadManager(player, presenter, button, YES);
+ }
+}
+
+%end
+
+%ctor {
+ %init;
+ YMOverlayButtonSpec *download = [[YMOverlayButtonSpec alloc] init];
+ download.identifier = @"download.video";
+ download.symbolName = @"arrow.down.circle";
+ download.tintColor = [UIColor whiteColor];
+ download.sortOrder = 200;
+ download.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(DownloadManager);
+ };
+ download.onTap = ^(YTPlayerViewController *player, UIButton *button) {
+ UIViewController *presenter = YouModPresenterForSender(button, player ?: YouModCurrentPlayerViewController);
+ YTPlayerViewController *resolved = YouModPlayerFromViewController(presenter) ?: player ?: YouModCurrentPlayerViewController;
+ YouModShowDownloadManager(resolved, presenter, button, NO);
+ };
+ YMRegisterOverlayButton(download);
+}
diff --git a/Files/Download_FFmpeg.txt b/Files/Download_FFmpeg.txt
new file mode 100644
index 00000000..eccd24af
--- /dev/null
+++ b/Files/Download_FFmpeg.txt
@@ -0,0 +1,2390 @@
+// HUGE thanks to @daisuke1227 for implementing all of this
+#import "Headers.h"
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+
+@interface YTDefaultSheetController (YouModDownload)
++ (instancetype)sheetControllerWithParentResponder:(id)parentResponder;
+- (void)addAction:(YTActionSheetAction *)action;
+- (void)presentFromView:(UIView *)view animated:(BOOL)animated completion:(void (^)(void))completion;
+- (void)presentFromViewController:(UIViewController *)vc animated:(BOOL)animated completion:(void (^)(void))completion;
+@end
+
+@interface YTPlayerViewController (YouModDownload)
+- (YTPlayerResponse *)contentPlayerResponse;
+@end
+
+@interface YTIPlayerResponse (YouModDownload)
+- (id)streamingData;
+@end
+
+@interface YTIStreamingData : NSObject
+- (NSArray *)adaptiveFormatsArray;
+@end
+
+@interface YTIFormatStream (YouModDownload)
+- (NSString *)mimeType;
+- (BOOL)hasContentLength;
+- (unsigned long long)contentLength;
+- (unsigned long long)approxDurationMs;
+@end
+
+@interface YTIVideoDetails (YouModDownload)
+- (NSString *)title;
+- (NSString *)author;
+- (NSString *)shortDescription;
+@end
+
+static UIImage *YouModIconImage(NSInteger iconType) {
+ YTIIcon *icon = [%c(YTIIcon) new];
+ icon.iconType = iconType;
+ UIImage *image = [icon iconImageWithColor:[UIColor labelColor]];
+ return [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
+}
+
+@interface YouModMenuItem : NSObject
+@property (nonatomic, copy) NSString *title;
+@property (nonatomic, copy) NSString *subtitle;
+@property (nonatomic, strong) UIImage *iconImage;
+@property (nonatomic, copy) void (^handler)(void);
++ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle handler:(void (^)(void))handler;
++ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle icon:(UIImage *)icon handler:(void (^)(void))handler;
+@end
+
+@implementation YouModMenuItem
++ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle handler:(void (^)(void))handler {
+ return [self itemWithTitle:title subtitle:subtitle icon:nil handler:handler];
+}
++ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle icon:(UIImage *)icon handler:(void (^)(void))handler {
+ YouModMenuItem *item = [YouModMenuItem new];
+ item.title = title;
+ item.subtitle = subtitle;
+ item.iconImage = icon;
+ item.handler = handler;
+ return item;
+}
+@end
+
+@interface YouModMediaFormat : NSObject
+@property (nonatomic, strong) id source;
+@property (nonatomic, copy) NSString *urlString;
+@property (nonatomic, copy) NSString *qualityLabel;
+@property (nonatomic, copy) NSString *mimeType;
+@property (nonatomic, copy) NSDictionary *httpHeaders;
+@property (nonatomic, assign) unsigned long long contentLength;
+@property (nonatomic, assign) unsigned long long durationMs;
+@property (nonatomic, assign) NSInteger fps;
+@property (nonatomic, assign) BOOL video;
+@property (nonatomic, copy) NSString *languageCode;
+@property (nonatomic, copy) NSString *languageName;
+@property (nonatomic, assign) BOOL drcAudio;
+@end
+
+@implementation YouModMediaFormat
+@end
+
+@interface YouModAudioOutputFormat : NSObject
+@property (nonatomic, copy) NSString *identifier;
+@property (nonatomic, copy) NSString *title;
+@property (nonatomic, copy) NSString *subtitle;
+@property (nonatomic, copy) NSString *fileExtension;
+@property (nonatomic, copy) NSArray *ffmpegArguments;
+@property (nonatomic, assign) BOOL passthroughWhenCompatible;
+@property (nonatomic, assign) BOOL supported;
+@end
+
+@implementation YouModAudioOutputFormat
+@end
+
+typedef void (^YouModFileDownloadCompletion)(NSURL *fileURL, NSError *error);
+typedef void (^YouModMergeCompletion)(BOOL success, NSError *error);
+typedef void (^YouModRangeDownloadProgress)(unsigned long long completedBytes);
+
+@interface YouModDownloadChunk : NSObject
+@property (nonatomic, assign) unsigned long long offset;
+@property (nonatomic, assign) unsigned long long length;
+@property (nonatomic, assign) NSUInteger attempts;
+@end
+
+@implementation YouModDownloadChunk
+@end
+
+@interface YouModRangeDownloader : NSObject
+@property (nonatomic, strong) NSURL *url;
+@property (nonatomic, strong) NSURL *destinationURL;
+@property (nonatomic, copy) NSDictionary *httpHeaders;
+@property (nonatomic, assign) unsigned long long expectedBytes;
+@property (nonatomic, copy) YouModRangeDownloadProgress progress;
+@property (nonatomic, copy) YouModFileDownloadCompletion completion;
+@property (nonatomic, strong) NSURLSession *session;
+@property (nonatomic, strong) NSFileHandle *fileHandle;
+@property (nonatomic, strong) NSMutableArray *pendingChunks;
+@property (nonatomic, strong) NSMutableSet *tasks;
+@property (nonatomic, strong) dispatch_queue_t stateQueue;
+@property (nonatomic, strong) dispatch_queue_t fileQueue;
+@property (nonatomic, assign) NSUInteger activeTaskCount;
+@property (nonatomic, assign) NSUInteger totalChunkCount;
+@property (nonatomic, assign) unsigned long long completedBytes;
+@property (nonatomic, assign) BOOL cancelled;
+@property (nonatomic, assign) BOOL finished;
+- (instancetype)initWithURL:(NSURL *)url destinationURL:(NSURL *)destinationURL expectedBytes:(unsigned long long)expectedBytes headers:(NSDictionary *)headers progress:(YouModRangeDownloadProgress)progress completion:(YouModFileDownloadCompletion)completion;
+- (void)start;
+- (void)cancel;
+@end
+
+@interface YouModDownloadCoordinator : NSObject
+@property (nonatomic, strong) NSURLSession *session;
+@property (nonatomic, strong) NSURLSessionDownloadTask *task;
+@property (nonatomic, strong) NSURLSessionDataTask *metadataTask;
+@property (nonatomic, strong) YouModRangeDownloader *rangeDownloader;
+@property (nonatomic, strong) UIAlertController *progressAlert;
+@property (nonatomic, strong) UIProgressView *progressView;
+@property (nonatomic, weak) UIViewController *presenter;
+@property (nonatomic, copy) YouModFileDownloadCompletion fileCompletion;
+@property (nonatomic, strong) NSURL *destinationURL;
+@property (nonatomic, strong) NSURL *videoTempURL;
+@property (nonatomic, strong) NSURL *audioTempURL;
+@property (nonatomic, assign) unsigned long long completedBytes;
+@property (nonatomic, assign) unsigned long long totalBytes;
+@property (nonatomic, assign) unsigned long long currentBytes;
+@property (nonatomic, assign) unsigned long long currentExpectedBytes;
+@property (nonatomic, assign) BOOL currentResolvedSizeAddedToTotal;
+@property (nonatomic, assign) BOOL active;
+@property (nonatomic, assign) BOOL finishedCurrentFile;
+@property (nonatomic, assign) BOOL cancelled;
+@property (nonatomic, copy) NSString *baseProgressTitle;
+@property (nonatomic, assign) NSTimeInterval downloadStartTime;
++ (instancetype)sharedCoordinator;
+- (void)startVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
+- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
+- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter;
+- (void)startDirectVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
+- (void)startDirectSingleVideoDownloadWithFormat:(YouModMediaFormat *)format fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter;
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter;
+- (void)mergeVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL fileName:(NSString *)fileName outputExtension:(NSString *)outputExtension durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter;
+- (void)mergeVideoWithAVFoundationVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter fallbackError:(NSError *)fallbackError;
+- (void)trimSingleVideoURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter;
+- (void)convertAudioURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL outputFormat:(YouModAudioOutputFormat *)outputFormat durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter;
+@end
+
+static const unsigned long long YouModFastDownloadMinimumBytes = 256ULL * 1024ULL;
+static const unsigned long long YouModFastDownloadChunkBytes = 4ULL * 1024ULL * 1024ULL;
+static const NSUInteger YouModFastDownloadConcurrency = 8;
+static const NSUInteger YouModFastDownloadMaxAttempts = 3;
+
+static BOOL YouModHTTPHeadersContainField(NSDictionary *headers, NSString *field) {
+ for (id key in headers) {
+ if ([key isKindOfClass:NSString.class] && [(NSString *)key caseInsensitiveCompare:field] == NSOrderedSame)
+ return YES;
+ }
+ return NO;
+}
+
+static NSString *YouModYouTubeCookiesString(void) {
+ NSMutableArray *cookieStrings = [NSMutableArray array];
+ for (NSHTTPCookie *cookie in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]) {
+ if ([cookie.domain containsString:@"youtube.com"]) {
+ [cookieStrings addObject:[NSString stringWithFormat:@"%@=%@", cookie.name, cookie.value]];
+ }
+ }
+ return [cookieStrings componentsJoinedByString:@"; "];
+}
+
+static NSString *YouModNativeUserAgent(void) {
+ NSString *version = @"21.18.4";
+ NSString *sysVersion = [[UIDevice currentDevice].systemVersion stringByReplacingOccurrencesOfString:@"." withString:@"_"] ?: @"18_7";
+ return [NSString stringWithFormat:@"com.google.ios.youtube/%@ (iPhone; CPU iPhone OS %@ like Mac OS X)", version, sysVersion];
+}
+
+static void YouModApplyDownloadHeaders(NSMutableURLRequest *request, NSDictionary *headers) {
+ for (id key in headers) {
+ id value = headers[key];
+ if ([key isKindOfClass:NSString.class] && [value isKindOfClass:NSString.class])
+ [request setValue:value forHTTPHeaderField:key];
+ }
+ if (!YouModHTTPHeadersContainField(headers, @"User-Agent"))
+ [request setValue:YouModNativeUserAgent() forHTTPHeaderField:@"User-Agent"];
+ if (!YouModHTTPHeadersContainField(headers, @"Origin"))
+ [request setValue:@"https://www.youtube.com" forHTTPHeaderField:@"Origin"];
+ if (!YouModHTTPHeadersContainField(headers, @"Referer"))
+ [request setValue:@"https://www.youtube.com/" forHTTPHeaderField:@"Referer"];
+ if (!YouModHTTPHeadersContainField(headers, @"Cookie")) {
+ NSString *cookies = YouModYouTubeCookiesString();
+ if (cookies.length > 0) [request setValue:cookies forHTTPHeaderField:@"Cookie"];
+ }
+ extern NSString *YouModGlobalAuthHeader;
+ if (YouModGlobalAuthHeader && !YouModHTTPHeadersContainField(headers, @"Authorization")) {
+ [request setValue:YouModGlobalAuthHeader forHTTPHeaderField:@"Authorization"];
+ }
+ [request setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"];
+}
+
+@implementation YouModRangeDownloader
+
+- (instancetype)initWithURL:(NSURL *)url destinationURL:(NSURL *)destinationURL expectedBytes:(unsigned long long)expectedBytes headers:(NSDictionary *)headers progress:(YouModRangeDownloadProgress)progress completion:(YouModFileDownloadCompletion)completion {
+ self = [super init];
+ if (self) {
+ _url = url;
+ _destinationURL = destinationURL;
+ _httpHeaders = [headers copy];
+ _expectedBytes = expectedBytes;
+ _progress = [progress copy];
+ _completion = [completion copy];
+ _pendingChunks = [NSMutableArray array];
+ _tasks = [NSMutableSet set];
+ _stateQueue = dispatch_queue_create("com.youmod.download.range.state", DISPATCH_QUEUE_SERIAL);
+ _fileQueue = dispatch_queue_create("com.youmod.download.range.file", DISPATCH_QUEUE_SERIAL);
+
+ NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
+ configuration.HTTPMaximumConnectionsPerHost = YouModFastDownloadConcurrency;
+ configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
+ configuration.timeoutIntervalForResource = 300;
+ NSMutableDictionary *additionalHeaders = [NSMutableDictionary dictionary];
+ for (id key in headers) {
+ id value = headers[key];
+ if ([key isKindOfClass:NSString.class] && [value isKindOfClass:NSString.class])
+ additionalHeaders[key] = value;
+ }
+ if (!YouModHTTPHeadersContainField(additionalHeaders, @"User-Agent"))
+ additionalHeaders[@"User-Agent"] = YouModNativeUserAgent();
+ if (!YouModHTTPHeadersContainField(additionalHeaders, @"Origin"))
+ additionalHeaders[@"Origin"] = @"https://www.youtube.com";
+ if (!YouModHTTPHeadersContainField(additionalHeaders, @"Referer"))
+ additionalHeaders[@"Referer"] = @"https://www.youtube.com/";
+ if (!YouModHTTPHeadersContainField(additionalHeaders, @"Cookie")) {
+ NSString *cookies = YouModYouTubeCookiesString();
+ if (cookies.length > 0) additionalHeaders[@"Cookie"] = cookies;
+ }
+ extern NSString *YouModGlobalAuthHeader;
+ if (YouModGlobalAuthHeader && !YouModHTTPHeadersContainField(additionalHeaders, @"Authorization")) {
+ additionalHeaders[@"Authorization"] = YouModGlobalAuthHeader;
+ }
+ additionalHeaders[@"Accept-Encoding"] = @"identity";
+ configuration.HTTPAdditionalHeaders = additionalHeaders;
+ _session = [NSURLSession sessionWithConfiguration:configuration];
+ }
+ return self;
+}
+
+- (NSError *)errorWithCode:(NSInteger)code message:(NSString *)message {
+ return [NSError errorWithDomain:@"YouMod" code:code userInfo:@{NSLocalizedDescriptionKey: message ?: @"Download failed"}];
+}
+
+- (BOOL)prepareDestinationWithError:(NSError **)error {
+ [NSFileManager.defaultManager removeItemAtURL:self.destinationURL error:nil];
+ if (![NSFileManager.defaultManager createFileAtPath:self.destinationURL.path contents:nil attributes:nil]) {
+ if (error) *error = [self errorWithCode:20 message:@"Cannot create file"];
+ return NO;
+ }
+
+ self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.destinationURL.path];
+ if (!self.fileHandle) {
+ if (error) *error = [self errorWithCode:21 message:@"Cannot open file"];
+ return NO;
+ }
+
+ @try {
+ [self.fileHandle truncateFileAtOffset:self.expectedBytes];
+ } @catch (NSException *exception) {
+ if (error) *error = [self errorWithCode:22 message:exception.reason ?: @"Cannot allocate file"];
+ return NO;
+ }
+ return YES;
+}
+
+- (void)start {
+ dispatch_async(self.stateQueue, ^{
+ if (self.expectedBytes == 0) {
+ [self finishWithErrorLocked:[self errorWithCode:23 message:@"Unknown stream size"]];
+ return;
+ }
+
+ NSError *error = nil;
+ if (![self prepareDestinationWithError:&error]) {
+ [self finishWithErrorLocked:error];
+ return;
+ }
+
+ unsigned long long chunkSize = self.expectedBytes / YouModFastDownloadConcurrency;
+ if (chunkSize < 256ULL * 1024ULL) chunkSize = 256ULL * 1024ULL;
+ if (chunkSize > YouModFastDownloadChunkBytes) chunkSize = YouModFastDownloadChunkBytes;
+
+ for (unsigned long long offset = 0; offset < self.expectedBytes; offset += chunkSize) {
+ YouModDownloadChunk *chunk = [YouModDownloadChunk new];
+ chunk.offset = offset;
+ unsigned long long remaining = self.expectedBytes - offset;
+ chunk.length = remaining < chunkSize ? remaining : chunkSize;
+ [self.pendingChunks addObject:chunk];
+ }
+ self.totalChunkCount = self.pendingChunks.count;
+ [self scheduleChunksLocked];
+ });
+}
+
+- (void)cancel {
+ dispatch_async(self.stateQueue, ^{
+ if (self.finished) return;
+ self.cancelled = YES;
+ self.finished = YES;
+ for (NSURLSessionDataTask *task in self.tasks) [task cancel];
+ [self.tasks removeAllObjects];
+ [self.session invalidateAndCancel];
+ dispatch_async(self.fileQueue, ^{
+ @try {
+ [self.fileHandle closeFile];
+ } @catch (__unused NSException *exception) {
+ }
+ [NSFileManager.defaultManager removeItemAtURL:self.destinationURL error:nil];
+ });
+ });
+}
+
+- (void)scheduleChunksLocked {
+ if (self.finished || self.cancelled) return;
+ while (self.activeTaskCount < YouModFastDownloadConcurrency && self.pendingChunks.count > 0) {
+ YouModDownloadChunk *chunk = self.pendingChunks.firstObject;
+ [self.pendingChunks removeObjectAtIndex:0];
+ [self startChunkLocked:chunk];
+ }
+
+ if (self.activeTaskCount == 0 && self.pendingChunks.count == 0) {
+ [self finishSuccessfullyLocked];
+ }
+}
+
+- (void)startChunkLocked:(YouModDownloadChunk *)chunk {
+ unsigned long long end = chunk.offset + chunk.length - 1;
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:self.url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:60.0];
+ YouModApplyDownloadHeaders(request, self.httpHeaders);
+ [request setValue:[NSString stringWithFormat:@"bytes=%llu-%llu", chunk.offset, end] forHTTPHeaderField:@"Range"];
+
+ __weak typeof(self) weakSelf = self;
+ __block NSURLSessionDataTask *task = nil;
+ task = [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) return;
+ [self completeChunk:chunk task:task data:data response:response error:error];
+ }];
+ [self.tasks addObject:task];
+ self.activeTaskCount++;
+ [task resume];
+}
+
+- (NSError *)validationErrorForChunk:(YouModDownloadChunk *)chunk data:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error {
+ if (error) return error;
+
+ NSHTTPURLResponse *httpResponse = [response isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)response : nil;
+ NSInteger statusCode = httpResponse.statusCode;
+ BOOL statusOK = statusCode == 206 || (self.totalChunkCount == 1 && statusCode == 200);
+ if (httpResponse && !statusOK)
+ return [self errorWithCode:24 message:@"Range request rejected by server"];
+
+ if (data.length != chunk.length)
+ return [self errorWithCode:25 message:@"Incomplete chunk"];
+
+ return nil;
+}
+
+- (void)completeChunk:(YouModDownloadChunk *)chunk task:(NSURLSessionDataTask *)task data:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error {
+ dispatch_async(self.stateQueue, ^{
+ if (self.activeTaskCount > 0) self.activeTaskCount--;
+ if (task) [self.tasks removeObject:task];
+ if (self.finished || self.cancelled) return;
+
+ NSError *validationError = [self validationErrorForChunk:chunk data:data response:response error:error];
+ if (validationError) {
+ if (validationError.code == 24) {
+ [self finishWithErrorLocked:validationError];
+ return;
+ }
+ if (chunk.attempts + 1 < YouModFastDownloadMaxAttempts) {
+ chunk.attempts++;
+ [self.pendingChunks insertObject:chunk atIndex:0];
+ [self scheduleChunksLocked];
+ } else {
+ [self finishWithErrorLocked:validationError];
+ }
+ return;
+ }
+
+ NSData *chunkData = [data copy];
+ dispatch_async(self.fileQueue, ^{
+ NSError *writeError = nil;
+ @try {
+ [self.fileHandle seekToFileOffset:chunk.offset];
+ [self.fileHandle writeData:chunkData];
+ } @catch (NSException *exception) {
+ writeError = [self errorWithCode:26 message:exception.reason ?: @"Write failed"];
+ }
+
+ dispatch_async(self.stateQueue, ^{
+ if (self.finished || self.cancelled) return;
+ if (writeError) {
+ [self finishWithErrorLocked:writeError];
+ return;
+ }
+
+ self.completedBytes += chunkData.length;
+ if (self.progress) {
+ unsigned long long completed = self.completedBytes;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ self.progress(completed);
+ });
+ }
+ [self scheduleChunksLocked];
+ });
+ });
+ });
+}
+
+- (void)finishSuccessfullyLocked {
+ if (self.finished) return;
+ self.finished = YES;
+ [self.session finishTasksAndInvalidate];
+ dispatch_async(self.fileQueue, ^{
+ @try {
+ [self.fileHandle closeFile];
+ } @catch (__unused NSException *exception) {
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (self.completion) self.completion(self.destinationURL, nil);
+ });
+ });
+}
+
+- (void)finishWithErrorLocked:(NSError *)error {
+ if (self.finished) return;
+ self.finished = YES;
+ for (NSURLSessionDataTask *task in self.tasks) [task cancel];
+ [self.tasks removeAllObjects];
+ [self.session invalidateAndCancel];
+ dispatch_async(self.fileQueue, ^{
+ @try {
+ [self.fileHandle closeFile];
+ } @catch (__unused NSException *exception) {
+ }
+ [NSFileManager.defaultManager removeItemAtURL:self.destinationURL error:nil];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (self.completion) self.completion(nil, error ?: [self errorWithCode:27 message:@"Download failed"]);
+ });
+ });
+}
+
+@end
+
+static __weak YTPlayerViewController *YouModCurrentPlayerViewController;
+
+void YouModDownloadSetCurrentPlayer(YTPlayerViewController *player) {
+ YouModCurrentPlayerViewController = player;
+}
+
+static NSString *YouModStringFromSelector(id object, SEL selector) {
+ if (!object) return nil;
+ id value = nil;
+ if ([object respondsToSelector:selector]) {
+ value = ((id (*)(id, SEL))objc_msgSend)(object, selector);
+ } else {
+ @try {
+ value = [object valueForKey:NSStringFromSelector(selector)];
+ } @catch (__unused NSException *exception) {
+ value = nil;
+ }
+ }
+ if ([value isKindOfClass:NSString.class]) return value;
+ if ([value isKindOfClass:NSURL.class]) return [(NSURL *)value absoluteString];
+ if ([value respondsToSelector:@selector(stringValue)]) return [value stringValue];
+ return [value respondsToSelector:@selector(description)] ? [value description] : nil;
+}
+
+static id YouModObjectFromSelector(id object, SEL selector) {
+ if (!object) return nil;
+ if ([object respondsToSelector:selector]) {
+ return ((id (*)(id, SEL))objc_msgSend)(object, selector);
+ }
+ @try {
+ return [object valueForKey:NSStringFromSelector(selector)];
+ } @catch (__unused NSException *exception) {
+ return nil;
+ }
+}
+
+static unsigned long long YouModUnsignedLongLongFromSelector(id object, SEL selector) {
+ if (!object) return 0;
+ if ([object respondsToSelector:selector]) {
+ return ((unsigned long long (*)(id, SEL))objc_msgSend)(object, selector);
+ }
+ @try {
+ id value = [object valueForKey:NSStringFromSelector(selector)];
+ if ([value respondsToSelector:@selector(unsignedLongLongValue)])
+ return [value unsignedLongLongValue];
+ } @catch (__unused NSException *exception) {
+ }
+ return 0;
+}
+
+static BOOL YouModBoolFromSelector(id object, SEL selector) {
+ if (!object) return NO;
+ if ([object respondsToSelector:selector]) {
+ return ((BOOL (*)(id, SEL))objc_msgSend)(object, selector);
+ }
+ @try {
+ id value = [object valueForKey:NSStringFromSelector(selector)];
+ if ([value respondsToSelector:@selector(boolValue)])
+ return [value boolValue];
+ } @catch (__unused NSException *exception) {
+ }
+ return NO;
+}
+
+static NSInteger YouModIntegerFromSelector(id object, SEL selector) {
+ if (!object) return 0;
+ if ([object respondsToSelector:selector]) {
+ return ((NSInteger (*)(id, SEL))objc_msgSend)(object, selector);
+ }
+ @try {
+ id value = [object valueForKey:NSStringFromSelector(selector)];
+ if ([value respondsToSelector:@selector(integerValue)])
+ return [value integerValue];
+ } @catch (__unused NSException *exception) {
+ }
+ return 0;
+}
+
+static UIViewController *YouModTopViewController(UIViewController *root) {
+ if (!root) {
+ UIWindow *keyWindow = nil;
+ for (UIWindow *window in UIApplication.sharedApplication.windows) {
+ if (window.isKeyWindow) {
+ keyWindow = window;
+ break;
+ }
+ }
+ root = keyWindow.rootViewController;
+ }
+ while (root.presentedViewController) root = root.presentedViewController;
+ if ([root isKindOfClass:UINavigationController.class])
+ return YouModTopViewController(((UINavigationController *)root).topViewController);
+ if ([root isKindOfClass:UITabBarController.class])
+ return YouModTopViewController(((UITabBarController *)root).selectedViewController);
+ return root;
+}
+
+static void YouModSendToast(NSString *message, id responder) {
+ Class toastClass = NSClassFromString(@"YTToastResponderEvent");
+ id event = [toastClass eventWithMessage:message firstResponder:responder ?: YouModTopViewController(nil)];
+ if ([event respondsToSelector:@selector(send)]) {
+ [event send];
+ return;
+ }
+
+ UIViewController *presenter = YouModTopViewController([responder isKindOfClass:UIViewController.class] ? responder : nil);
+ if (!presenter) return;
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:message preferredStyle:UIAlertControllerStyleAlert];
+ [presenter presentViewController:alert animated:YES completion:^{
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+ [alert dismissViewControllerAnimated:YES completion:nil];
+ });
+ }];
+}
+
+static NSString *YouModByteCount(unsigned long long bytes) {
+ if (bytes == 0) return nil;
+ NSByteCountFormatter *formatter = [NSByteCountFormatter new];
+ formatter.countStyle = NSByteCountFormatterCountStyleFile;
+ return [formatter stringFromByteCount:(long long)bytes];
+}
+
+static NSString *YouModGenerateCPN(void) {
+ static NSString *const alphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ NSMutableString *nonce = [NSMutableString stringWithCapacity:16];
+ for (NSUInteger i = 0; i < 16; i++)
+ [nonce appendFormat:@"%C", [alphabet characterAtIndex:arc4random_uniform((uint32_t)alphabet.length)]];
+ return nonce;
+}
+
+static NSString *YouModURLStringBypassingThrottle(NSString *urlString) {
+ if (urlString.length == 0) return urlString;
+ NSURLComponents *components = [NSURLComponents componentsWithString:urlString];
+ if (components) {
+ NSMutableArray *queryItems = [components.queryItems mutableCopy] ?: [NSMutableArray array];
+ NSMutableArray *filtered = [NSMutableArray array];
+ for (NSURLQueryItem *item in queryItems) {
+ if (![item.name isEqualToString:@"n"])
+ [filtered addObject:item];
+ }
+ BOOL hasRateBypass = NO;
+ for (NSURLQueryItem *item in filtered) {
+ if ([item.name isEqualToString:@"ratebypass"]) { hasRateBypass = YES; break; }
+ }
+ if (!hasRateBypass)
+ [filtered addObject:[NSURLQueryItem queryItemWithName:@"ratebypass" value:@"yes"]];
+ components.queryItems = filtered;
+ NSString *result = components.string;
+ if (result.length > 0) return result;
+ }
+ return urlString;
+}
+
+static NSString *YouModURLStringWithCPN(NSString *urlString) {
+ if (urlString.length == 0) return urlString;
+ urlString = YouModURLStringBypassingThrottle(urlString);
+ if ([urlString containsString:@"cpn="]) return urlString;
+ Class ytDataUtils = NSClassFromString(@"YTDataUtils");
+ NSString *cpn = ((id (*)(Class, SEL))objc_msgSend)(ytDataUtils, @selector(generateClientSideNonce));
+ if (![cpn isKindOfClass:NSString.class] || cpn.length == 0)
+ cpn = YouModGenerateCPN();
+ NSString *separator = [urlString containsString:@"?"] ? @"&" : @"?";
+ return [NSString stringWithFormat:@"%@%@cpn=%@", urlString, separator, cpn];
+}
+
+static NSString *YouModSanitizedFileName(NSString *name) {
+ if (name.length == 0) return @"YouTube Video";
+ NSMutableCharacterSet *invalid = [NSMutableCharacterSet characterSetWithCharactersInString:@"/\\?%*|\"<>:"];
+ [invalid formUnionWithCharacterSet:NSCharacterSet.newlineCharacterSet];
+ NSArray *parts = [name componentsSeparatedByCharactersInSet:invalid];
+ NSString *clean = [[parts componentsJoinedByString:@" "] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ while ([clean containsString:@" "]) clean = [clean stringByReplacingOccurrencesOfString:@" " withString:@" "];
+ if (clean.length > 120) clean = [clean substringToIndex:120];
+ return clean.length ? clean : @"YouTube Video";
+}
+
+static NSURL *YouModDownloadsDirectoryURL(void) {
+ NSURL *documentsURL = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
+ NSURL *downloadsURL = [documentsURL URLByAppendingPathComponent:@"YouMod Downloads" isDirectory:YES];
+ [NSFileManager.defaultManager createDirectoryAtURL:downloadsURL withIntermediateDirectories:YES attributes:nil error:nil];
+ return downloadsURL;
+}
+
+static NSString *YouModLastDownloadDiagnostic;
+
+static NSURL *YouModDiagnosticLogURL(void) {
+ return [YouModDownloadsDirectoryURL() URLByAppendingPathComponent:@"youmod-download-diagnostics.txt"];
+}
+
+static void YouModRecordDownloadDiagnostic(NSString *context, NSString *details) {
+ if (context.length == 0 && details.length == 0) return;
+
+ NSDateFormatter *formatter = [NSDateFormatter new];
+ formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
+ formatter.dateFormat = @"yyyy-MM-dd HH:mm:ss ZZZZZ";
+ NSString *timestamp = [formatter stringFromDate:NSDate.date];
+ NSString *entry = [NSString stringWithFormat:@"[%@]\n%@\n%@\n\n", timestamp ?: @"", context ?: @"", details ?: @""];
+ YouModLastDownloadDiagnostic = entry;
+
+ NSURL *logURL = YouModDiagnosticLogURL();
+ NSData *data = [entry dataUsingEncoding:NSUTF8StringEncoding];
+ if (![NSFileManager.defaultManager fileExistsAtPath:logURL.path])
+ [NSFileManager.defaultManager createFileAtPath:logURL.path contents:nil attributes:nil];
+
+ NSFileHandle *handle = [NSFileHandle fileHandleForWritingAtPath:logURL.path];
+ if (!handle) return;
+ @try {
+ [handle seekToEndOfFile];
+ [handle writeData:data];
+ [handle closeFile];
+ } @catch (__unused NSException *exception) {
+ }
+}
+
+static NSString *YouModDownloadDiagnosticText(void) {
+ if (YouModLastDownloadDiagnostic.length) return YouModLastDownloadDiagnostic;
+ NSString *log = [NSString stringWithContentsOfURL:YouModDiagnosticLogURL() encoding:NSUTF8StringEncoding error:nil];
+ if (log.length == 0) return nil;
+ NSUInteger maxLength = 12000;
+ return log.length > maxLength ? [log substringFromIndex:log.length - maxLength] : log;
+}
+
+static void YouModCopyDownloadDiagnostics(UIViewController *presenter) {
+ NSString *diagnostic = YouModDownloadDiagnosticText();
+ if (diagnostic.length == 0) {
+ YouModSendToast(@"No download diagnostics yet.", presenter);
+ return;
+ }
+ UIPasteboard.generalPasteboard.string = diagnostic;
+ YouModSendToast(@"Copied download diagnostics", presenter);
+}
+
+static NSURL *YouModUniqueFileURL(NSString *fileName, NSString *extension) {
+ NSString *safeName = YouModSanitizedFileName(fileName);
+ NSURL *directoryURL = YouModDownloadsDirectoryURL();
+ NSURL *candidate = [directoryURL URLByAppendingPathComponent:[safeName stringByAppendingPathExtension:extension]];
+ NSUInteger index = 2;
+ while ([NSFileManager.defaultManager fileExistsAtPath:candidate.path]) {
+ NSString *indexed = [NSString stringWithFormat:@"%@ %lu", safeName, (unsigned long)index++];
+ candidate = [directoryURL URLByAppendingPathComponent:[indexed stringByAppendingPathExtension:extension]];
+ }
+ return candidate;
+}
+
+static NSURL *YouModTemporaryFileURL(NSString *extension) {
+ NSString *name = [[NSUUID UUID].UUIDString stringByAppendingPathExtension:extension];
+ return [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:name]];
+}
+
+static NSInteger YouModResolutionFromQuality(NSString *quality);
+static NSInteger YouModFPSFromQuality(NSString *quality);
+static NSInteger YouModNormalizedFPS(NSInteger fps);
+static NSInteger YouModDisplayHeightForVideoHeight(NSInteger height);
+static NSString *YouModQualityLabel(NSInteger height, NSInteger fps, NSString *fallback);
+static BOOL YouModFFmpegKitAvailable(void);
+
+static unsigned long long YouModDurationMsForURL(NSURL *url) {
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
+ if (!CMTIME_IS_NUMERIC(asset.duration) || !CMTIME_IS_VALID(asset.duration)) return 0;
+ Float64 seconds = CMTimeGetSeconds(asset.duration);
+ if (!isfinite(seconds) || seconds <= 0.0) return 0;
+ return (unsigned long long)llround(seconds * 1000.0);
+}
+
+static NSString *YouModDurationSecondsArgument(unsigned long long durationMs) {
+ return [NSString stringWithFormat:@"%.3f", (double)durationMs / 1000.0];
+}
+
+static BOOL YouModCMTimeIsUsable(CMTime time) {
+ if (!CMTIME_IS_VALID(time) || !CMTIME_IS_NUMERIC(time) || CMTIME_IS_INDEFINITE(time)) return NO;
+ Float64 seconds = CMTimeGetSeconds(time);
+ return isfinite(seconds) && seconds > 0.0;
+}
+
+static CMTime YouModMinUsableDuration(CMTime first, CMTime second) {
+ BOOL firstOK = YouModCMTimeIsUsable(first);
+ BOOL secondOK = YouModCMTimeIsUsable(second);
+ if (firstOK && secondOK) return CMTIME_COMPARE_INLINE(first, <, second) ? first : second;
+ if (firstOK) return first;
+ if (secondOK) return second;
+ return kCMTimeInvalid;
+}
+
+static CMTime YouModExportDuration(AVAsset *videoAsset, AVAsset *audioAsset, unsigned long long expectedDurationMs) {
+ CMTime duration = kCMTimeInvalid;
+ if (expectedDurationMs > 0)
+ duration = CMTimeMakeWithSeconds((double)expectedDurationMs / 1000.0, 600);
+
+ CMTime videoDuration = YouModMinUsableDuration(videoAsset.duration, [[videoAsset tracksWithMediaType:AVMediaTypeVideo] firstObject].timeRange.duration);
+ CMTime audioDuration = audioAsset ? YouModMinUsableDuration(audioAsset.duration, [[audioAsset tracksWithMediaType:AVMediaTypeAudio] firstObject].timeRange.duration) : kCMTimeInvalid;
+ CMTime mediaDuration = audioAsset ? YouModMinUsableDuration(videoDuration, audioDuration) : videoDuration;
+
+ if (!YouModCMTimeIsUsable(duration)) return mediaDuration;
+ if (YouModCMTimeIsUsable(mediaDuration) && CMTIME_COMPARE_INLINE(duration, >, mediaDuration))
+ return mediaDuration;
+ return duration;
+}
+
+static NSMutableArray *YouModFFmpegKitLoadEntries(void) {
+ static NSMutableArray *entries = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ entries = [NSMutableArray array];
+ });
+ return entries;
+}
+
+static void YouModAppendFFmpegKitLoadEntry(NSString *format, ...) {
+ if (format.length == 0) return;
+
+ va_list arguments;
+ va_start(arguments, format);
+ NSString *entry = [[NSString alloc] initWithFormat:format arguments:arguments];
+ va_end(arguments);
+ if (entry.length == 0) return;
+
+ NSMutableArray *entries = YouModFFmpegKitLoadEntries();
+ @synchronized(entries) {
+ [entries addObject:entry];
+ if (entries.count > 220)
+ [entries removeObjectsInRange:NSMakeRange(0, entries.count - 220)];
+ }
+}
+
+static NSArray *YouModFFmpegKitSearchDirectories(void) {
+ NSMutableOrderedSet *directories = [NSMutableOrderedSet orderedSet];
+
+ // Path to YouMod.bundle/Frameworks inside the main app bundle
+ NSString *bundlePath = [[NSBundle.mainBundle resourcePath] stringByAppendingPathComponent:@"YouMod.bundle"];
+ NSString *frameworksInsideBundle = [bundlePath stringByAppendingPathComponent:@"Frameworks"];
+
+ // Safety check: only add if the directory actually exists
+ BOOL isDir = NO;
+ if ([[NSFileManager defaultManager] fileExistsAtPath:frameworksInsideBundle isDirectory:&isDir] && isDir) {
+ [directories addObject:frameworksInsideBundle];
+ }
+
+ return directories.array;
+}
+
+static void YouModDlopenPath(NSString *path, BOOL requireExistingFile) {
+ if (path.length == 0) return;
+ if (requireExistingFile && ![NSFileManager.defaultManager fileExistsAtPath:path]) {
+ YouModAppendFFmpegKitLoadEntry(@"missing %@", path);
+ return;
+ }
+
+ dlerror();
+ void *handle = dlopen(path.fileSystemRepresentation, RTLD_NOW | RTLD_GLOBAL);
+ const char *error = dlerror();
+ if (handle) {
+ YouModAppendFFmpegKitLoadEntry(@"loaded %@", path);
+ } else {
+ YouModAppendFFmpegKitLoadEntry(@"failed %@\n dlerror=%@", path, error ? [NSString stringWithUTF8String:error] : @"unknown");
+ }
+}
+
+static void YouModDlopenPathIfPresent(NSString *path) {
+ YouModDlopenPath(path, YES);
+}
+
+static void YouModLoadFrameworkBinary(NSString *directory, NSString *frameworkName, NSString *binaryName) {
+ if (directory.length == 0 || frameworkName.length == 0 || binaryName.length == 0) return;
+ YouModDlopenPathIfPresent([[directory stringByAppendingPathComponent:[frameworkName stringByAppendingString:@".framework"]] stringByAppendingPathComponent:binaryName]);
+ YouModDlopenPathIfPresent([[directory stringByAppendingPathComponent:[frameworkName stringByAppendingString:@".framework"]] stringByAppendingPathComponent:frameworkName]);
+}
+
+static void YouModLoadFFmpegKitIfNeeded(void) {
+ static BOOL attempted = NO;
+ if (NSClassFromString(@"FFmpegKit")) return;
+ if (attempted) return;
+ attempted = YES;
+
+ YouModAppendFFmpegKitLoadEntry(@"[YouMod] Starting bundled FFmpegKit load...");
+
+ // Order is important: load dependencies (avutil, etc.) before the main toolkit
+ NSArray *> *frameworks = @[
+ @[@"libavutil", @"libavutil"],
+ @[@"libswresample", @"libswresample"],
+ @[@"libswscale", @"libswscale"],
+ @[@"libavcodec", @"libavcodec"],
+ @[@"libavformat", @"libavformat"],
+ @[@"libavfilter", @"libavfilter"],
+ @[@"libavdevice", @"libavdevice"],
+ @[@"ffmpegkit", @"ffmpegkit"],
+ @[@"FFmpegKit", @"FFmpegKit"],
+ ];
+
+ NSArray *searchDirs = YouModFFmpegKitSearchDirectories();
+ if (searchDirs.count == 0) {
+ YouModAppendFFmpegKitLoadEntry(@"[YouMod] Error: Bundled Frameworks directory not found.");
+ return;
+ }
+
+ // Only iterate through our controlled bundle directory
+ for (NSString *directory in searchDirs) {
+ for (NSArray *framework in frameworks) {
+ // This helper uses dlopen on the direct path within our bundle
+ YouModLoadFrameworkBinary(directory, framework.firstObject, framework.lastObject);
+ }
+
+ if (NSClassFromString(@"FFmpegKit")) {
+ YouModAppendFFmpegKitLoadEntry(@"[YouMod] Success: FFmpegKit loaded from bundle.");
+ return;
+ }
+ }
+
+ YouModAppendFFmpegKitLoadEntry(@"[YouMod] Critical: FFmpegKit could not be found in YouMod.bundle.");
+}
+
+static Class YouModFFmpegKitClass(void) {
+ Class ffmpegKitClass = NSClassFromString(@"FFmpegKit");
+ if (!ffmpegKitClass) {
+ YouModLoadFFmpegKitIfNeeded();
+ ffmpegKitClass = NSClassFromString(@"FFmpegKit");
+ }
+ return ffmpegKitClass;
+}
+
+static BOOL YouModFFmpegKitAvailable(void) {
+ Class ffmpegKitClass = YouModFFmpegKitClass();
+ return ffmpegKitClass && [ffmpegKitClass respondsToSelector:@selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:)];
+}
+
+static NSString *YouModFFmpegKitDiagnosticText(YouModAudioOutputFormat *outputFormat, YouModMediaFormat *sourceFormat, NSString *videoID) {
+ YouModLoadFFmpegKitIfNeeded();
+
+ Class ffmpegKitClass = NSClassFromString(@"FFmpegKit");
+ SEL executeSelector = @selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:);
+ NSMutableArray *lines = [NSMutableArray array];
+ NSBundle *mainBundle = NSBundle.mainBundle;
+ NSString *resourcePath = mainBundle.resourcePath ?: @"";
+ NSString *privateFrameworksPath = mainBundle.privateFrameworksPath ?: @"";
+ NSString *executablePath = mainBundle.executablePath ?: @"";
+ NSString *bundlePath = [resourcePath stringByAppendingPathComponent:@"YouMod.bundle"];
+ NSString *packageFrameworkPath = [resourcePath stringByAppendingPathComponent:@"YouMod.bundle/Frameworks"];
+
+ [lines addObject:@"FFmpegKit lookup"];
+ [lines addObject:[NSString stringWithFormat:@"videoID=%@", videoID ?: @""]];
+ [lines addObject:[NSString stringWithFormat:@"requestedFormat=%@ (%@)", outputFormat.title ?: @"", outputFormat.identifier ?: @""]];
+ [lines addObject:[NSString stringWithFormat:@"sourceMime=%@", sourceFormat.mimeType ?: @""]];
+ [lines addObject:[NSString stringWithFormat:@"sourceQuality=%@", sourceFormat.qualityLabel ?: @""]];
+ [lines addObject:[NSString stringWithFormat:@"sourceBytes=%llu", sourceFormat.contentLength]];
+ [lines addObject:[NSString stringWithFormat:@"mainBundle=%@", mainBundle.bundlePath ?: @""]];
+ [lines addObject:[NSString stringWithFormat:@"resourcePath=%@", resourcePath]];
+ [lines addObject:[NSString stringWithFormat:@"privateFrameworksPath=%@", privateFrameworksPath]];
+ [lines addObject:[NSString stringWithFormat:@"executablePath=%@", executablePath]];
+ [lines addObject:[NSString stringWithFormat:@"YouMod.bundle exists=%@", [NSFileManager.defaultManager fileExistsAtPath:bundlePath] ? @"YES" : @"NO"]];
+ [lines addObject:[NSString stringWithFormat:@"YouMod.bundle/Frameworks exists=%@", [NSFileManager.defaultManager fileExistsAtPath:packageFrameworkPath] ? @"YES" : @"NO"]];
+ [lines addObject:[NSString stringWithFormat:@"FFmpegKit class=%@", ffmpegKitClass ? @"YES" : @"NO"]];
+ [lines addObject:[NSString stringWithFormat:@"FFmpegKit execute selector=%@", [ffmpegKitClass respondsToSelector:executeSelector] ? @"YES" : @"NO"]];
+ [lines addObject:[NSString stringWithFormat:@"ReturnCode class=%@", NSClassFromString(@"ReturnCode") ? @"YES" : @"NO"]];
+ [lines addObject:@"searchDirectories:"];
+ for (NSString *directory in YouModFFmpegKitSearchDirectories()) {
+ BOOL isDirectory = NO;
+ BOOL exists = [NSFileManager.defaultManager fileExistsAtPath:directory isDirectory:&isDirectory];
+ [lines addObject:[NSString stringWithFormat:@" %@ exists=%@ directory=%@", directory, exists ? @"YES" : @"NO", isDirectory ? @"YES" : @"NO"]];
+ }
+
+ NSMutableArray *entries = YouModFFmpegKitLoadEntries();
+ [lines addObject:@"dlopenAttempts:"];
+ @synchronized(entries) {
+ [lines addObjectsFromArray:entries];
+ }
+ return [lines componentsJoinedByString:@"\n"];
+}
+
+static void YouModCancelFFmpegKit(void) {
+ Class ffmpegKitClass = YouModFFmpegKitClass();
+ if ([ffmpegKitClass respondsToSelector:@selector(cancel)])
+ ((void (*)(Class, SEL))objc_msgSend)(ffmpegKitClass, @selector(cancel));
+}
+
+static NSError *YouModFFmpegErrorFromSession(id session) {
+ NSString *failure = YouModStringFromSelector(session, @selector(getFailStackTrace));
+ NSString *message = failure.length ? failure : @"FFmpeg failed";
+ return [NSError errorWithDomain:@"YouMod" code:7 userInfo:@{NSLocalizedDescriptionKey: message}];
+}
+
+static BOOL YouModPathExtensionIsPhotosVideo(NSString *extension) {
+ NSString *lower = extension.lowercaseString ?: @"";
+ return [@[@"mp4", @"m4v", @"mov"] containsObject:lower];
+}
+
+static BOOL YouModStartFFmpegKitMerge(NSURL *videoURL, NSURL *audioURL, NSURL *outputURL, unsigned long long durationMs, void (^progress)(float progress), YouModMergeCompletion completion) {
+ Class ffmpegKitClass = YouModFFmpegKitClass();
+ SEL executeSelector = @selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:);
+ if (![ffmpegKitClass respondsToSelector:executeSelector]) return NO;
+
+ NSMutableArray *arguments = [@[
+ @"-y",
+ @"-i", videoURL.path,
+ @"-i", audioURL.path,
+ @"-map", @"0:v:0",
+ @"-map", @"1:a:0",
+ ] mutableCopy];
+ if (durationMs > 0)
+ [arguments addObjectsFromArray:@[@"-t", YouModDurationSecondsArgument(durationMs)]];
+ [arguments addObjectsFromArray:@[
+ @"-c", @"copy",
+ @"-shortest",
+ @"-avoid_negative_ts", @"make_zero",
+ ]];
+ if (YouModPathExtensionIsPhotosVideo(outputURL.pathExtension))
+ [arguments addObjectsFromArray:@[@"-movflags", @"+faststart"]];
+ [arguments addObject:outputURL.path];
+
+ id completeBlock = [^(id session) {
+ Class returnCodeClass = NSClassFromString(@"ReturnCode");
+ id returnCode = YouModObjectFromSelector(session, @selector(getReturnCode));
+ BOOL success = NO;
+ if ([returnCodeClass respondsToSelector:@selector(isSuccess:)])
+ success = ((BOOL (*)(Class, SEL, id))objc_msgSend)(returnCodeClass, @selector(isSuccess:), returnCode);
+
+ NSError *error = success ? nil : YouModFFmpegErrorFromSession(session);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (success && [NSFileManager.defaultManager fileExistsAtPath:outputURL.path]) {
+ completion(YES, nil);
+ } else {
+ completion(NO, error ?: [NSError errorWithDomain:@"YouMod" code:7 userInfo:@{NSLocalizedDescriptionKey: @"Merge failed"}]);
+ }
+ });
+ } copy];
+
+ id statisticsBlock = durationMs ? [^(id statistics) {
+ if (!progress || ![statistics respondsToSelector:@selector(getTime)]) return;
+ double timeMs = ((double (*)(id, SEL))objc_msgSend)(statistics, @selector(getTime));
+ if (!isfinite(timeMs) || timeMs <= 0.0) return;
+ float mergeProgress = 0.985f + (0.01f * fminf((float)(timeMs / (double)durationMs), 1.0f));
+ dispatch_async(dispatch_get_main_queue(), ^{
+ progress(mergeProgress);
+ });
+ } copy] : nil;
+
+ ((id (*)(Class, SEL, NSArray *, id, id, id))objc_msgSend)(ffmpegKitClass, executeSelector, arguments, completeBlock, nil, statisticsBlock);
+ return YES;
+}
+
+static BOOL YouModStartFFmpegKitAudioConvert(NSURL *inputURL, NSURL *outputURL, YouModAudioOutputFormat *outputFormat, unsigned long long durationMs, void (^progress)(float progress), YouModMergeCompletion completion) {
+ Class ffmpegKitClass = YouModFFmpegKitClass();
+ SEL executeSelector = @selector(executeWithArgumentsAsync:withCompleteCallback:withLogCallback:withStatisticsCallback:);
+ if (![ffmpegKitClass respondsToSelector:executeSelector] || outputFormat.ffmpegArguments.count == 0) return NO;
+
+ NSMutableArray *arguments = [@[@"-y", @"-i", inputURL.path] mutableCopy];
+ [arguments addObjectsFromArray:outputFormat.ffmpegArguments];
+ [arguments addObject:outputURL.path];
+
+ id completeBlock = [^(id session) {
+ Class returnCodeClass = NSClassFromString(@"ReturnCode");
+ id returnCode = YouModObjectFromSelector(session, @selector(getReturnCode));
+ BOOL success = NO;
+ if ([returnCodeClass respondsToSelector:@selector(isSuccess:)])
+ success = ((BOOL (*)(Class, SEL, id))objc_msgSend)(returnCodeClass, @selector(isSuccess:), returnCode);
+
+ NSError *error = success ? nil : YouModFFmpegErrorFromSession(session);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (success && [NSFileManager.defaultManager fileExistsAtPath:outputURL.path]) {
+ completion(YES, nil);
+ } else {
+ completion(NO, error ?: [NSError errorWithDomain:@"YouMod" code:13 userInfo:@{NSLocalizedDescriptionKey: @"Conversion failed"}]);
+ }
+ });
+ } copy];
+
+ id statisticsBlock = durationMs ? [^(id statistics) {
+ if (!progress || ![statistics respondsToSelector:@selector(getTime)]) return;
+ double timeMs = ((double (*)(id, SEL))objc_msgSend)(statistics, @selector(getTime));
+ if (!isfinite(timeMs) || timeMs <= 0.0) return;
+ float convertProgress = 0.985f + (0.01f * fminf((float)(timeMs / (double)durationMs), 1.0f));
+ dispatch_async(dispatch_get_main_queue(), ^{
+ progress(convertProgress);
+ });
+ } copy] : nil;
+
+ ((id (*)(Class, SEL, NSArray *, id, id, id))objc_msgSend)(ffmpegKitClass, executeSelector, arguments, completeBlock, nil, statisticsBlock);
+ return YES;
+}
+
+static NSString *YouModMimeDetail(NSString *mimeType) {
+ NSString *lower = mimeType.lowercaseString ?: @"";
+ if ([lower containsString:@"mp4"]) return @"MP4";
+ if ([lower containsString:@"webm"]) return @"WebM";
+ if ([lower containsString:@"mp3"]) return @"MP3";
+ if ([lower containsString:@"aac"]) return @"AAC";
+ return mimeType.length ? mimeType : @"Stream";
+}
+
+static NSString *YouModFileExtensionForFormat(YouModMediaFormat *format, NSString *fallbackExtension) {
+ NSString *lower = format.mimeType.lowercaseString ?: @"";
+ if ([lower containsString:@"webm"]) return @"webm";
+ if ([lower containsString:@"matroska"]) return @"mkv";
+ if ([lower containsString:@"quicktime"]) return @"mov";
+ if ([lower containsString:@"m4a"]) return @"m4a";
+ if ([lower containsString:@"mp4"]) return @"mp4";
+ return fallbackExtension ?: @"mp4";
+}
+
+static BOOL YouModFormatLooksMP4Family(YouModMediaFormat *format) {
+ NSString *mime = format.mimeType.lowercaseString ?: @"";
+ NSString *extension = YouModFileExtensionForFormat(format, @"").lowercaseString ?: @"";
+ return [mime containsString:@"mp4"] || [mime containsString:@"m4a"] || [mime containsString:@"quicktime"] || [@[@"mp4", @"m4a", @"m4v", @"mov"] containsObject:extension];
+}
+
+static BOOL YouModFormatLooksWebM(YouModMediaFormat *format) {
+ NSString *mime = format.mimeType.lowercaseString ?: @"";
+ NSString *extension = YouModFileExtensionForFormat(format, @"").lowercaseString ?: @"";
+ return [mime containsString:@"webm"] || [extension isEqualToString:@"webm"];
+}
+
+static NSString *YouModMergedVideoOutputExtension(YouModMediaFormat *videoFormat, YouModMediaFormat *audioFormat) {
+ if (YouModFormatLooksMP4Family(videoFormat) && YouModFormatLooksMP4Family(audioFormat)) return @"mp4";
+ if (YouModFormatLooksWebM(videoFormat) && YouModFormatLooksWebM(audioFormat)) return @"webm";
+ return @"mkv";
+}
+
+static BOOL YouModVideoFileCanUseAVFoundation(NSURL *fileURL) {
+ return YouModPathExtensionIsPhotosVideo(fileURL.pathExtension);
+}
+
+static BOOL YouModVideoFileCanSaveToPhotos(NSURL *fileURL) {
+ return YouModPathExtensionIsPhotosVideo(fileURL.pathExtension);
+}
+
+static YouModAudioOutputFormat *YouModAudioOutputFormatMake(NSString *identifier, NSString *title, NSString *subtitle, NSString *fileExtension, NSArray *ffmpegArguments, BOOL passthroughWhenCompatible, BOOL supported) {
+ YouModAudioOutputFormat *format = [YouModAudioOutputFormat new];
+ format.identifier = identifier;
+ format.title = title;
+ format.subtitle = subtitle;
+ format.fileExtension = fileExtension;
+ format.ffmpegArguments = ffmpegArguments;
+ format.passthroughWhenCompatible = passthroughWhenCompatible;
+ format.supported = supported;
+ return format;
+}
+
+static NSArray *YouModAudioOutputFormats(void) {
+ static NSArray *formats = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ formats = @[
+ YouModAudioOutputFormatMake(@"m4a", @"M4A", @"AAC container, passthrough when possible", @"m4a", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"aac", @"-b:a", @"192k", @"-movflags", @"+faststart"], YES, YES),
+ YouModAudioOutputFormatMake(@"aac", @"AAC", @"Lossy (192k)", @"aac", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"aac", @"-b:a", @"192k", @"-f", @"adts"], YES, YES),
+ YouModAudioOutputFormatMake(@"mp3", @"MP3", @"Lossy, widely compatible", @"mp3", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"libmp3lame", @"-q:a", @"2"], NO, YES),
+ YouModAudioOutputFormatMake(@"opus", @"Opus", @"Lossy, small file size", @"opus", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"libopus", @"-b:a", @"160k", @"-vbr", @"on"], NO, YES),
+ YouModAudioOutputFormatMake(@"ogg", @"OGG", @"Vorbis lossy", @"ogg", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"libvorbis", @"-q:a", @"6"], NO, YES),
+ YouModAudioOutputFormatMake(@"flac", @"FLAC", @"Lossless compressed", @"flac", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"flac", @"-compression_level", @"8"], NO, YES),
+ YouModAudioOutputFormatMake(@"alac", @"ALAC", @"Apple lossless (M4A)", @"m4a", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"alac", @"-movflags", @"+faststart"], NO, YES),
+ YouModAudioOutputFormatMake(@"wav", @"WAV", @"Uncompressed PCM", @"wav", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"pcm_s24le"], NO, YES),
+ YouModAudioOutputFormatMake(@"aiff", @"AIFF", @"Apple PCM", @"aiff", @[@"-map", @"0:a:0", @"-vn", @"-c:a", @"pcm_s24be"], NO, YES),
+ ];
+ });
+ return formats;
+}
+
+static YouModAudioOutputFormat *YouModDefaultAudioOutputFormat(void) {
+ return [YouModAudioOutputFormats() firstObject];
+}
+
+static BOOL YouModAudioOutputFormatCanPassthrough(YouModAudioOutputFormat *outputFormat, YouModMediaFormat *sourceFormat) {
+ if (!outputFormat.passthroughWhenCompatible) return NO;
+ NSString *identifier = outputFormat.identifier.lowercaseString ?: @"";
+ NSString *mime = sourceFormat.mimeType.lowercaseString ?: @"";
+ NSString *extension = YouModFileExtensionForFormat(sourceFormat, @"").lowercaseString ?: @"";
+ if ([identifier isEqualToString:@"m4a"] || [identifier isEqualToString:@"aac"])
+ return [extension isEqualToString:@"m4a"] || [mime containsString:@"mp4"] || [mime containsString:@"m4a"];
+ return NO;
+}
+
+static NSString *YouModAudioOutputFileExtension(YouModAudioOutputFormat *outputFormat, YouModMediaFormat *sourceFormat, BOOL passthrough) {
+ NSString *identifier = outputFormat.identifier.lowercaseString ?: @"";
+ NSString *mime = sourceFormat.mimeType.lowercaseString ?: @"";
+ if (passthrough && ([identifier isEqualToString:@"m4a"] || [identifier isEqualToString:@"aac"]) && ([mime containsString:@"mp4"] || [mime containsString:@"m4a"]))
+ return @"m4a";
+ return outputFormat.fileExtension ?: YouModFileExtensionForFormat(sourceFormat, @"m4a");
+}
+
+static NSString *YouModAudioOutputSubtitle(YouModAudioOutputFormat *outputFormat) {
+ return [NSString stringWithFormat:@"%@", outputFormat.subtitle];
+}
+
+static NSString *YouModFormatSubtitle(YouModMediaFormat *format) {
+ NSMutableArray *parts = [NSMutableArray array];
+ NSString *language = format.languageName.length ? format.languageName : format.languageCode;
+ if (language.length) [parts addObject:language];
+ if (format.drcAudio) [parts addObject:@"DRC"];
+ NSString *detail = YouModMimeDetail(format.mimeType);
+ if (detail.length) [parts addObject:detail];
+ NSString *size = YouModByteCount(format.contentLength);
+ if (size.length) [parts addObject:size];
+ return [parts componentsJoinedByString:@" - "];
+}
+
+static NSString *YouModVideoIDForPlayer(YTPlayerViewController *player) {
+ NSString *videoID = [player contentVideoID];
+ if (videoID.length == 0)
+ videoID = [player currentVideoID];
+ return videoID;
+}
+
+static NSArray *YouModPlayerResponsesForPlayer(YTPlayerViewController *player) {
+ NSMutableArray *responses = [NSMutableArray array];
+ id response = YouModObjectFromSelector(player, @selector(contentPlayerResponse));
+ if (response) [responses addObject:response];
+
+ id activeVideo = YouModObjectFromSelector(player, @selector(activeVideo));
+ response = YouModObjectFromSelector(activeVideo, @selector(contentPlayerResponse));
+ if (response && ![responses containsObject:response]) [responses addObject:response];
+ return responses.copy;
+}
+
+// Where is this going to?
+static NSArray *YouModCaptionTracksForPlayer(YTPlayerViewController *player) {
+ for (id response in YouModPlayerResponsesForPlayer(player)) {
+ id playerData = YouModObjectFromSelector(response, @selector(playerData)) ?: response;
+ id captions = YouModObjectFromSelector(playerData, @selector(captions));
+ id tracklistRenderer = YouModObjectFromSelector(captions, @selector(playerCaptionsTracklistRenderer));
+ NSArray *tracks = YouModObjectFromSelector(tracklistRenderer, @selector(captionTracksArray));
+ if (tracks.count > 0) return tracks;
+ }
+ return nil;
+}
+
+static id YouModPlayerDataForPlayer(YTPlayerViewController *player) {
+ id response = YouModPlayerResponsesForPlayer(player).firstObject;
+ id playerData = YouModObjectFromSelector(response, @selector(playerData));
+ return playerData ?: response;
+}
+
+static NSString *YouModTitleForPlayer(YTPlayerViewController *player) {
+ id playerData = YouModPlayerDataForPlayer(player);
+ id details = YouModObjectFromSelector(playerData, @selector(videoDetails));
+ NSString *title = YouModStringFromSelector(details, @selector(title));
+ NSString *author = YouModStringFromSelector(details, @selector(author));
+ if (author.length && title.length) {
+ return [NSString stringWithFormat:@"%@ - %@", author, title];
+ } else if (title.length) {
+ return title;
+ }
+ NSString *videoID = YouModVideoIDForPlayer(player);
+ return videoID.length ? [NSString stringWithFormat:@"YouTube %@", videoID] : @"YouTube Video";
+}
+
+static NSArray *YouModAdaptiveFormatObjectsForPlayer(YTPlayerViewController *player) {
+ NSMutableArray *formats = [NSMutableArray array];
+ NSMutableSet *seenPointers = [NSMutableSet set];
+
+ void (^appendFormats)(NSArray *) = ^(NSArray *candidateFormats) {
+ if (![candidateFormats isKindOfClass:NSArray.class]) return;
+ for (id format in candidateFormats) {
+ NSString *key = [NSString stringWithFormat:@"%p", format];
+ if ([seenPointers containsObject:key]) continue;
+ [seenPointers addObject:key];
+ [formats addObject:format];
+ }
+ };
+
+ id activeVideo = YouModObjectFromSelector(player, @selector(activeVideo));
+ id streamingData = YouModObjectFromSelector(activeVideo, @selector(streamingData));
+ appendFormats(YouModObjectFromSelector(streamingData, @selector(adaptiveStreams)));
+ appendFormats(YouModObjectFromSelector(activeVideo, @selector(selectableVideoFormats)));
+
+ for (id response in YouModPlayerResponsesForPlayer(player)) {
+ id playerData = YouModObjectFromSelector(response, @selector(playerData)) ?: response;
+ id responseStreamingData = YouModObjectFromSelector(playerData, @selector(streamingData));
+ appendFormats(YouModObjectFromSelector(responseStreamingData, @selector(adaptiveFormatsArray)));
+ }
+
+ return formats.copy;
+}
+
+static YouModMediaFormat *YouModMediaFormatFromStream(id stream, BOOL video) {
+ id formatStream = YouModObjectFromSelector(stream, @selector(formatStream));
+ NSString *url = YouModStringFromSelector(stream, @selector(URL));
+ if (url.length == 0) url = YouModStringFromSelector(formatStream, @selector(URL));
+ if (url.length == 0) url = YouModStringFromSelector(stream, @selector(url));
+ if (url.length == 0) url = YouModStringFromSelector(formatStream, @selector(url));
+ if (url.length == 0) return nil;
+
+ NSString *mimeType = YouModStringFromSelector(stream, @selector(mimeType));
+ if (mimeType.length == 0) mimeType = YouModStringFromSelector(formatStream, @selector(mimeType));
+ NSString *lowerMime = mimeType.lowercaseString ?: @"";
+ BOOL streamSaysVideo = YouModBoolFromSelector(stream, @selector(isVideo)) || YouModBoolFromSelector(formatStream, @selector(isVideo));
+ BOOL streamSaysAudio = YouModBoolFromSelector(stream, @selector(isAudio)) || YouModBoolFromSelector(formatStream, @selector(isAudio));
+ NSInteger itag = YouModIntegerFromSelector(stream, @selector(itag));
+ if (itag == 0) itag = YouModIntegerFromSelector(formatStream, @selector(itag));
+
+ NSSet *mp4VideoItags = [NSSet setWithObjects:@18, @22, @37, @38, @59, @78, @133, @134, @135, @136, @137, @160, @212, @264, @266, @298, @299, nil];
+ NSSet *m4aAudioItags = [NSSet setWithObjects:@139, @140, @141, @256, @258, @325, @328, nil];
+ BOOL itagMatches = video ? [mp4VideoItags containsObject:@(itag)] : [m4aAudioItags containsObject:@(itag)];
+ BOOL typeMatches = video ? ([lowerMime containsString:@"video/"] || streamSaysVideo || itagMatches) : ([lowerMime containsString:@"audio/"] || streamSaysAudio || itagMatches);
+ if (!typeMatches) return nil;
+
+ BOOL mimeLooksMP4 = [lowerMime containsString:@"mp4"] || [lowerMime containsString:@"m4a"];
+ BOOL canRemuxWithFFmpeg = YouModFFmpegKitAvailable();
+ if (mimeType.length && !mimeLooksMP4 && !itagMatches && !canRemuxWithFFmpeg) return nil;
+
+ YouModMediaFormat *format = [YouModMediaFormat new];
+ format.source = stream;
+ format.video = video;
+ format.urlString = YouModURLStringWithCPN(url);
+ format.mimeType = mimeType.length ? mimeType : (video ? @"video/mp4" : @"audio/mp4");
+ NSInteger height = YouModIntegerFromSelector(stream, @selector(height));
+ if (height == 0) height = YouModIntegerFromSelector(formatStream, @selector(height));
+ NSInteger fps = YouModIntegerFromSelector(stream, @selector(fps));
+ if (fps == 0) fps = YouModIntegerFromSelector(formatStream, @selector(fps));
+ if (fps == 0) fps = YouModIntegerFromSelector(stream, @selector(framesPerSecond));
+ if (fps == 0) fps = YouModIntegerFromSelector(formatStream, @selector(framesPerSecond));
+ if (fps == 0) fps = YouModIntegerFromSelector(stream, @selector(frameRate));
+ if (fps == 0) fps = YouModIntegerFromSelector(formatStream, @selector(frameRate));
+ fps = YouModNormalizedFPS(fps);
+ format.fps = fps;
+ format.qualityLabel = YouModStringFromSelector(stream, @selector(qualityLabel));
+ if (format.qualityLabel.length == 0) format.qualityLabel = YouModStringFromSelector(formatStream, @selector(qualityLabel));
+ if (video) {
+ NSInteger labelHeight = YouModResolutionFromQuality(format.qualityLabel);
+ NSInteger labelFPS = YouModFPSFromQuality(format.qualityLabel);
+ if (labelHeight == 960) format.qualityLabel = YouModQualityLabel(labelHeight, fps ?: labelFPS, nil);
+ else if (labelFPS == 0 && fps > 0) format.qualityLabel = YouModQualityLabel(height, fps, format.qualityLabel);
+ if (format.qualityLabel.length == 0) format.qualityLabel = YouModQualityLabel(height, fps, nil);
+ }
+ if (format.qualityLabel.length == 0 && !video) format.qualityLabel = @"Audio";
+ if (!video) {
+ NSString *languageCode = YouModStringFromSelector(stream, @selector(languageCode));
+ if (languageCode.length == 0) languageCode = YouModStringFromSelector(formatStream, @selector(languageCode));
+ if (languageCode.length == 0) languageCode = YouModStringFromSelector(stream, @selector(language));
+ if (languageCode.length == 0) languageCode = YouModStringFromSelector(formatStream, @selector(language));
+ format.languageCode = languageCode;
+
+ NSString *languageName = YouModStringFromSelector(stream, @selector(languageName));
+ if (languageName.length == 0) languageName = YouModStringFromSelector(formatStream, @selector(languageName));
+ if (languageName.length == 0) languageName = YouModStringFromSelector(stream, @selector(displayName));
+ if (languageName.length == 0) languageName = YouModStringFromSelector(formatStream, @selector(displayName));
+ format.languageName = languageName.length ? languageName : languageCode;
+
+ NSMutableArray *audioTraits = [NSMutableArray array];
+ for (NSString *value in @[
+ mimeType ?: @"",
+ format.qualityLabel ?: @"",
+ YouModStringFromSelector(stream, @selector(audioTrack)) ?: @"",
+ YouModStringFromSelector(formatStream, @selector(audioTrack)) ?: @"",
+ YouModStringFromSelector(stream, @selector(audioTrackType)) ?: @"",
+ YouModStringFromSelector(formatStream, @selector(audioTrackType)) ?: @"",
+ YouModStringFromSelector(stream, @selector(audioTrackDisplayName)) ?: @"",
+ YouModStringFromSelector(formatStream, @selector(audioTrackDisplayName)) ?: @"",
+ ]) {
+ if (value.length) [audioTraits addObject:value];
+ }
+ format.drcAudio = [[audioTraits componentsJoinedByString:@" "] localizedCaseInsensitiveContainsString:@"drc"];
+ }
+ if (YouModBoolFromSelector(stream, @selector(hasContentLength)) || [stream respondsToSelector:@selector(contentLength)])
+ format.contentLength = YouModUnsignedLongLongFromSelector(stream, @selector(contentLength));
+ if (format.contentLength == 0 && (YouModBoolFromSelector(formatStream, @selector(hasContentLength)) || [formatStream respondsToSelector:@selector(contentLength)]))
+ format.contentLength = YouModUnsignedLongLongFromSelector(formatStream, @selector(contentLength));
+ format.durationMs = YouModUnsignedLongLongFromSelector(stream, @selector(approxDurationMs));
+ if (format.durationMs == 0) format.durationMs = YouModUnsignedLongLongFromSelector(formatStream, @selector(approxDurationMs));
+
+ NSMutableDictionary *headers = [NSMutableDictionary dictionary];
+ NSDictionary *streamHeaders = YouModObjectFromSelector(stream, @selector(httpHeaders));
+ if (![streamHeaders isKindOfClass:NSDictionary.class]) streamHeaders = YouModObjectFromSelector(formatStream, @selector(httpHeaders));
+ if (![streamHeaders isKindOfClass:NSDictionary.class]) streamHeaders = YouModObjectFromSelector(stream, @selector(headers));
+ if (![streamHeaders isKindOfClass:NSDictionary.class]) streamHeaders = YouModObjectFromSelector(formatStream, @selector(headers));
+ if ([streamHeaders isKindOfClass:NSDictionary.class]) {
+ for (id key in streamHeaders) {
+ id value = streamHeaders[key];
+ if ([key isKindOfClass:NSString.class] && [value isKindOfClass:NSString.class])
+ headers[key] = value;
+ }
+ }
+ if (!YouModHTTPHeadersContainField(headers, @"Origin"))
+ headers[@"Origin"] = @"https://www.youtube.com";
+ if (!YouModHTTPHeadersContainField(headers, @"Referer"))
+ headers[@"Referer"] = @"https://www.youtube.com/";
+ format.httpHeaders = headers;
+ return format;
+}
+
+static NSInteger YouModResolutionFromQuality(NSString *quality) {
+ NSScanner *scanner = [NSScanner scannerWithString:quality ?: @""];
+ NSInteger value = 0;
+ [scanner scanInteger:&value];
+ return value;
+}
+
+static NSInteger YouModFPSFromQuality(NSString *quality) {
+ NSString *lower = quality.lowercaseString ?: @"";
+ NSRange pRange = [lower rangeOfString:@"p"];
+ if (pRange.location != NSNotFound && pRange.location + 1 < lower.length) {
+ NSString *afterP = [lower substringFromIndex:pRange.location + 1];
+ NSScanner *scanner = [NSScanner scannerWithString:afterP];
+ NSInteger fps = 0;
+ if ([scanner scanInteger:&fps] && fps > 0) return fps;
+ }
+ if ([lower containsString:@"60fps"] || [lower containsString:@"60 fps"]) return 60;
+ if ([lower containsString:@"30fps"] || [lower containsString:@"30 fps"]) return 30;
+ return 0;
+}
+
+static NSInteger YouModNormalizedFPS(NSInteger fps) {
+ if (fps >= 50 && fps <= 61) return 60;
+ if (fps >= 24 && fps <= 31) return 30;
+ return fps;
+}
+
+static NSInteger YouModDisplayHeightForVideoHeight(NSInteger height) {
+ if (height >= 900 && height < 1080) return 1080;
+ return height;
+}
+
+static NSString *YouModQualityLabel(NSInteger height, NSInteger fps, NSString *fallback) {
+ height = YouModDisplayHeightForVideoHeight(height);
+ fps = YouModNormalizedFPS(fps);
+ if (height > 0 && fps > 0) return [NSString stringWithFormat:@"%ldp%ld", (long)height, (long)fps];
+ if (height > 0) return [NSString stringWithFormat:@"%ldp", (long)height];
+ if (fallback.length && fps > 0 && ![fallback.lowercaseString containsString:@"fps"])
+ return [NSString stringWithFormat:@"%@ %ldfps", fallback, (long)fps];
+ return fallback;
+}
+
+static NSArray *YouModFormatsForPlayer(YTPlayerViewController *player, BOOL video) {
+ NSMutableArray *formats = [NSMutableArray array];
+ for (id stream in YouModAdaptiveFormatObjectsForPlayer(player)) {
+ YouModMediaFormat *format = YouModMediaFormatFromStream(stream, video);
+ if (format) [formats addObject:format];
+ }
+
+ [formats sortUsingComparator:^NSComparisonResult(YouModMediaFormat *left, YouModMediaFormat *right) {
+ if (video) {
+ NSInteger leftRes = YouModResolutionFromQuality(left.qualityLabel);
+ NSInteger rightRes = YouModResolutionFromQuality(right.qualityLabel);
+ if (leftRes != rightRes) return leftRes > rightRes ? NSOrderedAscending : NSOrderedDescending;
+ NSInteger leftFPS = left.fps ?: YouModFPSFromQuality(left.qualityLabel);
+ NSInteger rightFPS = right.fps ?: YouModFPSFromQuality(right.qualityLabel);
+ if (leftFPS != rightFPS) return leftFPS > rightFPS ? NSOrderedAscending : NSOrderedDescending;
+ }
+
+ BOOL leftMP4 = YouModFormatLooksMP4Family(left);
+ BOOL rightMP4 = YouModFormatLooksMP4Family(right);
+ if (leftMP4 != rightMP4) return leftMP4 ? NSOrderedAscending : NSOrderedDescending;
+
+ if (!video && IS_ENABLED(DownloadPreferDRCAudio) && left.drcAudio != right.drcAudio)
+ return left.drcAudio ? NSOrderedAscending : NSOrderedDescending;
+ if (left.contentLength != right.contentLength)
+ return left.contentLength > right.contentLength ? NSOrderedAscending : NSOrderedDescending;
+ return NSOrderedSame;
+ }];
+
+ NSMutableArray *unique = [NSMutableArray array];
+ NSMutableSet *seen = [NSMutableSet set];
+ for (YouModMediaFormat *format in formats) {
+ NSInteger fps = format.fps ?: YouModFPSFromQuality(format.qualityLabel);
+ NSString *key = video
+ ? [NSString stringWithFormat:@"%@-%ld-%@", format.qualityLabel ?: @"", (long)fps, YouModMimeDetail(format.mimeType)]
+ : [NSString stringWithFormat:@"%@-%@-%@-%@", format.qualityLabel ?: @"", format.languageCode ?: @"", format.drcAudio ? @"drc" : @"std", YouModMimeDetail(format.mimeType)];
+ if ([seen containsObject:key]) continue;
+ [seen addObject:key];
+ [unique addObject:format];
+ }
+ return unique.copy;
+}
+
+static YouModMediaFormat *YouModBestAudioFormatForPlayer(YTPlayerViewController *player) {
+ NSArray *audioFormats = YouModFormatsForPlayer(player, NO);
+ return audioFormats.firstObject;
+}
+
+static UIViewController *YouModPresenterForSender(UIView *sender, YTPlayerViewController *player) {
+ UIViewController *presenter = nil;
+ if ([sender respondsToSelector:@selector(_viewControllerForAncestor)])
+ presenter = [sender _viewControllerForAncestor];
+ if (!presenter) presenter = player;
+ return YouModTopViewController(presenter);
+}
+
+static YTPlayerViewController *YouModPlayerFromViewController(UIViewController *vc) {
+ Class playerClass = NSClassFromString(@"YTPlayerViewController");
+ UIViewController *cursor = vc;
+ while (cursor) {
+ if (playerClass && [cursor isKindOfClass:playerClass]) return (YTPlayerViewController *)cursor;
+ id player = YouModObjectFromSelector(cursor, @selector(playerViewController));
+ if (playerClass && [player isKindOfClass:playerClass]) return (YTPlayerViewController *)player;
+ cursor = cursor.parentViewController;
+ }
+ return YouModCurrentPlayerViewController;
+}
+
+static NSURL *YouModThumbnailURLForVideoID(NSString *videoID) {
+ if (videoID.length == 0) return nil;
+ NSString *urlString = [NSString stringWithFormat:@"https://i.ytimg.com/vi/%@/maxresdefault.jpg", videoID];
+ return [NSURL URLWithString:urlString];
+}
+
+static void YouModRequestPhotoAccess(void (^completion)(BOOL granted)) {
+ if (@available(iOS 14.0, *)) {
+ [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly handler:^(PHAuthorizationStatus status) {
+ completion(status == PHAuthorizationStatusAuthorized || status == PHAuthorizationStatusLimited);
+ }];
+ } else {
+ [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
+ completion(status == PHAuthorizationStatusAuthorized);
+ }];
+ }
+}
+
+static void YouModSaveVideoToPhotos(NSURL *fileURL, UIViewController *presenter, void (^completion)(BOOL success, NSError *error)) {
+ YouModRequestPhotoAccess(^(BOOL granted) {
+ if (!granted) {
+ NSError *error = [NSError errorWithDomain:@"YouMod" code:1 userInfo:@{NSLocalizedDescriptionKey: @"Photos access denied"}];
+ completion(NO, error);
+ return;
+ }
+ [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
+ [PHAssetCreationRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
+ } completionHandler:^(BOOL success, NSError *error) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ completion(success, error);
+ });
+ }];
+ });
+}
+
+static void YouModShareFile(NSURL *fileURL, UIViewController *presenter) {
+ if (!fileURL || !presenter) return;
+ UIActivityViewController *activity = [[UIActivityViewController alloc] initWithActivityItems:@[fileURL] applicationActivities:nil];
+ // Fix for iPad and specific presentation alignment
+ if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
+ activity.popoverPresentationController.sourceView = presenter.view;
+ // Position at the bottom center of the screen
+ activity.popoverPresentationController.sourceRect = CGRectMake(presenter.view.bounds.size.width / 2, presenter.view.bounds.size.height, 0, 0);
+ activity.popoverPresentationController.permittedArrowDirections = 0; // No arrow pointing to a button
+ } else {
+ // On iPhone, UIActivityViewController naturally comes from the bottom center
+ activity.popoverPresentationController.sourceView = presenter.view;
+ }
+ [presenter presentViewController:activity animated:YES completion:nil];
+}
+
+static void YouModPresentMenu(NSString *title, NSArray *items, UIViewController *presenter, UIView *sender) {
+ presenter = YouModTopViewController(presenter);
+ Class sheetClass = NSClassFromString(@"YTDefaultSheetController");
+ if (sheetClass && [sheetClass respondsToSelector:@selector(sheetControllerWithParentResponder:)]) {
+ YTDefaultSheetController *sheet = [sheetClass sheetControllerWithParentResponder:presenter];
+ Class actionClass = NSClassFromString(@"YTActionSheetAction");
+ for (YouModMenuItem *item in items) {
+ id action = nil;
+ if ([actionClass respondsToSelector:@selector(actionWithTitle:subtitle:iconImage:handler:)]) {
+ action = ((id (*)(Class, SEL, NSString *, NSString *, UIImage *, id))objc_msgSend)(actionClass, @selector(actionWithTitle:subtitle:iconImage:handler:), item.title, item.subtitle, item.iconImage, ^(__unused id action) {
+ if (item.handler) item.handler();
+ });
+ } else {
+ action = ((id (*)(Class, SEL, NSString *, NSInteger, id))objc_msgSend)(actionClass, @selector(actionWithTitle:style:handler:), item.title, 0, ^(__unused id action) {
+ if (item.handler) item.handler();
+ });
+ }
+ if (action) [sheet addAction:action];
+ }
+ if (sender && [sheet respondsToSelector:@selector(presentFromView:animated:completion:)])
+ [sheet presentFromView:sender animated:YES completion:nil];
+ else
+ [sheet presentFromViewController:presenter animated:YES completion:nil];
+ return;
+ }
+
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:nil preferredStyle:UIAlertControllerStyleActionSheet];
+ for (YouModMenuItem *item in items) {
+ NSString *rowTitle = item.subtitle.length ? [NSString stringWithFormat:@"%@\n%@", item.title, item.subtitle] : item.title;
+ [alert addAction:[UIAlertAction actionWithTitle:rowTitle style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
+ if (item.handler) item.handler();
+ }]];
+ }
+ [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
+ alert.popoverPresentationController.sourceView = sender ?: presenter.view;
+ [presenter presentViewController:alert animated:YES completion:nil];
+}
+
+@implementation YouModDownloadCoordinator
+
++ (instancetype)sharedCoordinator {
+ static YouModDownloadCoordinator *coordinator;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ coordinator = [YouModDownloadCoordinator new];
+ });
+ return coordinator;
+}
+
+- (instancetype)init {
+ self = [super init];
+ if (self) {
+ NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
+ configuration.HTTPAdditionalHeaders = @{
+ @"User-Agent": @"Mozilla/5.0",
+ @"Origin": @"https://www.youtube.com",
+ @"Referer": @"https://www.youtube.com/",
+ };
+ configuration.HTTPMaximumConnectionsPerHost = YouModFastDownloadConcurrency;
+ configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
+ configuration.timeoutIntervalForResource = 300;
+ _session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
+ }
+ return self;
+}
+
+- (void)showProgressWithTitle:(NSString *)title presenter:(UIViewController *)presenter {
+ self.presenter = presenter;
+ self.baseProgressTitle = title;
+ self.downloadStartTime = [NSDate timeIntervalSinceReferenceDate];
+ self.progressAlert = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"%@ - 0%%", title] message:@"\n" preferredStyle:UIAlertControllerStyleAlert];
+ self.progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
+ self.progressView.progress = 0.0;
+ self.progressView.translatesAutoresizingMaskIntoConstraints = NO;
+ [self.progressAlert.view addSubview:self.progressView];
+ [NSLayoutConstraint activateConstraints:@[
+ [self.progressView.leadingAnchor constraintEqualToAnchor:self.progressAlert.view.leadingAnchor constant:24.0],
+ [self.progressView.trailingAnchor constraintEqualToAnchor:self.progressAlert.view.trailingAnchor constant:-24.0],
+ [self.progressView.bottomAnchor constraintEqualToAnchor:self.progressAlert.view.bottomAnchor constant:-56.0],
+ ]];
+ __weak typeof(self) weakSelf = self;
+ [self.progressAlert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(__unused UIAlertAction *action) {
+ [weakSelf cancelWithMessage:@"Download cancelled"];
+ }]];
+ [presenter presentViewController:self.progressAlert animated:YES completion:nil];
+}
+
+- (void)updateProgressTitle:(NSString *)title progress:(float)progress {
+ self.progressAlert.title = [NSString stringWithFormat:@"%@ - %ld%%", title, (long)lrintf(progress * 100.0f)];
+ self.progressAlert.message = @"\n";
+ [self.progressView setProgress:progress animated:YES];
+}
+
+- (void)cancelWithMessage:(NSString *)message {
+ [self.task cancel];
+ [self.metadataTask cancel];
+ [self.rangeDownloader cancel];
+ YouModCancelFFmpegKit();
+ self.task = nil;
+ self.metadataTask = nil;
+ self.rangeDownloader = nil;
+ self.fileCompletion = nil;
+ self.active = NO;
+ self.cancelled = YES;
+ [self cleanupTemporaryFiles];
+ if (message.length) YouModSendToast(message, self.presenter);
+}
+
+- (void)cleanupTemporaryFiles {
+ if (self.videoTempURL) [NSFileManager.defaultManager removeItemAtURL:self.videoTempURL error:nil];
+ if (self.audioTempURL) [NSFileManager.defaultManager removeItemAtURL:self.audioTempURL error:nil];
+ self.videoTempURL = nil;
+ self.audioTempURL = nil;
+}
+
+- (void)downloadURL:(NSURL *)url toURL:(NSURL *)destinationURL expectedBytes:(unsigned long long)expectedBytes headers:(NSDictionary *)headers completion:(YouModFileDownloadCompletion)completion {
+ self.currentResolvedSizeAddedToTotal = NO;
+ self.currentExpectedBytes = expectedBytes;
+ self.currentBytes = 0;
+ if (expectedBytes == 0) {
+ __weak typeof(self) weakSelf = self;
+ [self resolveExpectedBytesForURL:url headers:headers completion:^(unsigned long long bytes) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ if (bytes > 0) [self adjustCurrentExpectedBytesIfNeeded:bytes];
+ [self beginDownloadURL:url toURL:destinationURL expectedBytes:bytes headers:headers allowFast:YES completion:completion];
+ }];
+ return;
+ }
+ [self beginDownloadURL:url toURL:destinationURL expectedBytes:expectedBytes headers:headers allowFast:YES completion:completion];
+}
+
+- (void)beginDownloadURL:(NSURL *)url toURL:(NSURL *)destinationURL expectedBytes:(unsigned long long)expectedBytes headers:(NSDictionary *)headers allowFast:(BOOL)allowFast completion:(YouModFileDownloadCompletion)completion {
+ self.destinationURL = destinationURL;
+ self.currentExpectedBytes = expectedBytes;
+ self.currentBytes = 0;
+ self.finishedCurrentFile = NO;
+ self.fileCompletion = completion;
+ [NSFileManager.defaultManager removeItemAtURL:destinationURL error:nil];
+
+ if (allowFast && expectedBytes == 0) allowFast = NO;
+
+ if (allowFast && expectedBytes >= YouModFastDownloadMinimumBytes) {
+ __weak typeof(self) weakSelf = self;
+ self.rangeDownloader = [[YouModRangeDownloader alloc] initWithURL:url destinationURL:destinationURL expectedBytes:expectedBytes headers:headers progress:^(unsigned long long completedBytes) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ self.currentBytes = completedBytes;
+ [self updateDownloadProgressWithCurrentBytes:completedBytes expectedBytes:expectedBytes];
+ } completion:^(NSURL *fileURL, NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ self.rangeDownloader = nil;
+ if (error) {
+ [self beginDownloadURL:url toURL:destinationURL expectedBytes:expectedBytes headers:headers allowFast:NO completion:completion];
+ return;
+ }
+ if (completion) completion(fileURL, nil);
+ }];
+ [self.rangeDownloader start];
+ return;
+ }
+
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:60.0];
+ YouModApplyDownloadHeaders(request, headers);
+ self.task = [self.session downloadTaskWithRequest:request];
+ [self.task resume];
+}
+
+- (void)resolveExpectedBytesForURL:(NSURL *)url headers:(NSDictionary *)headers completion:(void (^)(unsigned long long bytes))completion {
+ NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:15.0];
+ request.HTTPMethod = @"HEAD";
+ YouModApplyDownloadHeaders(request, headers);
+
+ __weak typeof(self) weakSelf = self;
+ self.metadataTask = [NSURLSession.sharedSession dataTaskWithRequest:request completionHandler:^(__unused NSData *data, NSURLResponse *response, __unused NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) return;
+
+ unsigned long long bytes = 0;
+ if (response.expectedContentLength > 0) {
+ bytes = (unsigned long long)response.expectedContentLength;
+ } else if ([response isKindOfClass:NSHTTPURLResponse.class]) {
+ id header = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Length"];
+ if ([header respondsToSelector:@selector(unsignedLongLongValue)])
+ bytes = [header unsignedLongLongValue];
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ self.metadataTask = nil;
+ completion(bytes);
+ });
+ }];
+ [self.metadataTask resume];
+}
+
+- (void)updateDownloadProgressWithCurrentBytes:(unsigned long long)currentBytes expectedBytes:(unsigned long long)expectedBytes {
+ unsigned long long total = self.totalBytes ?: expectedBytes;
+ float progress = total ? (float)(self.completedBytes + currentBytes) / (float)total : 0.0f;
+ progress = fminf(fmaxf(progress, 0.0f), 0.985f);
+
+ NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate];
+ NSTimeInterval elapsed = now - self.downloadStartTime;
+ double speedMBps = 0;
+ if (elapsed > 0) {
+ speedMBps = ((double)(self.completedBytes + currentBytes) / 1048576.0) / elapsed;
+ }
+ double totalMB = (double)total / 1048576.0;
+
+ self.progressAlert.title = [NSString stringWithFormat:@"%@ - %ld%%", self.baseProgressTitle ?: @"Downloading", (long)lrintf(progress * 100.0f)];
+ if (total > 0) {
+ self.progressAlert.message = [NSString stringWithFormat:@"%.1f MB/s - %.1f MB\n", speedMBps, totalMB];
+ } else {
+ self.progressAlert.message = [NSString stringWithFormat:@"%.1f MB/s\n", speedMBps];
+ }
+ [self.progressView setProgress:progress animated:YES];
+}
+
+- (void)adjustCurrentExpectedBytesIfNeeded:(unsigned long long)newExpectedBytes {
+ unsigned long long oldExpectedBytes = self.currentExpectedBytes;
+ if (newExpectedBytes <= oldExpectedBytes) return;
+
+ self.currentExpectedBytes = newExpectedBytes;
+ if (oldExpectedBytes > 0) {
+ self.totalBytes += newExpectedBytes - oldExpectedBytes;
+ } else if (!self.currentResolvedSizeAddedToTotal) {
+ self.totalBytes += newExpectedBytes;
+ self.currentResolvedSizeAddedToTotal = YES;
+ }
+}
+
+- (void)startVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+ if (self.active) {
+ YouModSendToast(@"Already downloading", presenter);
+ return;
+ }
+ [self startDirectVideoDownloadWithVideoFormat:videoFormat audioFormat:audioFormat fileName:fileName videoID:videoID presenter:presenter];
+}
+
+- (void)startDirectVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+ NSURL *videoURL = [NSURL URLWithString:videoFormat.urlString];
+ NSURL *audioURL = [NSURL URLWithString:audioFormat.urlString];
+ if (!videoURL || !audioURL) {
+ YouModSendToast(@"No stream URL found", presenter);
+ return;
+ }
+
+ self.active = YES;
+ self.cancelled = NO;
+ self.completedBytes = 0;
+ self.totalBytes = videoFormat.contentLength + audioFormat.contentLength;
+ self.videoTempURL = YouModTemporaryFileURL(YouModFileExtensionForFormat(videoFormat, @"mp4"));
+ self.audioTempURL = YouModTemporaryFileURL(YouModFileExtensionForFormat(audioFormat, @"m4a"));
+ NSString *outputExtension = YouModMergedVideoOutputExtension(videoFormat, audioFormat);
+ [self showProgressWithTitle:@"Downloading video" presenter:presenter];
+
+ __weak typeof(self) weakSelf = self;
+ [self downloadURL:videoURL toURL:self.videoTempURL expectedBytes:videoFormat.contentLength headers:videoFormat.httpHeaders completion:^(NSURL *videoFileURL, NSError *videoError) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || videoError) {
+ [self failWithError:videoError ?: [NSError errorWithDomain:@"YouMod" code:2 userInfo:@{NSLocalizedDescriptionKey: @"Video download failed"}]];
+ return;
+ }
+
+ self.completedBytes += MAX(videoFormat.contentLength, self.currentBytes);
+ [self updateProgressTitle:@"Downloading audio" progress:(self.totalBytes ? (float)self.completedBytes / (float)self.totalBytes : 0.5f)];
+ [self downloadURL:audioURL toURL:self.audioTempURL expectedBytes:audioFormat.contentLength headers:audioFormat.httpHeaders completion:^(NSURL *audioFileURL, NSError *audioError) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || audioError) {
+ [self failWithError:audioError ?: [NSError errorWithDomain:@"YouMod" code:3 userInfo:@{NSLocalizedDescriptionKey: @"Audio download failed"}]];
+ return;
+ }
+ unsigned long long durationMs = videoFormat.durationMs ?: audioFormat.durationMs;
+ [self mergeVideoURL:videoFileURL audioURL:audioFileURL fileName:fileName outputExtension:outputExtension durationMs:durationMs presenter:presenter];
+ }];
+ }];
+}
+
+- (void)startDirectSingleVideoDownloadWithFormat:(YouModMediaFormat *)format fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+ NSURL *videoURL = [NSURL URLWithString:format.urlString];
+ if (!videoURL) {
+ YouModSendToast(@"No stream URL found", presenter);
+ return;
+ }
+
+ self.active = YES;
+ self.cancelled = NO;
+ self.completedBytes = 0;
+ self.totalBytes = format.contentLength;
+ NSString *extension = YouModFileExtensionForFormat(format, @"mp4");
+ BOOL canFinalizeWithAVFoundation = format.durationMs > 0 && YouModPathExtensionIsPhotosVideo(extension);
+ NSURL *finalURL = YouModUniqueFileURL(fileName, extension);
+ NSURL *downloadURL = canFinalizeWithAVFoundation ? YouModTemporaryFileURL(extension) : finalURL;
+ self.videoTempURL = canFinalizeWithAVFoundation ? downloadURL : nil;
+ [self showProgressWithTitle:@"Downloading video" presenter:presenter];
+
+ __weak typeof(self) weakSelf = self;
+ [self downloadURL:videoURL toURL:downloadURL expectedBytes:format.contentLength headers:format.httpHeaders completion:^(NSURL *fileURL, NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || error) {
+ [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:8 userInfo:@{NSLocalizedDescriptionKey: @"Video download failed"}]];
+ return;
+ }
+ if (canFinalizeWithAVFoundation) {
+ [self trimSingleVideoURL:fileURL outputURL:finalURL durationMs:format.durationMs presenter:presenter];
+ return;
+ }
+ [self completeWithFileURL:fileURL isVideo:YES presenter:presenter];
+ }];
+}
+
+- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+ [self startAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:nil presenter:presenter];
+}
+
+- (void)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter {
+ if (self.active) {
+ YouModSendToast(@"Already downloading", presenter);
+ return;
+ }
+ [self startDirectAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:outputFormat presenter:presenter];
+}
+
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID presenter:(UIViewController *)presenter {
+ [self startDirectAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:nil presenter:presenter];
+}
+
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName videoID:(NSString *)videoID outputFormat:(YouModAudioOutputFormat *)outputFormat presenter:(UIViewController *)presenter {
+ NSURL *audioURL = [NSURL URLWithString:audioFormat.urlString];
+ if (!audioURL) {
+ YouModSendToast(@"No audio URL found", presenter);
+ return;
+ }
+ outputFormat = outputFormat ?: YouModDefaultAudioOutputFormat();
+ if (!outputFormat.supported) {
+ YouModSendToast([NSString stringWithFormat:@"%@ not supported", outputFormat.title ?: @"Format"], presenter);
+ return;
+ }
+
+ self.active = YES;
+ self.cancelled = NO;
+ self.completedBytes = 0;
+ self.totalBytes = audioFormat.contentLength;
+ BOOL passthrough = YouModAudioOutputFormatCanPassthrough(outputFormat, audioFormat);
+ if (!passthrough && !YouModFFmpegKitAvailable()) {
+ self.active = NO;
+ NSString *details = YouModFFmpegKitDiagnosticText(outputFormat, audioFormat, videoID);
+ YouModRecordDownloadDiagnostic(@"FFmpegKit unavailable for audio conversion", details);
+ NSString *diagnostic = YouModDownloadDiagnosticText();
+ if (diagnostic.length) {
+ UIPasteboard.generalPasteboard.string = diagnostic;
+ YouModSendToast(@"FFmpegKit not loaded, diagnostics copied", presenter);
+ } else {
+ YouModSendToast([NSString stringWithFormat:@"FFmpegKit required for %@", outputFormat.title ?: @"this format"], presenter);
+ }
+ return;
+ }
+
+ NSURL *finalURL = YouModUniqueFileURL(fileName, YouModAudioOutputFileExtension(outputFormat, audioFormat, passthrough));
+ NSURL *downloadURL = passthrough ? finalURL : YouModTemporaryFileURL(YouModFileExtensionForFormat(audioFormat, @"m4a"));
+ self.audioTempURL = passthrough ? nil : downloadURL;
+ [self showProgressWithTitle:@"Downloading audio" presenter:presenter];
+
+ __weak typeof(self) weakSelf = self;
+ [self downloadURL:audioURL toURL:downloadURL expectedBytes:audioFormat.contentLength headers:audioFormat.httpHeaders completion:^(NSURL *fileURL, NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || error) {
+ [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:4 userInfo:@{NSLocalizedDescriptionKey: @"Audio download failed"}]];
+ return;
+ }
+ if (!passthrough) {
+ unsigned long long durationMs = audioFormat.durationMs ?: YouModDurationMsForURL(fileURL);
+ [self convertAudioURL:fileURL outputURL:finalURL outputFormat:outputFormat durationMs:durationMs presenter:presenter];
+ return;
+ }
+ [self completeWithFileURL:fileURL isVideo:NO presenter:presenter];
+ }];
+}
+
+- (void)convertAudioURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL outputFormat:(YouModAudioOutputFormat *)outputFormat durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter {
+ [self updateProgressTitle:[NSString stringWithFormat:@"Converting to %@", outputFormat.title ?: @"audio"] progress:0.985f];
+ [NSFileManager.defaultManager removeItemAtURL:outputURL error:nil];
+
+ __weak typeof(self) weakSelf = self;
+ BOOL started = YouModStartFFmpegKitAudioConvert(inputURL, outputURL, outputFormat, durationMs, ^(float progress) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ [self updateProgressTitle:[NSString stringWithFormat:@"Converting to %@", outputFormat.title ?: @"audio"] progress:progress];
+ }, ^(BOOL success, NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ if (success) {
+ [self completeWithFileURL:outputURL isVideo:NO presenter:presenter];
+ return;
+ }
+ [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:14 userInfo:@{NSLocalizedDescriptionKey: @"Conversion failed"}]];
+ });
+
+ if (!started) {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:15 userInfo:@{NSLocalizedDescriptionKey: @"Format unavailable"}]];
+ }
+}
+
+- (void)mergeVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL fileName:(NSString *)fileName outputExtension:(NSString *)outputExtension durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter {
+ [self updateProgressTitle:@"Merging video" progress:0.985f];
+ NSURL *outputURL = YouModUniqueFileURL(fileName, outputExtension.length ? outputExtension : @"mp4");
+ if (durationMs == 0) durationMs = YouModDurationMsForURL(videoURL);
+
+ if (YouModFFmpegKitAvailable()) {
+ __weak typeof(self) weakSelf = self;
+ BOOL started = YouModStartFFmpegKitMerge(videoURL, audioURL, outputURL, durationMs, ^(float progress) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ [self updateProgressTitle:@"Merging video" progress:progress];
+ }, ^(BOOL success, NSError *error) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ if (success) {
+ [self completeWithFileURL:outputURL isVideo:YES presenter:presenter];
+ return;
+ }
+
+ [NSFileManager.defaultManager removeItemAtURL:outputURL error:nil];
+ if (YouModVideoFileCanUseAVFoundation(outputURL)) {
+ [self mergeVideoWithAVFoundationVideoURL:videoURL audioURL:audioURL outputURL:outputURL durationMs:durationMs presenter:presenter fallbackError:error];
+ } else {
+ [self failWithError:error ?: [NSError errorWithDomain:@"YouMod" code:16 userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit required for this stream"}]];
+ }
+ });
+ if (started) return;
+ }
+
+ if (YouModVideoFileCanUseAVFoundation(outputURL)) {
+ [self mergeVideoWithAVFoundationVideoURL:videoURL audioURL:audioURL outputURL:outputURL durationMs:durationMs presenter:presenter fallbackError:nil];
+ } else {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:16 userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit required for this stream"}]];
+ }
+}
+
+- (void)mergeVideoWithAVFoundationVideoURL:(NSURL *)videoURL audioURL:(NSURL *)audioURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter fallbackError:(NSError *)fallbackError {
+ [self updateProgressTitle:fallbackError ? @"Merging video with fallback" : @"Merging video" progress:0.985f];
+ AVURLAsset *videoAsset = [AVURLAsset URLAssetWithURL:videoURL options:nil];
+ AVURLAsset *audioAsset = [AVURLAsset URLAssetWithURL:audioURL options:nil];
+ AVMutableComposition *composition = [AVMutableComposition composition];
+
+ AVAssetTrack *videoTrack = [[videoAsset tracksWithMediaType:AVMediaTypeVideo] firstObject];
+ AVAssetTrack *audioTrack = [[audioAsset tracksWithMediaType:AVMediaTypeAudio] firstObject];
+ if (!videoTrack || !audioTrack) {
+ [self failWithError:fallbackError ?: [NSError errorWithDomain:@"YouMod" code:5 userInfo:@{NSLocalizedDescriptionKey: @"Merge failed"}]];
+ return;
+ }
+
+ CMTime duration = YouModExportDuration(videoAsset, audioAsset, durationMs);
+ if (!YouModCMTimeIsUsable(duration)) {
+ [self failWithError:fallbackError ?: [NSError errorWithDomain:@"YouMod" code:9 userInfo:@{NSLocalizedDescriptionKey: @"Cannot determine duration"}]];
+ return;
+ }
+ NSError *insertError = nil;
+ AVMutableCompositionTrack *compositionVideo = [composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
+ [compositionVideo insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration) ofTrack:videoTrack atTime:kCMTimeZero error:&insertError];
+ compositionVideo.preferredTransform = videoTrack.preferredTransform;
+ if (insertError) {
+ [self failWithError:insertError];
+ return;
+ }
+
+ AVMutableCompositionTrack *compositionAudio = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
+ [compositionAudio insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration) ofTrack:audioTrack atTime:kCMTimeZero error:&insertError];
+ if (insertError) {
+ [self failWithError:insertError];
+ return;
+ }
+
+ AVAssetExportSession *exporter = [[AVAssetExportSession alloc] initWithAsset:composition presetName:AVAssetExportPresetPassthrough];
+ exporter.outputURL = outputURL;
+ exporter.outputFileType = AVFileTypeMPEG4;
+ exporter.shouldOptimizeForNetworkUse = YES;
+
+ __weak typeof(self) weakSelf = self;
+ [exporter exportAsynchronouslyWithCompletionHandler:^{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) return;
+ if (exporter.status == AVAssetExportSessionStatusCompleted) {
+ [self completeWithFileURL:outputURL isVideo:YES presenter:presenter];
+ } else {
+ [self failWithError:exporter.error ?: [NSError errorWithDomain:@"YouMod" code:6 userInfo:@{NSLocalizedDescriptionKey: @"Merge failed"}]];
+ }
+ });
+ }];
+}
+
+- (void)trimSingleVideoURL:(NSURL *)inputURL outputURL:(NSURL *)outputURL durationMs:(unsigned long long)durationMs presenter:(UIViewController *)presenter {
+ [self updateProgressTitle:@"Finalizing video" progress:0.99f];
+ [NSFileManager.defaultManager removeItemAtURL:outputURL error:nil];
+
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:inputURL options:nil];
+ AVAssetTrack *videoTrack = [[asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
+ if (!videoTrack) {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:10 userInfo:@{NSLocalizedDescriptionKey: @"Cannot finalize video"}]];
+ return;
+ }
+
+ CMTime duration = YouModExportDuration(asset, nil, durationMs);
+ if (!YouModCMTimeIsUsable(duration)) {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:11 userInfo:@{NSLocalizedDescriptionKey: @"Cannot determine duration"}]];
+ return;
+ }
+
+ AVMutableComposition *composition = [AVMutableComposition composition];
+ NSError *insertError = nil;
+ AVMutableCompositionTrack *compositionVideo = [composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
+ [compositionVideo insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration) ofTrack:videoTrack atTime:kCMTimeZero error:&insertError];
+ compositionVideo.preferredTransform = videoTrack.preferredTransform;
+ if (insertError) {
+ [self failWithError:insertError];
+ return;
+ }
+
+ AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
+ if (audioTrack) {
+ CMTime audioDuration = YouModMinUsableDuration(duration, audioTrack.timeRange.duration);
+ AVMutableCompositionTrack *compositionAudio = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
+ [compositionAudio insertTimeRange:CMTimeRangeMake(kCMTimeZero, audioDuration) ofTrack:audioTrack atTime:kCMTimeZero error:&insertError];
+ if (insertError) {
+ [self failWithError:insertError];
+ return;
+ }
+ }
+
+ AVAssetExportSession *exporter = [[AVAssetExportSession alloc] initWithAsset:composition presetName:AVAssetExportPresetPassthrough];
+ exporter.outputURL = outputURL;
+ exporter.outputFileType = AVFileTypeMPEG4;
+ exporter.shouldOptimizeForNetworkUse = YES;
+
+ __weak typeof(self) weakSelf = self;
+ [exporter exportAsynchronouslyWithCompletionHandler:^{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) return;
+ if (exporter.status == AVAssetExportSessionStatusCompleted) {
+ [self completeWithFileURL:outputURL isVideo:YES presenter:presenter];
+ } else {
+ [self failWithError:exporter.error ?: [NSError errorWithDomain:@"YouMod" code:12 userInfo:@{NSLocalizedDescriptionKey: @"Finalize failed"}]];
+ }
+ });
+ }];
+}
+
+- (void)completeWithFileURL:(NSURL *)fileURL isVideo:(BOOL)isVideo presenter:(UIViewController *)presenter {
+ self.active = NO;
+ [self updateProgressTitle:@"Download completed" progress:1.0f];
+ [self.progressAlert dismissViewControllerAnimated:YES completion:nil];
+ self.progressAlert = nil;
+ self.progressView = nil;
+
+ BOOL canSaveToPhotos = isVideo && YouModVideoFileCanSaveToPhotos(fileURL);
+ if (isVideo && IS_ENABLED(DownloadSaveToPhotos) && canSaveToPhotos) {
+ [self cleanupTemporaryFiles];
+ YouModSaveVideoToPhotos(fileURL, presenter, ^(BOOL success, NSError *error) {
+ if (success) {
+ YouModSendToast(@"Saved to Photos", presenter);
+ } else {
+ YouModSendToast(error.localizedDescription ?: @"Cannot save to Photos", presenter);
+ YouModShareFile(fileURL, presenter);
+ }
+ });
+ } else {
+ [self cleanupTemporaryFiles];
+ YouModSendToast(isVideo ? @"Download completed" : @"Audio saved", presenter);
+ if (!isVideo || (isVideo && !canSaveToPhotos)) YouModShareFile(fileURL, presenter);
+ }
+}
+
+- (void)failWithError:(NSError *)error {
+ self.active = NO;
+ [self.progressAlert dismissViewControllerAnimated:YES completion:nil];
+ self.progressAlert = nil;
+ self.progressView = nil;
+ [self cleanupTemporaryFiles];
+ YouModSendToast(error.localizedDescription ?: @"Download failed", self.presenter);
+}
+
+- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
+ self.currentBytes = (unsigned long long)MAX(totalBytesWritten, 0);
+ if (totalBytesExpectedToWrite > 0)
+ [self adjustCurrentExpectedBytesIfNeeded:(unsigned long long)totalBytesExpectedToWrite];
+ if (self.currentBytes > self.currentExpectedBytes)
+ [self adjustCurrentExpectedBytesIfNeeded:self.currentBytes];
+ [self updateDownloadProgressWithCurrentBytes:self.currentBytes expectedBytes:self.currentExpectedBytes];
+}
+
+- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location {
+ self.finishedCurrentFile = YES;
+ NSError *error = nil;
+ [NSFileManager.defaultManager removeItemAtURL:self.destinationURL error:nil];
+ [NSFileManager.defaultManager moveItemAtURL:location toURL:self.destinationURL error:&error];
+ if (self.fileCompletion) self.fileCompletion(error ? nil : self.destinationURL, error);
+}
+
+- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
+ if (error && !self.finishedCurrentFile && self.fileCompletion) {
+ self.fileCompletion(nil, error);
+ }
+}
+
+@end
+
+static void YouModDownloadThumbnail(NSString *videoID, UIViewController *presenter) {
+ NSURL *thumbnailURL = YouModThumbnailURLForVideoID(videoID);
+ if (!thumbnailURL) {
+ YouModSendToast(@"No thumbnail found", presenter);
+ return;
+ }
+
+ YouModSendToast(@"Downloading thumbnail", presenter);
+ [[NSURLSession.sharedSession dataTaskWithURL:thumbnailURL completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ UIImage *image = data ? [UIImage imageWithData:data] : nil;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (!image || error) {
+ YouModSendToast(error.localizedDescription ?: @"Thumbnail failed", presenter);
+ return;
+ }
+ YouModRequestPhotoAccess(^(BOOL granted) {
+ if (!granted) {
+ YouModSendToast(@"Photos access denied", presenter);
+ return;
+ }
+ [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
+ [PHAssetChangeRequest creationRequestForAssetFromImage:image];
+ } completionHandler:^(BOOL success, NSError *saveError) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ YouModSendToast(success ? @"Saved to Photos" : (saveError.localizedDescription ?: @"Save failed"), presenter);
+ });
+ }];
+ });
+ });
+ }] resume];
+}
+
+static void YouModCopyVideoInfo(YTPlayerViewController *player, UIViewController *presenter) {
+ NSString *videoID = YouModVideoIDForPlayer(player);
+ NSString *title = YouModTitleForPlayer(player);
+ NSString *url = videoID.length ? [NSString stringWithFormat:@"https://youtu.be/%@", videoID] : @"";
+ UIPasteboard.generalPasteboard.string = url.length ? [NSString stringWithFormat:@"%@\n%@", title, url] : title;
+ YouModSendToast(@"Copied video information", presenter);
+}
+
+static void YouModShowVideoQualitySheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSArray *videoFormats = YouModFormatsForPlayer(player, YES);
+ YouModMediaFormat *audioFormat = YouModBestAudioFormatForPlayer(player);
+ NSString *title = YouModTitleForPlayer(player);
+ NSString *videoID = YouModVideoIDForPlayer(player);
+
+ if (videoFormats.count == 0 || !audioFormat) {
+ YouModSendToast(@"No video/audio streams found", presenter);
+ return;
+ }
+
+ NSMutableArray *items = [NSMutableArray array];
+ for (YouModMediaFormat *format in videoFormats) {
+ NSString *rowTitle = format.qualityLabel.length ? format.qualityLabel : @"Video";
+ NSString *subtitle = YouModFormatSubtitle(format);
+ [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModIconImage(658) handler:^{
+ [[YouModDownloadCoordinator sharedCoordinator] startVideoDownloadWithVideoFormat:format audioFormat:audioFormat fileName:title videoID:videoID presenter:presenter];
+ }]];
+ }
+ YouModPresentMenu(@"Download video", items, presenter, sender);
+}
+
+static void YouModShowAudioSourceSheet(YTPlayerViewController *player, YouModAudioOutputFormat *outputFormat, UIViewController *presenter, UIView *sender) {
+ NSArray *audioFormats = YouModFormatsForPlayer(player, NO);
+ NSString *title = YouModTitleForPlayer(player);
+ NSString *videoID = YouModVideoIDForPlayer(player);
+ NSMutableArray *items = [NSMutableArray array];
+
+ if (audioFormats.count == 0) {
+ if (items.count) {
+ YouModPresentMenu(@"Download audio", items, presenter, sender);
+ return;
+ }
+ YouModSendToast(@"No audio streams found", presenter);
+ return;
+ }
+
+ NSUInteger index = 1;
+ for (YouModMediaFormat *format in audioFormats) {
+ NSString *rowTitle = audioFormats.count == 1 ? @"Audio" : [NSString stringWithFormat:@"Audio %lu", (unsigned long)index++];
+ NSString *subtitle = YouModFormatSubtitle(format);
+ [items addObject:[YouModMenuItem itemWithTitle:rowTitle subtitle:subtitle icon:YouModIconImage(21) handler:^{
+ [[YouModDownloadCoordinator sharedCoordinator] startAudioDownloadWithAudioFormat:format fileName:title videoID:videoID outputFormat:outputFormat presenter:presenter];
+ }]];
+ }
+ NSString *menuTitle = outputFormat.title.length ? [NSString stringWithFormat:@"Download %@", outputFormat.title] : @"Download audio";
+ YouModPresentMenu(menuTitle, items, presenter, sender);
+}
+
+static void YouModShowAudioSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSMutableArray *items = [NSMutableArray array];
+ for (YouModAudioOutputFormat *format in YouModAudioOutputFormats()) {
+ [items addObject:[YouModMenuItem itemWithTitle:format.title subtitle:YouModAudioOutputSubtitle(format) icon:YouModIconImage(21) handler:^{
+ if (!format.supported) {
+ YouModSendToast(@"DSD export is not supported by bundled FFmpeg.", presenter);
+ return;
+ }
+ YouModShowAudioSourceSheet(player, format, presenter, sender);
+ }]];
+ }
+ YouModPresentMenu(@"Audio format", items, presenter, sender);
+}
+
+static void YouModShowCaptionsSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSArray *tracks = YouModCaptionTracksForPlayer(player);
+ if (tracks.count == 0) {
+ YouModSendToast(@"No captions available for this video.", presenter);
+ return;
+ }
+
+ NSMutableArray *items = [NSMutableArray array];
+ for (id track in tracks) {
+ NSString *baseURL = YouModStringFromSelector(track, @selector(baseURL));
+ if (baseURL.length == 0) continue;
+
+ NSString *languageCode = YouModStringFromSelector(track, @selector(languageCode));
+ NSString *vssId = YouModStringFromSelector(track, @selector(vssId));
+ NSString *nameStr = nil;
+ id nameObj = YouModObjectFromSelector(track, @selector(name));
+ nameStr = YouModStringFromSelector(nameObj, @selector(simpleText));
+ if (!nameStr.length) {
+ NSArray *runs = YouModObjectFromSelector(nameObj, @selector(runsArray));
+ if (runs.count > 0) nameStr = YouModStringFromSelector(runs.firstObject, @selector(text));
+ }
+ if (!nameStr.length) nameStr = languageCode;
+ if (!nameStr.length) nameStr = vssId;
+
+ [items addObject:[YouModMenuItem itemWithTitle:nameStr subtitle:languageCode icon:YouModIconImage(637) handler:^{
+ NSString *vttURL = [baseURL stringByAppendingString:@"&fmt=vtt"];
+ NSURL *url = [NSURL URLWithString:vttURL];
+ if (!url) {
+ YouModSendToast(@"Invalid caption URL.", presenter);
+ return;
+ }
+ YouModSendToast(@"Downloading captions...", presenter);
+ [[NSURLSession.sharedSession dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (error || data.length == 0) {
+ YouModSendToast(@"Failed to download captions.", presenter);
+ return;
+ }
+ NSString *videoID = YouModVideoIDForPlayer(player) ?: @"video";
+ NSString *filename = [NSString stringWithFormat:@"%@_%@.vtt", videoID, languageCode ?: @"captions"];
+ NSURL *tempURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:filename]];
+ [data writeToURL:tempURL atomically:YES];
+ YouModShareFile(tempURL, presenter);
+ });
+ }] resume];
+ }]];
+ }
+
+ if (items.count == 0) {
+ YouModSendToast(@"No valid caption URLs found.", presenter);
+ return;
+ }
+
+ YouModPresentMenu(@"Download captions", items, presenter, sender);
+}
+
+static void YouModShowDownloadManager(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ if (!player) {
+ YouModSendToast(@"Open a video before using the download manager.", presenter);
+ return;
+ }
+
+ NSString *videoID = YouModVideoIDForPlayer(player);
+ NSMutableArray *items = [NSMutableArray array];
+ [items addObject:[YouModMenuItem itemWithTitle:@"Download video" subtitle:@"Choose quality" icon:YouModIconImage(658) handler:^{
+ YouModShowVideoQualitySheet(player, presenter, sender);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:@"Download audio" subtitle:@"Choose format" icon:YouModIconImage(21) handler:^{
+ YouModShowAudioSheet(player, presenter, sender);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:@"Download captions" subtitle:@"Save subtitles as VTT" icon:YouModIconImage(637) handler:^{
+ YouModShowCaptionsSheet(player, presenter, sender);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:@"Copy diagnostics" subtitle:@"Copy last error log" icon:YouModIconImage(870) handler:^{
+ YouModCopyDownloadDiagnostics(presenter);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:@"Save thumbnail" subtitle:@"Save to Photos" icon:YouModIconImage(367) handler:^{
+ YouModDownloadThumbnail(videoID, presenter);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:@"Copy video information" subtitle:@"Copy title and URL" icon:YouModIconImage(250) handler:^{
+ YouModCopyVideoInfo(player, presenter);
+ }]];
+ YouModPresentMenu(@"Download manager", items, presenter, sender);
+}
+
+void YouModConfigureDownloadButton(_ASDisplayView *view) {
+ if (![view.accessibilityIdentifier isEqualToString:@"id.ui.add_to.offline.button"]) return;
+ if (!IS_ENABLED(DownloadManager) || IS_ENABLED(HideDownloadButton)) return;
+ if (objc_getAssociatedObject(view, @selector(YouModDownloadButtonTapped:))) return;
+
+ view.userInteractionEnabled = YES;
+ UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:view action:@selector(YouModDownloadButtonTapped:)];
+ tap.cancelsTouchesInView = YES;
+ tap.delaysTouchesBegan = YES;
+ tap.delaysTouchesEnded = YES;
+ [view addGestureRecognizer:tap];
+ objc_setAssociatedObject(view, @selector(YouModDownloadButtonTapped:), @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+}
+
+%hook _ASDisplayView
+
+%new
+- (void)YouModDownloadButtonTapped:(UITapGestureRecognizer *)sender {
+ if (sender.state != UIGestureRecognizerStateEnded) return;
+ UIViewController *presenter = YouModPresenterForSender(self, YouModCurrentPlayerViewController);
+ YTPlayerViewController *player = YouModPlayerFromViewController(presenter);
+ YouModShowDownloadManager(player, presenter, self);
+}
+
+%end
+
+%hook YTPlayerViewController
+
+- (void)viewDidAppear:(BOOL)animated {
+ %orig;
+ YouModCurrentPlayerViewController = self;
+}
+
+- (void)viewWillDisappear:(BOOL)animated {
+ %orig;
+ if (YouModCurrentPlayerViewController == self)
+ YouModCurrentPlayerViewController = nil;
+}
+
+%end
+
+NSString *YouModGlobalAuthHeader = nil;
+
+%hook SSOAuthorization
+- (id)accessToken {
+ id token = %orig;
+ if ([token isKindOfClass:[NSString class]] && [(NSString *)token length] > 0) {
+ YouModGlobalAuthHeader = [NSString stringWithFormat:@"Bearer %@", token];
+ }
+ return token;
+}
+%end
+
+%hook SSOAuthorizationImpl
+- (id)accessToken {
+ id token = %orig;
+ if ([token isKindOfClass:[NSString class]] && [(NSString *)token length] > 0) {
+ YouModGlobalAuthHeader = [NSString stringWithFormat:@"Bearer %@", token];
+ }
+ return token;
+}
+%end
+
+%hook GNPSSOAuthorizationService
+- (id)authToken {
+ id token = %orig;
+ if ([token isKindOfClass:[NSString class]] && [(NSString *)token length] > 0) {
+ YouModGlobalAuthHeader = [NSString stringWithFormat:@"Bearer %@", token];
+ }
+ return token;
+}
+%end
\ No newline at end of file
diff --git a/Files/Feed.x b/Files/Feed.x
index 4670281c..9a759e22 100644
--- a/Files/Feed.x
+++ b/Files/Feed.x
@@ -1,64 +1,11 @@
#import "Headers.h"
-// YTUnShorts (https://github.com/PoomSmart/YTUnShorts)
-static NSMutableArray *filteredArray(NSArray *array) {
- NSMutableArray *newArray = [array mutableCopy];
- NSIndexSet *removeIndexes = [newArray indexesOfObjectsPassingTest:^BOOL(YTIItemSectionRenderer *sectionRenderer, NSUInteger idx, BOOL *stop) {
- if ([sectionRenderer isKindOfClass:%c(YTIShelfRenderer)]) {
- YTIShelfSupportedRenderers *content = ((YTIShelfRenderer *)sectionRenderer).content;
- YTIHorizontalListRenderer *horizontalListRenderer = content.horizontalListRenderer;
- NSMutableArray *itemsArray = horizontalListRenderer.itemsArray;
- NSIndexSet *removeItemsArrayIndexes = [itemsArray indexesOfObjectsPassingTest:^BOOL(YTIHorizontalListSupportedRenderers *horizontalListSupportedRenderers, NSUInteger idx2, BOOL *stop2) {
- YTIElementRenderer *elementRenderer = horizontalListSupportedRenderers.elementRenderer;
- NSString *description = [elementRenderer description];
- BOOL hasShorts = [description containsString:@"shorts_video_cell"];
- if (hasShorts) *stop2 = YES;
- return hasShorts;
- }];
- return removeItemsArrayIndexes.count > 0;
- }
- if ([sectionRenderer isKindOfClass:%c(YTIItemSectionRenderer)]) {
- NSString *description = [sectionRenderer description];
- if ([description containsString:@"shorts_shelf.eml"])
- return YES;
- }
- return NO;
- }];
- [newArray removeObjectsAtIndexes:removeIndexes];
- return newArray;
-}
-
-%group Shorts
-%hook YTInnerTubeCollectionViewController
-
-- (void)displaySectionsWithReloadingSectionControllerByRenderer:(id)renderer {
- NSMutableArray *sectionRenderers = [self valueForKey:@"_sectionRenderers"];
- [self setValue:filteredArray(sectionRenderers) forKey:@"_sectionRenderers"];
- %orig;
-}
-
-- (void)addSectionsFromArray:(NSArray *)array {
- %orig(filteredArray(array));
-}
-
-%end
-%end
-
// Hide Subbar
-%hook YTMySubsFilterHeaderView
-- (void)setChipFilterView:(id)arg1 { if (!IS_ENABLED(HideSubbar)) %orig; }
-%end
-
%hook YTHeaderContentComboView
- (void)enableSubheaderBarWithView:(id)arg1 { if (!IS_ENABLED(HideSubbar)) %orig; }
-- (void)setFeedHeaderScrollMode:(int)arg1 { IS_ENABLED(HideSubbar) ? %orig(0) : %orig; }
-%end
-
-%hook YTChipCloudCell
-- (void)layoutSubviews {
- if (self.superview && IS_ENABLED(HideSubbar)) {
- [self removeFromSuperview];
- } %orig;
+- (void)setFeedHeaderScrollMode:(int)arg1 {
+ int temp = IS_ENABLED(HideSubbar) ? 0 : arg1;
+ %orig(temp);
}
%end
@@ -78,9 +25,14 @@ static NSMutableArray *filteredArray(NSArray
#import
#import
+#import
#import
#import
#import
@@ -26,35 +27,77 @@
#import
#import
#import
-#import
#import
-#import
#import
#import
#import
#import
+#import
+#import
#import
+#import
+#import
#import
+#import
+#import
+#import
#import
+#import
+#import
+#import
+#import
#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
-// For Settings.x
+// For Settings.x and SponsorBlockSettings.x
#import
#import
-#import
#import
-#import
#import
#import
-#import
+#import
+#import
#import
+#define DownloadFix @"YouModDownloadFix"
+#define DownloadServerIndex @"YouModDownloadServerIndex"
+
#define IS_ENABLED(k) [[NSUserDefaults standardUserDefaults] boolForKey:k]
#define INTFORVAL(v) [[NSUserDefaults standardUserDefaults] integerForKey:v]
+#define FixPlaybackIssues @"YouModFixPlaybackIssues"
+#define MuteButton @"YouModMuteButton"
+#define SpeedButton @"YouModSpeedButton"
+#define ShareButton @"YouModShareButton"
+#define LoopButton @"YouModLoopButton"
+#define CaptionButton @"YouModCaptionButton"
+#define KeepMutedKey @"YouModKeepMutedKey"
+#define KeepLoopKey @"YouModKeepLoopKey"
+#define QualityButton @"YouModQualityButton"
+#define GlobalSpeedLocked @"YouModGlobalSpeedLocked"
+#define GlobalSavedNormalRate @"YouModGlobalSavedNormalRate"
// Downloading
#define DownloadManager @"YouModDownloadManager"
#define DownloadSaveToPhotos @"YouModDownloadSaveToPhotos"
-#define DownloadPreferDRCAudio @"YouModDownloadPreferDRCAudio"
+#define AddDownloadToShorts @"YouModAddDownloadToShorts"
+#define AudioPreferIndex @"YouModAudioPreferIndex"
+#define DownloadComment @"YouModDownloadComment"
+#define DownloadPost @"YouModDownloadPost"
// Cache
#define AutoClearCache @"YouModAutoClearCache"
// Appearance
@@ -63,44 +106,67 @@
// Navigation bar
#define HideYTLogo @"YouModHideYTLogo"
#define YTPremiumLogo @"YouModYTPremiumLogo"
+#define StickyNavBar @"YouModStickyNavBar"
#define HideNoti @"YouModHideNotificationButton"
#define HideSearch @"YouModHideSearchButton"
#define HideVoiceSearch @"YouModHideVoiceSearchButton"
#define HideCastButtonNav @"YouModHideCastButtonNavigationBar"
// Feed
#define HideSubbar @"YouModHideSubbar"
+#define HideHoriShelf @"YouModHideHoriShelf"
#define HideGenMusicShelf @"YouModHideGenMusicShelf"
#define HideFeedPost @"YouModHideFeedPost"
+#define HidePlayables @"YouModHidePlayables"
#define HideShortsShelf @"YouModHideShortsShelf"
+#define KeepShortsSubscript @"YouModKeepShortsSubscript"
#define HideSearchHis @"YouModHideSearchHistoryAndSuggestions"
-#define HideSubButton @"YouModHideSubscribeButton"
-#define HideShoppingButton @"YouModHideShoppingButton"
-#define HideMemberButton @"YouModHideMemberButton"
+#define HideSurveys @"YouModHideSurveys"
+#define HideRelatedVideos @"YouModHideRelatedVideos"
// Player
+#define WifiQualityIndex @"YouModWifiQualityIndex"
+#define CellQualityIndex @"YouModCellQualityIndex"
+#define LowPowerQualityIndex @"YouModLowPowerQualityIndex"
+#define AudioTrack @"YouModAudioTrackSegment"
+#define AudioTrackLangIndex @"YouModAudioTrackLangIndex"
+#define NoDubbedAudioTrack @"YouModNoDubbedAudioTrack"
+#define CaptionTrack @"YouModCaptionTrack"
+#define CaptionTrackLangIndex @"YouModCaptionTrackLangIndex"
+#define DisablesCaptionTrack @"YouModDisablesCaptionTrack"
+#define AutoSpeedIndex @"YouModAutoSpeedIndex"
+#define HoldToSpeedIndex @"YouModHoldToSpeedIndex"
#define HideAutoPlayToggle @"YouModHideAutoPlayToggle"
#define HideCaptionsButton @"YouModHideCaptionsButton"
#define HideCastButtonPlayer @"YouModHideCastButtonPlayer"
-#define HidePrevButton @"YouModHidePrevButton"
-#define HideNextButton @"YouModHideNextButton"
+#define HideNextAndPrevButtons @"YouModHideNextAndPrevButtons"
#define ReplacePrevNextButtons @"YouModReplacePrevNextButtons"
+#define SkipBackwardEnabled @"YouModSkipBackwardEnabled"
+#define SkipForwardEnabled @"YouModSkipForwardEnabled"
+#define RewindSeconds @"YouModRewindSeconds"
+#define ForwardSeconds @"YouModForwardSeconds"
#define RemoveDarkOverlay @"YouModRemoveDarkOverlay"
#define RemoveAmbiant @"YouModRemoveAmbiantColors"
#define HideEndScreenCards @"YouModHideEndScreenCards"
#define HideSuggestedVideo @"YouModHideSuggestedVideoOnFinish"
#define HidePaidPromoOverlay @"YouModHidePaidPromoOverlay"
#define HideWaterMark @"YouModHideWaterMark"
+#define DisablesEngagementPanel @"YouModDisablesEngagementPanel"
+#define DontSnapToChapter @"YouModDontSnapToChapter"
+#define PauseOnOverlay @"YouModPauseOnOverlay"
#define GestureControls @"YouModEnableGesturesControls"
#define GestureActivationArea @"YouModGestureActivationArea"
#define LeftSideGesture @"YouModLeftSideGesture"
#define RightSideGesture @"YouModRightSideGesture"
#define GestureHUD @"YouModGestureHUD"
+#define GestureHUDSize @"YouModGestureHUDSize"
+#define GestureHUDPosition @"YouModGestureHUDPosition"
#define DisablesDoubleTap @"YouModDisablesDoubleTap"
#define DisablesLongHold @"YouModDisablesLongHold"
#define AutoExitFullScreen @"YouModAutoExitFullScreen"
-#define DisablesCaptions @"YouModAutoDisablesCaptions"
#define DisablesShowRemaining @"YouModDisablesShowRemainingTime"
#define AlwaysShowRemaining @"YouModAlwaysShowRemainingTime"
#define ShowExtraTimeRemaining @"YouModShowExtraTimeRemaining"
+#define Uses24HoursTime @"YouModUses24HoursTime"
+#define CopyWithTimestampOnPause @"YouModCopyWithTimestampOnPause"
#define HideFullAction @"YouModHideFullScreenAction"
#define HideFullvidTitle @"YouModHideFullscreenVideoTitle"
#define StopAutoplayVideo @"YouModStopAutoplayVideo"
@@ -109,57 +175,108 @@
#define PortFull @"YouModPortraitFullscreen"
#define OldQualityPicker @"YouModUseOldQualityPicker"
#define ExtraSpeed @"YouModAddExtraSpeed"
-#define DisableHints @"YouModDisableHints"
#define ForceMiniPlayer @"YouModForceMiniPlayer"
#define AlwaysShowSeekbar @"YouModAlwaysShowSeekbar"
-#define HideLikeButton @"YouModHideLikeButton"
-#define HideDisLikeButton @"YouModHideDisLikeButton"
-#define HideShareButton @"YouModHideShareButton"
-#define HideDownloadButton @"YouModHideDownloadButton"
-#define HideClipButton @"YouModHideClipButton"
-#define HideRemixButton @"YouModHideRemixButton"
-#define HideSaveButton @"YouModHideSaveButton"
+#define DisablesFreeZoom @"YouModDisablesFreeZoom"
+#define TapToSeek @"YouModTapToSeek"
+#define PauseTwoFingers @"YouModPauseTwoFingers"
+#define HideCommentsSection @"YouModHideCommentsSection"
+#define HideCommentsPreview @"YouModHideCommentsPreview"
+#define LockSpeed @"YouModLockSpeed"
+#define UseAnotherMiniplayer @"YouModUseAnotherMiniplayer"
+#define SeekOnOverlay @"YouModSeekOnOverlay"
// Shorts
-#define HideShortsLikeButton @"YouModHideShortsLikeButton"
-#define HideShortsDisLikeButton @"YouModHideShortsDisLikeButton"
-#define HideShortsCommentButton @"YouModHideShortsCommentButton"
-#define HideShortsShareButton @"YouModHideShortsShareButton"
-#define HideShortsRemixButton @"YouModHideShortsRemixButton"
-#define HideShortsMetaButton @"YouModHideShortsMetaButton"
+#define FullScreenShorts @"YouModFullScreenShorts"
+#define RemoveShortsLive @"YouModRemoveShortsLive"
+#define RemoveShortsPosts @"YouModRemoveShortsPosts"
#define HideShortsProducts @"YouModHideShortsProducts"
#define HideShortsRecbar @"YouModHideShortsRecbar"
-#define HideShortsCommit @"YouModHideShortsCommit"
-#define HideShortsSubscriptButton @"YouModHideShortsSubscriptButton"
-#define HideShortsLiveButton @"YouModHideShortsLiveButton"
-#define HideShortsLensButton @"YouModHideShortsLensButton"
-#define HideShortsTrendsButton @"YouModHideShortsTrendsButton"
-#define HideShortsToVideo @"YouModHideShortsToVideo"
#define EnablesShortsQuality @"YouModEnablesShortsQuality"
#define ShowShortsSeekbar @"YouModShowShortsSeekbar"
+#define ShortsActionIndex @"YouModMakeAShortsAction"
+#define ShortsOnly @"YouModShortsOnly"
// Tab bar
#define DefaultTab @"YouModDefaultStartupTab"
+#define TabOrder @"YouModTabOrder"
#define HideTabIndi @"YouModHideTabIndicators"
#define HideTabLabels @"YouModHideTabLabels"
-#define HideHomeTab @"YouModHideHomeTab"
-#define HideShortsTab @"YouModHideShortsTab"
-#define HideCreateButton @"YouModHideCreateButton"
-#define HideSubscriptTab @"YouModHideSubscriptionsTab"
+#define UseFrostedTabBar @"YouModUseFrostedTabBar"
// Miscellaneous
#define BackgroundPlayback @"YouModEnablesBackgroundPlayback"
#define DisablesShortsPiP @"YouModTrytoDisablesShortsPiP"
+#define DisableHints @"YouModDisableHints"
#define BlockUpgradeDialogs @"YouModBlockUpgradeDialogs"
#define HideAreYouThereDialog @"YouModHideAreYouThereDialog"
#define FixesSlowMiniPlayer @"YouModFixesSlowMiniPlayer"
#define DisablesNewMiniPlayer @"YouModDisablesNewMiniPlayer"
#define DisablesSnackBar @"YouModDisablesSnackBar"
#define HideStartupAni @"YouModHideStartupAnimations"
-#define HidePlayInNextQueue @"YouModHidePlayInNextQueue"
#define HideLikeDislikeVotes @"YouModHideLikeDislikeVotes"
+#define HideCommuGuide @"YouModHideCommuGuide"
+#define DisablesRTL @"YouModDisablesRTL"
+#define DeviceUIIndex @"YouModDeviceUIIndex"
+#define FloatingKeyboard @"YouModFloatingKeyboard"
+#define AutoOpenLink @"YouModAutoOpenLink"
// #define CustomStartup @"YouModUseCustomVideoStartup"
+// Flyout menu
+#define RemovePlayInNextQueueOption @"YouModRemovePlayInNextQueueOption"
+#define RemoveDownloadOption @"YouModRemoveDownloadOption"
+#define RemoveWatchLaterOption @"YouModRemoveWatchLaterOption"
+#define RemoveSaveOption @"YouModRemoveSaveOption"
+#define RemoveRemoveFromPlaylistOption @"YouModRemoveRemoveFromPlaylistOption"
+#define RemoveShareOption @"YouModRemoveShareOption"
+#define RemoveNotInterestedOption @"YouModRemoveNotInterestedOption"
+#define RemoveInfoOption @"YouModRemoveInfoOption"
+#define RemoveFilterOption @"YouModRemoveFilterOption"
+#define RemoveReportOption @"YouModRemoveReportOption"
+#define RemoveYouTubeMusicOption @"YouModRemoveYouTubeMusicOption"
+#define RemoveFeedBackOption @"YouModRemoveFeedBackOption"
+#define RemoveDontRecommendOption @"YouModRemoveDontRecommendOption"
+#define RemoveCastOption @"YouModRemoveCastOption"
+#define RemoveShuffleOption @"YouModRemoveShuffleOption"
+#define RemoveUnSubOption @"YouModRemoveUnSubOption"
+#define RemoveHideFromPlaylistOption @"YouModRemoveHideFromPlaylistOption"
+#define RemoveHelpOption @"YouModRemoveHelpOption"
+#define RemoveNotifyOption @"YouModRemoveNotifyOption"
+#define RemoveClearScreenOption @"YouModRemoveClearScreenOption"
+// SponsorBlock
+#define SBEnabled @"YouModSBEnabled"
+#define SBShowButton @"YouModSBShowButton"
+#define SBShowNotifications @"YouModSBShowNotifications"
+#define SBAudioNotification @"YouModSBAudioNotification"
+#define SBSegmentsInPlayer @"YouModSBSegmentsInPlayer"
+#define SBSegmentsInFeed @"YouModSBSegmentsInFeed"
+#define SBSegmentsInMiniPlayer @"YouModSBSegmentsInMiniPlayer"
+#define SBShowDuration @"YouModSBShowDuration"
+#define SBMinDuration @"YouModSBMinDuration"
+#define SBSkipAlertDuration @"YouModSBSkipAlertDuration"
+#define SBUnskipAlertDuration @"YouModSBUnskipAlertDuration"
+
+#define SB_ACTION_KEY(cat) [NSString stringWithFormat:@"YouModSBAction_%@", cat]
+#define SB_COLOR_KEY(cat) [NSString stringWithFormat:@"YouModSBColor_%@", cat]
+
+#define FLOAT_FOR_KEY(k) [[NSUserDefaults standardUserDefaults] floatForKey:k]
#define YT_BUNDLE_ID @"com.google.ios.youtube"
#define YT_NAME @"YouTube"
+@interface YTMenuItemMDCButton : UIButton
+@end
+
+@interface YTPageHeaderViewController : UIViewController
+@end
+
+@interface YTIPageHeaderRenderer : GPBMessage
+@end
+
+@interface YTDefaultSheetController (YouMod)
++ (instancetype)sheetControllerWithParentResponder:(id)parentResponder;
+- (void)addAction:(YTActionSheetAction *)action;
+- (void)presentFromView:(UIView *)view animated:(BOOL)animated completion:(void (^)(void))completion;
+- (void)presentFromViewController:(UIViewController *)vc animated:(BOOL)animated completion:(void (^)(void))completion;
+- (void)addHeaderWithTitle:(NSString *)arg1 subtitle:(NSString *)arg2;
+@end
+
// Gesture Section Enum
typedef NS_ENUM(NSUInteger, GestureSection) {
GestureSectionTop,
@@ -167,6 +284,41 @@ typedef NS_ENUM(NSUInteger, GestureSection) {
GestureSectionInvalid
};
+@interface YTWatchController (YouMod)
+- (void)reload;
+@end
+
+@interface YTELMViewController : UIViewController
+@end
+
+@interface YTInlineScrubGestureView : UIView
+@end
+
+@interface YTReelContainerViewController : UIViewController
+@end
+
+@interface YTAppReelWatchRootViewController : UIViewController
+@end
+
+@interface YTPivotBarView : UIView
+@end
+
+@interface YTPivotBarItemView (YouMod)
+@end
+
+@interface YTContextualSheetView : UIView
+@end
+
+@interface YTShortsAdsPlayerViewController : YTReelPlayerViewController
+@end
+
+@interface YTIBrowseRequest (YouMod)
++ (NSString *)browseIDForGamingDestination;
++ (NSString *)browseIDForSportsDestination;
++ (NSString *)browseIDForNotificationsInbox;
++ (NSString *)browseIDForHistory;
+@end
+
@interface YTITopbarLogoRenderer : NSObject
@property(readonly, nonatomic) YTIIcon *iconImage;
@end
@@ -177,15 +329,13 @@ typedef NS_ENUM(NSUInteger, GestureSection) {
@end
@interface YTMainAppVideoPlayerOverlayView (YouMod)
+@property (nonatomic, weak, readwrite) YTMainAppVideoPlayerOverlayViewController *delegate;
@property (nonatomic, strong) YTQTMButton *playbackRouteButton;
@end
@interface YTNavigationBarTitleView : UIView
@end
-@interface YTChipCloudCell : UICollectionViewCell
-@end
-
@interface YTSearchViewController : UIViewController
@end
@@ -199,21 +349,132 @@ typedef NS_ENUM(NSUInteger, GestureSection) {
@interface YTPivotBarViewController : UIViewController
- (void)selectItemWithPivotIdentifier:(id)pivotIndentifier;
+- (void)YouModReloadTabBar:(id)arg;
+@end
+
+@interface YTAppViewController (YouMod)
+@property (nonatomic, assign, readonly) YTPivotBarViewController *pivotBarViewController;
+- (void)hidePivotBar;
+- (void)showPivotBar;
+- (void)refreshPivotBarWithTriggedByNotification:(BOOL)arg;
+- (BOOL)isPivotBarHidden;
+@end
+
+@interface YTAppViewControllerImpl (YouMod)
+@property (nonatomic, assign, readonly) YTPivotBarViewController *pivotBarViewController;
+- (void)hidePivotBar;
+- (void)showPivotBar;
+- (void)refreshPivotBarWithTriggedByNotification:(BOOL)arg;
+- (BOOL)isPivotBarHidden;
+@end
+
+@interface YTReelWatchPlaybackOverlayView : UIView
+@property (nonatomic, retain) UIPinchGestureRecognizer *YouModFullscreenGesture;
+@end
+
+@interface YTReelContentView (YouMod)
+@property (nonatomic, retain) UILongPressGestureRecognizer *YouModExitShortsOnlyGesture;
+- (YTReelWatchPlaybackOverlayView *)playbackOverlay;
+@end
+
+@interface YTLanguages : NSObject
++ (instancetype)languageList;
+@end
+
+@interface YTICaptionTrackEntry : GPBMessage
+- (NSString *)baseURL;
+- (NSString *)vssId;
+- (NSString *)languageCode;
+- (YTIFormattedString *)name;
@end
@interface YTPlayerViewController (YouMod)
@property (nonatomic, retain) UIPanGestureRecognizer *YouModPanGesture;
+@property (nonatomic, retain) UITapGestureRecognizer *YouModTapGesture;
@property (nonatomic, retain) UILabel *YouModGestureHUD;
+@property (nonatomic, weak, readwrite) UIViewController *parentViewController;
+@property (nonatomic, assign, readonly) BOOL isInlinePlaybackActive;
+@property (nonatomic, assign, readonly) BOOL isPlayingAd;
+@property (nonatomic, strong) UIView *YouModSpeedToastView;
+@property (nonatomic, strong) UILabel *YouModSpeedToastLabel;
+@property (nonatomic, retain) UILongPressGestureRecognizer *YouModHoldGesture;
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;
- (void)YouModAutoFullscreen;
-- (void)YouModTurnOffCaptions;
-- (void)setActiveCaptionTrack:(id)arg1 source:(long long)arg2;
+- (void)YouModSetAutoSpeed;
- (void)setPlaybackRate:(float)rate;
+- (void)setActiveCaptionTrack:(MLInnerTubeCaptionTrack *)arg1 source:(NSInteger)arg2;
+- (void)setActiveCaptionTrack:(MLInnerTubeCaptionTrack *)arg;
+- (void)play;
+- (void)pause;
+- (void)YouModAutoMute;
+- (void)YouModAutoAudioTrack;
+- (void)YouModAutoCaptions;
+- (void)YouModLoopButton;
+- (void)YouModCaptionsHelper:(MLInnerTubeCaptionTrack *)arg;
+- (void)YouModShareButton:(UIView *)sourceView;
+- (NSInteger)playerState;
+- (YTPlayerResponse *)contentPlayerResponse;
+- (YTPlayerResponse *)playerResponse;
+- (id)audioTrackController;
+- (void)setAudioTrack:(YTIAudioTrack *)arg1 source:(NSInteger)arg2;
+- (void)YouModHideSpeedToast;
+- (void)YouModShowSpeedToast:(CGFloat)speed isLocked:(BOOL)isLocked;
+@end
+
+@interface YTPlayerBarController (YouMod)
+- (void)didScrub:(UIPanGestureRecognizer *)gesture;
+@end
+
+@interface YTFullscreenEngagementOverlayView : UIView
+@end
+
+@interface YTRelatedVideosView : UIView
+@end
+
+@interface YTAutoplayAutonavController : NSObject
+- (void)setLoopMode:(NSInteger)loopMode;
+@end
+
+@interface YTInlineMutedPlaybackPlayerOverlayViewController : UIViewController
+@end
+
+@interface YTInlineMutedPlaybackPlayerOverlayView : UIView
+@end
+
+@interface YTWatchFloatingMiniplayerViewController : UIViewController
+@end
+
+@interface YTWatchFloatingMiniplayerWithPersistentControlsView : UIView
+@end
+
+@interface YTWatchFloatingMiniplayerProgressBarView : UIView
@end
@interface SSOConfiguration : NSObject
@end
+@interface YTIMySubsFilterHeaderRenderer : GPBMessage
+@end
+
+@interface YTMySubsFilterHeaderViewController : UIViewController
+@end
+
+@interface YTEngagementPanelView : UIView
+- (UIView *)footerView;
+@end
+
+@interface YTEngagementPanelContainerView : UIView
+- (NSInteger)engagementPanelState;
+@end
+
+@interface YTRelatedVideosViewController : UIViewController
+- (BOOL)isExpanded;
+@end
+
+@interface YTMainAppControlsOverlayView (YouMod)
+- (YTMainAppVideoPlayerOverlayViewController *)eventsDelegate;
+@end
+
@interface YTVideoQualitySwitchOriginalController (YouMod)
@property (retain, nonatomic) YTVideoQualitySwitchRedesignedController *redesignedController;
@end
@@ -241,6 +502,10 @@ typedef NS_ENUM(NSUInteger, GestureSection) {
- (void)YouModAutoClearCache;
@end
+@interface YTInlinePlayerBarContainerView (YouMod)
+@property (nonatomic, strong) NSString *endTimeString;
+@end
+
// Custom perferences logics
@interface YouModPrefsManager : NSObject
+ (instancetype)sharedManager;
@@ -249,34 +514,248 @@ typedef NS_ENUM(NSUInteger, GestureSection) {
- (void)restoreYouModDefaults;
@end
+@interface YTIAudioTrack (YouMod)
+@property (nonatomic, assign, readwrite) BOOL isAutoDubbed;
+- (BOOL)hasId_p;
+@end
+
+@interface MLInnerTubeCaptionTrack (YouMod)
+- (NSString *)languageCode;
+- (NSString *)VSSID;
+@end
+
+@interface YTCaptionTrackSwitchController : NSObject
+@end
+
// Player Gestures - @bhackel (YTLitePlus)
-@interface YTFineScrubberFilmstripView : UIView
+@interface YTMainAppVideoPlayerOverlayViewController (YouMod)
+@property (nonatomic, assign) YTPlayerViewController *parentViewController;
+- (YTCaptionTrackSwitchController *)captionTrackController;
+- (NSString *)videoID;
+- (CGFloat)mediaTime;
@end
-@interface YTFineScrubberFilmstripCollectionView : UICollectionView
+@interface YTSingleVideoController (YouMod)
+- (CGFloat)totalMediaTime;
+- (void)setVideoFormatConstraint:(id)arg;
+- (void)YouModAutoQuality;
+- (NSArray *)availableCaptionTracks;
+- (MLInnerTubeCaptionTrack *)activeCaptionTrack;
@end
-@interface YTWatchFullscreenViewController : YTMultiSizeViewController
+@interface YTReelPlayerViewController (YouMod)
+- (void)reelContentViewRequestsAdvanceToNextVideo:(id)arg;
+- (void)reelContentViewRequestsPlayPauseToggle:(id)arg;
+- (id)audioTrackController;
+- (void)YouModAutoAudioTrack:(YTPlayerViewController *)pv;
+- (void)YouModOnlyShorts;
@end
-@interface YTPlayerBarController (YouMod)
-- (void)didScrub:(UIPanGestureRecognizer *)gestureRecognizer;
-- (void)startScrubbing;
-- (void)didScrubToPoint:(CGPoint)point;
-- (void)endScrubbingForSeekSource:(int)seekSource;
+@interface YTIPlayerCaptionsTrackListRenderer : GPBMessage
+- (NSMutableArray *)captionTracksArray;
@end
-@interface YTMainAppVideoPlayerOverlayViewController (YouMod)
-@property (nonatomic, strong, readwrite) YTPlayerBarController *playerBarController;
+@interface YTICaptionsSupportedRenderers : GPBMessage
+- (YTIPlayerCaptionsTrackListRenderer *)playerCaptionsTracklistRenderer;
@end
-@interface YTInlinePlayerBarContainerView (YouMod)
-@property UIPanGestureRecognizer *scrubGestureRecognizer;
-@property (nonatomic, strong, readwrite) YTFineScrubberFilmstripView *fineScrubberFilmstrip;
-@property (nonatomic, strong, readwrite) NSString *endTimeString;
-- (CGFloat)scrubXForScrubRange:(CGFloat)scrubRange;
+@interface YTIPlayerResponse (YouMod)
+- (YTIStreamingData *)streamingData;
+- (YTICaptionsSupportedRenderers *)captions;
@end
-@interface YTSingleVideoController (YouMod)
-@property (nonatomic, assign, readonly) CGFloat totalMediaTime;
+@interface YTIFormatStream (YouMod)
+- (NSString *)mimeType;
+- (NSInteger)contentLength;
+- (NSUInteger)approxDurationMs;
+- (int)height;
+- (int)fps;
+- (YTIAudioTrack *)audioTrack;
+- (int)itag;
+@end
+
+@interface YTIFormattedString (YouMod)
+- (NSString *)dropdownOptionTitle;
+@end
+
+@interface YTIVideoDetails (YouMod)
+- (NSString *)title;
+- (NSString *)author;
+- (NSString *)shortDescription;
+@end
+
+@interface YTDataUtils : NSObject
++ (instancetype)generateClientSideNonce;
+@end
+
+@interface YCHAsyncLiveChatCollectionViewController : UIViewController
+@end
+
+@interface YTStartupAnimationViewController : UIViewController
+@end
+
+// SponsorBlock action modes
+typedef NS_ENUM(NSInteger, SBSegmentAction) {
+ SBSegmentActionDisable = 0,
+ SBSegmentActionAutoSkip = 1,
+ SBSegmentActionAsk = 2,
+ SBSegmentActionDisplay = 3,
+ SBSegmentActionSkipTo = 4
+};
+
+@interface SBSegment : NSObject
+@property (nonatomic, strong) NSString *UUID;
+@property (nonatomic, strong) NSString *category;
+@property (nonatomic, assign) float startTime;
+@property (nonatomic, assign) float endTime;
+@property (nonatomic, strong) NSString *actionType;
++ (instancetype)segmentWithUUID:(NSString *)UUID category:(NSString *)category start:(float)start end:(float)end action:(NSString *)actionType;
+- (SBSegmentAction)configuredAction;
+- (UIColor *)segmentColor;
+@end
+
+@interface SBRequest : NSObject
++ (void)fetchSegmentsForVideoID:(NSString *)videoID completion:(void (^)(NSArray *segments))completion;
+@end
+
+@interface SBSkipNotificationView : UIView
+@property (nonatomic, strong) UILabel *messageLabel;
+@property (nonatomic, strong) UIButton *actionButton;
+@property (nonatomic, strong) UIView *progressOverlay;
+@property (nonatomic, copy) void (^onAction)(void);
+@property (nonatomic, assign) NSTimeInterval totalDuration;
+@property (nonatomic, assign) NSTimeInterval remainingDuration;
+@property (nonatomic, assign) BOOL isPaused;
+@property (nonatomic, assign) BOOL isHighlightPill;
+@property (nonatomic, strong) NSDate *backgroundDate;
++ (instancetype)showInView:(UIView *)parentView message:(NSString *)message buttonTitle:(NSString *)buttonTitle action:(void (^)(void))action duration:(NSTimeInterval)duration;
++ (instancetype)showSuccessInView:(UIView *)parentView message:(NSString *)message duration:(NSTimeInterval)duration;
++ (instancetype)showErrorInView:(UIView *)parentView message:(NSString *)message duration:(NSTimeInterval)duration;
+- (void)dismiss;
+- (void)pauseProgress;
+- (void)resumeProgress;
+@end
+
+extern UIView *sbGetNotificationParent(void);
+extern void sbUpdateOverlayInsetForPivotBar(void);
+extern void YMPresentTabOrderModally(id parentResponder);
+
+// The ordered set of SponsorBlock categories YouMod supports. Both the core
+// (segment fetching / skipping) and the settings UI read from this single list,
+// so a category can never be fetchable without a control, or configurable
+// without being fetched.
+extern NSArray *sbAllCategories(void);
+
+// Tag stamped on every seek-bar segment marker view, used to find and remove
+// them across the player-bar layout hooks that don't hold a direct reference.
+static const NSInteger SBSegmentMarkerTag = 9900;
+
+// Supported range and default for the skip/unskip banner duration (seconds).
+// The settings sliders expose this range and the core clamps stored values to
+// it, so both read from one source and can never drift out of agreement.
+static const CGFloat SBAlertDurationMin = 2.0;
+static const CGFloat SBAlertDurationMax = 20.0;
+static const CGFloat SBAlertDurationDefault = 4.0;
+
+#pragma mark - Custom Overlay Button Registry
+
+// A registered button shown in the player's controls overlay (top-right, under
+// YouTube's settings gear). Features register a spec from their own %ctor; the
+// single YTMainAppControlsOverlayView hook in OverlayButtons.x lays them all out.
+@interface YMOverlayButtonSpec : NSObject
+@property (nonatomic, copy) NSString *identifier; // unique, e.g. @"sponsorblock.toggle"
+@property (nonatomic, copy) NSString *symbolName; // SF Symbol name (icon button)
+@property (nonatomic, copy) NSString *title; // text label; set this instead of symbolName for a text button
+@property (nonatomic, strong) UIColor *tintColor; // default tint (used if tintProvider is nil)
+@property (nonatomic, assign) NSInteger sortOrder; // ascending; lower = closer to gear (rightmost)
+@property (nonatomic, copy) void (^onTap)(YTPlayerViewController *player, YTQTMButton *button);
+@property (nonatomic, copy) BOOL (^isVisible)(YTPlayerViewController *player); // nil = always visible
+@property (nonatomic, copy) UIColor *(^tintProvider)(YTPlayerViewController *player); // nil = use tintColor
+@property (nonatomic, assign) NSInteger viewTag; // assigned by the registry; do not set
+@end
+
+extern void YMRegisterOverlayButton(YMOverlayButtonSpec *spec);
+extern NSArray *YMRegisteredOverlayButtons(void);
+
+#pragma mark - Settings Search
+
+// One row in the global settings-search results. A row renders its own cell and
+// (optionally) handles its own tap, so a single results table can host cells from
+// different settings pages (the generic YouMod pages and SponsorBlock) without the
+// search controller knowing how any of them are built. searchText is what the query
+// is matched against (title + description). makeCell builds the live, editable
+// control; onSelect handles taps that need to present UI (e.g. the colour picker),
+// receiving the presenting VC and a reload block to refresh the results.
+@interface YMSearchRow : NSObject
+@property (nonatomic, copy) NSString *searchText;
+@property (nonatomic, copy) UITableViewCell *(^makeCell)(UITableView *tableView);
+@property (nonatomic, assign) CGFloat cellHeight; // 0 = UITableViewAutomaticDimension
+@property (nonatomic, copy) void (^onSelect)(UIViewController *presenter, void (^reload)(void));
+@end
+
+// SponsorBlock's searchable rows (toggles, sliders, per-category action pickers and
+// colour circles), rendered by SponsorBlock's own cell builders so its settings are
+// editable inline in the global search. host adopts the rendering VC as a child so
+// the cells inherit the correct trait collection (light/dark). Defined in
+// SponsorBlockSettings.x; consumed by the search VC in YouModSettings.x.
+extern NSArray *sbSearchRows(UIViewController *host);
+
+extern NSBundle *YouModBundle();
+extern UIImage *YouModYTIconImage(NSInteger iconType, BOOL useCustomColor, UIColor *customColor);
+extern NSArray *getAllSystemLanguageTitles();
+extern NSArray *getAllSystemLanguageValues();
+extern UIViewController *YouModTopViewController(UIViewController *root);
+
+#define LOC(x) [YouModBundle() localizedStringForKey:x value:nil table:nil]
+
+@interface YMDownloadProgressView : UIView
+@property (nonatomic, strong) UILabel *titleLabel;
+@property (nonatomic, strong) UILabel *subtitleLabel;
+@property (nonatomic, strong) UIProgressView *progressBar;
+@property (nonatomic, strong) UIButton *cancelButton;
+@property (nonatomic, copy) void (^onCancel)(void);
++ (instancetype)showInView:(UIView *)parentView message:(NSString *)message cancelAction:(void (^)(void))cancelAction;
+- (void)updateProgress:(float)progress title:(NSString *)title subtitle:(NSString *)subtitle;
+- (void)dismiss;
+@end
+
+@interface YTPlayerViewController (SponsorBlock)
+@property (nonatomic, strong) NSString *sbLastVideoID;
+@property (nonatomic, strong) NSArray *sbSegments;
+@property (nonatomic, strong) NSMutableSet *sbSkippedSegments;
+@property (nonatomic, strong) SBSkipNotificationView *sbNotificationView;
+@property (nonatomic, assign) BOOL sbEnabledForVideo;
+- (void)sbCheckSegmentsAtCurrentTime;
+- (void)sbPerformSkip:(SBSegment *)segment;
+- (void)sbShowAskNotification:(SBSegment *)segment;
+- (void)sbShowHighlightBannerIfNeeded:(NSArray *)segments;
+- (void)sbSkipToHighlight;
+- (void)sbRefreshMarkers:(NSArray *)segments;
+@end
+
+@interface YouModThumbnailViewController : UIViewController
+@property (nonatomic, strong) UIImage *thumbnailImage;
+@property (nonatomic, strong) UIScrollView *scrollView;
+@property (nonatomic, strong) UIImageView *imageView;
+@end
+
+@interface YouModTranslationViewController : UIViewController
+@property (nonatomic, copy) NSString *originalText;
+@property (nonatomic, strong) UILabel *langValueLabel;
+@property (nonatomic, strong) UIButton *reloadButton;
+@property (nonatomic, strong) UITextView *resultTextView;
+@property (nonatomic, copy) NSString *selectedLangCode;
+@property (nonatomic, copy) NSString *selectedLangName;
+@property (nonatomic, strong) NSArray *languageTitles;
+@property (nonatomic, strong) NSArray *languageCodes;
+- (void)performTranslation;
+@end
+
+@interface YouModLanguagePickerViewController : UIViewController
+@property (nonatomic, copy) NSString *selectedLangCode;
+@property (nonatomic, copy) NSArray *titles;
+@property (nonatomic, copy) NSArray *codes;
+@property (nonatomic, copy) void (^onSelectLanguage)(NSString *code, NSString *title);
+@property (nonatomic, strong) UITableView *tableView;
+@property (nonatomic, strong) UIView *containerView;
@end
\ No newline at end of file
diff --git a/Files/Navbar.x b/Files/Navbar.x
index 5316f291..58ace9d3 100644
--- a/Files/Navbar.x
+++ b/Files/Navbar.x
@@ -15,7 +15,10 @@
%orig(renderer);
}
// For when spoofing before 18.34.5
-- (void)setPremiumLogo:(BOOL)arg { IS_ENABLED(YTPremiumLogo) ? %orig(YES) : %orig; }
+- (void)setPremiumLogo:(BOOL)arg {
+ BOOL temp = IS_ENABLED(YTPremiumLogo) ? YES : arg;
+ %orig(temp);
+}
- (BOOL)isPremiumLogo { return IS_ENABLED(YTPremiumLogo) ? YES : %orig; }
%end
@@ -33,7 +36,10 @@
%orig(renderer);
}
// For when spoofing before 18.34.5
-- (void)setPremiumLogo:(BOOL)arg { IS_ENABLED(YTPremiumLogo) ? %orig(YES) : %orig; }
+- (void)setPremiumLogo:(BOOL)arg {
+ BOOL temp = IS_ENABLED(YTPremiumLogo) ? YES : arg;
+ %orig(temp);
+}
- (BOOL)isPremiumLogo { return IS_ENABLED(YTPremiumLogo) ? YES : %orig; }
%end
@@ -65,8 +71,16 @@
%hook YTNavigationBarTitleView
- (void)layoutSubviews {
%orig;
- if (self.subviews.count > 1 && [self.subviews[1].accessibilityIdentifier isEqualToString:@"id.yoodle.logo"] && IS_ENABLED(HideYTLogo)) {
+ if (self.subviews.count > 1 && [self.subviews[1].accessibilityIdentifier isEqualToString:@"id.youtube.logo"] && IS_ENABLED(HideYTLogo)) {
self.subviews[1].hidden = YES;
}
}
+%end
+
+%hook YTHeaderView
+- (BOOL)stickyNavHeaderEnabled { return IS_ENABLED(StickyNavBar) ? YES : %orig; }
+- (void)setStickyNavHeaderEnabled:(BOOL)arg {
+ BOOL temp = IS_ENABLED(StickyNavBar) ? YES : arg;
+ %orig(temp);
+}
%end
\ No newline at end of file
diff --git a/Files/Others.x b/Files/Others.x
index e8a2eebe..e8351dd3 100644
--- a/Files/Others.x
+++ b/Files/Others.x
@@ -1,15 +1,6 @@
#import "Headers.h"
-Class YTILikeResponseClass, YTIDislikeResponseClass, YTIRemoveLikeResponseClass;
-
// Background playback
-%group BackgroundPlayback
-%hook YTIBackgroundOfflineSettingCategoryEntryRenderer
-%new(B@:)
-- (BOOL)isBackgroundEnabled { return YES; }
-%end
-%end
-
%hook MLVideo
- (BOOL)playableInBackground { return IS_ENABLED(BackgroundPlayback) ? YES : %orig; }
%end
@@ -26,10 +17,18 @@ Class YTILikeResponseClass, YTIDislikeResponseClass, YTIRemoveLikeResponseClass;
- (BOOL)isPlayableInBackground { return IS_ENABLED(BackgroundPlayback) ? YES : %orig; }
%end
-// Try to disable Shorts PiP
%hook YTColdConfig
+// Try to disable Shorts PiP
- (BOOL)shortsPlayerGlobalConfigEnableReelsPictureInPicture { return IS_ENABLED(DisablesShortsPiP) ? NO : %orig; }
- (BOOL)shortsPlayerGlobalConfigEnableReelsPictureInPictureIos { return IS_ENABLED(DisablesShortsPiP) ? NO : %orig; }
+// Hide startup animations
+- (BOOL)mainAppCoreClientIosEnableStartupAnimation { return IS_ENABLED(HideStartupAni) ? NO : %orig; }
+// Prevent YouTube from asking "Are you there?"
+- (BOOL)enableYouthereCommandsOnIos { return IS_ENABLED(BlockUpgradeDialogs) ? NO : %orig; }
+// Fixes slow miniplayer
+- (BOOL)enableIosFloatingMiniplayerDoubleTapToResize { return IS_ENABLED(FixesSlowMiniPlayer) ? NO : %orig; }
+// Use old miniplayer
+- (BOOL)enableIosFloatingMiniplayer { return IS_ENABLED(DisablesNewMiniPlayer) ? NO : %orig; }
%end
%hook YTHotConfig
@@ -42,12 +41,38 @@ Class YTILikeResponseClass, YTIDislikeResponseClass, YTIRemoveLikeResponseClass;
%hook YTReelPlayerViewController
- (BOOL)isPictureInPictureAllowed { return IS_ENABLED(DisablesShortsPiP) ? NO : %orig; }
+- (void)setupPlayerForPiP { if (!IS_ENABLED(DisablesShortsPiP)) %orig; }
%end
%hook YTReelWatchRootViewController
- (void)switchToPictureInPicture { if (!IS_ENABLED(DisablesShortsPiP)) %orig; }
%end
+// Disable Hints
+%hook YTSettings
+- (BOOL)areHintsDisabled { return IS_ENABLED(DisableHints) ? YES : %orig; }
+- (void)setHintsDisabled:(BOOL)arg1 {
+ BOOL temp = IS_ENABLED(DisableHints) ? YES : arg1;
+ %orig(temp);
+}
+%end
+
+%hook YTSettingsImpl
+- (BOOL)areHintsDisabled { return IS_ENABLED(DisableHints) ? YES : %orig; }
+- (void)setHintsDisabled:(BOOL)arg1 {
+ BOOL temp = IS_ENABLED(DisableHints) ? YES : arg1;
+ %orig(temp);
+}
+%end
+
+%hook YTUserDefaults
+- (BOOL)areHintsDisabled { return IS_ENABLED(DisableHints) ? YES : %orig; }
+- (void)setHintsDisabled:(BOOL)arg1 {
+ BOOL temp = IS_ENABLED(DisableHints) ? YES : arg1;
+ %orig(temp);
+}
+%end
+
// Block upgrade dialogs
%hook YTGlobalConfig
- (BOOL)shouldBlockUpgradeDialog { return IS_ENABLED(BlockUpgradeDialogs) ? YES : %orig; }
@@ -56,11 +81,6 @@ Class YTILikeResponseClass, YTIDislikeResponseClass, YTIRemoveLikeResponseClass;
- (BOOL)shouldForceUpgrade { return IS_ENABLED(BlockUpgradeDialogs) ? NO : %orig; }
%end
-// Prevent YouTube from asking "Are you there?"
-%hook YTColdConfig
-- (BOOL)enableYouthereCommandsOnIos { return IS_ENABLED(BlockUpgradeDialogs) ? NO : %orig; }
-%end
-
%hook YTYouThereController
- (BOOL)shouldShowYouTherePrompt { return IS_ENABLED(HideAreYouThereDialog) ? NO : %orig; }
- (void)showYouTherePrompt { if (!IS_ENABLED(HideAreYouThereDialog)) %orig; }
@@ -71,16 +91,6 @@ Class YTILikeResponseClass, YTIDislikeResponseClass, YTIRemoveLikeResponseClass;
- (void)showYouTherePrompt { if (!IS_ENABLED(HideAreYouThereDialog)) %orig; }
%end
-// Fixes slow miniplayer
-%hook YTColdConfig
-- (BOOL)enableIosFloatingMiniplayerDoubleTapToResize { return IS_ENABLED(FixesSlowMiniPlayer) ? NO : %orig; }
-%end
-
-// Use old miniplayer
-%hook YTColdConfig
-- (BOOL)enableIosFloatingMiniplayer { return IS_ENABLED(DisablesNewMiniPlayer) ? NO : %orig; }
-%end
-
// Disables Snackbar
%hook GOOHUDManagerInternal
- (id)sharedInstance { return IS_ENABLED(DisablesSnackBar) ? nil : %orig; }
@@ -89,72 +99,108 @@ Class YTILikeResponseClass, YTIDislikeResponseClass, YTIRemoveLikeResponseClass;
- (void)displayHUDViewForMessage:(id)arg { if (!IS_ENABLED(DisablesSnackBar)) %orig; }
%end
-// Hide startup animations
-%hook YTColdConfig
-- (BOOL)mainAppCoreClientIosEnableStartupAnimation { return IS_ENABLED(HideStartupAni) ? NO : %orig; }
-%end
-
// Remove "Play next in queue" from the menu @PoomSmart (https://github.com/qnblackcat/uYouPlus/issues/1138#issuecomment-1606415080)
%hook YTMenuItemVisibilityHandler
- (BOOL)shouldShowServiceItemRenderer:(YTIMenuConditionalServiceItemRenderer *)renderer {
- if (renderer.icon.iconType == 251 && IS_ENABLED(HidePlayInNextQueue)) {
+ if (renderer.icon.iconType == 251 && IS_ENABLED(RemovePlayInNextQueueOption)) {
return NO;
- } return %orig;
+ }
+ return %orig;
}
%end
%hook YTMenuItemVisibilityHandlerImpl
- (BOOL)shouldShowServiceItemRenderer:(YTIMenuConditionalServiceItemRenderer *)renderer {
- if (renderer.icon.iconType == 251 && IS_ENABLED(HidePlayInNextQueue)) {
+ if (renderer.icon.iconType == 251 && IS_ENABLED(RemovePlayInNextQueueOption)) {
return NO;
- } return %orig;
+ }
+ return %orig;
}
%end
-/* untested
-// Remove Download button from the menu
+// Remove flyout menu options
%hook YTDefaultSheetController
- (void)addAction:(YTActionSheetAction *)action {
- NSString *identifier = [action valueForKey:@"_accessibilityIdentifier"];
+ if (![action.button isKindOfClass:%c(YTMenuItemMDCButton)]) {
+ %orig;
+ return;
+ }
+ YTMenuItemMDCButton *button = (YTMenuItemMDCButton *)action.button;
+ NSString *iden = button.accessibilityIdentifier;
+ NSString *imageName = [button.currentImage description];
+ // Method 1: Filter from accessibilityIdentifier
NSDictionary *actionsToRemove = @{
- @"7": @(ytlBool(@"removeDownloadMenu")),
- @"1": @(ytlBool(@"removeWatchLaterMenu")),
- @"3": @(ytlBool(@"removeSaveToPlaylistMenu")),
- @"5": @(ytlBool(@"removeShareMenu")),
- @"12": @(ytlBool(@"removeNotInterestedMenu")),
- @"31": @(ytlBool(@"removeDontRecommendMenu")),
- @"58": @(ytlBool(@"removeReportMenu"))
+ @"7": @(IS_ENABLED(RemoveDownloadOption)),
+ @"1": @(IS_ENABLED(RemoveWatchLaterOption)),
+ @"3": @(IS_ENABLED(RemoveSaveOption)),
+ @"4": @(IS_ENABLED(RemoveRemoveFromPlaylistOption)),
+ @"5": @(IS_ENABLED(RemoveShareOption)),
+ @"6": @(IS_ENABLED(RemoveShareOption)),
+ @"12": @(IS_ENABLED(RemoveNotInterestedOption)),
+ @"22": @(IS_ENABLED(RemoveInfoOption)),
+ @"36": @(IS_ENABLED(RemoveFilterOption)),
+ @"40": @(IS_ENABLED(RemoveNotifyOption)),
+ @"58": @(IS_ENABLED(RemoveReportOption))
};
-
- if (![actionsToRemove[identifier] boolValue]) {
- %orig;
+ if ([actionsToRemove[iden] boolValue]) return;
+
+ // Method 2: Filter from imageName
+ NSDictionary *imageNameToRemove = @{
+ @"youtube_music": @(IS_ENABLED(RemoveYouTubeMusicOption)),
+ @"flag": @(IS_ENABLED(RemoveReportOption)),
+ @"alert_bubble": @(IS_ENABLED(RemoveFeedBackOption)),
+ @"bookmark": @(IS_ENABLED(RemoveSaveOption)),
+ @"circle_slash": @(IS_ENABLED(RemoveNotInterestedOption)),
+ @"x_circle": @(IS_ENABLED(RemoveDontRecommendOption)),
+ @"chromecast": @(IS_ENABLED(RemoveCastOption)),
+ @"shuffle": @(IS_ENABLED(RemoveShuffleOption)),
+ @"person_x": @(IS_ENABLED(RemoveUnSubOption)),
+ @"help_circle": @(IS_ENABLED(RemoveHelpOption)),
+ @"eye_slash": @(IS_ENABLED(RemoveHideFromPlaylistOption)),
+ @"player_full_enter_alt": @(IS_ENABLED(RemoveClearScreenOption)),
+ @"info_circle": @(IS_ENABLED(RemoveInfoOption))
+ };
+ for (NSString *key in imageNameToRemove) {
+ if ([imageName containsString:key]) {
+ if ([imageNameToRemove[key] boolValue]) {
+ return;
+ }
+ break;
+ }
}
+ %orig;
}
%end
-*/
// YTSlientVote (https://github.com/PoomSmart/YTSilentVote)
-%group SlientVote
%hook YTInnerTubeResponseWrapper
- (id)initWithResponse:(id)response cacheContext:(id)arg2 requestStatistics:(id)arg3 mutableSharedData:(id)arg4 {
- if ([response isKindOfClass:YTILikeResponseClass]
- || [response isKindOfClass:YTIDislikeResponseClass]
- || [response isKindOfClass:YTIRemoveLikeResponseClass]) return nil;
+ if (!IS_ENABLED(HideLikeDislikeVotes)) return %orig;
+ if ([response isKindOfClass:%c(YTILikeResponse)]
+ || [response isKindOfClass:%c(YTIDislikeResponse)]
+ || [response isKindOfClass:%c(YTIRemoveLikeResponse)]) return nil;
return %orig;
}
%end
+
+%hook NSParagraphStyle
++ (NSWritingDirection)defaultWritingDirectionForLanguage:(id)lang { return IS_ENABLED(DisablesRTL) ? NSWritingDirectionLeftToRight : %orig; }
++ (NSWritingDirection)_defaultWritingDirection { return IS_ENABLED(DisablesRTL) ? NSWritingDirectionLeftToRight : %orig; }
%end
-%ctor {
- YTILikeResponseClass = %c(YTILikeResponse);
- YTIDislikeResponseClass = %c(YTIDislikeResponse);
- YTIRemoveLikeResponseClass = %c(YTIRemoveLikeResponse);
- %init;
- if (IS_ENABLED(HideLikeDislikeVotes)) {
- %init(SlientVote);
+%hook UIDevice
+- (UIUserInterfaceIdiom)userInterfaceIdiom {
+ if (INTFORVAL(DeviceUIIndex) == 1) {
+ return UIUserInterfaceIdiomPad;
}
- if (IS_ENABLED(BackgroundPlayback)) {
- %init(BackgroundPlayback);
+ if (INTFORVAL(DeviceUIIndex) == 2) {
+ return UIUserInterfaceIdiomPhone;
}
-}
\ No newline at end of file
+ return %orig;
+}
+%end
+
+%hook UIKeyboardImpl
++ (BOOL)isFloating { return IS_ENABLED(FloatingKeyboard) ? YES : %orig; }
+%end
\ No newline at end of file
diff --git a/Files/OverlayButtons.x b/Files/OverlayButtons.x
new file mode 100644
index 00000000..224270f7
--- /dev/null
+++ b/Files/OverlayButtons.x
@@ -0,0 +1,551 @@
+#import "Headers.h"
+
+static NSString *YouModUpdateSpeedLabel = @"YouModUpdateSpeedLabel";
+static NSString *currentSpeedLabel = @"1x";
+static float currentPlaybackRate = 1.0;
+
+static NSString *YouModUpdateNotification = @"YouModUpdateNotification";
+static NSString *currentQualityLabel = @"Auto";
+
+static NSString *speedLabel(float rate) {
+ NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
+ formatter.numberStyle = NSNumberFormatterDecimalStyle;
+ formatter.minimumFractionDigits = 0;
+ formatter.maximumFractionDigits = 2;
+ NSString *rateString = [formatter stringFromNumber:[NSNumber numberWithFloat:rate]];
+ return [NSString stringWithFormat:@"%@x", rateString];
+}
+
+static void didSelectRate(float rate) {
+ currentPlaybackRate = rate;
+ currentSpeedLabel = speedLabel(rate);
+ [[NSNotificationCenter defaultCenter] postNotificationName:YouModUpdateSpeedLabel object:nil];
+}
+
+@interface YTMainAppControlsOverlayView ()
+- (void)updateQualityButton:(id)arg;
+- (void)updateSpeedButton:(id)arg;
+@end
+
+// YouGetCaption (https://github.com/PoomSmart/YouGetCaption)
+static void showTranscript(YTFormat3CaptionViewController *cvc) {
+ UIView *parent = sbGetNotificationParent();
+ MLFormat3Captions *currentCaptions = [cvc valueForKey:@"_currentCaptions"];
+ YTIntervalTree *tree = currentCaptions.captions;
+ NSMutableString *transcript = [NSMutableString string];
+ [tree enumerateAllIntervalsWithBlock:^(YTInterval *interval) {
+ MLCaption *caption = (MLCaption *)interval;
+ NSArray *segments = caption.segments;
+ for (MLCaptionSegment *segment in segments) {
+ [transcript appendString:segment.text];
+ }
+ }];
+ if (transcript.length == 0) {
+ [SBSkipNotificationView showErrorInView:parent message:LOC(@"NO_CAPTIONS") duration:4.0];
+ return;
+ }
+ YTAlertView *alertView = [%c(YTAlertView) confirmationDialogWithAction:^{
+ UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
+ pasteboard.string = transcript;
+ [SBSkipNotificationView showSuccessInView:parent message:LOC(@"COPIED_TO_CLIPBOARD") duration:3.0];
+ } actionTitle:LOC(@"COPY")];
+ alertView.title = nil;
+ alertView.subtitle = transcript;
+ alertView.shouldDismissOnBackgroundTap = YES;
+ [alertView show];
+}
+
+#pragma mark - YMOverlayButtonSpec
+
+@implementation YMOverlayButtonSpec
+@end
+
+#pragma mark - Registry
+
+// Base of the view-tag range for registered overlay buttons. Chosen to avoid
+// colliding with other tagged views in the player overlay (e.g. the seek-bar
+// segment markers at 9900).
+static const NSInteger YMOverlayButtonBaseTag = 9910;
+
+// Button geometry. The top inset places the row just below YouTube's own
+// CC/gear row in the top-right corner of the player overlay.
+static const CGFloat YMOverlayButtonSize = 30.0;
+static const CGFloat YMOverlayButtonSpace = 5.0;
+static const CGFloat YMOverlayButtonGap = 18.0;
+static const CGFloat YMOverlayButtonTopInset = 52.0; // fallback row top when the gear can't be located
+static const CGFloat YMOverlayButtonEdgePadding = 12.0; // fallback right padding when the gear isn't found
+
+// Width of a text button. Tweak this to make text buttons wider or narrower; icon
+// buttons stay square at YMOverlayButtonSize.
+static const CGFloat YMOverlayTextButtonWidth = 30.0;
+
+static NSMutableArray *gOverlayButtons = nil;
+static NSInteger gOverlayButtonNextTag = YMOverlayButtonBaseTag;
+
+void YMRegisterOverlayButton(YMOverlayButtonSpec *spec) {
+ if (!spec || spec.identifier.length == 0) return;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ gOverlayButtons = [NSMutableArray array]; });
+
+ // Replace any previous registration with the same identifier (idempotent).
+ for (YMOverlayButtonSpec *existing in [gOverlayButtons copy]) {
+ if ([existing.identifier isEqualToString:spec.identifier]) {
+ spec.viewTag = existing.viewTag;
+ [gOverlayButtons removeObject:existing];
+ }
+ }
+ if (spec.viewTag == 0) spec.viewTag = gOverlayButtonNextTag++;
+ [gOverlayButtons addObject:spec];
+}
+
+NSArray *YMRegisteredOverlayButtons(void) {
+ if (!gOverlayButtons) return @[];
+ return [gOverlayButtons sortedArrayUsingComparator:^NSComparisonResult(YMOverlayButtonSpec *a, YMOverlayButtonSpec *b) {
+ if (a.sortOrder == b.sortOrder) return [a.identifier compare:b.identifier];
+ return a.sortOrder < b.sortOrder ? NSOrderedAscending : NSOrderedDescending;
+ }];
+}
+
+#pragma mark - Helpers
+
+// The player view controller that owns this controls overlay, reached through the
+// overlay's events delegate. Button handlers use it to act on the current video.
+static YTPlayerViewController *YMPlayerVCFromOverlay(YTMainAppControlsOverlayView *overlay) {
+ YTMainAppVideoPlayerOverlayViewController *mainOverlayController = (YTMainAppVideoPlayerOverlayViewController *)overlay.eventsDelegate;
+ return mainOverlayController.parentViewController;
+}
+
+// Recursively find the right-most YTQTMButton in the overlay's top region. YouTube
+// nests the gear/CC/cast buttons inside a container, so a one-level scan would miss
+// them; recursion reaches the nested buttons wherever they sit.
+static void YMScanForGearFrame(UIView *view, YTMainAppControlsOverlayView *overlay, CGFloat topRegionMaxY, CGRect *bestFrame) {
+ for (UIView *sub in view.subviews) {
+ if ([sub isKindOfClass:%c(YTQTMButton)]) {
+ CGRect f = [sub convertRect:sub.bounds toView:overlay];
+ if (CGRectGetMidY(f) <= topRegionMaxY) { // in the top button row
+ // The CGRectIsNull check must stay first: CGRectGetMidX(CGRectNull) is
+ // infinite, so the > comparison alone would never accept the first match.
+ if (CGRectIsNull(*bestFrame) || CGRectGetMidX(f) > CGRectGetMidX(*bestFrame)) *bestFrame = f;
+ }
+ }
+ YMScanForGearFrame(sub, overlay, topRegionMaxY, bestFrame);
+ }
+}
+
+// Find YouTube's settings/overflow button so we can anchor our row directly beneath it.
+// Prefer the overlay's own overflowButton; otherwise take the right-most YTQTMButton in
+// the overlay's top region. Returns its frame in the overlay's coordinate space, or
+// CGRectNull if not found (the caller then falls back to the screen edge / top inset).
+static CGRect YMGearFrameInOverlay(YTMainAppControlsOverlayView *overlay) {
+ YTQTMButton *overflow = [overlay valueForKey:@"_overflowButton"];
+ if (overflow.window) {
+ return [overflow convertRect:overflow.bounds toView:overlay];
+ }
+
+ CGFloat topRegionMaxY = overlay.bounds.size.height * 0.25;
+ CGRect bestFrame = CGRectNull;
+ YMScanForGearFrame(overlay, overlay, topRegionMaxY, &bestFrame);
+ return bestFrame;
+}
+
+// The font for a text button's label, in YouTube Sans to match native controls,
+// with a plain system-font fallback on versions lacking the YouTube Sans style API.
+static UIFont *YMOverlayTextButtonFont(NSString *text, CGSize maxSize) {
+ if (text.length == 0) return [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
+
+ YTDefaultTypeStyle *typeStyle = [%c(YTTypeStyle) defaultTypeStyle];
+ BOOL hasYTFont = [typeStyle respondsToSelector:@selector(ytSansFontOfSize:weight:)];
+ NSInteger bestSize = 10;
+
+ for (NSInteger size = (NSInteger)maxSize.height; size >= 8; size--) {
+ UIFont *testFont = hasYTFont ? [typeStyle ytSansFontOfSize:(CGFloat)size weight:UIFontWeightSemibold] : [UIFont systemFontOfSize:(CGFloat)size weight:UIFontWeightSemibold];
+ CGRect rect = [text boundingRectWithSize:CGSizeMake(maxSize.width, CGFLOAT_MAX)
+ options:NSStringDrawingUsesLineFragmentOrigin
+ attributes:@{NSFontAttributeName: testFont}
+ context:nil];
+ if (ceil(rect.size.width) <= maxSize.width && ceil(rect.size.height) <= maxSize.height) {
+ bestSize = size;
+ break;
+ }
+ }
+ return hasYTFont ? [typeStyle ytSansFontOfSize:(CGFloat)bestSize weight:UIFontWeightSemibold] : [UIFont systemFontOfSize:(CGFloat)bestSize weight:UIFontWeightSemibold];
+}
+
+static YTQTMButton *YMCreateOverlayButton(YTMainAppControlsOverlayView *overlay, YMOverlayButtonSpec *spec) {
+ YTQTMButton *button;
+ UIColor *tint = spec.tintColor ?: [UIColor whiteColor];
+
+ if (spec.title.length > 0) {
+ // Text button: a label instead of an icon. customTitleColor is YTQTMButton's
+ // own text-colour channel; sizeWithPaddingAndInsets is disabled so the width
+ // stays fixed rather than expanding to fit the text.
+ button = [%c(YTQTMButton) textButton];
+ [button setTitle:spec.title forState:UIControlStateNormal];
+ button.customTitleColor = tint;
+ button.titleLabel.font = YMOverlayTextButtonFont(spec.title, CGSizeMake(25, 25));
+ button.titleLabel.textAlignment = NSTextAlignmentCenter;
+ button.sizeWithPaddingAndInsets = NO;
+ button.titleLabel.numberOfLines = 2;
+ button.titleLabel.adjustsFontSizeToFitWidth = YES;
+ button.titleLabel.lineBreakMode = NSLineBreakByClipping;
+ button.titleLabel.baselineAdjustment = UIBaselineAdjustmentAlignCenters;
+ button.contentEdgeInsets = UIEdgeInsetsZero;
+ button.titleEdgeInsets = UIEdgeInsetsZero;
+ button.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter;
+ button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentCenter;
+ } else {
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:20 weight:UIImageSymbolWeightMedium];
+ // Template rendering so YTQTMButton's tint colours the glyph reliably.
+ UIImage *icon = [[UIImage systemImageNamed:spec.symbolName withConfiguration:config] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
+ button = [%c(YTQTMButton) iconButton];
+ [button setImage:icon forState:UIControlStateNormal];
+ button.tintColor = tint;
+ button.imageView.contentMode = UIViewContentModeScaleAspectFit;
+ }
+
+ button.exclusiveTouch = YES;
+ button.tag = spec.viewTag;
+ // The row's frame is assigned authoritatively in layoutSubviews.
+ button.frame = CGRectMake(0, 0, YMOverlayButtonSize, YMOverlayButtonSize);
+ [button addTarget:overlay action:@selector(ymOverlayButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
+ [overlay addSubview:button];
+ return button;
+}
+
+#pragma mark - YTMainAppControlsOverlayView Hook
+
+static BOOL isRelatedVideosExpanded = NO;
+
+%hook YTMainAppControlsOverlayView
+
+- (void)layoutSubviews {
+ %orig;
+ NSArray *specs = YMRegisteredOverlayButtons();
+ if (specs.count == 0) return;
+
+ YTPlayerViewController *player = YMPlayerVCFromOverlay(self);
+ BOOL overlayVisible = self.isOverlayVisible;
+ CGRect gearFrame = YMGearFrameInOverlay(self);
+ BOOL hasGear = !CGRectIsNull(gearFrame);
+ CGFloat trailingCenterX = hasGear ? CGRectGetMidX(gearFrame) : self.bounds.size.width - YMOverlayButtonEdgePadding - YMOverlayButtonSize / 2.0;
+ CGFloat rowTop = hasGear ? CGRectGetMaxY(gearFrame) + YMOverlayButtonSpace : YMOverlayButtonTopInset;
+ CGFloat prevHalfWidth = 0;
+
+ for (YMOverlayButtonSpec *spec in specs) {
+ BOOL visible = (spec.isVisible == nil) || spec.isVisible(player);
+ YTQTMButton *btn = (YTQTMButton *)[self viewWithTag:spec.viewTag];
+
+ if (!visible) {
+ if (btn) [btn removeFromSuperview];
+ continue;
+ }
+ if (!btn) btn = YMCreateOverlayButton(self, spec);
+
+ btn.hidden = !overlayVisible || isRelatedVideosExpanded;
+
+ if (spec.tintProvider) {
+ UIColor *dynamic = spec.tintProvider(player);
+ if (spec.title.length > 0) btn.customTitleColor = dynamic;
+ else btn.tintColor = dynamic;
+ }
+
+ CGFloat width = (spec.title.length > 0) ? YMOverlayTextButtonWidth : YMOverlayButtonSize;
+ CGFloat centerX = (prevHalfWidth == 0)
+ ? trailingCenterX
+ : trailingCenterX - prevHalfWidth - YMOverlayButtonGap - width / 2.0;
+
+ btn.frame = CGRectMake(centerX - width / 2.0, rowTop, width, YMOverlayButtonSize);
+ trailingCenterX = centerX;
+ prevHalfWidth = width / 2.0;
+ [self bringSubviewToFront:btn];
+ }
+}
+
+- (void)setOverlayVisible:(BOOL)visible {
+ %orig;
+ for (YMOverlayButtonSpec *spec in YMRegisteredOverlayButtons()) {
+ YTQTMButton *btn = (YTQTMButton *)[self viewWithTag:spec.viewTag];
+ if (btn) btn.hidden = !visible || isRelatedVideosExpanded;
+ }
+}
+
+%new
+- (void)ymOverlayButtonTapped:(YTQTMButton *)sender {
+ YMOverlayButtonSpec *matched = nil;
+ for (YMOverlayButtonSpec *spec in YMRegisteredOverlayButtons()) {
+ if (spec.viewTag == sender.tag) { matched = spec; break; }
+ }
+ if (!matched || !matched.onTap) return;
+
+ YTPlayerViewController *player = YMPlayerVCFromOverlay(self);
+ matched.onTap(player, sender);
+}
+
+- (id)initWithDelegate:(id)delegate {
+ self = %orig;
+ [self updateSpeedButton:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateSpeedButton:) name:YouModUpdateSpeedLabel object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateQualityButton:) name:YouModUpdateNotification object:nil];
+ return self;
+}
+
+- (id)initWithDelegate:(id)delegate autoplaySwitchEnabled:(BOOL)autoplaySwitchEnabled {
+ self = %orig;
+ [self updateSpeedButton:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateSpeedButton:) name:YouModUpdateSpeedLabel object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateQualityButton:) name:YouModUpdateNotification object:nil];
+ return self;
+}
+
+- (void)dealloc {
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:YouModUpdateSpeedLabel object:nil];
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:YouModUpdateNotification object:nil];
+ %orig;
+}
+
+%new
+- (void)updateSpeedButton:(id)arg {
+ for (YMOverlayButtonSpec *spec in YMRegisteredOverlayButtons()) {
+ if ([spec.identifier isEqualToString:@"speed.video"]) {
+ spec.title = currentSpeedLabel;
+
+ YTQTMButton *btn = (YTQTMButton *)[self viewWithTag:spec.viewTag];
+ if (btn) {
+ [btn setTitle:currentSpeedLabel forState:UIControlStateNormal];
+ btn.titleLabel.font = YMOverlayTextButtonFont(currentSpeedLabel, CGSizeMake(25, 25));
+ }
+ break;
+ }
+ }
+}
+
+%new
+- (void)updateQualityButton:(id)arg {
+ for (YMOverlayButtonSpec *spec in YMRegisteredOverlayButtons()) {
+ if ([spec.identifier isEqualToString:@"quality.video"]) {
+ spec.title = currentQualityLabel;
+
+ YTQTMButton *btn = (YTQTMButton *)[self viewWithTag:spec.viewTag];
+ if (btn) {
+ [btn setTitle:currentQualityLabel forState:UIControlStateNormal];
+ btn.titleLabel.font = YMOverlayTextButtonFont(currentQualityLabel, CGSizeMake(25, 25));
+ }
+ break;
+ }
+ }
+}
+%end
+
+%hook YTRelatedVideosViewController
+- (void)setExpanded:(BOOL)arg {
+ %orig;
+ isRelatedVideosExpanded = arg;
+ YTRelatedVideosView *relatedview = (YTRelatedVideosView *)self.view;
+ YTFullscreenEngagementOverlayView *fullov = (YTFullscreenEngagementOverlayView *)relatedview.superview;
+ YTMainAppVideoPlayerOverlayView *mainov = (YTMainAppVideoPlayerOverlayView *)fullov.superview;
+ YTMainAppControlsOverlayView *conov = [mainov controlsOverlayView];
+ [conov setNeedsLayout];
+}
+%end
+
+static void YouModShowShareNotification(NSString *message, BOOL success) {
+ UIView *parent = sbGetNotificationParent();
+ if (success) {
+ [SBSkipNotificationView showSuccessInView:parent message:message duration:3.0];
+ } else {
+ [SBSkipNotificationView showErrorInView:parent message:message duration:4.0];
+ }
+}
+
+%hook YTPlayerViewController
+%new
+- (void)YouModShareButton:(UIView *)sourceView {
+ if (!self.currentVideoID) {
+ YouModShowShareNotification(LOC(@"ERROR_VIDEOID"), NO);
+ return;
+ } else if (self.isPlayingAd) {
+ YouModShowShareNotification(LOC(@"ERROR_ADS"), NO);
+ return;
+ }
+
+ NSString *videoURL = [NSString stringWithFormat:@"https://youtube.com/watch?v=%@", self.currentVideoID];
+ NSInteger seconds = (NSInteger)floor(self.currentVideoMediaTime);
+ NSString *timestampURL = [NSString stringWithFormat:@"%@&t=%lds", videoURL, (long)seconds];
+
+ UIViewController *presenter = (UIViewController *)[self activeVideoPlayerOverlay];
+ YTDefaultSheetController *sheet = [%c(YTDefaultSheetController) sheetControllerWithParentResponder:presenter];
+
+ YTActionSheetAction *copyURL = [%c(YTActionSheetAction) actionWithTitle:LOC(@"COPY_URL") iconImage:YouModYTIconImage(250, NO, nil) style:0 handler:^(__unused YTActionSheetAction *action) {
+ UIPasteboard.generalPasteboard.string = videoURL;
+ YouModShowShareNotification(LOC(@"URL_COPIED"), YES);
+ }];
+
+ YTActionSheetAction *copyTimestamp = [%c(YTActionSheetAction) actionWithTitle:LOC(@"COPY_URL_TIMESTAMP") iconImage:YouModYTIconImage(250, NO, nil) style:0 handler:^(__unused YTActionSheetAction *action) {
+ UIPasteboard.generalPasteboard.string = timestampURL;
+ YouModShowShareNotification(LOC(@"URL_TIMESTAMP_COPIED"), YES);
+ }];
+
+ [sheet addAction:copyURL];
+ [sheet addAction:copyTimestamp];
+
+ [sheet presentFromView:sourceView animated:YES completion:nil];
+}
+%new
+- (void)YouModLoopButton {
+ YTMainAppVideoPlayerOverlayViewController *playerOverlay = self.activeVideoPlayerOverlay;
+ YTAutoplayAutonavController *autoplayController = [playerOverlay valueForKey:@"_autonavController"];
+ BOOL isLoopEnabled = !IS_ENABLED(KeepLoopKey);
+ [[NSUserDefaults standardUserDefaults] setBool:isLoopEnabled forKey:KeepLoopKey];
+ [autoplayController setLoopMode:isLoopEnabled ? 2 : 0];
+ YouModShowShareNotification(LOC(isLoopEnabled ? @"LOOP_ENABLED" : @"LOOP_DISABLED"), YES);
+}
+- (void)setPlaybackRate:(float)rate {
+ didSelectRate(rate);
+ %orig;
+}
+%end
+
+%hook YTAutoplayAutonavController
+- (id)initWithParentResponder:(id)arg {
+ self = %orig;
+ if (self && IS_ENABLED(KeepLoopKey)) {
+ [self setLoopMode:2];
+ }
+ return self;
+}
+- (void)setLoopMode:(NSInteger)arg {
+ NSInteger set = IS_ENABLED(KeepLoopKey) ? 2 : arg;
+ %orig(set);
+}
+%end
+
+static NSString *getCompactQualityLabel(MLFormat *format) {
+ NSString *qualityLabel = [format qualityLabel];
+ BOOL shouldShowFPS = [format FPS] > 30;
+ if ([qualityLabel hasPrefix:@"2160p"])
+ qualityLabel = [qualityLabel stringByReplacingOccurrencesOfString:@"2160p" withString:@"4K"];
+ else if ([qualityLabel hasPrefix:@"1440p"])
+ qualityLabel = [qualityLabel stringByReplacingOccurrencesOfString:@"1440p" withString:@"2K"];
+ else if ([qualityLabel hasPrefix:@"1080p"])
+ qualityLabel = [qualityLabel stringByReplacingOccurrencesOfString:@"1080p" withString:@"FHD"];
+ else if ([qualityLabel hasPrefix:@"720p"])
+ qualityLabel = [qualityLabel stringByReplacingOccurrencesOfString:@"720p" withString:@"HD"];
+ else if (shouldShowFPS)
+ qualityLabel = [qualityLabel stringByReplacingOccurrencesOfString:@"p" withString:@""];
+ if ([qualityLabel hasSuffix:@" HDR"])
+ qualityLabel = [qualityLabel stringByReplacingOccurrencesOfString:@" HDR" withString:@"\nHDR"];
+ return qualityLabel;
+}
+
+%hook YTVideoQualitySwitchOriginalController
+
+- (void)singleVideo:(id)singleVideo didSelectVideoFormat:(MLFormat *)format {
+ currentQualityLabel = getCompactQualityLabel(format);
+ [[NSNotificationCenter defaultCenter] postNotificationName:YouModUpdateNotification object:nil];
+ %orig;
+}
+
+%end
+
+%hook YTVideoQualitySwitchRedesignedController
+
+- (void)singleVideo:(id)singleVideo didSelectVideoFormat:(MLFormat *)format {
+ currentQualityLabel = getCompactQualityLabel(format);
+ [[NSNotificationCenter defaultCenter] postNotificationName:YouModUpdateNotification object:nil];
+ %orig;
+}
+
+%end
+
+%ctor {
+ YMOverlayButtonSpec *mute = [[YMOverlayButtonSpec alloc] init];
+ mute.identifier = @"mute.video";
+ mute.symbolName = IS_ENABLED(KeepMutedKey) ? @"speaker.slash" : @"speaker.wave.2";
+ mute.tintColor = [UIColor whiteColor];
+ mute.sortOrder = 300;
+ mute.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(MuteButton);
+ };
+ mute.onTap = ^(YTPlayerViewController *player, YTQTMButton *button) {
+ YTSingleVideoController *sgvid = player.activeVideo;
+ BOOL muteStatus = ![sgvid isMuted];
+ [[NSUserDefaults standardUserDefaults] setBool:muteStatus forKey:KeepMutedKey];
+ [sgvid setMuted:muteStatus];
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:20 weight:UIImageSymbolWeightMedium];
+ UIImage *newIcon = [UIImage systemImageNamed:muteStatus ? @"speaker.slash" : @"speaker.wave.2" withConfiguration:config];
+ [button setImage:newIcon forState:UIControlStateNormal];
+ };
+ YMRegisterOverlayButton(mute);
+ YMOverlayButtonSpec *speed = [[YMOverlayButtonSpec alloc] init];
+ speed.identifier = @"speed.video";
+ speed.title = currentSpeedLabel;
+ speed.sortOrder = 400;
+ speed.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(SpeedButton);
+ };
+ speed.onTap = ^(YTPlayerViewController *player, YTQTMButton *button) {
+ YTMainAppVideoPlayerOverlayViewController *ovcon = [player activeVideoPlayerOverlay];
+ YTMainAppVideoPlayerOverlayView *ovview = [ovcon videoPlayerOverlayView];
+ YTMainAppControlsOverlayView *conview = [ovview controlsOverlayView];
+ [ovcon didPressVarispeed:button];
+ [conview updateSpeedButton:nil];
+ };
+ YMRegisterOverlayButton(speed);
+ YMOverlayButtonSpec *quality = [[YMOverlayButtonSpec alloc] init];
+ quality.identifier = @"quality.video";
+ quality.title = currentQualityLabel;
+ quality.sortOrder = 500;
+ quality.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(QualityButton);
+ };
+ quality.onTap = ^(YTPlayerViewController *player, YTQTMButton *button) {
+ YTMainAppVideoPlayerOverlayViewController *ovcon = [player activeVideoPlayerOverlay];
+ YTMainAppVideoPlayerOverlayView *ovview = [ovcon videoPlayerOverlayView];
+ YTMainAppControlsOverlayView *conview = [ovview controlsOverlayView];
+ [ovcon didPressVideoQuality:button];
+ [conview updateQualityButton:nil];
+ };
+ YMRegisterOverlayButton(quality);
+ YMOverlayButtonSpec *share = [[YMOverlayButtonSpec alloc] init];
+ share.identifier = @"share.video";
+ share.symbolName = @"arrowshape.turn.up.right";
+ share.tintColor = [UIColor whiteColor];
+ share.sortOrder = 600;
+ share.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(ShareButton);
+ };
+ share.onTap = ^(YTPlayerViewController *player, YTQTMButton *button) {
+ [player YouModShareButton:button];
+ };
+ YMRegisterOverlayButton(share);
+ YMOverlayButtonSpec *loop = [[YMOverlayButtonSpec alloc] init];
+ loop.identifier = @"loop.video";
+ loop.symbolName = IS_ENABLED(KeepLoopKey) ? @"repeat.1" : @"repeat";
+ loop.tintColor = [UIColor whiteColor];
+ loop.sortOrder = 700;
+ loop.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(LoopButton);
+ };
+ loop.onTap = ^(YTPlayerViewController *player, YTQTMButton *button) {
+ [player YouModLoopButton];
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:20 weight:UIImageSymbolWeightMedium];
+ UIImage *newIcon = [UIImage systemImageNamed:IS_ENABLED(KeepLoopKey) ? @"repeat.1" : @"repeat" withConfiguration:config];
+ [button setImage:newIcon forState:UIControlStateNormal];
+ };
+ YMRegisterOverlayButton(loop);
+ YMOverlayButtonSpec *caption = [[YMOverlayButtonSpec alloc] init];
+ caption.identifier = @"caption.video";
+ caption.symbolName = @"captions.bubble";
+ caption.tintColor = [UIColor whiteColor];
+ caption.sortOrder = 800;
+ caption.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(CaptionButton);
+ };
+ caption.onTap = ^(YTPlayerViewController *player, YTQTMButton *button) {
+ YTMainAppVideoPlayerOverlayViewController *c = [player activeVideoPlayerOverlay];
+ YTFormat3CaptionViewController *cvc = [c valueForKey:@"_captionOverlayViewController"];
+ showTranscript(cvc);
+ };
+ YMRegisterOverlayButton(caption);
+ %init;
+}
diff --git a/Files/Player.x b/Files/Player.x
index 63418794..4ce97f40 100644
--- a/Files/Player.x
+++ b/Files/Player.x
@@ -1,53 +1,325 @@
#import "Headers.h"
+static BOOL isWiFiConnected() {
+ struct sockaddr_in zeroAddress;
+ bzero(&zeroAddress, sizeof(zeroAddress));
+ zeroAddress.sin_len = sizeof(zeroAddress);
+ zeroAddress.sin_family = AF_INET;
+
+ SCNetworkReachabilityRef reachability = SCNetworkReachabilityCreateWithAddress(kCFAllocatorDefault, (const struct sockaddr *)&zeroAddress);
+ if (!reachability) return NO;
+
+ SCNetworkReachabilityFlags flags;
+ BOOL retrievedFlags = SCNetworkReachabilityGetFlags(reachability, &flags);
+ CFRelease(reachability);
+
+ if (!retrievedFlags) return NO;
+
+ BOOL isReachable = (flags & kSCNetworkReachabilityFlagsReachable) != 0;
+ BOOL needsConnection = (flags & kSCNetworkReachabilityFlagsConnectionRequired) != 0;
+ BOOL canConnect = isReachable && !needsConnection;
+
+ if (!canConnect) return NO;
+
+ BOOL isCellular = (flags & kSCNetworkReachabilityFlagsIsWWAN) != 0;
+ return !isCellular;
+}
+
extern void YouModDownloadSetCurrentPlayer(YTPlayerViewController *player);
+extern YTPlayerViewController *YouModDownloadGetCurrentPlayer(void);
-float playbackRate = 1.0;
+#pragma mark - Rewind / Fast-forward on iOS media controls
-/*
-static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoController *video, YTSingleVideoTime *time) {
- if (!IS_ENABLED(ShowExtraTimeRemaining)) return;
+// The user-chosen skip amount for each direction, in seconds. Zero means the
+// preference was never set, in which case the seek falls back to 10 seconds.
+static CGFloat YouModRewindSecondsValue(void) {
+ CGFloat s = FLOAT_FOR_KEY(RewindSeconds);
+ return s > 0 ? s : 10.0;
+}
- CGFloat rate = playbackRate != 0 ? playbackRate : 1.0;
- NSTimeInterval remainingTime = (lround(video.totalMediaTime) - lround(time.time)) / rate;
+static CGFloat YouModForwardSecondsValue(void) {
+ CGFloat s = FLOAT_FOR_KEY(ForwardSeconds);
+ return s > 0 ? s : 10.0;
+}
- NSDate *estimatedEndTime = [NSDate dateWithTimeIntervalSinceNow:remainingTime];
+// Seeks the active player by delta seconds, clamped to [0, duration]. Returns NO
+// when no active player is available (e.g. a media key is pressed after playback
+// ended), so the caller can report the command as having nothing to act on.
+//
+// The seek runs on the main queue because MPRemoteCommand handlers may be invoked
+// off the main thread (notably from Bluetooth and CarPlay), while seekToTime: and
+// the player's time accessors are main-thread-only.
+static BOOL YouModSeekByInterval(CGFloat delta) {
+ YTPlayerViewController *player = YouModDownloadGetCurrentPlayer();
+ if (!player || ![player respondsToSelector:@selector(seekToTime:)]) return NO;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ CGFloat cur = [player currentVideoMediaTime];
+ CGFloat dur = [player currentVideoTotalMediaTime];
+ CGFloat target = cur + delta;
+ if (target < 0) target = 0;
+ if (dur > 0 && target > dur) target = dur;
+ [player seekToTime:target];
+ });
+ return YES;
+}
- NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
- [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
- [dateFormatter setDateFormat:@"HH:mm"];
- // [dateFormatter setDateFormat:ytlBool(@"24hrFormat") ? @"HH:mm" : @"h:mm a"];
+// Retained handler tokens for the two skip commands. A non-nil token marks a
+// command whose handler is already installed, so it is installed only once.
+static id gYouModRewindTarget = nil;
+static id gYouModForwardTarget = nil;
+
+// Points the system now-playing skip controls (lock screen, Bluetooth, Control
+// Center, CarPlay) at our per-direction seek. When enabled, the OS previous/next
+// commands are turned off and skip-backward/forward take over with the user's
+// intervals; when disabled, previous/next are restored. Only these system
+// controls are configurable — the on-screen player rewind/fast-forward buttons
+// are rendered by YouTube with an amount it owns, so they are left alone.
+//
+// The handlers are installed once and thereafter only their enabled state and
+// preferred intervals are updated. Because the handlers read the seconds at press
+// time, a changed preference always seeks by the new amount immediately; the
+// interval shown on the OS controls reflects the value captured the last time this
+// ran (video change or launch).
+static void YouModConfigureRemoteSkipCommands(void) {
+ MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter];
+ BOOL back = IS_ENABLED(SkipBackwardEnabled);
+ BOOL fwd = IS_ENABLED(SkipForwardEnabled);
+
+ // Each direction is independent: the previous/next track command is remapped
+ // to a skip only on the side the user enabled, so a mixed state (e.g. rewind
+ // on, fast-forward off) shows a skip-back control alongside the stock next
+ // track button.
+ cc.skipBackwardCommand.enabled = back;
+ cc.skipBackwardCommand.preferredIntervals = @[@(YouModRewindSecondsValue())];
+ cc.skipForwardCommand.enabled = fwd;
+ cc.skipForwardCommand.preferredIntervals = @[@(YouModForwardSecondsValue())];
+ cc.previousTrackCommand.enabled = !back;
+ cc.nextTrackCommand.enabled = !fwd;
+
+ if (!gYouModRewindTarget) {
+ gYouModRewindTarget = [cc.skipBackwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
+ return YouModSeekByInterval(-YouModRewindSecondsValue()) ? MPRemoteCommandHandlerStatusSuccess : MPRemoteCommandHandlerStatusNoSuchContent;
+ }];
+ }
+ if (!gYouModForwardTarget) {
+ gYouModForwardTarget = [cc.skipForwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
+ return YouModSeekByInterval(YouModForwardSecondsValue()) ? MPRemoteCommandHandlerStatusSuccess : MPRemoteCommandHandlerStatusNoSuchContent;
+ }];
+ }
+}
- NSString *formattedEndTime = [dateFormatter stringFromDate:estimatedEndTime];
+static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoController *video, YTSingleVideoTime *time) {
+ if (!IS_ENABLED(ShowExtraTimeRemaining) && !IS_ENABLED(SBShowDuration)) return;
+
+ YTMainAppVideoPlayerOverlayViewController *con = [self activeVideoPlayerOverlay];
+ if (![con isKindOfClass:%c(YTMainAppVideoPlayerOverlayViewController)]) return;
+ CGFloat rate = [con currentPlaybackRate] != 0 ? [con currentPlaybackRate] : 1.0;
+ NSTimeInterval remainingSeconds = (lround(video.totalMediaTime) - lround(time.time)) / rate;
+
+ NSString *remainingTimeText;
+ NSString *SBTimeRemaining = nil;
+ NSTimeInterval SBTotalTimeRemaining = 0.0;
+
+ if (IS_ENABLED(SBShowDuration)) {
+ if (self.sbSegments && self.sbSegments.count > 0 && self.sbEnabledForVideo) {
+ for (SBSegment *segment in self.sbSegments) {
+ SBSegmentAction action = [segment configuredAction];
+ if (action == SBSegmentActionDisable) continue;
+
+ CGFloat timeValue = segment.endTime - segment.startTime;
+ SBTotalTimeRemaining = SBTotalTimeRemaining + timeValue;
+ }
+ if (SBTotalTimeRemaining != 0.0) {
+ NSTimeInterval SBRemaining = video.totalMediaTime - SBTotalTimeRemaining;
+ int hours = (int)(SBRemaining / 3600);
+ int minutes = (int)(((int)SBRemaining % 3600) / 60);
+ int seconds = (int)((int)SBRemaining % 60);
+ if (hours > 0) {
+ SBTimeRemaining = [NSString stringWithFormat:@"%d:%02d:%02d", hours, minutes, seconds];
+ } else {
+ SBTimeRemaining = [NSString stringWithFormat:@"%d:%02d", minutes, seconds];
+ }
+ }
+ }
+ }
+
+ if (IS_ENABLED(ShowExtraTimeRemaining)) {
+ NSDate *estimatedEndTime = [NSDate dateWithTimeIntervalSinceNow:remainingSeconds];
+ NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
+ [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
+ [dateFormatter setDateFormat:IS_ENABLED(Uses24HoursTime) ? @"HH:mm" : @"h:mm a"];
+ remainingTimeText = [dateFormatter stringFromDate:estimatedEndTime];
+ }
+
+ NSString *safeRemainingTimeText = remainingTimeText ?: @"";
+ NSString *safeSBTimeRemaining = SBTimeRemaining ?: @"";
- YTPlayerView *playerView = (YTPlayerView *)self.view;
+ YTPlayerView *playerView = (YTPlayerView *)self.playerView;
if (![playerView.overlayView isKindOfClass:%c(YTMainAppVideoPlayerOverlayView)]) return;
YTMainAppVideoPlayerOverlayView *overlay = (YTMainAppVideoPlayerOverlayView*)playerView.overlayView;
YTLabel *durationLabel = overlay.playerBar.durationLabel;
- overlay.playerBar.endTimeString = formattedEndTime;
+
+ NSString *labelText = durationLabel.text ?: @"";
+
+ NSString *baseText = labelText;
+ NSRange extraTimeRange = [baseText rangeOfString:@" • "];
+ if (extraTimeRange.location != NSNotFound) {
+ baseText = [baseText substringToIndex:extraTimeRange.location];
+ }
+ NSRange sbRange = [baseText rangeOfString:@" ("];
+ if (sbRange.location != NSNotFound) {
+ baseText = [baseText substringToIndex:sbRange.location];
+ }
- if (![durationLabel.text containsString:formattedEndTime]) {
- durationLabel.text = [durationLabel.text stringByAppendingString:[NSString stringWithFormat:@" • %@", formattedEndTime]];
+ NSMutableString *newLabelText = [NSMutableString stringWithString:baseText];
+ if (IS_ENABLED(SBShowDuration) && safeSBTimeRemaining.length > 0) {
+ [newLabelText appendFormat:@" (%@)", safeSBTimeRemaining];
+ }
+ if (IS_ENABLED(ShowExtraTimeRemaining) && safeRemainingTimeText.length > 0) {
+ [newLabelText appendFormat:@" • %@", safeRemainingTimeText];
+ }
+
+ if (![labelText isEqualToString:newLabelText]) {
+ durationLabel.text = newLabelText;
+ overlay.playerBar.endTimeString = newLabelText;
[durationLabel sizeToFit];
}
}
-*/
+
+%hook YTInlinePlayerBarContainerView
+%property (nonatomic, strong) NSString *endTimeString;
+- (void)layoutSubviews {
+ %orig;
+ if (!IS_ENABLED(TapToSeek)) return;
+ for (UIView *subview in self.subviews) {
+ if ([subview isKindOfClass:%c(YTInlineScrubGestureView)]) {
+ BOOL hasCustomTap = NO;
+ for (UIGestureRecognizer *gesture in subview.gestureRecognizers) {
+ if ([gesture isKindOfClass:[UITapGestureRecognizer class]] &&
+ [gesture.name isEqualToString:@"YouModTapToSeek"]) {
+ hasCustomTap = YES;
+ break;
+ }
+ }
+ if (!hasCustomTap) {
+ UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleYouModScrubTap:)];
+ tap.name = @"YouModTapToSeek";
+ [subview addGestureRecognizer:tap];
+ }
+ break;
+ }
+ }
+}
+%new
+- (void)handleYouModScrubTap:(UITapGestureRecognizer *)gesture {
+ if (gesture.state == UIGestureRecognizerStateEnded) {
+ UIView *progressBar = nil;
+
+ for (UIView *subview in self.subviews) {
+ if ([subview isKindOfClass:%c(YTModularPlayerBarView)]) {
+ progressBar = subview;
+ break;
+ }
+ }
+ if (!progressBar) return;
+
+ CGPoint touchPoint = [gesture locationInView:progressBar];
+ CGFloat barWidth = progressBar.bounds.size.width;
+
+ if (barWidth > 0) {
+ CGFloat relativeX = touchPoint.x;
+ CGFloat percentage = relativeX / barWidth;
+ CGFloat snapThreshold = 8.0;
+
+ if (relativeX <= snapThreshold) {
+ percentage = 0.0;
+ } else if (relativeX >= barWidth - snapThreshold) {
+ percentage = 1.0;
+ } else {
+ if (percentage < 0.0) percentage = 0.0;
+ if (percentage > 1.0) percentage = 1.0;
+ }
+
+ UIResponder *responder = self.nextResponder;
+ while (responder && ![responder isKindOfClass:%c(YTMainAppVideoPlayerOverlayViewController)]) {
+ responder = responder.nextResponder;
+ }
+
+ if (responder) {
+ YTMainAppVideoPlayerOverlayViewController *controller = (YTMainAppVideoPlayerOverlayViewController *)responder;
+ YTPlayerViewController *controller2 = controller.parentViewController;
+ CGFloat totalDuration = [controller2 currentVideoTotalMediaTime];
+ CGFloat targetTime = totalDuration * percentage;
+ [controller2 seekToTime:targetTime];
+ }
+ }
+ }
+}
+// Disable toggle time remaining - @bhackel
+- (void)setShouldDisplayTimeRemaining:(BOOL)arg1 {
+ BOOL temp;
+ if (IS_ENABLED(DisablesShowRemaining)) {
+ temp = NO;
+ } else if (IS_ENABLED(AlwaysShowRemaining)) {
+ temp = YES;
+ } else {
+ temp = arg1;
+ }
+ %orig(temp);
+}
+// Always show seekbar
+- (void)setPlayerBarAlpha:(CGFloat)alpha {
+ CGFloat temp = IS_ENABLED(AlwaysShowSeekbar) ? 1.0 : alpha;
+ %orig(temp);
+}
+// Disables snap to chapter
+- (void)inlinePlayerBarView:(id)arg1 didScrubToChapteredTime:(CGFloat)arg2 shouldSnap:(BOOL)arg3 {
+ BOOL temp = IS_ENABLED(DontSnapToChapter) ? NO : arg3;
+ %orig(arg1, arg2, temp);
+}
+- (void)setPeekableViewVisible:(BOOL)visible {
+ %orig;
+ if (!IS_ENABLED(ShowExtraTimeRemaining) && !IS_ENABLED(SBShowDuration)) return;
+
+ YTLabel *dLabel = self.durationLabel;
+ if (dLabel && self.endTimeString) {
+ if (![dLabel.text isEqualToString:self.endTimeString]) {
+ dLabel.text = self.endTimeString;
+ [dLabel sizeToFit];
+ }
+ }
+}
+%end
%hook YTMainAppControlsOverlayView
// Hide autoplay Switch
- (void)setAutoplaySwitchButtonRenderer:(id)arg1 { if (!IS_ENABLED(HideAutoPlayToggle)) %orig; }
// Hide captions Button
- (void)setClosedCaptionsOrSubtitlesButtonAvailable:(BOOL)arg1 { if (!IS_ENABLED(HideCaptionsButton)) %orig; }
-- (void)setPreviousButtonHidden:(BOOL)arg { IS_ENABLED(HidePrevButton) ? %orig(YES) : %orig; }
-- (void)setNextButtonHidden:(BOOL)arg { IS_ENABLED(HideNextButton) ? %orig(YES) : %orig; }
// Hide video title in full screen
- (BOOL)titleViewHidden { return IS_ENABLED(HideFullvidTitle) ? YES : %orig; }
+// Pause On Overlay
+- (void)setOverlayVisible:(BOOL)visible {
+ %orig;
+ if (!IS_ENABLED(PauseOnOverlay)) return;
+ YTMainAppVideoPlayerOverlayViewController *mainOverlayController = (YTMainAppVideoPlayerOverlayViewController *)self.eventsDelegate;
+ YTPlayerViewController *playerViewController = mainOverlayController.parentViewController;
+ visible ? [playerViewController pause] : [playerViewController play];
+}
%end
%hook YTAutonavEndscreenController
- (void)showEndscreen { if (!IS_ENABLED(HideSuggestedVideo)) %orig; }
-- (void)showEndscreenControlsInPlayerBar:(BOOL)arg { IS_ENABLED(HideSuggestedVideo) ? %orig(NO) : %orig; }
+- (void)showEndscreenControlsInPlayerBar:(BOOL)arg {
+ BOOL temp = IS_ENABLED(HideSuggestedVideo) ? NO : arg;
+ %orig(temp);
+}
+%end
+
+// YTAnotherMiniplayer (https://github.com/PoomSmart/YTAnotherMiniplayer)
+%hook YTWatchMiniplayerConstants
++ (NSInteger)miniplayerVariant { return IS_ENABLED(UseAnotherMiniplayer) ? 2 : %orig; }
%end
%hook YTSettings
@@ -58,38 +330,25 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
- (BOOL)isAutoplayEnabled { return IS_ENABLED(HideAutoPlayToggle) ? NO : %orig; }
%end
-/* idk what is this thing does
%hook YTColdConfig
-- (BOOL)isLandscapeEngagementPanelEnabled {
- return NO;
-}
-%end
-*/
-
-/*
-%hook YTHeaderView
-- (BOOL)stickyNavHeaderEnabled { return IS_ENABLED(YTPremiumLogo) ? YES : NO; } // idk what is this does, the nav is already sticky... Or this thing only happens in iPhone?
-- (void)setStickyNavHeaderEnabled:(BOOL)arg { IS_ENABLED(YTPremiumLogo) ? %orig(YES) : %orig(NO); }
-%end
-*/
-
-// Remove Dark Background in Overlay
-%hook YTMainAppVideoPlayerOverlayView
-- (void)setBackgroundVisible:(BOOL)arg1 isGradientBackground:(BOOL)arg2 { IS_ENABLED(RemoveDarkOverlay) ? %orig(NO, arg2) : %orig; }
-// Hide Watermarks
-- (BOOL)isWatermarkEnabled { return IS_ENABLED(HideWaterMark) ? NO : %orig; }
-- (void)setWatermarkEnabled:(BOOL)arg { IS_ENABLED(HideWaterMark) ? %orig(NO) : %orig; }
-- (void)layoutSubviews {
- %orig;
- if (IS_ENABLED(HideCastButtonPlayer)) self.playbackRouteButton.hidden = YES;
-}
-- (BOOL)isFullscreenActionsVisible { return IS_ENABLED(HideFullAction) ? NO : %orig; }
+- (BOOL)isLandscapeEngagementPanelEnabled { return IS_ENABLED(DisablesEngagementPanel) ? NO : %orig; }
+- (BOOL)removeNextPaddleForAllVideos { return IS_ENABLED(HideNextAndPrevButtons) ? YES : %orig; }
+- (BOOL)removePreviousPaddleForAllVideos { return IS_ENABLED(HideNextAndPrevButtons) ? YES : %orig; }
+// Replace previous/next buttons with back and forward
+- (BOOL)replaceNextPaddleWithFastForwardButtonForSingletonVods { return IS_ENABLED(ReplacePrevNextButtons) ? YES : %orig; }
+- (BOOL)replacePreviousPaddleWithRewindButtonForSingletonVods { return IS_ENABLED(ReplacePrevNextButtons) ? YES : %orig; }
%end
// No Endscreen Cards
%hook YTCreatorEndscreenView
-- (void)setHidden:(BOOL)arg1 { IS_ENABLED(HideEndScreenCards) ? %orig(YES) : %orig; }
-- (void)setHoverCardHidden:(BOOL)arg { IS_ENABLED(HideEndScreenCards) ? %orig(YES) : %orig; }
+- (void)setHidden:(BOOL)arg1 {
+ BOOL temp = IS_ENABLED(HideEndScreenCards) ? YES : arg1;
+ %orig(temp);
+}
+- (void)setHoverCardHidden:(BOOL)arg {
+ BOOL temp = IS_ENABLED(HideEndScreenCards) ? YES : arg;
+ %orig(temp);
+}
- (void)setHoverCardRenderer:(id)arg { if (!IS_ENABLED(HideEndScreenCards)) %orig; }
%end
@@ -98,26 +357,28 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
- (BOOL)allowDoubleTapToSeekGestureRecognizer { return IS_ENABLED(DisablesDoubleTap) ? NO : %orig; }
// Disable long hold
- (BOOL)allowLongPressGestureRecognizerInView:(id)arg { return IS_ENABLED(DisablesLongHold) ? NO : %orig; }
-%end
-
-// YTNoPaidPromo (https://github.com/PoomSmart/YTNoPaidPromo)
-%group PaidPromoOverlay
-%hook YTMainAppVideoPlayerOverlayViewController
-- (void)setPaidContentWithPlayerData:(id)data {}
-- (void)playerOverlayProvider:(YTPlayerOverlayProvider *)provider didInsertPlayerOverlay:(YTPlayerOverlay *)overlay {
- if ([[overlay overlayIdentifier] isEqualToString:@"player_overlay_paid_content"]) return;
+// Copy timestamp on pause
+- (void)didPressPause:(id)arg {
%orig;
+ if (!IS_ENABLED(CopyWithTimestampOnPause)) return;
+ CGFloat mediaTimeIn = self.mediaTime;
+ NSString *vidID = self.videoID;
+ if (vidID.length)
+ UIPasteboard.generalPasteboard.string = [NSString stringWithFormat:@"https://www.youtube.com/watch?v=%@&t=%lds", vidID, (long)mediaTimeIn];
}
+- (BOOL)isZoomEnabled { return IS_ENABLED(DisablesFreeZoom) ? NO : %orig; }
+- (void)setPaidContentWithPlayerData:(id)data { if (!IS_ENABLED(HidePaidPromoOverlay)) %orig; }
%end
+// YTNoPaidPromo (https://github.com/PoomSmart/YTNoPaidPromo)
%hook YTInlineMutedPlaybackPlayerOverlayViewController
-- (void)setPaidContentWithPlayerData:(id)data {}
-%end
+- (void)setPaidContentWithPlayerData:(id)data { if (!IS_ENABLED(HidePaidPromoOverlay)) %orig; }
%end
// Remove Watermarks
%hook YTAnnotationsViewController
- (void)loadFeaturedChannelWatermark { if (!IS_ENABLED(HideWaterMark)) %orig; }
+- (void)setWatermarkImage:(id)arg1 height:(unsigned long long)arg2 { if (!IS_ENABLED(HideWaterMark)) %orig; }
%end
// Exit Fullscreen on Finish
@@ -125,17 +386,6 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
- (BOOL)shouldExitFullScreenOnFinish { return IS_ENABLED(AutoExitFullScreen) ? YES : %orig; }
%end
-// Disable toggle time remaining - @bhackel
-%hook YTInlinePlayerBarContainerView
-- (void)setShouldDisplayTimeRemaining:(BOOL)arg1 {
- if (IS_ENABLED(DisablesShowRemaining)) {
- %orig(NO);
- return;
- }
- IS_ENABLED(AlwaysShowRemaining) ? %orig(YES) : %orig;
-}
-%end
-
// Always use remaining time in the video player - @bhackel
%hook YTPlayerBarController
// When a new video is played, enable time remaining flag
@@ -149,6 +399,16 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
playerBar.shouldDisplayTimeRemaining = YES;
}
}
+ YTSingleVideoController *sgvid = [self valueForKey:@"_currentSingleVideo"];
+ YTPlayerView *playerview = [sgvid valueForKey:@"_playerView"];
+ YTPlayerViewController *playerviewController = [playerview valueForKey:@"_playerViewDelegate"];
+ YouModDownloadSetCurrentPlayer(playerviewController);
+ YouModConfigureRemoteSkipCommands();
+ if (IS_ENABLED(MuteButton)) [playerviewController YouModAutoMute];
+ if (IS_ENABLED(AutoFullScreen)) [playerviewController performSelector:@selector(YouModAutoFullscreen) withObject:nil afterDelay:0.5];
+ if (INTFORVAL(CaptionTrack) != 0) [playerviewController performSelector:@selector(YouModAutoCaptions) withObject:nil afterDelay:0.5];
+ if (INTFORVAL(AutoSpeedIndex) != 0) [playerviewController performSelector:@selector(YouModSetAutoSpeed) withObject:nil afterDelay:0.5];
+ if (INTFORVAL(AudioTrack) != 0) [playerviewController performSelector:@selector(YouModAutoAudioTrack) withObject:nil afterDelay:0.5];
}
%end
@@ -166,7 +426,10 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
// Disable Autoplay
%hook YTPlaybackConfig
-- (void)setStartPlayback:(BOOL)arg1 { IS_ENABLED(StopAutoplayVideo) ? %orig(NO) : %orig; }
+- (void)setStartPlayback:(BOOL)arg1 {
+ BOOL temp = IS_ENABLED(StopAutoplayVideo) ? NO : arg1;
+ %orig(temp);
+}
%end
// Skip Content Warning (https://github.com/qnblackcat/uYouPlus/blob/main/uYouPlus.xm#L452-L454)
@@ -178,32 +441,11 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
- (void)showConfirmAlert { IS_ENABLED(HideContentWarning) ? [self confirmAlertDidPressConfirm] : %orig; }
%end
-// Always show seekbar
-%hook YTInlinePlayerBarContainerView
-- (void)setPlayerBarAlpha:(CGFloat)alpha { IS_ENABLED(AlwaysShowSeekbar) ? %orig(1.0) : %orig; }
-%end
-
// Portrait Fullscreen
%hook YTWatchViewController
- (unsigned long long)allowedFullScreenOrientations { return IS_ENABLED(PortFull) ? UIInterfaceOrientationMaskAllButUpsideDown : %orig; }
%end
-/* Disable Snap To Chapter (https://github.com/qnblackcat/uYouPlus/blob/main/uYouPlus.xm#L457-464) - GOT REMOVED
-%hook YTSegmentableInlinePlayerBarView
-- (void)didMoveToWindow { %orig; if (ytlBool(@"dontSnapToChapter")) self.enableSnapToChapter = NO; }
-%end
-
-%hook YTModularPlayerBarController
-- (void)setEnableSnapToChapter:(BOOL)arg { %orig(NO); } // idk this works or not
-%end
-*/
-
-// Replace previous/next buttons with back and forward
-%hook YTColdConfig
-- (BOOL)replaceNextPaddleWithFastForwardButtonForSingletonVods { return IS_ENABLED(ReplacePrevNextButtons) ? YES : %orig; }
-- (BOOL)replacePreviousPaddleWithRewindButtonForSingletonVods { return IS_ENABLED(ReplacePrevNextButtons) ? YES : %orig; }
-%end
-
%group ForceMiniPlayer
%hook YTIMiniplayerRenderer
%new
@@ -258,23 +500,6 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
%end
-%hook YTVarispeedSwitchControllerImpl
-
-- (id)init {
- self = %orig;
- float speeds[] = {0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 5.0, 7.5, 10.0};
- id options[itemCount];
- Class YTVarispeedSwitchControllerOptionClass = %c(YTVarispeedSwitchControllerOption);
- for (int i = 0; i < itemCount; ++i) {
- NSString *title = [NSString stringWithFormat:@"%.2fx", speeds[i]];
- options[i] = [[YTVarispeedSwitchControllerOptionClass alloc] initWithTitle:title rate:speeds[i]];
- }
- [self setValue:[NSArray arrayWithObjects:options count:itemCount] forKey:@"_options"];
- return self;
-}
-
-%end
-
%hook YTIPlayerHotConfig
%new(f@:)
@@ -294,90 +519,109 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
%end
%end
-// Disable Hints
-%hook YTSettings
-- (BOOL)areHintsDisabled { return IS_ENABLED(DisableHints) ? YES : %orig; }
-- (void)setHintsDisabled:(BOOL)arg1 { IS_ENABLED(DisableHints) ? %orig(YES) : %orig; }
-%end
-
-%hook YTSettingsImpl
-- (BOOL)areHintsDisabled { return IS_ENABLED(DisableHints) ? YES : %orig; }
-- (void)setHintsDisabled:(BOOL)arg1 { IS_ENABLED(DisableHints) ? %orig(YES) : %orig; }
-%end
-
-%hook YTUserDefaults
-- (BOOL)areHintsDisabled { return IS_ENABLED(DisableHints) ? YES : %orig; }
-- (void)setHintsDisabled:(BOOL)arg1 { IS_ENABLED(DisableHints) ? %orig(YES) : %orig; }
-%end
+static NSArray *YouModHoldSpeedValues(void) {
+ return @[@0.0, @0.25, @0.5, @0.75, @1.0, @1.25, @1.5, @1.75, @2.0, @3.0, @4.0, @5.0];
+}
-%hook YTPlayerViewController
-- (void)loadWithPlayerTransition:(id)arg1 playbackConfig:(id)arg2 {
- %orig;
- YouModDownloadSetCurrentPlayer(self);
- if (IS_ENABLED(AutoFullScreen)) [self performSelector:@selector(YouModAutoFullscreen) withObject:nil afterDelay:0.75];
- // if (ytlBool(@"shortsToRegular")) [self performSelector:@selector(shortsToRegular) withObject:nil afterDelay:0.75];
- if (IS_ENABLED(DisablesCaptions)) [self performSelector:@selector(YouModTurnOffCaptions) withObject:nil afterDelay:1.0];
+static CGFloat YouModSpeedForHoldIndex(NSInteger index) {
+ NSArray *values = YouModHoldSpeedValues();
+ return [values[index] floatValue];
}
-- (void)prepareToLoadWithPlayerTransition:(id)arg1 expectedLayout:(id)arg2 {
+%hook YTMainAppVideoPlayerOverlayView
+- (void)setLongPressGestureRecognizer:(id)arg {
+ if (INTFORVAL(HoldToSpeedIndex) != 0) return;
%orig;
- YouModDownloadSetCurrentPlayer(self);
- if (IS_ENABLED(AutoFullScreen)) [self performSelector:@selector(YouModAutoFullscreen) withObject:nil afterDelay:0.75];
- // if (ytlBool(@"shortsToRegular")) [self performSelector:@selector(shortsToRegular) withObject:nil afterDelay:0.75];
- if (IS_ENABLED(DisablesCaptions)) [self performSelector:@selector(YouModTurnOffCaptions) withObject:nil afterDelay:1.0];
}
-
-%new
-- (void)YouModTurnOffCaptions {
- if ([self.view.superview isKindOfClass:NSClassFromString(@"YTWatchView")]) {
- [self setActiveCaptionTrack:nil source:0];
- }
+// Remove Dark Background in Overlay
+- (void)setBackgroundVisible:(BOOL)arg1 isGradientBackground:(BOOL)arg2 {
+ BOOL temp = IS_ENABLED(RemoveDarkOverlay) ? NO : arg1;
+ %orig(temp, arg2);
}
-
-%new
-- (void)YouModAutoFullscreen {
- YTWatchController *watchController = [self valueForKey:@"_UIDelegate"];
- [watchController showFullScreen];
+// Hide Watermarks
+- (BOOL)isWatermarkEnabled { return IS_ENABLED(HideWaterMark) ? NO : %orig; }
+- (void)setWatermarkEnabled:(BOOL)arg {
+ BOOL temp = IS_ENABLED(HideWaterMark) ? NO : arg;
+ %orig(temp);
}
-
-/*
-- (void)singleVideo:(YTSingleVideoController *)video currentVideoTimeDidChange:(YTSingleVideoTime *)time {
+- (void)layoutSubviews {
%orig;
- YouModAddEndTime(self, video, time);
+ if (IS_ENABLED(HideCastButtonPlayer)) self.playbackRouteButton.hidden = YES;
}
+- (BOOL)isFullscreenActionsVisible { return IS_ENABLED(HideFullAction) ? NO : %orig; }
+%end
-- (void)potentiallyMutatedSingleVideo:(YTSingleVideoController *)video currentVideoTimeDidChange:(YTSingleVideoTime *)time {
- %orig;
- YouModAddEndTime(self, video, time);
-}
-*/
+%hook YTSingleVideoController
-- (void)setPlaybackRate:(float)rate {
- playbackRate = rate;
+- (void)playerItem:(id)arg1 hasSelectableVideoFormats:(id)arg2 {
%orig;
+ if (!arg2) return;
+ [self YouModAutoQuality];
}
-%end
-/*
%new
-- (void)shortsToRegular {
- if (self.contentVideoID != nil && [self.parentViewController isKindOfClass:NSClassFromString(@"YTShortsPlayerViewController")]) {
- NSString *vidLink = [NSString stringWithFormat:@"vnd.youtube://%@", self.contentVideoID]; // idk about this
- if ([[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:vidLink]]) {
- [[UIApplication sharedApplication] openURL:[NSURL URLWithString:vidLink] options:@{} completionHandler:nil];
+- (void)YouModAutoQuality {
+ NSArray *videoFormats = self.selectableVideoFormats;
+ // Return early if there aren't any video formats available
+ // eg. Voice comments and others
+ if (!videoFormats || videoFormats.count == 0) return;
+ NSInteger kQualityIndex = isWiFiConnected() ? INTFORVAL(WifiQualityIndex) : INTFORVAL(CellQualityIndex);
+ if ([NSProcessInfo processInfo].lowPowerModeEnabled) kQualityIndex = INTFORVAL(LowPowerQualityIndex);
+ if (kQualityIndex == 0) return;
+
+ NSString *bestQualityLabel;
+ int highestResolution = 0;
+ for (MLFormat *format in videoFormats) {
+ int reso = format.singleDimensionResolution;
+ if (reso > highestResolution) {
+ highestResolution = reso;
+ bestQualityLabel = format.qualityLabel;
}
}
-}
-%end
-// Fix Playlist Mini-bar Height For Small Screens
-%hook YTPlaylistMiniBarView
-- (void)setFrame:(CGRect)frame {
- if (frame.size.height < 54.0) frame.size.height = 54.0; // what
- %orig(frame);
+ NSArray *qualityLabels = @[@"Default", bestQualityLabel, @"2160p60", @"2160p", @"1440p60", @"1440p", @"1080p60", @"1080p", @"720p60", @"720p", @"480p", @"360p", @"240p", @"144p"];
+ NSString *qualityLabel = qualityLabels[kQualityIndex];
+
+ if (![qualityLabel isEqualToString:bestQualityLabel]) {
+ BOOL exactMatch = NO;
+ NSString *closestQualityLabel = qualityLabel;
+
+ for (MLFormat *format in videoFormats) {
+ if ([format.qualityLabel isEqualToString:qualityLabel]) {
+ exactMatch = YES;
+ break;
+ }
+ }
+
+ if (!exactMatch) {
+ NSInteger bestQualityDifference = NSIntegerMax;
+
+ for (MLFormat *format in videoFormats) {
+ NSArray *formatСomponents = [format.qualityLabel componentsSeparatedByString:@"p"];
+ NSArray *targetComponents = [qualityLabel componentsSeparatedByString:@"p"];
+ if (formatСomponents.count == 2) {
+ NSInteger formatQuality = [formatСomponents.firstObject integerValue];
+ NSInteger targetQuality = [targetComponents.firstObject integerValue];
+ NSInteger difference = labs(formatQuality - targetQuality);
+ if (difference < bestQualityDifference) {
+ bestQualityDifference = difference;
+ closestQualityLabel = format.qualityLabel;
+ }
+ }
+ }
+
+ qualityLabel = closestQualityLabel;
+ }
+ }
+
+ MLQuickMenuVideoQualitySettingFormatConstraint *fc = [%c(MLQuickMenuVideoQualitySettingFormatConstraint) alloc];
+ if ([fc respondsToSelector:@selector(initWithVideoQualitySetting:formatSelectionReason:qualityLabel:resolutionCap:)]) {
+ [self setVideoFormatConstraint:[fc initWithVideoQualitySetting:3 formatSelectionReason:2 qualityLabel:qualityLabel resolutionCap:0]];
+ } else {
+ [self setVideoFormatConstraint:[fc initWithVideoQualitySetting:3 formatSelectionReason:2 qualityLabel:qualityLabel]];
+ }
}
%end
-*/
// YTClassicVideoQuality (https://github.com/PoomSmart/YTClassicVideoQuality)
%group OldVideoQuality
@@ -424,17 +668,27 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
%end
// Gestures - @bhackel (YTLitePlus)
-%group Gestures
%hook YTWatchLayerViewController
// invoked when the player view controller is either created or destroyed
- (void)watchController:(YTWatchController *)watchController didSetPlayerViewController:(YTPlayerViewController *)playerViewController {
if (playerViewController) {
- // check to see if the pan gesture is already created
- if (!playerViewController.YouModPanGesture) {
+ YTPlayerView *pv = playerViewController.playerView;
+ if (!playerViewController.YouModPanGesture && (IS_ENABLED(GestureControls) || IS_ENABLED(SeekOnOverlay))) {
playerViewController.YouModPanGesture = [[UIPanGestureRecognizer alloc] initWithTarget:playerViewController action:@selector(YouModHandlePanGesture:)];
playerViewController.YouModPanGesture.delegate = playerViewController;
- [playerViewController.playerView addGestureRecognizer:playerViewController.YouModPanGesture];
- }
+ [pv addGestureRecognizer:playerViewController.YouModPanGesture];
+ }
+ if (!playerViewController.YouModTapGesture && IS_ENABLED(PauseTwoFingers)) {
+ playerViewController.YouModTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:playerViewController action:@selector(YouModHandleTapGesture:)];
+ playerViewController.YouModTapGesture.numberOfTouchesRequired = 2;
+ playerViewController.YouModTapGesture.delegate = playerViewController;
+ [pv addGestureRecognizer:playerViewController.YouModTapGesture];
+ }
+ if (!playerViewController.YouModHoldGesture && INTFORVAL(HoldToSpeedIndex) != 0) {
+ playerViewController.YouModHoldGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:playerViewController action:@selector(YouModHoldToSpeed:)];
+ playerViewController.YouModHoldGesture.minimumPressDuration = 0.4;
+ [pv addGestureRecognizer:playerViewController.YouModHoldGesture];
+ }
}
%orig;
}
@@ -442,45 +696,88 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
%hook YTPlayerViewController
%property (nonatomic, retain) UIPanGestureRecognizer *YouModPanGesture;
+%property (nonatomic, retain) UITapGestureRecognizer *YouModTapGesture;
%property (nonatomic, retain) UILabel *YouModGestureHUD;
+%property (nonatomic, strong) UIView *YouModSpeedToastView;
+%property (nonatomic, strong) UILabel *YouModSpeedToastLabel;
+%property (nonatomic, retain) UILongPressGestureRecognizer *YouModHoldGesture;
%new
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
if (gestureRecognizer == self.YouModPanGesture) {
+ // Return NO if user is choosing other videos in fullscreen
+ YTMainAppVideoPlayerOverlayViewController *ovcon = [self activeVideoPlayerOverlay];
+ YTMainAppVideoPlayerOverlayView *ov = [ovcon videoPlayerOverlayView];
+ YTFullscreenEngagementOverlayView *fullov = [ov valueForKey:@"_fullscreenEngagementOverlayView"];
+ if (fullov) {
+ YTRelatedVideosView *relatedview = [fullov valueForKey:@"_relatedVideosView"];
+ YTRelatedVideosViewController *relatedcon = [relatedview valueForKey:@"_delegate"];
+ if ([relatedcon isExpanded]) return NO;
+ }
+
UIPanGestureRecognizer *panGesture = (UIPanGestureRecognizer *)gestureRecognizer;
CGPoint startLocation = [panGesture locationInView:self.view];
- CGFloat viewWidth = self.view.bounds.size.width;
-
- float areaPercent = 0.15;
- int areaSetting = INTFORVAL(GestureActivationArea);
- if (areaSetting == 0) areaPercent = 0.10;
- else if (areaSetting == 2) areaPercent = 0.20;
- else if (areaSetting == 3) areaPercent = 0.25;
- else if (areaSetting == 4) areaPercent = 0.30;
- else if (areaSetting == 5) areaPercent = 0.35;
- else if (areaSetting == 6) areaPercent = 0.40;
- else if (areaSetting == 7) areaPercent = 0.45;
- else if (areaSetting == 8) areaPercent = 0.50;
-
- int leftAction = [[NSUserDefaults standardUserDefaults] objectForKey:LeftSideGesture] ? INTFORVAL(LeftSideGesture) : 1;
- int rightAction = [[NSUserDefaults standardUserDefaults] objectForKey:RightSideGesture] ? INTFORVAL(RightSideGesture) : 2;
-
- // Ignore touches in the center area -> YouTube's default features (swipe down to dismiss, etc.) work normally
- if (startLocation.x > viewWidth * areaPercent && startLocation.x < viewWidth * (1.0 - areaPercent)) return NO;
-
- // Ignore touches in the area where 'None' is selected in settings
- if (startLocation.x <= viewWidth * areaPercent && leftAction == 0) return NO;
- if (startLocation.x >= viewWidth * (1.0 - areaPercent) && rightAction == 0) return NO;
-
- // Only works for vertical swipes -> Does not interfere with YouTube's horizontal seek bar
CGPoint velocity = [panGesture velocityInView:self.view];
- if (fabs(velocity.x) > fabs(velocity.y)) return NO;
+ CGFloat fullWidth = self.view.bounds.size.width;
+ CGFloat activeWidth = fullWidth;
+
+ // Also return NO if the startLocation is on the engagement panel
+ YTEngagementPanelContainerView *engagecontainer = [ov valueForKey:@"_engagementPanelContainerView"];
+ if (engagecontainer) {
+ if (engagecontainer.engagementPanelState == 3) {
+ UIView *mainpanel = nil;
+ for (UIView *sub in engagecontainer.subviews) {
+ if ([sub isKindOfClass:%c(UILayoutContainerView)]) {
+ mainpanel = sub;
+ break;
+ }
+ }
+ if (mainpanel) {
+ CGFloat panelWidth = mainpanel.bounds.size.width;
+ if (panelWidth > 0 && panelWidth < fullWidth) {
+ CGFloat remainingWidth = fullWidth - panelWidth;
+ if (startLocation.x > remainingWidth) return NO;
+ activeWidth = remainingWidth;
+ }
+ }
+ }
+ }
- return YES;
+ BOOL isHorizontal = fabs(velocity.x) > fabs(velocity.y);
+
+ if (isHorizontal) {
+ return IS_ENABLED(SeekOnOverlay);
+ } else {
+ if (!IS_ENABLED(GestureControls)) return NO;
+
+ float areaPercent = 0.15;
+ int areaSetting = INTFORVAL(GestureActivationArea);
+ if (areaSetting == 0) areaPercent = 0.10;
+ else if (areaSetting == 2) areaPercent = 0.20;
+ else if (areaSetting == 3) areaPercent = 0.25;
+ else if (areaSetting == 4) areaPercent = 0.30;
+ else if (areaSetting == 5) areaPercent = 0.35;
+ else if (areaSetting == 6) areaPercent = 0.40;
+ else if (areaSetting == 7) areaPercent = 0.45;
+ else if (areaSetting == 8) areaPercent = 0.50;
+
+ int leftAction = [[NSUserDefaults standardUserDefaults] objectForKey:LeftSideGesture] ? INTFORVAL(LeftSideGesture) : 1;
+ int rightAction = [[NSUserDefaults standardUserDefaults] objectForKey:RightSideGesture] ? INTFORVAL(RightSideGesture) : 2;
+
+ if (startLocation.x > activeWidth * areaPercent && startLocation.x < activeWidth * (1.0 - areaPercent)) return NO;
+ if (startLocation.x <= activeWidth * areaPercent && leftAction == 0) return NO;
+ if (startLocation.x >= activeWidth * (1.0 - areaPercent) && rightAction == 0) return NO;
+
+ return YES;
+ }
}
return YES;
}
+
%new
- (void)YouModHandlePanGesture:(UIPanGestureRecognizer *)panGestureRecognizer {
+ // 0 = None, 1 = Vertical (Bright/Vol/Speed), 2 = Horizontal (Scrub)
+ static int currentPanMode = 0;
+
static float initialVolume;
static float initialBrightness;
static float initialSpeed;
@@ -502,6 +799,8 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
}
});
+ YTMainAppVideoPlayerOverlayViewController *ovcon = [self activeVideoPlayerOverlay];
+
if (IS_ENABLED(GestureHUD)) {
if (!self.YouModGestureHUD) {
self.YouModGestureHUD = [[UILabel alloc] initWithFrame:CGRectZero];
@@ -516,155 +815,537 @@ static void YouModAddEndTime(YTPlayerViewController *self, YTSingleVideoControll
}
if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
- CGPoint startLocation = [panGestureRecognizer locationInView:self.view];
- CGFloat viewWidth = self.view.bounds.size.width;
-
- float areaPercent = 0.15;
- int areaSetting = INTFORVAL(GestureActivationArea);
- if (areaSetting == 0) areaPercent = 0.10;
- else if (areaSetting == 2) areaPercent = 0.20;
- else if (areaSetting == 3) areaPercent = 0.25;
- else if (areaSetting == 4) areaPercent = 0.30;
- else if (areaSetting == 5) areaPercent = 0.35;
- else if (areaSetting == 6) areaPercent = 0.40;
- else if (areaSetting == 7) areaPercent = 0.45;
- else if (areaSetting == 8) areaPercent = 0.50;
-
- int leftAction = [[NSUserDefaults standardUserDefaults] objectForKey:LeftSideGesture] ? INTFORVAL(LeftSideGesture) : 1;
- int rightAction = [[NSUserDefaults standardUserDefaults] objectForKey:RightSideGesture] ? INTFORVAL(RightSideGesture) : 2;
-
- if (startLocation.x <= viewWidth * areaPercent) {
- controlType = leftAction;
- } else if (startLocation.x >= viewWidth * (1.0 - areaPercent)) {
- controlType = rightAction;
+ CGPoint velocity = [panGestureRecognizer velocityInView:self.view];
+ BOOL isHorizontal = fabs(velocity.x) > fabs(velocity.y);
+
+ if (isHorizontal && IS_ENABLED(SeekOnOverlay)) {
+ currentPanMode = 2;
} else {
- controlType = 0; // Center area
+ currentPanMode = 1;
}
-
- deadzoneStartingTranslation = [panGestureRecognizer translationInView:self.view].y;
-
- if (controlType == 1) {
- initialBrightness = [UIScreen mainScreen].brightness;
- } else if (controlType == 2) {
- initialVolume = [[AVAudioSession sharedInstance] outputVolume];
- } else if (controlType == 3) {
- initialSpeed = playbackRate;
- }
-
- if (IS_ENABLED(GestureHUD)) {
- int sizeSetting = [[NSUserDefaults standardUserDefaults] objectForKey:@"GestureHUDSize"] ? (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"GestureHUDSize"] : 1;
- CGFloat fontSize = 14.0 + (sizeSetting * 2.0);
- CGFloat hudWidth = 74.0 + (sizeSetting * 10.0);
- CGFloat hudHeight = 30.0 + (sizeSetting * 4.0);
-
- self.YouModGestureHUD.frame = CGRectMake(0, 0, hudWidth, hudHeight);
- self.YouModGestureHUD.layer.cornerRadius = hudHeight / 2.0;
- self.YouModGestureHUD.font = [UIFont boldSystemFontOfSize:fontSize];
- int posSetting = [[NSUserDefaults standardUserDefaults] objectForKey:@"GestureHUDPosition"] ? (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"GestureHUDPosition"] : 0;
- CGFloat viewHeight = self.view.bounds.size.height;
- CGFloat centerY = viewHeight / 6.0;
- if (posSetting == 1) centerY = viewHeight / 2.0;
- else if (posSetting == 2) centerY = viewHeight * 5.0 / 6.0;
+ if (currentPanMode == 2) {
+ YTMainAppVideoPlayerOverlayView *ovview = [ovcon videoPlayerOverlayView];
+ YTInlinePlayerBarContainerView *wth = ovview.playerBar;
+ YTPlayerBarController *playerbarcon = [wth valueForKey:@"_delegate"];
+ [playerbarcon didScrub:panGestureRecognizer];
+ } else if (currentPanMode == 1) {
+ CGPoint startLocation = [panGestureRecognizer locationInView:self.view];
+ CGFloat fullWidth = self.view.bounds.size.width;
+ CGFloat activeWidth = fullWidth;
+
+ YTMainAppVideoPlayerOverlayView *ov = [ovcon videoPlayerOverlayView];
+ YTEngagementPanelContainerView *engagecontainer = [ov valueForKey:@"_engagementPanelContainerView"];
+ if (engagecontainer && engagecontainer.engagementPanelState == 3) {
+ UIView *mainpanel = nil;
+ for (UIView *sub in engagecontainer.subviews) {
+ if ([sub isKindOfClass:%c(UILayoutContainerView)]) {
+ mainpanel = sub;
+ break;
+ }
+ }
+ if (mainpanel) {
+ CGFloat panelWidth = mainpanel.bounds.size.width;
+ if (panelWidth > 0 && panelWidth < fullWidth) {
+ activeWidth = fullWidth - panelWidth;
+ }
+ }
+ }
- [self.view bringSubviewToFront:self.YouModGestureHUD];
- self.YouModGestureHUD.center = CGPointMake(viewWidth / 2, centerY);
+ float areaPercent = 0.15;
+ int areaSetting = INTFORVAL(GestureActivationArea);
+ if (areaSetting == 0) areaPercent = 0.10;
+ else if (areaSetting == 2) areaPercent = 0.20;
+ else if (areaSetting == 3) areaPercent = 0.25;
+ else if (areaSetting == 4) areaPercent = 0.30;
+ else if (areaSetting == 5) areaPercent = 0.35;
+ else if (areaSetting == 6) areaPercent = 0.40;
+ else if (areaSetting == 7) areaPercent = 0.45;
+ else if (areaSetting == 8) areaPercent = 0.50;
+
+ int leftAction = [[NSUserDefaults standardUserDefaults] objectForKey:LeftSideGesture] ? INTFORVAL(LeftSideGesture) : 1;
+ int rightAction = [[NSUserDefaults standardUserDefaults] objectForKey:RightSideGesture] ? INTFORVAL(RightSideGesture) : 2;
+
+ if (startLocation.x <= activeWidth * areaPercent) {
+ controlType = leftAction;
+ } else if (startLocation.x >= activeWidth * (1.0 - areaPercent)) {
+ controlType = rightAction;
+ } else {
+ controlType = 0;
+ }
+
+ deadzoneStartingTranslation = [panGestureRecognizer translationInView:self.view].y;
+
+ if (controlType == 1) initialBrightness = [UIScreen mainScreen].brightness;
+ else if (controlType == 2) initialVolume = [[AVAudioSession sharedInstance] outputVolume];
+ else if (controlType == 3) initialSpeed = [ovcon currentPlaybackRate];
+
+ if (IS_ENABLED(GestureHUD) && controlType != 0) {
+ int sizeSetting = [[NSUserDefaults standardUserDefaults] objectForKey:GestureHUDSize] ? (int)[[NSUserDefaults standardUserDefaults] integerForKey:GestureHUDSize] : 1;
+ CGFloat fontSize = 14.0 + (sizeSetting * 2.0);
+ CGFloat hudWidth = 74.0 + (sizeSetting * 10.0);
+ CGFloat hudHeight = 30.0 + (sizeSetting * 4.0);
+
+ self.YouModGestureHUD.frame = CGRectMake(0, 0, hudWidth, hudHeight);
+ self.YouModGestureHUD.layer.cornerRadius = hudHeight / 2.0;
+ self.YouModGestureHUD.font = [UIFont boldSystemFontOfSize:fontSize];
+
+ int posSetting = [[NSUserDefaults standardUserDefaults] objectForKey:GestureHUDPosition] ? (int)[[NSUserDefaults standardUserDefaults] integerForKey:GestureHUDPosition] : 0;
+ CGFloat viewHeight = self.view.bounds.size.height;
+ CGFloat centerY = viewHeight / 6.0;
+ if (posSetting == 1) centerY = viewHeight / 2.0;
+ else if (posSetting == 2) centerY = viewHeight * 5.0 / 6.0;
+
+ [self.view bringSubviewToFront:self.YouModGestureHUD];
+ self.YouModGestureHUD.center = CGPointMake(activeWidth / 2, centerY);
+ }
}
}
if (panGestureRecognizer.state == UIGestureRecognizerStateChanged) {
- if (controlType == 0) return;
-
- CGPoint translation = [panGestureRecognizer translationInView:self.view];
- CGFloat adjustedTranslation = translation.y - deadzoneStartingTranslation;
-
- // Vertical swipe: Value increases as it goes up (translation.y decreases)
- float delta = (-adjustedTranslation / self.view.bounds.size.height) * sensitivityFactor;
-
- NSString *symbolName = nil;
- NSString *percentString = nil;
-
- if (controlType == 1) {
- float newBrightness = fmaxf(fminf(initialBrightness + delta, 1.0), 0.0);
- [[UIScreen mainScreen] setBrightness:newBrightness];
- symbolName = @"sun.max.fill";
- percentString = [NSString stringWithFormat:@" %d%%", (int)(newBrightness * 100)];
- } else if (controlType == 2) {
- float newVolume = fmaxf(fminf(initialVolume + delta, 1.0), 0.0);
- volumeViewSlider.value = newVolume;
- symbolName = @"speaker.wave.2.fill";
- percentString = [NSString stringWithFormat:@" %d%%", (int)(newVolume * 100)];
- } else if (controlType == 3) {
- float speedSensitivity = 8.0;
- float speedDelta = (-adjustedTranslation / self.view.bounds.size.height) * speedSensitivity;
- float rawSpeed = initialSpeed + speedDelta;
- float clampedSpeed = fmaxf(fminf(rawSpeed, 10.0), 0.25);
- // Quantize to 0.25x increments (e.g., 1.12 -> 1.0, 1.38 -> 1.25)
- float steppedSpeed = roundf(clampedSpeed * 4.0) / 4.0;
-
- // Only update if the stepped value has actually changed
- static float lastUpdatedSpeed = 0;
- if (steppedSpeed != lastUpdatedSpeed) {
- [self setPlaybackRate:steppedSpeed];
- lastUpdatedSpeed = steppedSpeed;
+ if (currentPanMode == 2) {
+ YTMainAppVideoPlayerOverlayView *ovview = [ovcon videoPlayerOverlayView];
+ YTInlinePlayerBarContainerView *wth = ovview.playerBar;
+ YTPlayerBarController *playerbarcon = [wth valueForKey:@"_delegate"];
+ [playerbarcon didScrub:panGestureRecognizer];
+ } else if (currentPanMode == 1 && controlType != 0) {
+ CGPoint translation = [panGestureRecognizer translationInView:self.view];
+ CGFloat adjustedTranslation = translation.y - deadzoneStartingTranslation;
+ float delta = (-adjustedTranslation / self.view.bounds.size.height) * sensitivityFactor;
+
+ NSString *symbolName = nil;
+ NSString *percentString = nil;
+
+ if (controlType == 1) {
+ float newBrightness = fmaxf(fminf(initialBrightness + delta, 1.0), 0.0);
+ [[UIScreen mainScreen] setBrightness:newBrightness];
+
+ if (newBrightness <= 0.5f) {
+ symbolName = @"sun.min.fill";
+ } else {
+ symbolName = @"sun.max.fill";
+ }
+
+ percentString = [NSString stringWithFormat:@" %d%%", (int)(newBrightness * 100)];
+ } else if (controlType == 2) {
+ float newVolume = fmaxf(fminf(initialVolume + delta, 1.0), 0.0);
+ volumeViewSlider.value = newVolume;
+
+ if (newVolume == 0.0f) {
+ symbolName = @"speaker.slash.fill";
+ } else if (newVolume <= 0.25f) {
+ symbolName = @"speaker.fill";
+ } else if (newVolume <= 0.50f) {
+ symbolName = @"speaker.wave.1.fill";
+ } else if (newVolume <= 0.75f) {
+ symbolName = @"speaker.wave.2.fill";
+ } else {
+ symbolName = @"speaker.wave.3.fill";
+ }
+
+ percentString = [NSString stringWithFormat:@" %d%%", (int)(newVolume * 100)];
+ } else if (controlType == 3) {
+ float speedSensitivity = 8.0;
+ float speedDelta = (-adjustedTranslation / self.view.bounds.size.height) * speedSensitivity;
+ float rawSpeed = initialSpeed + speedDelta;
+ float clampedSpeed = fmaxf(fminf(rawSpeed, 10.0), 0.25);
+ float steppedSpeed = roundf(clampedSpeed * 4.0) / 4.0;
+
+ static float lastUpdatedSpeed = 0;
+ if (steppedSpeed != lastUpdatedSpeed) {
+ [self setPlaybackRate:steppedSpeed];
+ lastUpdatedSpeed = steppedSpeed;
+ }
+
+ if (steppedSpeed < 1.0f) {
+ symbolName = @"tortoise.fill";
+ } else if (steppedSpeed == 1.0f) {
+ symbolName = @"speedometer";
+ } else if (steppedSpeed <= 5.0f) {
+ symbolName = @"hare.fill";
+ } else {
+ symbolName = @"bolt.fill";
+ }
+
+ percentString = [NSString stringWithFormat:@" %.2fx", steppedSpeed];
}
- symbolName = @"speedometer";
- percentString = [NSString stringWithFormat:@" %.2fx", steppedSpeed];
- }
- if (IS_ENABLED(GestureHUD) && symbolName) {
- NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
- UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:self.YouModGestureHUD.font.pointSize - 1];
- UIImage *icon = [UIImage systemImageNamed:symbolName withConfiguration:config];
- attachment.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
- CGFloat iconY = (self.YouModGestureHUD.font.capHeight - attachment.image.size.height) / 2.0;
- attachment.bounds = CGRectMake(0, iconY, attachment.image.size.width, attachment.image.size.height);
- NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]];
- NSAttributedString *textString = [[NSAttributedString alloc] initWithString:percentString attributes:@{NSFontAttributeName: self.YouModGestureHUD.font, NSForegroundColorAttributeName: self.YouModGestureHUD.textColor}];
- [attributedString appendAttributedString:textString];
- self.YouModGestureHUD.attributedText = attributedString;
+ if (IS_ENABLED(GestureHUD) && symbolName) {
+ NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:self.YouModGestureHUD.font.pointSize - 1];
+ UIImage *icon = [UIImage systemImageNamed:symbolName withConfiguration:config];
+ attachment.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
+ CGFloat iconY = (self.YouModGestureHUD.font.capHeight - attachment.image.size.height) / 2.0;
+ attachment.bounds = CGRectMake(0, iconY, attachment.image.size.width, attachment.image.size.height);
+ NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]];
+ NSAttributedString *textString = [[NSAttributedString alloc] initWithString:percentString attributes:@{NSFontAttributeName: self.YouModGestureHUD.font, NSForegroundColorAttributeName: self.YouModGestureHUD.textColor}];
+ [attributedString appendAttributedString:textString];
+ self.YouModGestureHUD.attributedText = attributedString;
+ self.YouModGestureHUD.alpha = 1.0;
+ }
}
- if (IS_ENABLED(GestureHUD)) self.YouModGestureHUD.alpha = 1.0;
- } else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded || panGestureRecognizer.state == UIGestureRecognizerStateCancelled || panGestureRecognizer.state == UIGestureRecognizerStateFailed) {
- if (IS_ENABLED(GestureHUD)) {
- [UIView animateWithDuration:0.3 delay:0.5 options:UIViewAnimationOptionCurveEaseOut animations:^{
- self.YouModGestureHUD.alpha = 0.0;
- } completion:nil];
+ }
+
+ if (panGestureRecognizer.state == UIGestureRecognizerStateEnded || panGestureRecognizer.state == UIGestureRecognizerStateCancelled || panGestureRecognizer.state == UIGestureRecognizerStateFailed) {
+ if (currentPanMode == 2) {
+ YTMainAppVideoPlayerOverlayView *ovview = [ovcon videoPlayerOverlayView];
+ YTInlinePlayerBarContainerView *wth = ovview.playerBar;
+ YTPlayerBarController *playerbarcon = [wth valueForKey:@"_delegate"];
+ [playerbarcon didScrub:panGestureRecognizer];
+ } else if (currentPanMode == 1) {
+ if (IS_ENABLED(GestureHUD)) {
+ [UIView animateWithDuration:0.3 delay:0.5 options:UIViewAnimationOptionCurveEaseOut animations:^{
+ self.YouModGestureHUD.alpha = 0.0;
+ } completion:nil];
+ }
}
+ currentPanMode = 0;
}
}
+
%new
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
- // Require other gestures (like YouTube's related videos swipe) to fail when our gesture is active to prevent conflicts.
if (gestureRecognizer == self.YouModPanGesture && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
return YES;
}
return NO;
}
+
%new
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
if (gestureRecognizer == self.YouModPanGesture) {
- return NO; // Prevents simultaneous recognition with YouTube's default swipe when gestures overlap.
+ return NO;
}
return YES;
}
-%end
+
+// Pause using Two fingers
+%new
+- (void)YouModHandleTapGesture:(UITapGestureRecognizer *)tapGestureRecognizer {
+ if (tapGestureRecognizer.state == UIGestureRecognizerStateEnded) {
+ if (self.playerState == 3) {
+ [self pause];
+ } else if (self.playerState == 4) {
+ [self play];
+ }
+ }
+}
+
+%new
+- (void)YouModAutoFullscreen {
+ YTWatchController *watchController = [self valueForKey:@"_UIDelegate"];
+ [watchController showFullScreen];
+}
+
+%new
+- (void)YouModSetAutoSpeed {
+ if (IS_ENABLED(GlobalSpeedLocked)) {
+ NSInteger speedIndex = INTFORVAL(HoldToSpeedIndex);
+ CGFloat speed = YouModSpeedForHoldIndex(speedIndex);
+ [self setPlaybackRate:speed];
+ return;
+ }
+ if (INTFORVAL(AutoSpeedIndex) == 0) return;
+ NSArray *speedLabels = @[@0.01, @0.25, @0.5, @0.75, @1.0, @1.25, @1.5, @1.75, @2.0, @3.0, @4.0, @5.0];
+ [self setPlaybackRate:[speedLabels[INTFORVAL(AutoSpeedIndex)] floatValue]];
+}
+
+- (void)singleVideo:(YTSingleVideoController *)video currentVideoTimeDidChange:(YTSingleVideoTime *)time {
+ %orig;
+ YouModAddEndTime(self, video, time);
+}
+
+- (void)potentiallyMutatedSingleVideo:(YTSingleVideoController *)video currentVideoTimeDidChange:(YTSingleVideoTime *)time {
+ %orig;
+ YouModAddEndTime(self, video, time);
+}
+
+%new
+- (void)YouModAutoMute {
+ YTSingleVideoController *sgvid = self.activeVideo;
+ BOOL muted = [sgvid isMuted];
+ [sgvid setMuted:[self isInlinePlaybackActive] ? muted : IS_ENABLED(KeepMutedKey)];
+}
+
+%new
+- (void)YouModAutoAudioTrack {
+ NSInteger selectedIndex = INTFORVAL(AudioTrackLangIndex);
+ NSArray *langCodes = getAllSystemLanguageValues();
+ NSString *userTargetLang = langCodes[selectedIndex];
+ id switchcon = self.audioTrackController;
+ NSArray *availableTracks = [switchcon valueForKey:@"_availableAudioTracks"];
+ if (!availableTracks || availableTracks.count == 0) return;
+ YTIAudioTrack *matchedTrack = nil;
+
+ if (INTFORVAL(AudioTrack) == 1) {
+ // Loop for all tracks
+ for (YTIAudioTrack *track in availableTracks) {
+ if ([track.id_p hasSuffix:@".4"]) {
+ matchedTrack = track;
+ break;
+ }
+ }
+ } else if (INTFORVAL(AudioTrack) == 2) {
+ // Loop for all tracks
+ for (YTIAudioTrack *track in availableTracks) {
+ if ([track.id_p hasPrefix:userTargetLang]) {
+ matchedTrack = track;
+ break;
+ }
+ }
+
+ // Check if it's dubbed
+ if (matchedTrack && [matchedTrack isAutoDubbed] && IS_ENABLED(NoDubbedAudioTrack)) {
+ matchedTrack = nil;
+ return;
+ }
+ }
+
+ // If found, change to it
+ if (matchedTrack) {
+ [self setAudioTrack:matchedTrack source:0];
+ } else if (!matchedTrack && IS_ENABLED(NoDubbedAudioTrack)) {
+ for (YTIAudioTrack *track in availableTracks) {
+ if ([track.id_p hasSuffix:@".4"]) {
+ [self setAudioTrack:track source:0];
+ break;
+ }
+ }
+ }
+}
+
+%new
+- (void)YouModAutoCaptions {
+ YTSingleVideoController *sgvid = self.activeVideo;
+ NSArray *allTracks = sgvid.availableCaptionTracks;
+ if (!allTracks || allTracks.count == 0) return;
+ NSInteger selectedIndex = INTFORVAL(CaptionTrackLangIndex);
+ NSArray *langCodes = getAllSystemLanguageValues();
+ NSString *userTargetLang = langCodes[selectedIndex];
+ MLInnerTubeCaptionTrack *currentTrack = sgvid.activeCaptionTrack;
+ MLInnerTubeCaptionTrack *matchedTrack;
+
+ if (INTFORVAL(CaptionTrack) == 1) {
+ if (currentTrack != nil) {
+ [self YouModCaptionsHelper:nil];
+ }
+ return;
+ }
+
+ for (MLInnerTubeCaptionTrack *track in allTracks) {
+ if ([track.languageCode isEqualToString:userTargetLang]) {
+ matchedTrack = track;
+ break;
+ }
+ }
+ if (matchedTrack && ([matchedTrack.VSSID hasPrefix:@"a."] || [matchedTrack.VSSID hasPrefix:@"ta."]) && IS_ENABLED(DisablesCaptionTrack)) {
+ matchedTrack = nil;
+ [self YouModCaptionsHelper:nil];
+ return;
+ } else if (!matchedTrack && IS_ENABLED(DisablesCaptionTrack)) {
+ [self YouModCaptionsHelper:nil];
+ return;
+ }
+ if (matchedTrack && matchedTrack != currentTrack) {
+ [self YouModCaptionsHelper:matchedTrack];
+ }
+}
+
+%new
+- (void)YouModCaptionsHelper:(MLInnerTubeCaptionTrack *)track {
+ if ([self respondsToSelector:@selector(setActiveCaptionTrack:source:)]) {
+ [self setActiveCaptionTrack:track source:0];
+ } else {
+ [self setActiveCaptionTrack:track];
+ }
+}
+%new
+- (void)YouModHideSpeedToast {
+ [UIView animateWithDuration:0.2 animations:^{
+ self.YouModSpeedToastView.alpha = 0.0;
+ }];
+}
+%new
+- (void)YouModShowSpeedToast:(CGFloat)speed isLocked:(BOOL)isLocked {
+ UIColor *themeTextColor = [UIColor labelColor];
+ UIColor *toastBgColor = [UIColor colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) {
+ if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
+ return [UIColor colorWithWhite:0.1 alpha:0.95];
+ } else {
+ return [UIColor colorWithWhite:0.95 alpha:0.95];
+ }
+ }];
+
+ if (!self.YouModSpeedToastView) {
+ self.YouModSpeedToastView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 160, 44)];
+ self.YouModSpeedToastView.layer.cornerRadius = 22;
+ self.YouModSpeedToastView.clipsToBounds = YES;
+ self.YouModSpeedToastView.alpha = 0.0;
+ self.YouModSpeedToastView.backgroundColor = toastBgColor;
+
+ self.YouModSpeedToastLabel = [[UILabel alloc] initWithFrame:self.YouModSpeedToastView.bounds];
+ self.YouModSpeedToastLabel.textAlignment = NSTextAlignmentCenter;
+ self.YouModSpeedToastLabel.textColor = themeTextColor;
+ self.YouModSpeedToastLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightMedium];
+ self.YouModSpeedToastLabel.numberOfLines = 2;
+ [self.YouModSpeedToastView addSubview:self.YouModSpeedToastLabel];
+
+ [self.playerView addSubview:self.YouModSpeedToastView];
+ }
+
+ self.YouModSpeedToastView.backgroundColor = toastBgColor;
+ self.YouModSpeedToastLabel.textColor = themeTextColor;
+
+ CGRect toastFrame = self.YouModSpeedToastView.frame;
+ toastFrame.origin.y = 18;
+ toastFrame.origin.x = (self.playerView.bounds.size.width - toastFrame.size.width) / 2.0;
+ self.YouModSpeedToastView.frame = toastFrame;
+
+ self.YouModSpeedToastView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleBottomMargin;
+ [self.playerView bringSubviewToFront:self.YouModSpeedToastView];
+
+ NSTextAttachment *topAttachment = [[NSTextAttachment alloc] init];
+ topAttachment.image = [[UIImage systemImageNamed:@"hare.fill"] imageWithTintColor:themeTextColor];
+ topAttachment.bounds = CGRectMake(0, -2, 14, 14);
+
+ NSTextAttachment *lockAttachment = nil;
+ if (IS_ENABLED(LockSpeed)) {
+ lockAttachment = [[NSTextAttachment alloc] init];
+ NSString *lockIconName = isLocked ? @"lock.fill" : @"lock.open.fill";
+ lockAttachment.image = [[UIImage systemImageNamed:lockIconName] imageWithTintColor:themeTextColor];
+ lockAttachment.bounds = CGRectMake(0, -1, 12, 12);
+ }
+
+ NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@" %@\n", LOC(@"PLAYBACK_SPEED")]];
+ if (topAttachment.image) {
+ NSAttributedString *topIconString = [NSAttributedString attributedStringWithAttachment:topAttachment];
+ [attrString insertAttributedString:topIconString atIndex:0];
+ }
+
+ if (lockAttachment && lockAttachment.image) {
+ NSAttributedString *lockIconString = [NSAttributedString attributedStringWithAttachment:lockAttachment];
+ [attrString appendAttributedString:lockIconString];
+ [attrString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]];
+ }
+
+ NSAttributedString *speedText = [[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@"%gx", speed]];
+ [attrString appendAttributedString:speedText];
+
+ self.YouModSpeedToastLabel.attributedText = attrString;
+
+ [UIView animateWithDuration:0.2 animations:^{
+ self.YouModSpeedToastView.alpha = 1.0;
+ }];
+}
+
+%new
+- (void)YouModHoldToSpeed:(UILongPressGestureRecognizer *)gesture {
+ if (self.playerState != 3) return;
+
+ NSInteger speedIndex = INTFORVAL(HoldToSpeedIndex);
+ CGFloat speed = YouModSpeedForHoldIndex(speedIndex);
+
+ static CGPoint startLocation;
+ static BOOL initialLockState;
+ static BOOL isPendingToggle;
+
+ NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
+
+ if (gesture.state == UIGestureRecognizerStateBegan) {
+ YTMainAppVideoPlayerOverlayViewController *con = [self activeVideoPlayerOverlay];
+ CGFloat currentRate = [con currentPlaybackRate];
+ CGFloat savedNormal = FLOAT_FOR_KEY(GlobalSpeedLocked);
+
+ if (savedNormal <= 0) {
+ savedNormal = (currentRate > 0) ? currentRate : 1.0;
+ [defaults setFloat:savedNormal forKey:GlobalSavedNormalRate];
+ [defaults setBool:NO forKey:GlobalSpeedLocked];
+ }
+
+ initialLockState = IS_ENABLED(GlobalSpeedLocked);
+ isPendingToggle = NO;
+ startLocation = [gesture locationInView:self.playerView];
+
+ if (!initialLockState) {
+ if (currentRate != speed) {
+ savedNormal = (currentRate > 0) ? currentRate : 1.0;
+ [defaults setFloat:savedNormal forKey:GlobalSavedNormalRate];
+ }
+ [self setPlaybackRate:speed];
+ [self YouModShowSpeedToast:speed isLocked:NO];
+ } else {
+ [self setPlaybackRate:speed];
+ [self YouModShowSpeedToast:speed isLocked:YES];
+ }
+ } else if (gesture.state == UIGestureRecognizerStateChanged) {
+ if (!IS_ENABLED(LockSpeed)) return;
+
+ CGPoint currentLocation = [gesture locationInView:self.playerView];
+ CGFloat dragDistanceY = currentLocation.y - startLocation.y;
+
+ BOOL stateChanged = NO;
+
+ if (!isPendingToggle && dragDistanceY > 50.0) {
+ isPendingToggle = YES;
+ stateChanged = YES;
+ } else if (isPendingToggle && dragDistanceY < 30.0) {
+ isPendingToggle = NO;
+ stateChanged = YES;
+ }
+
+ if (stateChanged) {
+ UIImpactFeedbackStyle feedbackStyle = isPendingToggle ? UIImpactFeedbackStyleMedium : UIImpactFeedbackStyleLight;
+ UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc] initWithStyle:feedbackStyle];
+ [feedback impactOccurred];
+
+ BOOL previewLockState = initialLockState ? !isPendingToggle : isPendingToggle;
+
+ if (previewLockState) {
+ [self YouModShowSpeedToast:speed isLocked:YES];
+ } else {
+ CGFloat toastSpeed;
+ if (initialLockState) {
+ CGFloat savedNormal = [defaults floatForKey:GlobalSavedNormalRate];
+ toastSpeed = (savedNormal >= 0.25) ? savedNormal : 1.0;
+ } else {
+ toastSpeed = speed;
+ }
+ [self YouModShowSpeedToast:toastSpeed isLocked:NO];
+ }
+ }
+ } else if (gesture.state == UIGestureRecognizerStateEnded ||
+ gesture.state == UIGestureRecognizerStateCancelled ||
+ gesture.state == UIGestureRecognizerStateFailed) {
+
+ BOOL finalLockState = initialLockState;
+ if (gesture.state == UIGestureRecognizerStateEnded) {
+ if (IS_ENABLED(LockSpeed) && isPendingToggle) {
+ finalLockState = !initialLockState;
+ [defaults setBool:finalLockState forKey:GlobalSpeedLocked];
+ }
+ }
+ if (finalLockState) {
+ [self setPlaybackRate:speed];
+ } else {
+ CGFloat savedNormal = [defaults floatForKey:GlobalSavedNormalRate];
+ CGFloat targetRate = (savedNormal >= 0.25) ? savedNormal : 1.0;
+ [self setPlaybackRate:targetRate];
+ }
+ isPendingToggle = NO;
+ [self YouModHideSpeedToast];
+ }
+}
%end
%ctor {
%init;
+ YouModConfigureRemoteSkipCommands();
if (IS_ENABLED(OldQualityPicker)) {
%init(OldVideoQuality);
}
- if (IS_ENABLED(ExtraSpeed) || IS_ENABLED(GestureControls)) {
+ if (IS_ENABLED(ExtraSpeed) || IS_ENABLED(GestureControls) || INTFORVAL(HoldToSpeedIndex) >= 9 || INTFORVAL(AutoSpeedIndex) >= 9) {
%init(Speed);
}
- if (IS_ENABLED(HidePaidPromoOverlay)) {
- %init(PaidPromoOverlay);
- }
- if (IS_ENABLED(GestureControls)) {
- %init(Gestures);
- }
if (IS_ENABLED(ForceMiniPlayer)) {
%init(ForceMiniPlayer);
}
diff --git a/Files/Settings.x b/Files/Settings.x
index f2967084..dbc55386 100644
--- a/Files/Settings.x
+++ b/Files/Settings.x
@@ -4,29 +4,31 @@
#define TweakName @"YouMod"
-#define LOC(x) [tweakBundle localizedStringForKey:x value:nil table:nil]
+#define YMLOC(x) [tweakBundle localizedStringForKey:x value:nil table:nil]
#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)
static const NSInteger TweakSection = 'ytmo';
+@class YMSettingsItem;
+extern void YMPushSubSettings(NSString *title, NSArray *items, id settingsVC, id parentResponder);
+extern YMSettingsItem *YMToggle(NSString *title, NSString *subtitle, NSString *key);
+extern YMSettingsItem *YMSlider(NSString *title, NSString *subtitle, NSString *key, float min, float max, float step, float defaultValue);
+extern YMSettingsItem *YMPicker(NSString *title, NSString *subtitle, NSString *key, NSArray *options, NSInteger defaultValue);
+extern YMSettingsItem *YMAction(NSString *title, NSString *subtitle, void (^action)(UIViewController *vc));
+extern YMSettingsItem *YMHeader(NSString *title);
+extern YMSettingsItem *YMSegment(NSString *title, NSString *key, NSArray *icons, NSInteger defaultValue);
+extern YMSettingsItem *YMTextSegment(NSString *title, NSString *key, NSArray *labels, NSInteger defaultValue);
+extern YMSettingsItem *YMImageSegment(NSString *title, NSString *key, NSArray *images, NSInteger defaultValue);
+extern void YMPushTabOrder(id settingsVC, id parentResponder);
+extern void YMRegisterSettingsGroup(NSString *title, NSArray *items);
+extern void YMPushSettingsSearch(id settingsVC, id parentResponder);
+
@interface YTSettingsSectionItemManager (YouMod)
- (void)updateYouModSectionWithEntry:(id)entry;
+- (void)updateSponsorBlockSectionWithEntry:(id)entry;
@end
-static NSBundle *YouModBundle() {
- static NSBundle *bundle = nil;
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- NSString *tweakBundlePath = [[NSBundle mainBundle] pathForResource:TweakName ofType:@"bundle"];
- if (tweakBundlePath)
- bundle = [NSBundle bundleWithPath:tweakBundlePath];
- else
- bundle = [NSBundle bundleWithPath:[NSString stringWithFormat:PS_ROOT_PATH_NS(@"/Library/Application Support/%@.bundle"), TweakName]];
- });
- return bundle;
-}
-
static NSString *GetCacheSize() { // YTLite - @dayanch96
NSString *cachePath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
NSArray *filesArray = [[NSFileManager defaultManager] subpathsOfDirectoryAtPath:cachePath error:nil];
@@ -41,34 +43,14 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
return [formatter stringFromByteCount:folderSize];
}
-// Basic switch item - YTLitePlus
-#define BASIC_SWITCH(title, description, key) \
- [YTSettingsSectionItemClass switchItemWithTitle:title \
- titleDescription:description \
- accessibilityIdentifier:nil \
- switchOn:IS_ENABLED(key) \
- switchBlock:^BOOL (YTSettingsCell *cell, BOOL enabled) { \
- [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:key]; \
- return YES; \
- } \
- settingItemId:0]
-
-// Settings header
-#define SETTINGS_HEADER \
- [YTSettingsSectionItemClass itemWithTitle:nil \
- titleDescription:LOC(@"SETTINGS") \
- accessibilityIdentifier:nil \
- detailTextBlock:nil \
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { \
- return NO; \
- }]
-
%hook YTSettingsGroupData
- (NSArray *)orderedCategories {
- if (self.type != 1 || class_getClassMethod(objc_getClass("YTSettingsGroupData"), @selector(tweaks)))
+ if (self.type != 1 || class_getClassMethod(objc_getClass("YTSettingsGroupData"), @selector(tweaks))) {
return %orig;
- NSMutableArray *mutableCategories = %orig.mutableCopy;
+ }
+ NSArray *temp = %orig;
+ NSMutableArray *mutableCategories = temp.mutableCopy;
[mutableCategories insertObject:@(TweakSection) atIndex:0];
return mutableCategories.copy;
}
@@ -101,7 +83,7 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Tweak Version (at the top)
// Thanks to the original codes from YTweaks by fosterbarnes - https://github.com/fosterbarnes/YTweaks/blob/e921591a89b87256a2b37c4788bd99282f70d9c2/Settings.x
- YTSettingsSectionItem *tweakVersion = [YTSettingsSectionItemClass itemWithTitle:@"YouMod v1.3.0"
+ YTSettingsSectionItem *tweakVersion = [YTSettingsSectionItemClass itemWithTitle:@"YouMod v2.0.0"
titleDescription:nil
accessibilityIdentifier:nil
detailTextBlock:nil
@@ -110,16 +92,6 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
}];
[sectionItems addObject:tweakVersion];
- // Note
- YTSettingsSectionItem *note = [YTSettingsSectionItemClass itemWithTitle:LOC(@"NOTE")
- titleDescription:nil
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- return NO;
- }];
- [sectionItems addObject:note];
-
// Section 0
// Github
YTSettingsSectionItem *github = [YTSettingsSectionItemClass itemWithTitle:nil
@@ -132,8 +104,8 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
[sectionItems addObject:github];
// Issues
- YTSettingsSectionItem *issues = [YTSettingsSectionItemClass itemWithTitle:LOC(@"NEW_ISSUES")
- titleDescription:LOC(@"NEW_ISSUES_DESC") // Found bug or Feature request -> Report Issues
+ YTSettingsSectionItem *issues = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"NEW_ISSUES")
+ titleDescription:YMLOC(@"NEW_ISSUES_DESC") // Found bug or Feature request -> Report Issues
accessibilityIdentifier:nil
detailTextBlock:nil
selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
@@ -143,8 +115,8 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
[sectionItems addObject:issues];
// Sources codes
- YTSettingsSectionItem *sourceCodes = [YTSettingsSectionItemClass itemWithTitle:LOC(@"SOURCE_CODES")
- titleDescription:LOC(@"SOURCE_CODES_DESC") // Take a look
+ YTSettingsSectionItem *sourceCodes = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"SOURCE_CODES")
+ titleDescription:YMLOC(@"SOURCE_CODES_DESC") // Take a look
accessibilityIdentifier:nil
detailTextBlock:nil
selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
@@ -153,23 +125,34 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
];
[sectionItems addObject:sourceCodes];
- /*
- // Center YT logo
- YTSettingsSectionItem *centerytlogo = [YTSettingsSectionItemClass switchItemWithTitle:LOC(@"CENTER_YT_LOGO")
- titleDescription:LOC(@"CENTER_YT_LOGO_DESC") // Set center logo
+ // ?
+ YTSettingsSectionItem *blank = [YTSettingsSectionItemClass itemWithTitle:nil
+ titleDescription:YMLOC(@"EXTRA")
accessibilityIdentifier:nil
- switchOn:IS_ENABLED(CenterYTLogo)
+ detailTextBlock:nil
+ selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ return NO;
+ }];
+ [sectionItems addObject:blank];
+
+ // Fix playback issues
+ YTSettingsSectionItem *fixPlaybackissues = [YTSettingsSectionItemClass switchItemWithTitle:YMLOC(@"FIX_PLAYBACK_ISSUES")
+ titleDescription:YMLOC(@"FIX_PLAYBACK_ISSUES_DESC")
+ accessibilityIdentifier:nil
+ switchOn:IS_ENABLED(FixPlaybackIssues)
switchBlock:^BOOL (YTSettingsCell *cell, BOOL enabled) {
- [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:CenterYTLogo];
+ [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:FixPlaybackIssues];
return YES;
}
settingItemId:0];
- [sectionItems addObject:centerytlogo];
- */
+ [sectionItems addObject:fixPlaybackissues];
+
+ // TODO: Center YT logo (not yet implemented)
+ // [sectionItems addObject: YMToggle(YMLOC(@"CENTER_YT_LOGO"), YMLOC(@"CENTER_YT_LOGO_DESC"), CenterYTLogo)];
// Settings
YTSettingsSectionItem *settings = [YTSettingsSectionItemClass itemWithTitle:nil
- titleDescription:LOC(@"SETTINGS")
+ titleDescription:YMLOC(@"SETTINGS")
accessibilityIdentifier:nil
detailTextBlock:nil
selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
@@ -177,17 +160,37 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
}];
[sectionItems addObject:settings];
+ // Search — opens the global settings search page. The top-level list is YouTube's
+ // native settings collection view, which we can't attach a live search bar to
+ // without triggering YouTube's own search mode (it hijacks the pane), so global
+ // search lives on a pushed page whose own search bar is focused on appear.
+ // No settingIcon: YouTube already uses the magnifier (YT_SEARCH) for the Navbar
+ // row, so an icon here would duplicate it. The row's title carries the meaning.
+ YTSettingsSectionItem *search = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"SEARCH")
+ titleDescription:YMLOC(@"SEARCH_DESC")
+ accessibilityIdentifier:nil
+ detailTextBlock:nil
+ selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSettingsSearch(settingsViewController, [self parentResponder]);
+ return YES;
+ }];
+ [sectionItems addObject:search];
+
// Section 1
// Downloading
- YTSettingsSectionItem *downloadinggroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"DOWNLOADING") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"DOWNLOAD_MANAGER"), LOC(@"DOWNLOAD_MANAGER_DESC"), DownloadManager),
- BASIC_SWITCH(LOC(@"DOWNLOAD_SAVE_PHOTOS"), LOC(@"DOWNLOAD_SAVE_PHOTOS_DESC"), DownloadSaveToPhotos),
- BASIC_SWITCH(LOC(@"DOWNLOAD_DRC_AUDIO"), LOC(@"DOWNLOAD_DRC_AUDIO"), DownloadPreferDRCAudio),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"DOWNLOADING") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *downloadingItems = @[
+ YMToggle(YMLOC(@"DOWNLOAD_MANAGER"), YMLOC(@"DOWNLOAD_MANAGER_DESC"), DownloadManager),
+ YMToggle(YMLOC(@"DOWNLOAD_SAVE_PHOTOS"), YMLOC(@"DOWNLOAD_SAVE_PHOTOS_DESC"), DownloadSaveToPhotos),
+ YMTextSegment(YMLOC(@"AUDIO_TRACK"), AudioPreferIndex, (@[YMLOC(@"SHOW_OPTIONS"), YMLOC(@"ORIGINAL"), YMLOC(@"ENGLISH")]), 0),
+ YMToggle(YMLOC(@"ADD_SHORTS_DOWNLOAD"), YMLOC(@"ADD_SHORTS_DOWNLOAD_DESC"), AddDownloadToShorts),
+ YMToggle(YMLOC(@"FIX_DOWNLOAD"), YMLOC(@"FIX_DOWNLOAD_DESC"), DownloadFix),
+ YMPicker(YMLOC(@"DOWNLOAD_SERVER"), YMLOC(@"CHOOSE_DOWNLOAD_SERVER"), DownloadServerIndex, (@[YMLOC(@"SERVER_EUROPRE1"), YMLOC(@"SERVER_ASIA1")]), 0),
+ YMToggle(YMLOC(@"DOWNLOAD_COMMENT"), YMLOC(@"DOWNLOAD_COMMENT_DESC"), DownloadComment),
+ YMToggle(YMLOC(@"DOWNLOAD_POST"), YMLOC(@"DOWNLOAD_POST_DESC"), DownloadPost),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"DOWNLOADING"), downloadingItems);
+ YTSettingsSectionItem *downloadinggroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"DOWNLOADING") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"DOWNLOADING"), downloadingItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *downloadIcon = [%c(YTIIcon) new];
@@ -197,14 +200,13 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 2
// Appearance
- YTSettingsSectionItem *appergroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"APPEARANCE") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"OLED_THEME"), LOC(@"OLED_THEME_DESC"), OLEDTheme),
- BASIC_SWITCH(LOC(@"OLED_KEYBOARD"), LOC(@"OLED_KEYBOARD_DESC"), OLEDKeyboard),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"APPEARANCE") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *appearanceItems = @[
+ YMToggle(YMLOC(@"OLED_THEME"), YMLOC(@"OLED_THEME_DESC"), OLEDTheme),
+ YMToggle(YMLOC(@"OLED_KEYBOARD"), YMLOC(@"OLED_KEYBOARD_DESC"), OLEDKeyboard),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"APPEARANCE"), appearanceItems);
+ YTSettingsSectionItem *appergroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"APPEARANCE") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"APPEARANCE"), appearanceItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon0 = [%c(YTIIcon) new];
@@ -214,18 +216,18 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 3
// Navigation bar
- YTSettingsSectionItem *navbargroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"NAVBAR") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"HIDE_YT_LOGO"), LOC(@"HIDE_YT_LOGO_DESC"), HideYTLogo),
- BASIC_SWITCH(LOC(@"PREMIUM_LOGO"), LOC(@"PREMIUM_LOGO_DESC"), YTPremiumLogo),
- BASIC_SWITCH(LOC(@"HIDE_NOTIFICATION_BUTTON"), LOC(@"HIDE_NOTIFICATION_BUTTON_DESC"), HideNoti),
- BASIC_SWITCH(LOC(@"HIDE_SEARCH_BUTTON"), LOC(@"HIDE_SEARCH_BUTTON_DESC"), HideSearch),
- BASIC_SWITCH(LOC(@"HIDE_VOICE_SEARCH_BUTTON"), LOC(@"HIDE_VOICE_SEARCH_BUTTON_DESC"), HideVoiceSearch),
- BASIC_SWITCH(LOC(@"HIDE_CAST_BUTTON_NAVBAR"), LOC(@"HIDE_CAST_BUTTON_NAVBAR_DESC"), HideCastButtonNav),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"NAVBAR") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *navbarItems = @[
+ YMToggle(YMLOC(@"STICKY_NAVBAR"), YMLOC(@"STICKY_NAVBAR_DESC"), StickyNavBar),
+ YMToggle(YMLOC(@"HIDE_YT_LOGO"), YMLOC(@"HIDE_YT_LOGO_DESC"), HideYTLogo),
+ YMToggle(YMLOC(@"PREMIUM_LOGO"), YMLOC(@"PREMIUM_LOGO_DESC"), YTPremiumLogo),
+ YMToggle(YMLOC(@"HIDE_NOTIFICATION_BUTTON"), YMLOC(@"HIDE_NOTIFICATION_BUTTON_DESC"), HideNoti),
+ YMToggle(YMLOC(@"HIDE_SEARCH_BUTTON"), YMLOC(@"HIDE_SEARCH_BUTTON_DESC"), HideSearch),
+ YMToggle(YMLOC(@"HIDE_VOICE_SEARCH_BUTTON"), YMLOC(@"HIDE_VOICE_SEARCH_BUTTON_DESC"), HideVoiceSearch),
+ YMToggle(YMLOC(@"HIDE_CAST_BUTTON_NAVBAR"), YMLOC(@"HIDE_CAST_BUTTON_NAVBAR_DESC"), HideCastButtonNav),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"NAVBAR"), navbarItems);
+ YTSettingsSectionItem *navbargroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"NAVBAR") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"NAVBAR"), navbarItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon1 = [%c(YTIIcon) new];
@@ -235,20 +237,20 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 4
// Feed
- YTSettingsSectionItem *feedgroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"FEED") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"HIDE_SUBBAR"), LOC(@"HIDE_SUBBAR_DESC"), HideSubbar),
- BASIC_SWITCH(LOC(@"HIDE_MUSIC_PLAYLISTS"), LOC(@"HIDE_MUSIC_PLAYLISTS_DESC"), HideGenMusicShelf),
- BASIC_SWITCH(LOC(@"HIDE_FEED_POST"), LOC(@"HIDE_FEED_POST_DESC"), HideFeedPost),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_SHELF"), LOC(@"HIDE_SHORTS_SHELF_DESC"), HideShortsShelf),
- BASIC_SWITCH(LOC(@"HIDE_SEARCH_HISTORY"), LOC(@"HIDE_SEARCH_HISTORY_DESC"), HideSearchHis),
- BASIC_SWITCH(LOC(@"HIDE_SUB_BUTTON"), LOC(@"HIDE_SUB_BUTTON_DESC"), HideSubButton),
- BASIC_SWITCH(LOC(@"HIDE_SHOP_BUTTON"), LOC(@"HIDE_SHOP_BUTTON_DESC"), HideShoppingButton),
- BASIC_SWITCH(LOC(@"HIDE_MEMBER_BUTTON"), LOC(@"HIDE_MEMBER_BUTTON_DESC"), HideMemberButton),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"FEED") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *feedItems = @[
+ YMToggle(YMLOC(@"HIDE_SUBBAR"), YMLOC(@"HIDE_SUBBAR_DESC"), HideSubbar),
+ YMToggle(YMLOC(@"HIDE_HORI_SHELF"), YMLOC(@"HIDE_HORI_SHELF_DESC"), HideHoriShelf),
+ YMToggle(YMLOC(@"HIDE_MUSIC_PLAYLISTS"), YMLOC(@"HIDE_MUSIC_PLAYLISTS_DESC"), HideGenMusicShelf),
+ YMToggle(YMLOC(@"HIDE_SURVEYS"), YMLOC(@"HIDE_SURVEYS_DESC"), HideSurveys),
+ YMToggle(YMLOC(@"HIDE_FEED_POST"), YMLOC(@"HIDE_FEED_POST_DESC"), HideFeedPost),
+ YMToggle(YMLOC(@"HIDE_PLAYABLES"), YMLOC(@"HIDE_PLAYABLES_DESC"), HidePlayables),
+ YMToggle(YMLOC(@"HIDE_SHORTS_SHELF"), YMLOC(@"HIDE_SHORTS_SHELF_DESC"), HideShortsShelf),
+ YMToggle(YMLOC(@"KEEP_SHORTS_SUBSCRIPT"), YMLOC(@"KEEP_SHORTS_SUBSCRIPT_DESC"), KeepShortsSubscript),
+ YMToggle(YMLOC(@"HIDE_SEARCH_HISTORY"), YMLOC(@"HIDE_SEARCH_HISTORY_DESC"), HideSearchHis),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"FEED"), feedItems);
+ YTSettingsSectionItem *feedgroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"FEED") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"FEED"), feedItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon2 = [%c(YTIIcon) new];
@@ -258,152 +260,86 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 5
// Player
- YTSettingsSectionItem *playergroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"PLAYER") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"HIDE_AUTOPLAY"), LOC(@"HIDE_AUTOPLAY_DESC"), HideAutoPlayToggle),
- BASIC_SWITCH(LOC(@"HIDE_CAPTIONS_BUTTON"), LOC(@"HIDE_CAPTIONS_BUTTON_DESC"), HideCaptionsButton),
- BASIC_SWITCH(LOC(@"HIDE_CAST_BUTTON_PLAYER"), LOC(@"HIDE_CAST_BUTTON_PLAYER_DESC"), HideCastButtonPlayer),
- BASIC_SWITCH(LOC(@"HIDE_PREV_BUTTON"), LOC(@"HIDE_PREV_BUTTON_DESC"), HidePrevButton),
- BASIC_SWITCH(LOC(@"HIDE_NEXT_BUTTON"), LOC(@"HIDE_NEXT_BUTTON_DESC"), HideNextButton),
- BASIC_SWITCH(LOC(@"REPLACE_PREVNEXT_BUTTONS"), LOC(@"REPLACE_PREVNEXT_BUTTONS_DESC"), ReplacePrevNextButtons),
- BASIC_SWITCH(LOC(@"REMOVE_DARK_OVERLAY"), LOC(@"REMOVE_DARK_OVERLAY_DESC"), RemoveDarkOverlay),
- BASIC_SWITCH(LOC(@"HIDE_END_SCREEN"), LOC(@"HIDE_END_SCREEN_DESC"), HideEndScreenCards),
- BASIC_SWITCH(LOC(@"REMOVE_AMBIANT"), LOC(@"REMOVE_AMBIANT_DESC"), RemoveAmbiant),
- BASIC_SWITCH(LOC(@"HIDE_SUGGESTED_VIDEO"), LOC(@"HIDE_SUGGESTED_VIDEO_DESC"), HideSuggestedVideo),
- BASIC_SWITCH(LOC(@"HIDE_PAID_OVERLAY"), LOC(@"HIDE_PAID_OVERLAY_DESC"), HidePaidPromoOverlay),
- BASIC_SWITCH(LOC(@"HIDE_WATERMARK"), LOC(@"HIDE_WATERMARK_DESC"), HideWaterMark),
- BASIC_SWITCH(LOC(@"GESTURES"), LOC(@"GESTURES_DESC"), GestureControls),
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"GESTURE_AREA")
- titleDescription:LOC(@"GESTURE_AREA_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- int selectedIndex = 1;
- int currentVal = INTFORVAL(GestureActivationArea);
- if (currentVal == 0) selectedIndex = 0;
- else if (currentVal == 1) selectedIndex = 1;
- else if (currentVal == 2) selectedIndex = 2;
- else if (currentVal == 3) selectedIndex = 3;
- else if (currentVal == 4) selectedIndex = 4;
- else if (currentVal == 5) selectedIndex = 5;
- else if (currentVal == 6) selectedIndex = 6;
- else if (currentVal == 7) selectedIndex = 7;
- else if (currentVal == 8) selectedIndex = 8;
-
- NSArray *rows = @[
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"10%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:0 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"15%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"20%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:2 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"25%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:3 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"30%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:4 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"35%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:5 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"40%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:6 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"45%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:7 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:@"50%" titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:8 forKey:GestureActivationArea]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }]
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"GESTURE_AREA") pickerSectionTitle:nil rows:rows selectedItemIndex:selectedIndex parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
- return YES;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"LEFT_SIDE_GESTURE")
- titleDescription:nil
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- int currentVal = [[NSUserDefaults standardUserDefaults] objectForKey:LeftSideGesture] ? INTFORVAL(LeftSideGesture) : 1;
- NSArray *rows = @[
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_NONE") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:0 forKey:LeftSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_BRIGHTNESS") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:LeftSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_VOLUME") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:2 forKey:LeftSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_SPEED") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:3 forKey:LeftSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }]
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"LEFT_SIDE_GESTURE") pickerSectionTitle:nil rows:rows selectedItemIndex:currentVal parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
- return YES;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"RIGHT_SIDE_GESTURE")
- titleDescription:nil
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- int currentVal = [[NSUserDefaults standardUserDefaults] objectForKey:RightSideGesture] ? INTFORVAL(RightSideGesture) : 2;
- NSArray *rows = @[
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_NONE") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:0 forKey:RightSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_BRIGHTNESS") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:RightSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_VOLUME") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:2 forKey:RightSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"GESTURE_SPEED") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:3 forKey:RightSideGesture]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }]
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"RIGHT_SIDE_GESTURE") pickerSectionTitle:nil rows:rows selectedItemIndex:currentVal parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
- return YES;
- }
- ],
- BASIC_SWITCH(LOC(@"GESTURE_HUD"), LOC(@"GESTURE_HUD_DESC"), GestureHUD),
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"GESTURE_HUD_SIZE")
- titleDescription:LOC(@"GESTURE_HUD_SIZE_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- int currentVal = [[NSUserDefaults standardUserDefaults] objectForKey:@"GestureHUDSize"] ? (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"GestureHUDSize"] : 1;
- NSArray *rows = @[
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"SMALL") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"GestureHUDSize"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"NORMAL") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:@"GestureHUDSize"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"LARGE") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:2 forKey:@"GestureHUDSize"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"EXTRALARGE") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:3 forKey:@"GestureHUDSize"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"MAX") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:4 forKey:@"GestureHUDSize"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }]
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"GESTURE_HUD_SIZE") pickerSectionTitle:nil rows:rows selectedItemIndex:currentVal parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
- return YES;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"GESTURE_HUD_POSITION")
- titleDescription:LOC(@"GESTURE_HUD_POSITION_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- int currentVal = [[NSUserDefaults standardUserDefaults] objectForKey:@"GestureHUDPosition"] ? (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"GestureHUDPosition"] : 0;
- NSArray *rows = @[
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"TOP") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:0 forKey:@"GestureHUDPosition"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"MIDDLE") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:@"GestureHUDPosition"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"BOTTOM") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) { [[NSUserDefaults standardUserDefaults] setInteger:2 forKey:@"GestureHUDPosition"]; [[NSUserDefaults standardUserDefaults] synchronize]; [settingsViewController reloadData]; return YES; }]
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"GESTURE_HUD_POSITION") pickerSectionTitle:nil rows:rows selectedItemIndex:currentVal parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
- return YES;
- }
- ],
- BASIC_SWITCH(LOC(@"DISABLES_DOUBLE_TAP"), LOC(@"DISABLES_DOUBLE_TAP_DESC"), DisablesDoubleTap),
- BASIC_SWITCH(LOC(@"DISABLES_LONG_HOLD"), LOC(@"DISABLES_LONG_HOLD_DESC"), DisablesLongHold),
- BASIC_SWITCH(LOC(@"AUTO_EXIT_FULLSCREEN"), LOC(@"AUTO_EXIT_FULLSCREEN_DESC"), AutoExitFullScreen),
- BASIC_SWITCH(LOC(@"AUTO_DISABLES_CAPTION"), LOC(@"AUTO_DISABLES_CAPTION_DESC"), DisablesCaptions),
- BASIC_SWITCH(LOC(@"DISABLES_SHOW_REMAINING"), LOC(@"DISABLES_SHOW_REMAINING_DESC"), DisablesShowRemaining),
- BASIC_SWITCH(LOC(@"ALWAYS_SHOW_REMAINING"), LOC(@"ALWAYS_SHOW_REMAINING_DESC"), AlwaysShowRemaining),
- // BASIC_SWITCH(LOC(@"SHOW_REMAINING_EXTRA"), LOC(@"SHOW_REMAINING_EXTRA_DESC"), ShowExtraTimeRemaining),
- BASIC_SWITCH(LOC(@"HIDE_FULLSCREEN_ACTIONS"), LOC(@"HIDE_FULLSCREEN_ACTIONS_DESC"), HideFullAction),
- BASIC_SWITCH(LOC(@"HIDE_FULL_VID_TITLE"), LOC(@"HIDE_FULL_VID_TITLE_DESC"), HideFullvidTitle),
- BASIC_SWITCH(LOC(@"STOP_AUTOPLAY_VIDEO"), LOC(@"STOP_AUTOPLAY_VIDEO_DESC"), StopAutoplayVideo),
- BASIC_SWITCH(LOC(@"HIDE_CONTENT_WARNING"), LOC(@"HIDE_CONTENT_WARNING_DESC"), HideContentWarning),
- BASIC_SWITCH(LOC(@"AUTO_FULLSCREEN"), LOC(@"AUTO_FULLSCREEN_DESC"), AutoFullScreen),
- BASIC_SWITCH(LOC(@"PORTRAIT_FULLSCREEN"), LOC(@"PORTRAIT_FULLSCREEN_DESC"), PortFull),
- BASIC_SWITCH(LOC(@"OLD_QUALITY_PICKER"), LOC(@"OLD_QUALITY_PICKER_DESC"), OldQualityPicker),
- BASIC_SWITCH(LOC(@"EXTRA_SPEED"), LOC(@"EXTRA_SPEED_DESC"), ExtraSpeed),
- BASIC_SWITCH(LOC(@"DISABLE_HINTS"), LOC(@"DISABLE_HINTS_DESC"), DisableHints),
- BASIC_SWITCH(LOC(@"FORCE_MINIPLAYER"), LOC(@"FORCE_MINIPLAYER_DESC"), ForceMiniPlayer),
- BASIC_SWITCH(LOC(@"FORCE_SEEKBAR"), LOC(@"FORCE_SEEKBAR_DESC"), AlwaysShowSeekbar),
- BASIC_SWITCH(LOC(@"HIDE_LIKE_BUTTON"), LOC(@"HIDE_LIKE_BUTTON_DESC"), HideLikeButton),
- BASIC_SWITCH(LOC(@"HIDE_DISLIKE_BUTTON"), LOC(@"HIDE_DISLIKE_BUTTON_DESC"), HideDisLikeButton),
- BASIC_SWITCH(LOC(@"HIDE_SHARE_BUTTON"), LOC(@"HIDE_SHARE_BUTTON_DESC"), HideShareButton),
- BASIC_SWITCH(LOC(@"HIDE_DOWNLOAD_BUTTON"), LOC(@"HIDE_DOWNLOAD_BUTTON_DESC"), HideDownloadButton),
- BASIC_SWITCH(LOC(@"HIDE_CLIP_BUTTON"), LOC(@"HIDE_CLIP_BUTTON_DESC"), HideClipButton),
- BASIC_SWITCH(LOC(@"HIDE_REMIX_BUTTON"), LOC(@"HIDE_REMIX_BUTTON_DESC"), HideRemixButton),
- BASIC_SWITCH(LOC(@"HIDE_SAVE_BUTTON"), LOC(@"HIDE_SAVE_BUTTON_DESC"), HideSaveButton),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"PLAYER") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *playerItems = @[
+ YMPicker(YMLOC(@"QUALITY_WIFI"), YMLOC(@"QUALITY_WIFI_DESC"), WifiQualityIndex, (@[YMLOC(@"DEFAULT"), YMLOC(@"BEST"), @"2160p60", @"2160p", @"1440p60", @"1440p", @"1080p60", @"1080p", @"720p60", @"720p", @"480p", @"360p", @"240p", @"144p"]), 0),
+ YMPicker(YMLOC(@"QUALITY_CELLULAR"), YMLOC(@"QUALITY_CELLULAR_DESC"), CellQualityIndex, (@[YMLOC(@"DEFAULT"), YMLOC(@"BEST"), @"2160p60", @"2160p", @"1440p60", @"1440p", @"1080p60", @"1080p", @"720p60", @"720p", @"480p", @"360p", @"240p", @"144p"]), 0),
+ YMPicker(YMLOC(@"QUALITY_LOW_POWER"), YMLOC(@"QUALITY_LOW_POWER_DESC"), LowPowerQualityIndex, (@[YMLOC(@"DEFAULT"), YMLOC(@"BEST"), @"2160p60", @"2160p", @"1440p60", @"1440p", @"1080p60", @"1080p", @"720p60", @"720p", @"480p", @"360p", @"240p", @"144p"]), 0),
+ YMTextSegment(YMLOC(@"AUDIO_TRACK"), AudioTrack, (@[YMLOC(@"DEFAULT"), YMLOC(@"ORIGINAL"), YMLOC(@"SELECT_MANUALLY")]), 0),
+ YMPicker(YMLOC(@"AUDIO_TRACK_SELECT"), YMLOC(@"AUDIO_TRACK_SELECT_DESC"), AudioTrackLangIndex, getAllSystemLanguageTitles(), 0),
+ YMToggle(YMLOC(@"NO_AUTO_DUBBED"), YMLOC(@"NO_AUTO_DUBBED_DESC"), NoDubbedAudioTrack),
+ YMTextSegment(YMLOC(@"CAPTION_TRACK"), CaptionTrack, (@[YMLOC(@"DEFAULT"), YMLOC(@"DISABLED"), YMLOC(@"SELECT_MANUALLY")]), 0),
+ YMPicker(YMLOC(@"CAPTION_TRACK_SELECT"), YMLOC(@"CAPTION_TRACK_SELECT_DESC"), CaptionTrackLangIndex, getAllSystemLanguageTitles(), 0),
+ YMToggle(YMLOC(@"DISABLES_CAPTION_TRACK"), YMLOC(@"DISABLES_CAPTION_TRACK_DESC"), DisablesCaptionTrack),
+ YMHeader(@""),
+ YMPicker(YMLOC(@"HOLD_TO_SPEED"), YMLOC(@"HOLD_TO_SPEED_DESC"), HoldToSpeedIndex, (@[YMLOC(@"DEFAULT"), @"0.25x", @"0.5x", @"0.75x", @"1x", @"1.25x", @"1.5x", @"1.75x", @"2x", @"3x", @"4x", @"5x"]), 0),
+ YMToggle(YMLOC(@"LOCK_SPEED"), YMLOC(@"LOCK_SPEED_DESC"), LockSpeed),
+ YMHeader(YMLOC(@"INTERFACE")),
+ YMToggle(YMLOC(@"MUTE_BUTTON"), YMLOC(@"MUTE_BUTTON_DESC"), MuteButton),
+ YMToggle(YMLOC(@"SPEED_BUTTON"), YMLOC(@"SPEED_BUTTON_DESC"), SpeedButton),
+ YMToggle(YMLOC(@"QUALITY_BUTTON"), YMLOC(@"QUALITY_BUTTON_DESC"), QualityButton),
+ YMToggle(YMLOC(@"SHARE_BUTTON"), YMLOC(@"SHARE_BUTTON_DESC"), ShareButton),
+ YMToggle(YMLOC(@"LOOP_BUTTON"), YMLOC(@"LOOP_BUTTON_DESC"), LoopButton),
+ YMToggle(YMLOC(@"CAPTION_BUTTON"), YMLOC(@"CAPTION_BUTTON_DESC"), CaptionButton),
+ YMToggle(YMLOC(@"HIDE_AUTOPLAY"), YMLOC(@"HIDE_AUTOPLAY_DESC"), HideAutoPlayToggle),
+ YMToggle(YMLOC(@"HIDE_FULL_VID_TITLE"), YMLOC(@"HIDE_FULL_VID_TITLE_DESC"), HideFullvidTitle),
+ YMToggle(YMLOC(@"HIDE_CAPTIONS_BUTTON"), YMLOC(@"HIDE_CAPTIONS_BUTTON_DESC"), HideCaptionsButton),
+ YMToggle(YMLOC(@"HIDE_CAST_BUTTON_PLAYER"), YMLOC(@"HIDE_CAST_BUTTON_PLAYER_DESC"), HideCastButtonPlayer),
+ YMToggle(YMLOC(@"HIDE_NEXT_AND_PREV_BUTTON"), YMLOC(@"HIDE_NEXT_AND_PREV_BUTTON_DESC"), HideNextAndPrevButtons),
+ YMToggle(YMLOC(@"REPLACE_PREVNEXT_BUTTONS"), YMLOC(@"REPLACE_PREVNEXT_BUTTONS_DESC"), ReplacePrevNextButtons),
+ YMToggle(YMLOC(@"REMOVE_AMBIANT"), YMLOC(@"REMOVE_AMBIANT_DESC"), RemoveAmbiant),
+ YMToggle(YMLOC(@"REMOVE_DARK_OVERLAY"), YMLOC(@"REMOVE_DARK_OVERLAY_DESC"), RemoveDarkOverlay),
+ YMToggle(YMLOC(@"HIDE_END_SCREEN"), YMLOC(@"HIDE_END_SCREEN_DESC"), HideEndScreenCards),
+ YMToggle(YMLOC(@"HIDE_SUGGESTED_VIDEO"), YMLOC(@"HIDE_SUGGESTED_VIDEO_DESC"), HideSuggestedVideo),
+ YMToggle(YMLOC(@"HIDE_PAID_OVERLAY"), YMLOC(@"HIDE_PAID_OVERLAY_DESC"), HidePaidPromoOverlay),
+ YMToggle(YMLOC(@"HIDE_WATERMARK"), YMLOC(@"HIDE_WATERMARK_DESC"), HideWaterMark),
+ YMToggle(YMLOC(@"HIDE_FULLSCREEN_ACTIONS"), YMLOC(@"HIDE_FULLSCREEN_ACTIONS_DESC"), HideFullAction),
+ YMToggle(YMLOC(@"FORCE_SEEKBAR"), YMLOC(@"FORCE_SEEKBAR_DESC"), AlwaysShowSeekbar),
+ YMToggle(YMLOC(@"DISABLES_SHOW_REMAINING"), YMLOC(@"DISABLES_SHOW_REMAINING_DESC"), DisablesShowRemaining),
+ YMToggle(YMLOC(@"ALWAYS_SHOW_REMAINING"), YMLOC(@"ALWAYS_SHOW_REMAINING_DESC"), AlwaysShowRemaining),
+ YMToggle(YMLOC(@"SHOW_REMAINING_EXTRA"), YMLOC(@"SHOW_REMAINING_EXTRA_DESC"), ShowExtraTimeRemaining),
+ YMToggle(YMLOC(@"USES_24_HOURS_TIME"), YMLOC(@"USES_24_HOURS_TIME_DESC"), Uses24HoursTime),
+ YMToggle(YMLOC(@"OLD_QUALITY_PICKER"), YMLOC(@"OLD_QUALITY_PICKER_DESC"), OldQualityPicker),
+ YMToggle(YMLOC(@"EXTRA_SPEED"), YMLOC(@"EXTRA_SPEED_DESC"), ExtraSpeed),
+ // YMToggle(YMLOC(@"USE_ANOTHER_MINIPLAYER"), YMLOC(@"USE_ANOTHER_MINIPLAYER_DESC"), UseAnotherMiniplayer),
+ YMToggle(YMLOC(@"PORTRAIT_FULLSCREEN"), YMLOC(@"PORTRAIT_FULLSCREEN_DESC"), PortFull),
+ YMToggle(YMLOC(@"HIDE_RELATED_VIDEOS"), YMLOC(@"HIDE_RELATED_VIDEOS_DESC"), HideRelatedVideos),
+ YMToggle(YMLOC(@"HIDE_COMMENTS_SECTION"), YMLOC(@"HIDE_COMMENTS_SECTION_DESC"), HideCommentsSection),
+ YMToggle(YMLOC(@"HIDE_COMMENTS_PREVIEW"), YMLOC(@"HIDE_COMMENTS_PREVIEW_DESC"), HideCommentsPreview),
+ YMHeader(YMLOC(@"CONTROL_CENTER")),
+ YMToggle(YMLOC(@"SKIP_BACKWARD"), YMLOC(@"SKIP_BACKWARD_DESC"), SkipBackwardEnabled),
+ YMSlider(YMLOC(@"REWIND_SECONDS"), nil, RewindSeconds, 5, 60, 5, 10),
+ YMToggle(YMLOC(@"SKIP_FORWARD"), YMLOC(@"SKIP_FORWARD_DESC"), SkipForwardEnabled),
+ YMSlider(YMLOC(@"FORWARD_SECONDS"), nil, ForwardSeconds, 5, 60, 5, 10),
+ YMHeader(YMLOC(@"PLAYER_ACTIONS")),
+ YMPicker(YMLOC(@"DEFAULT_SPEED"), YMLOC(@"DEFAULT_SPEED_DESC"), AutoSpeedIndex, (@[YMLOC(@"DISABLED"), @"0.25x", @"0.5x", @"0.75x", @"1x", @"1.25x", @"1.5x", @"1.75x", @"2x", @"3x", @"4x", @"5x"]), 0),
+ YMToggle(YMLOC(@"FORCE_MINIPLAYER"), YMLOC(@"FORCE_MINIPLAYER_DESC"), ForceMiniPlayer),
+ YMToggle(YMLOC(@"HIDE_CONTENT_WARNING"), YMLOC(@"HIDE_CONTENT_WARNING_DESC"), HideContentWarning),
+ YMToggle(YMLOC(@"STOP_AUTOPLAY_VIDEO"), YMLOC(@"STOP_AUTOPLAY_VIDEO_DESC"), StopAutoplayVideo),
+ YMToggle(YMLOC(@"AUTO_FULLSCREEN"), YMLOC(@"AUTO_FULLSCREEN_DESC"), AutoFullScreen),
+ YMToggle(YMLOC(@"AUTO_EXIT_FULLSCREEN"), YMLOC(@"AUTO_EXIT_FULLSCREEN_DESC"), AutoExitFullScreen),
+ YMHeader(YMLOC(@"GESTURE_HEADER")),
+ YMToggle(YMLOC(@"GESTURES"), YMLOC(@"GESTURES_DESC"), GestureControls),
+ YMPicker(YMLOC(@"GESTURE_AREA"), YMLOC(@"GESTURE_AREA_DESC"), GestureActivationArea, (@[@"10%", @"15%", @"20%", @"25%", @"30%", @"35%", @"40%", @"45%", @"50%"]), 1),
+ YMPicker(YMLOC(@"LEFT_SIDE_GESTURE"), nil, LeftSideGesture, (@[YMLOC(@"GESTURE_NONE"), YMLOC(@"GESTURE_BRIGHTNESS"), YMLOC(@"GESTURE_VOLUME"), YMLOC(@"GESTURE_SPEED")]), 1),
+ YMPicker(YMLOC(@"RIGHT_SIDE_GESTURE"), nil, RightSideGesture, (@[YMLOC(@"GESTURE_NONE"), YMLOC(@"GESTURE_BRIGHTNESS"), YMLOC(@"GESTURE_VOLUME"), YMLOC(@"GESTURE_SPEED")]), 2),
+ YMToggle(YMLOC(@"GESTURE_HUD"), YMLOC(@"GESTURE_HUD_DESC"), GestureHUD),
+ YMPicker(YMLOC(@"GESTURE_HUD_SIZE"), YMLOC(@"GESTURE_HUD_SIZE_DESC"), GestureHUDSize, (@[YMLOC(@"SMALL"), YMLOC(@"NORMAL"), YMLOC(@"LARGE"), YMLOC(@"EXTRALARGE"), YMLOC(@"MAX")]), 1),
+ YMPicker(YMLOC(@"GESTURE_HUD_POSITION"), YMLOC(@"GESTURE_HUD_POSITION_DESC"), GestureHUDPosition, (@[YMLOC(@"TOP"), YMLOC(@"MIDDLE"), YMLOC(@"BOTTOM")]), 0),
+ YMHeader(@""),
+ YMToggle(YMLOC(@"TAP_TO_SEEK"), YMLOC(@"TAP_TO_SEEK_DESC"), TapToSeek),
+ YMToggle(YMLOC(@"SEEK_ON_OVERLAY"), YMLOC(@"SEEK_ON_OVERLAY_DESC"), SeekOnOverlay),
+ YMToggle(YMLOC(@"PAUSE_TWO_FINGERS"), YMLOC(@"PAUSE_TWO_FINGERS_DESC"), PauseTwoFingers),
+ YMToggle(YMLOC(@"PAUSE_ON_OVERLAY"), YMLOC(@"PAUSE_ON_OVERLAY_DESC"), PauseOnOverlay),
+ YMToggle(YMLOC(@"COPY_TIMESTAMP_ON_PAUSE"), YMLOC(@"COPY_TIMESTAMP_ON_PAUSE_DESC"), CopyWithTimestampOnPause),
+ YMToggle(YMLOC(@"DISABLES_DOUBLE_TAP"), YMLOC(@"DISABLES_DOUBLE_TAP_DESC"), DisablesDoubleTap),
+ YMToggle(YMLOC(@"DISABLES_LONG_HOLD"), YMLOC(@"DISABLES_LONG_HOLD_DESC"), DisablesLongHold),
+ YMToggle(YMLOC(@"DISABLES_ZOOM"), YMLOC(@"DISABLES_ZOOM_DESC"), DisablesFreeZoom),
+ YMToggle(YMLOC(@"DISABLES_SNAP_TO_CHAPTER"), YMLOC(@"DISABLES_SNAP_TO_CHAPTER_DESC"), DontSnapToChapter),
+ YMToggle(YMLOC(@"DISABLES_ENGAGE_PANEL"), YMLOC(@"DISABLES_ENGAGE_PANEL_DESC"), DisablesEngagementPanel),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"PLAYER"), playerItems);
+ YTSettingsSectionItem *playergroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"PLAYER") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"PLAYER"), playerItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon3 = [%c(YTIIcon) new];
@@ -413,28 +349,21 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 6
// Shorts
- YTSettingsSectionItem *shortsgroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"SHORTS") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_LIKE_BUTTON"), LOC(@"HIDE_SHORTS_LIKE_BUTTON_DESC"), HideShortsLikeButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_DISLIKE_BUTTON"), LOC(@"HIDE_SHORTS_DISLIKE_BUTTON_DESC"), HideShortsDisLikeButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_COMMENT_BUTTON"), LOC(@"HIDE_SHORTS_COMMENT_BUTTON_DESC"), HideShortsCommentButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_SHARE_BUTTON"), LOC(@"HIDE_SHORTS_SHARE_BUTTON_DESC"), HideShortsShareButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_REMIX_BUTTON"), LOC(@"HIDE_SHORTS_REMIX_BUTTON_DESC"), HideShortsRemixButton),
- BASIC_SWITCH(LOC(@"HIDE_METADATA_BUTTON"), LOC(@"HIDE_METADATA_BUTTON_DESC"), HideShortsMetaButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_PRODUCT"), LOC(@"HIDE_SHORTS_PRODUCT_DESC"), HideShortsProducts),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_RECBAR"), LOC(@"HIDE_SHORTS_RECBAR_DESC"), HideShortsRecbar),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_COMMIT"), LOC(@"HIDE_SHORTS_COMMIT_DESC"), HideShortsCommit),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_SUBSCRIPT_BUTTON"), LOC(@"HIDE_SHORTS_SUBSCRIPT_BUTTON_DESC"), HideShortsSubscriptButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_LIVE_BUTTON"), LOC(@"HIDE_SHORTS_LIVE_BUTTON_DESC"), HideShortsLiveButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_LENS_BUTTON"), LOC(@"HIDE_SHORTS_LENS_BUTTON_DESC"), HideShortsLensButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_TRENDS_BUTTON"), LOC(@"HIDE_SHORTS_TRENDS_BUTTON_DESC"), HideShortsTrendsButton),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_TO_VIDEO"), LOC(@"HIDE_SHORTS_TO_VIDEO_DESC"), HideShortsToVideo),
- BASIC_SWITCH(LOC(@"ENABLES_SHORTS_QUALITY"), LOC(@"ENABLES_SHORTS_QUALITY_DESC"), EnablesShortsQuality),
- BASIC_SWITCH(LOC(@"SHOW_SHORTS_SEEKBAR"), LOC(@"SHOW_SHORTS_SEEKBAR_DESC"), ShowShortsSeekbar),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"SHORTS") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *shortsItems = @[
+ YMTextSegment(YMLOC(@"SHORTS_ACTION"), ShortsActionIndex, (@[YMLOC(@"LOOP"), YMLOC(@"SKIP_TO_NEXT_SHORTS"), YMLOC(@"PAUSE_SHORTS")]), 0),
+ YMToggle(YMLOC(@"ENABLES_SHORTS_QUALITY"), YMLOC(@"ENABLES_SHORTS_QUALITY_DESC"), EnablesShortsQuality),
+ YMToggle(YMLOC(@"SHOW_SHORTS_SEEKBAR"), YMLOC(@"SHOW_SHORTS_SEEKBAR_DESC"), ShowShortsSeekbar),
+ YMToggle(YMLOC(@"SHORTS_ONLY"), YMLOC(@"SHORTS_ONLY_DESC"), ShortsOnly),
+ YMToggle(YMLOC(@"SHORTS_FULLSCREEN"), YMLOC(@"SHORTS_FULLSCREEN_DESC"), FullScreenShorts),
+ YMToggle(YMLOC(@"REMOVE_LIVE_SHORTS"), YMLOC(@"REMOVE_LIVE_SHORTS_DESC"), RemoveShortsLive),
+ YMToggle(YMLOC(@"REMOVE_POSTS_SHORTS"), YMLOC(@"REMOVE_POSTS_SHORTS_DESC"), RemoveShortsPosts),
+ YMHeader(YMLOC(@"INTERFACE")),
+ YMToggle(YMLOC(@"HIDE_SHORTS_PRODUCT"), YMLOC(@"HIDE_SHORTS_PRODUCT_DESC"), HideShortsProducts),
+ YMToggle(YMLOC(@"HIDE_SHORTS_RECBAR"), YMLOC(@"HIDE_SHORTS_RECBAR_DESC"), HideShortsRecbar),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"SHORTS"), shortsItems);
+ YTSettingsSectionItem *shortsgroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"SHORTS") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"SHORTS"), shortsItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon4 = [%c(YTIIcon) new];
@@ -444,49 +373,54 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 7
// Tab bar
- YTSettingsSectionItem *tabgroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"TABBAR") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"DEFAULT_TAB")
- titleDescription:LOC(@"DEFAULT_TAB_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"HOME_NAME") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- [[NSUserDefaults standardUserDefaults] setInteger:0 forKey:DefaultTab];
- [settingsViewController reloadData];
- return YES;
- }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"Shorts") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:DefaultTab];
- [settingsViewController reloadData];
- return YES;
- }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"SUBSCRIPT_NAME") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- [[NSUserDefaults standardUserDefaults] setInteger:2 forKey:DefaultTab];
- [settingsViewController reloadData];
- return YES;
- }],
- [YTSettingsSectionItemClass checkmarkItemWithTitle:LOC(@"LIB_NAME") titleDescription:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- [[NSUserDefaults standardUserDefaults] setInteger:3 forKey:DefaultTab];
- [settingsViewController reloadData];
- return YES;
- }]
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"DEFAULT_TAB") pickerSectionTitle:nil rows:rows selectedItemIndex:INTFORVAL(DefaultTab) parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
- return YES;
- }],
- BASIC_SWITCH(LOC(@"HIDE_TAB_INDI"), LOC(@"HIDE_TAB_INDI_DESC"), HideTabIndi),
- BASIC_SWITCH(LOC(@"HIDE_TAB_LABELS"), LOC(@"HIDE_TAB_LABELS_DESC"), HideTabLabels),
- BASIC_SWITCH(LOC(@"HIDE_HOME_TAB"), LOC(@"HIDE_HOME_TAB_DESC"), HideHomeTab),
- BASIC_SWITCH(LOC(@"HIDE_SHORTS_TAB"), LOC(@"HIDE_SHORTS_TAB_DESC"), HideShortsTab),
- BASIC_SWITCH(LOC(@"HIDE_CREATE_BUTTON"), LOC(@"HIDE_CREATE_BUTTON_DESC"), HideCreateButton),
- BASIC_SWITCH(LOC(@"HIDE_SUBSCRIPT_TAB"), LOC(@"HIDE_SUBSCRIPT_TAB_DESC"), HideSubscriptTab),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"TABBAR") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ YTSettingsSectionItem *tabgroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"TABBAR") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ // Build dynamic image list from enabled tabs (standard + custom)
+ NSDictionary *tabYTIconMap = @{@"home": @(65), @"shorts": @(769), @"subscriptions": @(66), @"library": @(61)};
+ NSDictionary *tabBundleIconMap = @{@"history": @"icons/history", @"gaming": @"icons/gaming", @"sports": @"icons/sports", @"notifications": @"icons/noti", @"news": @"icons/news", @"music": @"icons/music", @"watchlater": @"icons/watchlater", @"playlist": @"icons/playlist", @"like": @"icons/like", @"live": @"icons/live", @"post": @"icons/post", @"video": @"icons/video", @"movie": @"icons/movie", @"course": @"icons/course", @"minigame": @"icons/minigame", @"fashion": @"icons/fashion", @"learning": @"icons/learning"};
+ NSBundle *ymBundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"YouMod" ofType:@"bundle"]];
+ YTAssetLoader *assetLoader = [[%c(YTAssetLoader) alloc] initWithBundle:ymBundle];
+
+ NSMutableArray *defaultTabImages = [NSMutableArray array];
+ NSArray *savedOrder = [[NSUserDefaults standardUserDefaults] arrayForKey:TabOrder];
+ if (savedOrder.count > 0) {
+ for (NSDictionary *entry in savedOrder) {
+ if (![entry[@"enabled"] boolValue]) continue;
+ NSString *tabID = entry[@"id"];
+ if ([tabID isEqualToString:@"create"]) continue;
+ NSNumber *ytIconType = tabYTIconMap[tabID];
+ if (ytIconType) {
+ UIImage *img = YouModYTIconImage([ytIconType intValue], YES, [UIColor whiteColor]);
+ if (img) [defaultTabImages addObject:img];
+ } else {
+ NSString *bundleName = tabBundleIconMap[tabID];
+ if (bundleName) {
+ UIImage *img = [assetLoader imageNamed:bundleName];
+ if (img) {
+ UIImage *whiteImg = [img imageWithTintColor:[UIColor whiteColor] renderingMode:UIImageRenderingModeAlwaysOriginal];
+ [defaultTabImages addObject:whiteImg];
+ }
+ }
+ }
+ }
+ }
+ if (defaultTabImages.count == 0) {
+ NSArray *fallbackIcons = @[@(65), @(769), @(66), @(61)];
+ for (NSNumber *iconType in fallbackIcons) {
+ UIImage *img = YouModYTIconImage([iconType intValue], YES, [UIColor whiteColor]);
+ if (img) [defaultTabImages addObject:img];
+ }
+ }
+
+ YMPushSubSettings(YMLOC(@"TABBAR"), @[
+ YMImageSegment(YMLOC(@"DEFAULT_TAB"), DefaultTab, defaultTabImages, 0),
+ YMTextSegment(YMLOC(@"FORSTED_TAB_BAR"), UseFrostedTabBar, (@[YMLOC(@"DEFAULT"),YMLOC(@"ENABLED"), YMLOC(@"DISABLED")]), 0),
+ YMToggle(YMLOC(@"HIDE_TAB_INDI"), YMLOC(@"HIDE_TAB_INDI_DESC"), HideTabIndi),
+ YMToggle(YMLOC(@"HIDE_TAB_LABELS"), YMLOC(@"HIDE_TAB_LABELS_DESC"), HideTabLabels),
+ YMAction(YMLOC(@"MANAGE_TABS"), YMLOC(@"MANAGE_TABS_DESC"), ^(UIViewController *vc) {
+ (void)vc;
+ YMPushTabOrder(settingsViewController, [self parentResponder]);
+ }),
+ ], settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon5 = [%c(YTIIcon) new];
@@ -496,22 +430,48 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
// Section 8
// Miscellaneous
- YTSettingsSectionItem *othergroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"MISCELLANEOUS") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- SETTINGS_HEADER,
- BASIC_SWITCH(LOC(@"BACKGROUND_PLAYBACK"), LOC(@"BACKGROUND_PLAYBACK_DESC"), BackgroundPlayback),
- BASIC_SWITCH(LOC(@"DISABLES_SHORTS_PIP"), LOC(@"DISABLES_SHORTS_PIP_DESC"), DisablesShortsPiP),
- BASIC_SWITCH(LOC(@"BLOCK_UPGRADE_DIALOGS"), LOC(@"BLOCK_UPGRADE_DIALOGS_DESC"), BlockUpgradeDialogs),
- BASIC_SWITCH(LOC(@"ARE_YOU_THERE_DIALOG"), LOC(@"ARE_YOU_THERE_DIALOG_DESC"), HideAreYouThereDialog),
- BASIC_SWITCH(LOC(@"FIXES_SLOW_MINIPLAYER"), LOC(@"FIXES_SLOW_MINIPLAYER_DESC"), FixesSlowMiniPlayer),
- BASIC_SWITCH(LOC(@"DISABLES_NEW_MINIPLAYER"), LOC(@"DISABLES_NEW_MINIPLAYER_DESC"), DisablesNewMiniPlayer),
- BASIC_SWITCH(LOC(@"DISABLES_SNACK_BAR"), LOC(@"DISABLES_SNACK_BAR_DESC"), DisablesSnackBar),
- BASIC_SWITCH(LOC(@"HIDE_STARTUP_ANIMATIONS"), LOC(@"HIDE_STARTUP_ANIMATIONS_DESC"), HideStartupAni),
- BASIC_SWITCH(LOC(@"HIDE_PLAY_IN_NEXT_QUEUE"), LOC(@"HIDE_PLAY_IN_NEXT_QUEUE_DESC"), HidePlayInNextQueue),
- BASIC_SWITCH(LOC(@"HIDE_LIKE_DISLIKE_VOTES"), LOC(@"HIDE_LIKE_DISLIKE_VOTES_DESC"), HideLikeDislikeVotes),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"MISCELLANEOUS") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ NSArray *miscItems = @[
+ YMToggle(YMLOC(@"BACKGROUND_PLAYBACK"), YMLOC(@"BACKGROUND_PLAYBACK_DESC"), BackgroundPlayback),
+ YMToggle(YMLOC(@"DISABLES_SHORTS_PIP"), YMLOC(@"DISABLES_SHORTS_PIP_DESC"), DisablesShortsPiP),
+ YMToggle(YMLOC(@"DISABLE_HINTS"), YMLOC(@"DISABLE_HINTS_DESC"), DisableHints),
+ YMToggle(YMLOC(@"BLOCK_UPGRADE_DIALOGS"), YMLOC(@"BLOCK_UPGRADE_DIALOGS_DESC"), BlockUpgradeDialogs),
+ YMToggle(YMLOC(@"ARE_YOU_THERE_DIALOG"), YMLOC(@"ARE_YOU_THERE_DIALOG_DESC"), HideAreYouThereDialog),
+ YMToggle(YMLOC(@"FIXES_SLOW_MINIPLAYER"), YMLOC(@"FIXES_SLOW_MINIPLAYER_DESC"), FixesSlowMiniPlayer),
+ YMToggle(YMLOC(@"DISABLES_NEW_MINIPLAYER"), YMLOC(@"DISABLES_NEW_MINIPLAYER_DESC"), DisablesNewMiniPlayer),
+ YMToggle(YMLOC(@"DISABLES_SNACK_BAR"), YMLOC(@"DISABLES_SNACK_BAR_DESC"), DisablesSnackBar),
+ YMToggle(YMLOC(@"HIDE_STARTUP_ANIMATIONS"), YMLOC(@"HIDE_STARTUP_ANIMATIONS_DESC"), HideStartupAni),
+ YMToggle(YMLOC(@"HIDE_LIKE_DISLIKE_VOTES"), YMLOC(@"HIDE_LIKE_DISLIKE_VOTES_DESC"), HideLikeDislikeVotes),
+ YMToggle(YMLOC(@"HIDE_COMMU_GUIDE"), YMLOC(@"HIDE_COMMU_GUIDE_DESC"), HideCommuGuide),
+ YMToggle(YMLOC(@"FLOATING_KEYBOARD"), YMLOC(@"FLOATING_KEYBOARD_DESC"), FloatingKeyboard),
+ YMToggle(YMLOC(@"DISABLES_RTL"), YMLOC(@"DISABLES_RTL_DESC"), DisablesRTL),
+ YMHeader(@""),
+ YMTextSegment(YMLOC(@"DEVICE_UI"), DeviceUIIndex, (@[YMLOC(@"DEFAULT"), YMLOC(@"iPad"), YMLOC(@"iPhone")]), 0),
+ YMToggle(YMLOC(@"AUTO_OPEN_LINK"), YMLOC(@"AUTO_OPEN_LINK_DESC"), AutoOpenLink),
+ YMHeader(YMLOC(@"FLYOUT_MENU")),
+ YMToggle(YMLOC(@"REMOVE_PLAY_IN_NEXT_QUEUE_OPTION"), YMLOC(@"REMOVE_PLAY_IN_NEXT_QUEUE_OPTION_DESC"), RemovePlayInNextQueueOption),
+ YMToggle(YMLOC(@"REMOVE_DOWNLOAD_OPTION"), YMLOC(@"REMOVE_DOWNLOAD_OPTION_DESC"), RemoveDownloadOption),
+ YMToggle(YMLOC(@"REMOVE_WATCH_LATER_OPTION"), YMLOC(@"REMOVE_WATCH_LATER_OPTION_DESC"), RemoveWatchLaterOption),
+ YMToggle(YMLOC(@"REMOVE_SAVE_OPTION"), YMLOC(@"REMOVE_SAVE_OPTION_DESC"), RemoveSaveOption),
+ YMToggle(YMLOC(@"REMOVE_REMOVE_FROM_PLAYLIST_OPTION"), YMLOC(@"REMOVE_REMOVE_FROM_PLAYLIST_OPTION_DESC"), RemoveRemoveFromPlaylistOption),
+ YMToggle(YMLOC(@"REMOVE_SHARE_OPTION"), YMLOC(@"REMOVE_SHARE_OPTION_DESC"), RemoveShareOption),
+ YMToggle(YMLOC(@"REMOVE_NOT_INTERESTED_OPTION"), YMLOC(@"REMOVE_NOT_INTERESTED_OPTION_DESC"), RemoveNotInterestedOption),
+ YMToggle(YMLOC(@"REMOVE_DONT_RECOMMEND_OPTION"), YMLOC(@"REMOVE_DONT_RECOMMEND_OPTION_DESC"), RemoveDontRecommendOption),
+ YMToggle(YMLOC(@"REMOVE_INFO_OPTION"), YMLOC(@"REMOVE_INFO_OPTION_DESC"), RemoveInfoOption),
+ YMToggle(YMLOC(@"REMOVE_FILTER_OPTION"), YMLOC(@"REMOVE_FILTER_OPTION_DESC"), RemoveFilterOption),
+ YMToggle(YMLOC(@"REMOVE_REPORT_OPTION"), YMLOC(@"REMOVE_REPORT_OPTION_DESC"), RemoveReportOption),
+ YMToggle(YMLOC(@"REMOVE_YOUTUBE_MUSIC_OPTION"), YMLOC(@"REMOVE_YOUTUBE_MUSIC_OPTION_DESC"), RemoveYouTubeMusicOption),
+ YMToggle(YMLOC(@"REMOVE_FEED_BACK_OPTION"), YMLOC(@"REMOVE_FEED_BACK_OPTION_DESC"), RemoveFeedBackOption),
+ YMToggle(YMLOC(@"REMOVE_CAST_OPTION"), YMLOC(@"REMOVE_CAST_OPTION_DESC"), RemoveCastOption),
+ YMToggle(YMLOC(@"REMOVE_SHUFFLE_OPTION"), YMLOC(@"REMOVE_SHUFFLE_OPTION_DESC"), RemoveShuffleOption),
+ YMToggle(YMLOC(@"REMOVE_UN_SUB_OPTION"), YMLOC(@"REMOVE_UN_SUB_OPTION_DESC"), RemoveUnSubOption),
+ YMToggle(YMLOC(@"REMOVE_HIDE_FROM_PLAYLIST_OPTION"), YMLOC(@"REMOVE_HIDE_FROM_PLAYLIST_OPTION_DESC"), RemoveHideFromPlaylistOption),
+ YMToggle(YMLOC(@"REMOVE_HELP_OPTION"), YMLOC(@"REMOVE_HELP_OPTION_DESC"), RemoveHelpOption),
+ YMToggle(YMLOC(@"REMOVE_NOTIFY_OPTION"), YMLOC(@"REMOVE_NOTIFY_OPTION_DESC"), RemoveNotifyOption),
+ YMToggle(YMLOC(@"REMOVE_CLEARSCREEN_OPTION"), YMLOC(@"REMOVE_CLEARSCREEN_OPTION_DESC"), RemoveClearScreenOption),
+ ];
+ YMRegisterSettingsGroup(YMLOC(@"MISCELLANEOUS"), miscItems);
+ YTSettingsSectionItem *othergroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"MISCELLANEOUS") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"MISCELLANEOUS"), miscItems, settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon6 = [%c(YTIIcon) new];
@@ -519,77 +479,99 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
othergroup.settingIcon = icon6;
[sectionItems addObject:othergroup];
+ // Section: SponsorBlock
+ YTSettingsSectionItem *sponsorblockgroup = [YTSettingsSectionItemClass itemWithTitle:@"SponsorBlock" accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ [self updateSponsorBlockSectionWithEntry:entry];
+ return YES;
+ }];
+ YTIIcon *iconSB = [%c(YTIIcon) new];
+ iconSB.iconType = 610;
+ sponsorblockgroup.settingIcon = iconSB;
+ [sectionItems addObject:sponsorblockgroup];
+
// Section 9
// Perferences
- YTSettingsSectionItem *perfgroup = [YTSettingsSectionItemClass itemWithTitle:LOC(@"PERFER_HEADER") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- NSArray *rows = @[
- [YTSettingsSectionItemClass itemWithTitle:nil
- titleDescription:LOC(@"PERFER")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- return NO;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"IMPORT")
- titleDescription:LOC(@"IMPORT_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- YTAlertView *alertView = [%c(YTAlertView) confirmationDialogWithAction:^{
- [[YouModPrefsManager sharedManager] importYouModSettingsFromVC:settingsViewController];
- } actionTitle:LOC(@"YES")];
- alertView.title = LOC(@"WARNING");
- alertView.subtitle = LOC(@"OVERRIDE");
- [alertView show];
- return YES;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"EXPORT")
- titleDescription:LOC(@"EXPORT_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- [[YouModPrefsManager sharedManager] exportYouModSettingsFromVC:settingsViewController];
- return YES;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"RESTORE")
- titleDescription:LOC(@"RESTORE_DESC")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- [[YouModPrefsManager sharedManager] restoreYouModDefaults];
- return YES;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:nil
- titleDescription:LOC(@"CACHE")
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- return NO;
- }
- ],
- [YTSettingsSectionItemClass itemWithTitle:LOC(@"CLEARCACHE")
- titleDescription:GetCacheSize()
- accessibilityIdentifier:nil
- detailTextBlock:nil
- selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- NSString *cachePath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
- [[NSFileManager defaultManager] removeItemAtPath:cachePath error:nil];
- dispatch_async(dispatch_get_main_queue(), ^{
- [[%c(YTToastResponderEvent) eventWithMessage:LOC(@"DONE") firstResponder:[self parentResponder]] send];
- });
+ YTSettingsSectionItem *perfgroup = [YTSettingsSectionItemClass itemWithTitle:YMLOC(@"PERFER_HEADER") accessibilityIdentifier:nil detailTextBlock:nil selectBlock:^BOOL (YTSettingsCell *cell, NSUInteger arg1) {
+ YMPushSubSettings(YMLOC(@"PERFER_HEADER"), @[
+ YMHeader(YMLOC(@"PERFER")),
+ YMAction(YMLOC(@"IMPORT"), YMLOC(@"IMPORT_DESC"), ^(UIViewController *vc) {
+ Class alertClass = NSClassFromString(@"YTAlertView");
+ YTAlertView *alertView = [alertClass confirmationDialogWithAction:^{
+ [[YouModPrefsManager sharedManager] importYouModSettingsFromVC:vc];
+ } actionTitle:YMLOC(@"YES")];
+ alertView.title = YMLOC(@"WARNING");
+ alertView.subtitle = YMLOC(@"OVERRIDE");
+ [alertView show];
+ }),
+ YMAction(YMLOC(@"EXPORT"), YMLOC(@"EXPORT_DESC"), ^(UIViewController *vc) {
+ [[YouModPrefsManager sharedManager] exportYouModSettingsFromVC:vc];
+ }),
+ YMAction(YMLOC(@"RESTORE"), YMLOC(@"RESTORE_DESC"), ^(UIViewController *vc) {
+ [[YouModPrefsManager sharedManager] restoreYouModDefaults];
+ }),
+ YMHeader(YMLOC(@"CACHE")),
+ YMAction(YMLOC(@"CLEARCACHE"), GetCacheSize(), ^(UIViewController *vc) {
+ __weak UIViewController *weakVC = vc;
+ NSString *clearTitle = YMLOC(@"CLEARCACHE");
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong UIViewController *strongVC = weakVC;
+ if (!strongVC) return;
+ if ([strongVC respondsToSelector:@selector(items)] && [strongVC respondsToSelector:@selector(tableView)]) {
+ NSArray *items = [(id)strongVC items];
+ for (id item in items) {
+ if ([[item title] isEqualToString:clearTitle]) {
+ [item setSubtitle:@""];
+ break;
+ }
+ }
+ UITableView *tableView = [(id)strongVC tableView];
+ [tableView reloadData];
+ for (UITableViewCell *cell in tableView.visibleCells) {
+ if ([cell.textLabel.text isEqualToString:clearTitle]) {
+ UIActivityIndicatorView *indicator = [cell viewWithTag:0xC0FFEE];
+ if (!indicator) {
+ indicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
+ indicator.tag = 0xC0FFEE;
+ [indicator startAnimating];
+ cell.accessoryView = indicator;
+ }
+ cell.detailTextLabel.text = @"";
+ break;
+ }
+ }
+ }
+ });
+
+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+ NSString *cachePath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
+ [[NSFileManager defaultManager] removeItemAtPath:cachePath error:nil];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong UIViewController *strongVC = weakVC;
+ if (!strongVC) return;
+ if ([strongVC respondsToSelector:@selector(tableView)]) {
+ UITableView *tableView = [(id)strongVC tableView];
+ for (UITableViewCell *cell in tableView.visibleCells) {
+ if ([cell.textLabel.text isEqualToString:YMLOC(@"CLEARCACHE")]) {
+ cell.accessoryView = nil;
+ break;
+ }
+ }
+ }
+ if ([strongVC respondsToSelector:@selector(items)] && [strongVC respondsToSelector:@selector(tableView)]) {
+ NSArray *items = [(id)strongVC items];
+ for (id item in items) {
+ if ([[item title] isEqualToString:clearTitle]) {
+ [item setSubtitle:@"0 KB"];
+ break;
+ }
+ }
+ [[(id)strongVC tableView] reloadData];
+ }
});
- return YES;
- }
- ],
- BASIC_SWITCH(LOC(@"AUTO_CLEARCACHE"), LOC(@"AUTO_CLEARCACHE_DESC"), AutoClearCache),
- ];
- YTSettingsPickerViewController *picker = [[%c(YTSettingsPickerViewController) alloc] initWithNavTitle:LOC(@"PERFER_HEADER") pickerSectionTitle:nil rows:rows selectedItemIndex:0 parentResponder:[self parentResponder]];
- [settingsViewController pushViewController:picker];
+ });
+ }),
+ YMToggle(YMLOC(@"AUTO_CLEARCACHE"), YMLOC(@"AUTO_CLEARCACHE_DESC"), AutoClearCache),
+ ], settingsViewController, [self parentResponder]);
return YES;
}];
YTIIcon *icon7 = [%c(YTIIcon) new];
@@ -619,13 +601,14 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
[[NSUserDefaults standardUserDefaults] registerDefaults:@{
AutoClearCache: @YES,
YTPremiumLogo: @YES,
- HideCreateButton: @YES,
HideCastButtonNav: @YES,
HideCastButtonPlayer: @YES,
BackgroundPlayback: @YES,
- OldQualityPicker: @YES,
DownloadManager: @YES,
DownloadSaveToPhotos: @YES,
+ DisableHints: @YES,
+ RewindSeconds: @10.0,
+ ForwardSeconds: @10.0,
}];
%init;
}
diff --git a/Files/Shorts.x b/Files/Shorts.x
index d73cbb24..95cba106 100644
--- a/Files/Shorts.x
+++ b/Files/Shorts.x
@@ -33,4 +33,245 @@
%hook YTHotConfig
- (BOOL)enablePlayerBarForVerticalVideoWhenControlsHiddenInFullscreen { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
-%end
\ No newline at end of file
+%end
+
+static void YouModMakeAShortsAction(YTReelPlayerViewController *self, YTSingleVideoController *video, YTSingleVideoTime *time) {
+ if (INTFORVAL(ShortsActionIndex) == 0) return;
+
+ if (floor(time.time) >= floor(video.totalMediaTime)) {
+ if (INTFORVAL(ShortsActionIndex) == 1) {
+ [self reelContentViewRequestsAdvanceToNextVideo:nil];
+ } else if (INTFORVAL(ShortsActionIndex) == 2) {
+ [self reelContentViewRequestsPlayPauseToggle:nil];
+ }
+ }
+}
+
+static BOOL isShortsOnlyOn = YES;
+
+%hook YTReelPlayerViewController
+- (void)singleVideo:(YTSingleVideoController *)video currentVideoTimeDidChange:(YTSingleVideoTime *)time {
+ %orig;
+ YouModMakeAShortsAction(self, video, time);
+}
+- (void)loadPlayerBar {
+ %orig;
+ if (isShortsOnlyOn && IS_ENABLED(ShortsOnly)) [self YouModOnlyShorts];
+ YTPlayerViewController *main = self.player;
+ if (INTFORVAL(CaptionTrack) != 0) [main performSelector:@selector(YouModAutoCaptions) withObject:nil afterDelay:0.5];
+ if (INTFORVAL(AutoSpeedIndex) != 0) [main performSelector:@selector(YouModSetAutoSpeed) withObject:nil afterDelay:0.5];
+ if (INTFORVAL(AudioTrack) != 0) [self performSelector:@selector(YouModAutoAudioTrack:) withObject:main afterDelay:0.5];
+}
+%new
+- (void)YouModOnlyShorts {
+ id appconmain = [self valueForKey:@"_pivotBarProvider"];
+ if ([appconmain isKindOfClass:%c(YTAppViewControllerImpl)]) {
+ YTAppViewControllerImpl *appcon = (YTAppViewControllerImpl *)appconmain;
+ [appcon hidePivotBar];
+ } else {
+ YTAppViewController *appcon = (YTAppViewController *)appconmain;
+ [appcon hidePivotBar];
+ }
+}
+%new
+- (void)YouModAutoAudioTrack:(YTPlayerViewController *)pv {
+ NSInteger selectedIndex = INTFORVAL(AudioTrackLangIndex);
+ NSArray *langCodes = getAllSystemLanguageValues();
+ NSString *userTargetLang = langCodes[selectedIndex];
+ id switchcon = self.audioTrackController;
+ NSArray *availableTracks = [switchcon valueForKey:@"_availableAudioTracks"];
+ if (!availableTracks || availableTracks.count == 0) return;
+ YTIAudioTrack *matchedTrack = nil;
+
+ if (INTFORVAL(AudioTrack) == 1) {
+ // Loop for all tracks
+ for (YTIAudioTrack *track in availableTracks) {
+ if ([track.id_p hasSuffix:@".4"]) {
+ matchedTrack = track;
+ break;
+ }
+ }
+ } else if (INTFORVAL(AudioTrack) == 2) {
+ // Loop for all tracks
+ for (YTIAudioTrack *track in availableTracks) {
+ if ([track.id_p hasPrefix:userTargetLang]) {
+ matchedTrack = track;
+ break;
+ }
+ }
+
+ // Check if it's dubbed
+ if (matchedTrack && [matchedTrack isAutoDubbed] && IS_ENABLED(NoDubbedAudioTrack)) {
+ matchedTrack = nil;
+ return;
+ }
+ }
+
+ // If found, change to it
+ if (matchedTrack) {
+ [pv setAudioTrack:matchedTrack source:0];
+ } else if (!matchedTrack && IS_ENABLED(NoDubbedAudioTrack)) {
+ for (YTIAudioTrack *track in availableTracks) {
+ if ([track.id_p hasSuffix:@".4"]) {
+ [pv setAudioTrack:track source:0];
+ break;
+ }
+ }
+ }
+}
+%end
+
+extern void YouModConfigureDownloadButton(_ASDisplayView *view);
+
+// _ASDisplayView filters
+%hook _ASDisplayView
+- (void)didMoveToWindow {
+ %orig;
+ YouModConfigureDownloadButton(self);
+ NSDictionary *elements = @{
+ @"product_sticker.main_target": @(IS_ENABLED(HideShortsProducts)),
+ @"product_sticker.secondary_target": @(IS_ENABLED(HideShortsProducts)),
+ @"id.elements.components.suggested_action": @(IS_ENABLED(HideShortsRecbar))
+ };
+ if ([elements[self.accessibilityIdentifier] boolValue]) [self removeFromSuperview];
+}
+%end
+
+static BOOL isFullscreenEnabled = NO;
+
+%hook YTAppDelegate
+- (void)appDidBecomeActive {
+ %orig;
+ if ((isFullscreenEnabled && IS_ENABLED(FullScreenShorts)) || (isShortsOnlyOn && IS_ENABLED(ShortsOnly))) {
+ id appviewcon = [self valueForKey:@"_appViewController"];
+ if ([appviewcon isKindOfClass:%c(YTAppViewControllerImpl)]) {
+ YTAppViewControllerImpl *con = (YTAppViewControllerImpl *)appviewcon;
+ [con hidePivotBar];
+ } else {
+ YTAppViewController *con = (YTAppViewController *)appviewcon;
+ [con hidePivotBar];
+ }
+ }
+}
+%end
+
+%hook YTReelWatchPlaybackOverlayView
+%property (nonatomic, retain) UIPinchGestureRecognizer *YouModFullscreenGesture;
+- (void)layoutSubviews {
+ %orig;
+ if (!IS_ENABLED(FullScreenShorts)) return;
+ if (!self.YouModFullscreenGesture) {
+ self.YouModFullscreenGesture = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(YouModFullscrrenGestureHandler:)];
+ self.YouModFullscreenGesture.delegate = (id)self;
+
+ [self.superview addGestureRecognizer:self.YouModFullscreenGesture];
+ }
+}
+%new
+- (void)YouModFullscrrenGestureHandler:(UIPinchGestureRecognizer *)gesture {
+ if (gesture.state != UIGestureRecognizerStateBegan || (isShortsOnlyOn && IS_ENABLED(ShortsOnly))) return;
+ id appconmain = [self valueForKey:@"_pivotBarProvider"];
+ if ([appconmain isKindOfClass:%c(YTAppViewControllerImpl)]) {
+ YTAppViewControllerImpl *appcon = (YTAppViewControllerImpl *)appconmain;
+ BOOL isTabBarHidden = [appcon isPivotBarHidden];
+ if (gesture.scale > 1.0) {
+ if (!isTabBarHidden) {
+ [appcon hidePivotBar];
+ [UIView animateWithDuration:0.3 animations:^{
+ self.alpha = 0;
+ }];
+ isFullscreenEnabled = YES;
+ }
+ } else if (gesture.scale < 1.0) {
+ if (isTabBarHidden) {
+ [appcon showPivotBar];
+ [UIView animateWithDuration:0.3 animations:^{
+ self.alpha = 1;
+ }];
+ isFullscreenEnabled = NO;
+ }
+ }
+ } else {
+ YTAppViewController *appcon = (YTAppViewController *)appconmain;
+ BOOL isTabBarHidden = [appcon isPivotBarHidden];
+ if (gesture.scale > 1.0) {
+ if (!isTabBarHidden) {
+ [appcon hidePivotBar];
+ [UIView animateWithDuration:0.3 animations:^{
+ self.alpha = 0;
+ }];
+ isFullscreenEnabled = YES;
+ }
+ } else if (gesture.scale < 1.0) {
+ if (isTabBarHidden) {
+ [appcon showPivotBar];
+ [UIView animateWithDuration:0.3 animations:^{
+ self.alpha = 1;
+ }];
+ isFullscreenEnabled = NO;
+ }
+ }
+ }
+}
+%new
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
+ if (gestureRecognizer == self.YouModFullscreenGesture) {
+ return YES;
+ }
+ return NO;
+}
+%end
+
+%hook YTReelContentView
+%property (nonatomic, retain) UILongPressGestureRecognizer *YouModExitShortsOnlyGesture;
+- (void)setPlaybackView:(id)arg1 {
+ %orig;
+ self.playbackOverlay.alpha = !isFullscreenEnabled;
+ if (!IS_ENABLED(ShortsOnly)) return;
+ if (isShortsOnlyOn) {
+ self.YouModExitShortsOnlyGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(YouModTurnOffShortsOnly:)];
+ self.YouModExitShortsOnlyGesture.numberOfTouchesRequired = 2;
+ self.YouModExitShortsOnlyGesture.minimumPressDuration = 0.5;
+
+ [self addGestureRecognizer:self.YouModExitShortsOnlyGesture];
+ }
+}
+%new
+- (void)YouModTurnOffShortsOnly:(UILongPressGestureRecognizer *)gesture {
+ if (gesture.state != UIGestureRecognizerStateBegan) return;
+ isShortsOnlyOn = NO;
+ UIView *parent = sbGetNotificationParent();
+ [SBSkipNotificationView showSuccessInView:parent message:LOC(@"SHORTS_ONLY_DISABLED") duration:3.0];
+
+ YTReelContainerViewController *reelcon = [self valueForKey:@"_parentResponder"];
+ YTAppReelWatchRootViewController *watchroot = [reelcon valueForKey:@"_delegate"];
+ id appconmain = [watchroot valueForKey:@"_pivotBarProvider"];
+ if ([appconmain isKindOfClass:%c(YTAppViewControllerImpl)]) {
+ YTAppViewControllerImpl *appcon = (YTAppViewControllerImpl *)appconmain;
+ [appcon showPivotBar];
+ [UIView animateWithDuration:0.3 animations:^{
+ self.playbackOverlay.alpha = 1;
+ }];
+ } else {
+ YTAppViewController *appcon = (YTAppViewController *)appconmain;
+ [appcon showPivotBar];
+ [UIView animateWithDuration:0.3 animations:^{
+ self.playbackOverlay.alpha = 1;
+ }];
+ }
+}
+%new
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
+ if (gestureRecognizer == self.YouModExitShortsOnlyGesture && [otherGestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]]) {
+ return YES;
+ }
+ return NO;
+}
+%new
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
+ if (gestureRecognizer == self.YouModExitShortsOnlyGesture) {
+ return NO;
+ }
+ return YES;
+}
+%end
diff --git a/Files/Sideloading.x b/Files/Sideloading.x
index 9c9a2870..7e46717b 100644
--- a/Files/Sideloading.x
+++ b/Files/Sideloading.x
@@ -1,8 +1,6 @@
// All Codes are adapt from YTLite and uYouEnhanced + Some of my research
#import "Headers.h"
-extern void YouModConfigureDownloadButton(_ASDisplayView *view);
-
// AccessGroupID
static NSString *accessGroupID() {
NSDictionary *query = [NSDictionary dictionaryWithObjectsAndKeys:
@@ -23,45 +21,6 @@ static NSString *accessGroupID() {
return accessGroup;
}
-// _ASDisplayView filters
-// This hook can hide A LOT of things
-%hook _ASDisplayView
-
-- (void)didMoveToWindow {
- %orig;
- YouModConfigureDownloadButton(self);
- // if (IS_ENABLED(HideShortsShelf) && [self.accessibilityIdentifier isEqualToString:@"eml.shorts-shelf"]) self.hidden = YES;
- if (IS_ENABLED(HideGenMusicShelf) && [self.accessibilityIdentifier isEqualToString:@"feed_nudge.view"]) self.hidden = YES;
- if (IS_ENABLED(HideFeedPost) && [self.accessibilityIdentifier isEqualToString:@"id.ui.backstage.original_post"]) self.hidden = YES;
- if (IS_ENABLED(HideLikeButton) && [self.accessibilityIdentifier isEqualToString:@"id.video.like.button"]) self.hidden = YES;
- if (IS_ENABLED(HideDisLikeButton) && [self.accessibilityIdentifier isEqualToString:@"id.video.dislike.button"]) self.hidden = YES;
- if (IS_ENABLED(HideShareButton) && [self.accessibilityIdentifier isEqualToString:@"id.video.share.button"]) self.hidden = YES;
- if (IS_ENABLED(HideDownloadButton) && [self.accessibilityIdentifier isEqualToString:@"id.ui.add_to.offline.button"]) self.hidden = YES;
- if (IS_ENABLED(HideClipButton) && [self.accessibilityIdentifier isEqualToString:@"clip_button.eml"]) self.hidden = YES;
- if (IS_ENABLED(HideRemixButton) && [self.accessibilityIdentifier isEqualToString:@"id.video.remix.button"]) self.hidden = YES;
- if (IS_ENABLED(HideSaveButton) && [self.accessibilityIdentifier isEqualToString:@"id.video.add_to.button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsLikeButton) && [self.accessibilityIdentifier isEqualToString:@"id.reel_like_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsDisLikeButton) && [self.accessibilityIdentifier isEqualToString:@"id.reel_dislike_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsCommentButton) && [self.accessibilityIdentifier isEqualToString:@"id.reel_comment_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsShareButton) && [self.accessibilityIdentifier isEqualToString:@"id.reel_share_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsRemixButton) && [self.accessibilityIdentifier isEqualToString:@"id.reel_remix_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsMetaButton) && [self.accessibilityIdentifier isEqualToString:@"id.reel_pivot_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsProducts) && [self.accessibilityIdentifier isEqualToString:@"product_sticker.main_target"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsProducts) && [self.accessibilityIdentifier isEqualToString:@"product_sticker.secondary_target"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsRecbar) && [self.accessibilityIdentifier isEqualToString:@"id.elements.components.suggested_action"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsCommit) && [self.accessibilityIdentifier isEqualToString:@"eml.shorts-disclosures"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsSubscriptButton) && [self.accessibilityIdentifier isEqualToString:@"id.ui.shorts_paused_state.subscriptions_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsLiveButton) && [self.accessibilityIdentifier isEqualToString:@"id.ui.shorts_paused_state.live_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsLensButton) && [self.accessibilityIdentifier isEqualToString:@"id.ui.shorts_paused_state.lens_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsTrendsButton) && [self.accessibilityIdentifier isEqualToString:@"id.ui.shorts_paused_state.trends_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShortsToVideo) && [self.accessibilityIdentifier isEqualToString:@"id.reel_multi_format_link"]) self.hidden = YES;
- if (IS_ENABLED(HideSubButton) && [self.accessibilityIdentifier isEqualToString:@"eml.animated_subscribe_button"]) self.hidden = YES;
- if (IS_ENABLED(HideShoppingButton) && [self.accessibilityIdentifier isEqualToString:@"eml.header_store_button"]) self.hidden = YES;
- if (IS_ENABLED(HideMemberButton) && [self.accessibilityIdentifier isEqualToString:@"id.sponsor_button"]) self.hidden = YES;
-}
-
-%end
-
// IAmYouTube (https://github.com/PoomSmart/IAmYouTube)
%hook YTVersionUtils
+ (NSString *)appName { return YT_NAME; }
@@ -208,6 +167,6 @@ static NSString *accessGroupID() {
NSURL *documentsURL = [paths lastObject];
return [documentsURL URLByAppendingPathComponent:@"AppGroup"];
}
- return %orig(groupIdentifier);
+ return %orig;
}
-%end
\ No newline at end of file
+%end
diff --git a/Files/SleepTimer.txt b/Files/SleepTimer.txt
new file mode 100644
index 00000000..23c5423b
--- /dev/null
+++ b/Files/SleepTimer.txt
@@ -0,0 +1,115 @@
+#import "Headers.h"
+
+@interface YouModSleepMenuItem : NSObject
+@property (nonatomic, copy) NSString *title;
+@property (nonatomic, copy) NSString *subtitle;
+@property (nonatomic, strong) UIImage *iconImage;
+@property (nonatomic, copy) void (^handler)(void);
++ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle handler:(void (^)(void))handler;
++ (instancetype)itemWithTitle:(NSString *)title subtitle:(NSString *)subtitle icon:(UIImage *)icon handler:(void (^)(void))handler;
+@end
+
+static void YouModPresentMenu(NSString *title, NSString *desc, UIViewController *presenter, UIView *sender) {
+ presenter = YouModTopViewController(presenter);
+ YTDefaultSheetController *sheet = [%c(YTDefaultSheetController) sheetControllerWithParentResponder:presenter];;
+ for (YouModSleepMenuItem *item in items) {
+ YTActionSheetAction *action = [%c(YTActionSheetAction) actionWithTitle:title subtitle:desc iconImage:nil handler:^(__unused YTActionSheetAction *action) {
+ if (handler) item.handler();
+ }];
+ [sheet addAction:action];
+ }
+ if (sender) {
+ [sheet presentFromView:sender animated:YES completion:nil];
+ } else {
+ [sheet presentFromViewController:presenter animated:YES completion:nil];
+ }
+}
+
+static __weak YTPlayerViewController *YouModSleepTimerPlayer = nil;
+static NSTimer *YouModSleepTimerTimer = nil;
+static BOOL YouModSleepTimerAtEndOfVideo = NO;
+
+static void YouModCancelSleepTimer(void) {
+ [YouModSleepTimerTimer invalidate];
+ YouModSleepTimerTimer = nil;
+ YouModSleepTimerAtEndOfVideo = NO;
+}
+
+static void YouModPauseForSleepTimer(void) {
+ YTPlayerViewController *player = YouModSleepTimerPlayer;
+ [player pause];
+ YouModCancelSleepTimer();
+ YouModShowToast(@"Sleep timer ended", player);
+}
+
+static void YouModStartSleepTimer(NSTimeInterval seconds, YTPlayerViewController *player) {
+ YouModCancelSleepTimer();
+ YouModSleepTimerPlayer = player;
+ YouModSleepTimerTimer = [NSTimer scheduledTimerWithTimeInterval:seconds repeats:NO block:^(__unused NSTimer *timer) {
+ YouModPauseForSleepTimer();
+ }];
+ YouModShowToast([NSString stringWithFormat:@"Sleep timer: %.0f minutes", seconds / 60.0], player);
+}
+
+static void YouModPresentSleepTimerCustomMinutes(YTPlayerViewController *player) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Sleep timer" message:@"Enter minutes" preferredStyle:UIAlertControllerStyleAlert];
+ [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
+ textField.keyboardType = UIKeyboardTypeNumberPad;
+ textField.placeholder = @"Minutes";
+ }];
+ [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
+ [alert addAction:[UIAlertAction actionWithTitle:@"Start" style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
+ NSInteger minutes = alert.textFields.firstObject.text.integerValue;
+ if (minutes <= 0) {
+ YouModShowToast(@"Enter a valid number of minutes", player);
+ return;
+ }
+ YouModStartSleepTimer((NSTimeInterval)minutes * 60.0, player);
+ }]];
+ [YouModTopViewController() presentViewController:alert animated:YES completion:nil];
+}
+
+static NSDate *YouModSleepTimerDateFromClockText(NSString *text) {
+ NSString *trimmed = [text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ if (trimmed.length == 0) return nil;
+
+ NSArray *formats = @[@"H:mm", @"HH:mm", @"h:mm a", @"h:mma"];
+ NSDate *parsed = nil;
+ NSDateFormatter *formatter = [NSDateFormatter new];
+ formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
+ for (NSString *format in formats) {
+ formatter.dateFormat = format;
+ parsed = [formatter dateFromString:trimmed];
+ if (parsed) break;
+ }
+ if (!parsed) return nil;
+
+ NSCalendar *calendar = NSCalendar.currentCalendar;
+ NSDateComponents *clock = [calendar components:NSCalendarUnitHour | NSCalendarUnitMinute fromDate:parsed];
+ NSDateComponents *today = [calendar components:NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay fromDate:NSDate.date];
+ today.hour = clock.hour;
+ today.minute = clock.minute;
+ today.second = 0;
+ NSDate *target = [calendar dateFromComponents:today];
+ if (target.timeIntervalSinceNow <= 5.0)
+ target = [calendar dateByAddingUnit:NSCalendarUnitDay value:1 toDate:target options:0];
+ return target;
+}
+
+static void YouModPresentSleepTimerEndAtTime(YTPlayerViewController *player) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Sleep timer" message:@"Enter a time" preferredStyle:UIAlertControllerStyleAlert];
+ [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
+ textField.keyboardType = UIKeyboardTypeNumbersAndPunctuation;
+ textField.placeholder = @"23:30 or 11:30 PM";
+ }];
+ [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
+ [alert addAction:[UIAlertAction actionWithTitle:@"Start" style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
+ NSDate *target = YouModSleepTimerDateFromClockText(alert.textFields.firstObject.text);
+ if (!target) {
+ YouModShowToast(@"Enter a valid time", player);
+ return;
+ }
+ YouModStartSleepTimer(target.timeIntervalSinceNow, player);
+ }]];
+ [YouModTopViewController() presentViewController:alert animated:YES completion:nil];
+}
diff --git a/Files/SponsorBlock.x b/Files/SponsorBlock.x
new file mode 100644
index 00000000..915ced20
--- /dev/null
+++ b/Files/SponsorBlock.x
@@ -0,0 +1,596 @@
+#import "Headers.h"
+#import
+
+BOOL useBackwardIconForButton;
+
+// System sound played on skip when haptic/audio feedback is enabled.
+static const SystemSoundID SBSkipHapticSoundID = 1519;
+
+// Skipping is suppressed within this many seconds of a segment's end, so a
+// segment already almost over isn't re-triggered by a late time-change callback.
+static const CGFloat SBSegmentEndGuardSeconds = 0.5;
+
+// The skip banner is shown after this delay so the seek completes first —
+// otherwise the following time-change callback dismisses it immediately.
+static const NSTimeInterval SBSkipNotificationDelaySeconds = 0.3;
+
+// Clamps a stored banner duration to the supported range, falling back to the
+// default when unset or out of range.
+static float SBClampedAlertDuration(NSString *key) {
+ float duration = FLOAT_FOR_KEY(key);
+ if (duration < SBAlertDurationMin || duration > SBAlertDurationMax) return SBAlertDurationDefault;
+ return duration;
+}
+
+@interface SBPassthroughView : UIView
+@end
+@implementation SBPassthroughView
+- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
+ UIView *hit = [super hitTest:point withEvent:event];
+ return (hit == self) ? nil : hit;
+}
+@end
+
+@interface SBPassthroughWindow : UIWindow
+@end
+@implementation SBPassthroughWindow
+- (BOOL)_canBecomeKeyWindow { return NO; }
+- (BOOL)_canAffectStatusBarAppearance { return NO; }
+- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
+ UIView *rootView = self.rootViewController.view;
+ if (!rootView) return nil;
+ CGPoint convertedPoint = [rootView convertPoint:point fromView:self];
+ UIView *hitView = [rootView hitTest:convertedPoint withEvent:event];
+ if (!hitView || hitView == rootView) return nil;
+ return hitView;
+}
+@end
+
+static SBPassthroughWindow *sbOverlayWindow = nil;
+
+void sbUpdateOverlayInsetForPivotBar() {
+ if (!sbOverlayWindow) return;
+ UIViewController *rootVC = sbOverlayWindow.rootViewController;
+ if (!rootVC) return;
+
+ // The overlay window's frame is set only at creation; the scene can change
+ // size afterward (rotation, iPhone fullscreen exit) and this plain-UIViewController
+ // window doesn't auto-resize with it. Re-syncing to the current scene bounds keeps
+ // the pill safe-area math on the correct (e.g. portrait) size rather than a stale
+ // landscape one — otherwise pills anchor to a mid-screen bottom edge.
+ UIWindowScene *scene = sbOverlayWindow.windowScene;
+ CGRect sceneBounds = scene ? scene.coordinateSpace.bounds : sbOverlayWindow.bounds;
+ if (scene && !CGRectEqualToRect(sbOverlayWindow.frame, sceneBounds)) {
+ sbOverlayWindow.frame = sceneBounds;
+ }
+
+ // Look up YouTube's root view controller in the SAME scene as our overlay
+ // window — on iPad multi-window the app delegate's window may belong to a
+ // different scene, so [delegate window] is not safe here.
+ UIWindow *ytWindow = nil;
+ for (UIWindow *win in sbOverlayWindow.windowScene.windows) {
+ if ([win.rootViewController isKindOfClass:NSClassFromString(@"YTAppViewController")] || [win.rootViewController isKindOfClass:NSClassFromString(@"YTAppViewControllerImpl")]) {
+ ytWindow = win;
+ break;
+ }
+ }
+ YTAppViewController *appVC = (YTAppViewController *)ytWindow.rootViewController;
+ YTAppViewControllerImpl *appVC2 = (YTAppViewControllerImpl *)ytWindow.rootViewController;
+ YTPivotBarViewController *pivotVC;
+ @try {
+ pivotVC = (YTPivotBarViewController *)appVC.pivotBarViewController;
+ } @catch (id ex) {
+ pivotVC = (YTPivotBarViewController *)appVC2.pivotBarViewController;
+ }
+ YTPivotBarView *pivot = (YTPivotBarView *)pivotVC.viewIfLoaded;
+
+ // Measure the pivot bar's visible top edge in our overlay window's coords
+ // and convert it into the inset our pills need above the device safe area.
+ // This avoids reading pivot.bounds.size.height directly — that value
+ // includes home-indicator padding on notched devices and would over-correct
+ // the safe area, leaving the pill floating too high above the tabbar.
+ CGFloat tabH = 0.0;
+ if (pivot && pivot.window != nil && !pivot.hidden && pivot.alpha > 0.01) {
+ UIView *overlayView = rootVC.view;
+ CGRect pivotInOverlay = [overlayView convertRect:pivot.bounds fromView:pivot];
+ CGFloat pivotTop = CGRectGetMinY(pivotInOverlay);
+ // The scene bounds are the authoritative overlay height. overlayView.bounds
+ // is avoided here because it only reflects a new window size once autoresizing
+ // has propagated, which may lag within the current runloop.
+ CGFloat overlayHeight = sceneBounds.size.height;
+ CGFloat deviceSafeBottom = sbOverlayWindow.safeAreaInsets.bottom;
+ CGFloat pivotHeight = pivot.bounds.size.height;
+ // Clamp to the pivot bar's own height: a legitimate inset can never exceed
+ // the bar it's clearing. If convertRect returns a stale value during a scene
+ // transition (e.g. fullscreen exit), the raw result balloons toward the full
+ // screen height — clamping to pivotHeight keeps the pill just above the safe
+ // area instead of letting it drift to the middle of the screen.
+ tabH = MAX(0.0, MIN(pivotHeight, overlayHeight - deviceSafeBottom - pivotTop));
+ }
+ UIEdgeInsets current = rootVC.additionalSafeAreaInsets;
+ if (current.bottom != tabH) {
+ rootVC.additionalSafeAreaInsets = UIEdgeInsetsMake(0, 0, tabH, 0);
+ }
+}
+
+static const NSTimeInterval SBOverlayRestoreFadeDuration = 0.15;
+
+// Hide the pill overlay instantly (non-animated): iOS captures the app-switcher
+// snapshot synchronously as the app deactivates, so an animated hide wouldn't
+// land in time and the pill would leak into the switcher card.
+static void sbHideOverlayForSnapshot(void) {
+ if (sbOverlayWindow) sbOverlayWindow.hidden = YES;
+}
+
+// Restore the overlay, fading it back in so the reappearance isn't a hard pop.
+// Guarded to the hidden state so the two "became active" notifications don't
+// each re-trigger the fade.
+static void sbRestoreOverlayAfterSnapshot(void) {
+ if (!sbOverlayWindow || !sbOverlayWindow.hidden) return;
+ sbOverlayWindow.alpha = 0.0;
+ sbOverlayWindow.hidden = NO;
+ [UIView animateWithDuration:SBOverlayRestoreFadeDuration animations:^{
+ sbOverlayWindow.alpha = 1.0;
+ }];
+}
+
+// Tracks which scene's lifecycle is currently observed. When sbOverlayWindow is
+// recreated for a different scene (after the original goes Unattached), we
+// re-bind observers to the new scene rather than leaving stale registrations.
+static UIWindowScene *sbObservedScene = nil;
+static id sbSceneDeactivateObserver = nil;
+static id sbSceneBackgroundObserver = nil;
+static id sbSceneActivateObserver = nil;
+static id sbAppResignObserver = nil;
+static id sbAppBackgroundObserver = nil;
+static id sbAppActivateObserver = nil;
+static id sbOrientationObserver = nil;
+
+static void sbRegisterOverlayLifecycleObservers(UIWindowScene *targetScene) {
+ if (!targetScene || sbObservedScene == targetScene) return;
+ NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
+
+ if (sbSceneDeactivateObserver) [nc removeObserver:sbSceneDeactivateObserver];
+ if (sbSceneBackgroundObserver) [nc removeObserver:sbSceneBackgroundObserver];
+ if (sbSceneActivateObserver) [nc removeObserver:sbSceneActivateObserver];
+ if (sbAppResignObserver) [nc removeObserver:sbAppResignObserver];
+ if (sbAppBackgroundObserver) [nc removeObserver:sbAppBackgroundObserver];
+ if (sbAppActivateObserver) [nc removeObserver:sbAppActivateObserver];
+ if (sbOrientationObserver) [nc removeObserver:sbOrientationObserver];
+
+ sbObservedScene = targetScene;
+
+ // Hide on deactivation, restore on activation. Deactivation is the trigger
+ // (not backgrounding) because invoking the app switcher only moves the app to
+ // the inactive state — a background-only observer misses that path. queue:nil
+ // runs the hide synchronously before the snapshot is captured. Scene
+ // notifications are filtered to targetScene so one scene's interruption
+ // doesn't hide another's overlay on iPad multi-window; the app-level
+ // notifications (object:nil) are an app-wide backstop.
+ sbSceneDeactivateObserver = [nc addObserverForName:UISceneWillDeactivateNotification object:targetScene queue:nil usingBlock:^(__unused NSNotification *note) {
+ sbHideOverlayForSnapshot();
+ }];
+ sbSceneBackgroundObserver = [nc addObserverForName:UISceneDidEnterBackgroundNotification object:targetScene queue:nil usingBlock:^(__unused NSNotification *note) {
+ sbHideOverlayForSnapshot();
+ }];
+ sbSceneActivateObserver = [nc addObserverForName:UISceneDidActivateNotification object:targetScene queue:nil usingBlock:^(__unused NSNotification *note) {
+ sbRestoreOverlayAfterSnapshot();
+ }];
+ sbAppResignObserver = [nc addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:nil usingBlock:^(__unused NSNotification *note) {
+ sbHideOverlayForSnapshot();
+ }];
+ sbAppBackgroundObserver = [nc addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:nil usingBlock:^(__unused NSNotification *note) {
+ sbHideOverlayForSnapshot();
+ }];
+ sbAppActivateObserver = [nc addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:nil usingBlock:^(__unused NSNotification *note) {
+ sbRestoreOverlayAfterSnapshot();
+ }];
+
+ // Recompute pivot-bar inset on rotation / dynamic tabbar height changes.
+ // UIDeviceOrientationDidChangeNotification only fires when device-orientation
+ // generation is enabled; this call is idempotent.
+ [[UIDevice currentDevice] beginGeneratingDeviceOrientationNotifications];
+ sbOrientationObserver = [nc addObserverForName:UIDeviceOrientationDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification *note) {
+ sbUpdateOverlayInsetForPivotBar();
+ }];
+}
+
+UIView *sbGetNotificationParent(void) {
+ if (sbOverlayWindow && sbOverlayWindow.windowScene.activationState == UISceneActivationStateUnattached) {
+ sbOverlayWindow = nil;
+ }
+ if (!sbOverlayWindow) {
+ UIWindowScene *activeScene = nil;
+ for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) {
+ if (scene.activationState == UISceneActivationStateForegroundActive) {
+ activeScene = scene;
+ break;
+ }
+ }
+ if (!activeScene) {
+ activeScene = (UIWindowScene *)[[[UIApplication sharedApplication].connectedScenes allObjects] firstObject];
+ }
+ if (!activeScene) return nil;
+
+ sbOverlayWindow = [[SBPassthroughWindow alloc] initWithWindowScene:activeScene];
+ sbOverlayWindow.frame = activeScene.coordinateSpace.bounds;
+ sbOverlayWindow.windowLevel = UIWindowLevelAlert - 1;
+ sbOverlayWindow.backgroundColor = [UIColor clearColor];
+ sbOverlayWindow.hidden = NO;
+
+ UIViewController *rootVC = [[UIViewController alloc] init];
+ rootVC.view = [[SBPassthroughView alloc] initWithFrame:sbOverlayWindow.bounds];
+ rootVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ rootVC.view.backgroundColor = [UIColor clearColor];
+ sbOverlayWindow.rootViewController = rootVC;
+
+ sbRegisterOverlayLifecycleObservers(activeScene);
+ sbUpdateOverlayInsetForPivotBar();
+ }
+ return sbOverlayWindow.rootViewController.view;
+}
+
+static NSMutableDictionary *> *sbSegmentCache;
+
+NSArray *sbAllCategories(void) {
+ static NSArray *cats;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ cats = @[@"sponsor", @"intro", @"outro", @"interaction", @"selfpromo",
+ @"music_offtopic", @"preview", @"hook", @"poi_highlight", @"filler"];
+ });
+ return cats;
+}
+
+static NSArray *sbEnabledCategories() {
+ NSMutableArray *enabled = [NSMutableArray array];
+ for (NSString *cat in sbAllCategories()) {
+ NSInteger action = [[NSUserDefaults standardUserDefaults] integerForKey:SB_ACTION_KEY(cat)];
+ if (action != SBSegmentActionDisable) {
+ [enabled addObject:cat];
+ }
+ }
+ return enabled;
+}
+
+UIColor *SBColorFromHex(NSString *hexString) {
+ if (!hexString || hexString.length < 7) return [UIColor whiteColor];
+ unsigned int hex = 0;
+ NSScanner *scanner = [NSScanner scannerWithString:[hexString substringFromIndex:1]];
+ [scanner scanHexInt:&hex];
+ return [UIColor colorWithRed:((hex >> 16) & 0xFF) / 255.0
+ green:((hex >> 8) & 0xFF) / 255.0
+ blue:(hex & 0xFF) / 255.0
+ alpha:1.0];
+}
+
+#pragma mark - SBSegment Implementation
+
+@implementation SBSegment
+
++ (instancetype)segmentWithUUID:(NSString *)UUID category:(NSString *)category start:(float)start end:(float)end action:(NSString *)actionType {
+ SBSegment *seg = [[SBSegment alloc] init];
+ seg.UUID = UUID;
+ seg.category = category;
+ seg.startTime = start;
+ seg.endTime = end;
+ seg.actionType = actionType;
+ return seg;
+}
+
+- (SBSegmentAction)configuredAction {
+ return (SBSegmentAction)[[NSUserDefaults standardUserDefaults] integerForKey:SB_ACTION_KEY(self.category)];
+}
+
+- (UIColor *)segmentColor {
+ NSString *hex = [[NSUserDefaults standardUserDefaults] stringForKey:SB_COLOR_KEY(self.category)];
+ return SBColorFromHex(hex);
+}
+
+@end
+
+#pragma mark - SBRequest Implementation
+
+@implementation SBRequest
+
++ (void)fetchSegmentsForVideoID:(NSString *)videoID completion:(void (^)(NSArray *))completion {
+ if (!videoID || videoID.length == 0) {
+ if (completion) completion(@[]);
+ return;
+ }
+
+ @synchronized(sbSegmentCache) {
+ NSArray *cached = sbSegmentCache[videoID];
+ if (cached) {
+ if (completion) completion(cached);
+ return;
+ }
+ }
+
+ NSArray *categories = sbEnabledCategories();
+ if (categories.count == 0) {
+ if (completion) completion(@[]);
+ return;
+ }
+
+ NSData *catJSON = [NSJSONSerialization dataWithJSONObject:categories options:0 error:nil];
+ NSString *catString = [[NSString alloc] initWithData:catJSON encoding:NSUTF8StringEncoding];
+ NSString *encoded = [catString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
+ NSString *urlStr = [NSString stringWithFormat:@"https://sponsor.ajay.app/api/skipSegments?videoID=%@&categories=%@", videoID, encoded];
+ NSURL *url = [NSURL URLWithString:urlStr];
+
+ NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ NSMutableArray *segments = [NSMutableArray array];
+
+ if (!error && data) {
+ NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response;
+ if (httpResp.statusCode == 200) {
+ NSArray *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+ if ([json isKindOfClass:[NSArray class]]) {
+ for (NSDictionary *item in json) {
+ NSArray *segment = item[@"segment"];
+ if (segment.count >= 2) {
+ SBSegment *seg = [SBSegment segmentWithUUID:item[@"UUID"] ?: @""
+ category:item[@"category"] ?: @""
+ start:[segment[0] floatValue]
+ end:[segment[1] floatValue]
+ action:item[@"actionType"] ?: @"skip"];
+ [segments addObject:seg];
+ }
+ }
+ }
+ }
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ @synchronized(sbSegmentCache) {
+ sbSegmentCache[videoID] = segments;
+ }
+ if (completion) completion(segments);
+ });
+ }];
+ [task resume];
+}
+
+@end
+
+#pragma mark - YTPlayerViewController Hooks
+
+%hook YTPlayerViewController
+%property (nonatomic, strong) NSString *sbLastVideoID;
+%property (nonatomic, strong) NSArray *sbSegments;
+%property (nonatomic, strong) NSMutableSet *sbSkippedSegments;
+%property (nonatomic, strong) SBSkipNotificationView *sbNotificationView;
+%property (nonatomic, assign) BOOL sbEnabledForVideo;
+- (void)playbackController:(id)playbackController didActivateVideo:(id)video withPlaybackData:(id)playbackData {
+ %orig;
+ if (!IS_ENABLED(SBEnabled) || self.isPlayingAd) return;
+ if ([self.parentViewController isKindOfClass:%c(YTShortsPlayerViewController)]) return;
+
+ self.sbEnabledForVideo = YES;
+ self.sbSkippedSegments = [NSMutableSet set];
+ self.sbSegments = nil;
+
+ [self.sbNotificationView dismiss];
+
+ NSString *videoID = [self currentVideoID];
+ if ([self.sbLastVideoID isEqualToString:videoID] && self.sbSegments.count > 0) return;
+ self.sbLastVideoID = videoID;
+
+ __weak typeof(self) weakSelf = self;
+ [SBRequest fetchSegmentsForVideoID:videoID completion:^(NSArray *segments) {
+ __strong typeof(weakSelf) strongSelf = weakSelf;
+ if (!strongSelf) return;
+ strongSelf.sbSegments = segments;
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"SBSegmentsDidLoad"
+ object:strongSelf
+ userInfo:@{@"segments": segments ?: @[]}];
+
+ [strongSelf sbShowHighlightBannerIfNeeded:segments];
+ }];
+}
+
+- (void)singleVideo:(id)video currentVideoTimeDidChange:(id)time {
+ %orig;
+ [self sbCheckSegmentsAtCurrentTime];
+}
+
+// Time-change hook for YouTube versions that use the renamed selector.
+- (void)potentiallyMutatedSingleVideo:(id)video currentVideoTimeDidChange:(id)time {
+ %orig;
+ [self sbCheckSegmentsAtCurrentTime];
+}
+
+// Evaluates the loaded segments against the current playback time and performs
+// the configured skip / ask action for the first matching segment. Shared by
+// both time-change hooks so the skip logic lives in one place.
+%new
+- (void)sbCheckSegmentsAtCurrentTime {
+ if (!IS_ENABLED(SBEnabled) || !self.sbEnabledForVideo || self.isPlayingAd) return;
+ if ([self.parentViewController isKindOfClass:%c(YTShortsPlayerViewController)]) return;
+
+ CGFloat currentTime = [self currentVideoMediaTime];
+ float minDuration = FLOAT_FOR_KEY(SBMinDuration);
+
+ for (SBSegment *segment in self.sbSegments) {
+ SBSegmentAction action = [segment configuredAction];
+ if (action == SBSegmentActionDisable || action == SBSegmentActionDisplay) continue;
+ if (action == SBSegmentActionSkipTo) continue;
+
+ float duration = segment.endTime - segment.startTime;
+ if (duration < minDuration) continue;
+
+ if (currentTime >= segment.startTime && currentTime < segment.endTime - SBSegmentEndGuardSeconds) {
+ NSString *segID = segment.UUID;
+ if ([self.sbSkippedSegments containsObject:segID]) continue;
+
+ if (action == SBSegmentActionAutoSkip) {
+ [self sbPerformSkip:segment];
+ } else if (action == SBSegmentActionAsk) {
+ [self sbShowAskNotification:segment];
+ }
+ break;
+ }
+ }
+}
+
+%new
+- (void)sbPerformSkip:(SBSegment *)segment {
+ [self.sbSkippedSegments addObject:segment.UUID];
+ [self seekToTime:(CGFloat)segment.endTime];
+
+ if (IS_ENABLED(SBAudioNotification)) {
+ AudioServicesPlaySystemSound(SBSkipHapticSoundID);
+ }
+
+ if (IS_ENABLED(SBShowNotifications)) {
+ useBackwardIconForButton = YES;
+ NSBundle *bundle = YouModBundle();
+ NSString *catName = [bundle localizedStringForKey:[NSString stringWithFormat:@"SB_CAT_%@", segment.category] value:segment.category table:nil];
+ NSString *message = [NSString stringWithFormat:[bundle localizedStringForKey:@"SB_SKIPPED" value:@"%@ skipped" table:nil], catName];
+ NSString *unskipTitle = [bundle localizedStringForKey:@"SB_UNSKIP" value:@"Unskip" table:nil];
+
+ float alertDuration = SBClampedAlertDuration(SBUnskipAlertDuration);
+
+ __weak typeof(self) weakSelf = self;
+ // Delay notification so the seek completes before the banner is shown,
+ // preventing the time-change callback from dismissing it immediately.
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SBSkipNotificationDelaySeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) strongSelf = weakSelf;
+ if (!strongSelf) return;
+ UIView *parentView = sbGetNotificationParent();
+ strongSelf.sbNotificationView = [SBSkipNotificationView showInView:parentView
+ message:message
+ buttonTitle:unskipTitle
+ action:^{
+ __strong typeof(weakSelf) ss = weakSelf;
+ if (ss) [ss seekToTime:(CGFloat)segment.startTime];
+ }
+ duration:alertDuration];
+ });
+ }
+}
+
+%new
+- (void)sbShowAskNotification:(SBSegment *)segment {
+ [self.sbSkippedSegments addObject:segment.UUID];
+
+ useBackwardIconForButton = NO;
+ NSBundle *bundle = YouModBundle();
+ NSString *catName = [bundle localizedStringForKey:[NSString stringWithFormat:@"SB_CAT_%@", segment.category] value:segment.category table:nil];
+ NSString *message = [NSString stringWithFormat:[bundle localizedStringForKey:@"SB_DETECTED" value:@"%@ detected" table:nil], catName];
+
+ float alertDuration = SBClampedAlertDuration(SBSkipAlertDuration);
+
+ UIView *parentView = sbGetNotificationParent();
+ __weak typeof(self) weakSelf = self;
+ self.sbNotificationView = [SBSkipNotificationView showInView:parentView
+ message:message
+ buttonTitle:[bundle localizedStringForKey:@"SB_SKIP_NOW" value:@"Skip" table:nil]
+ action:^{
+ __strong typeof(weakSelf) ss = weakSelf;
+ if (ss) [ss seekToTime:(CGFloat)segment.endTime];
+ }
+ duration:alertDuration];
+}
+
+%new
+- (void)sbShowHighlightBannerIfNeeded:(NSArray *)segments {
+ for (SBSegment *seg in segments) {
+ if ([seg.category isEqualToString:@"poi_highlight"] && [seg configuredAction] == SBSegmentActionAsk) {
+ useBackwardIconForButton = NO;
+ NSBundle *bundle = YouModBundle();
+ NSString *message = [bundle localizedStringForKey:@"SB_JUMP_TO_HIGHLIGHT" value:@"Highlight available. Jump to the point?" table:nil];
+ NSString *skipTitle = [bundle localizedStringForKey:@"SB_SKIP_NOW" value:@"Skip" table:nil];
+
+ float alertDuration = SBClampedAlertDuration(SBSkipAlertDuration);
+
+ UIView *parentView = sbGetNotificationParent();
+ SBSkipNotificationView *pill = [SBSkipNotificationView showInView:parentView
+ message:message
+ buttonTitle:skipTitle
+ action:^{ [self sbSkipToHighlight]; }
+ duration:alertDuration];
+ if (pill) {
+ pill.isHighlightPill = YES;
+ self.sbNotificationView = pill;
+ }
+ break;
+ }
+ }
+}
+
+%new
+- (void)sbSkipToHighlight {
+ self.sbNotificationView.isHighlightPill = NO;
+
+ for (SBSegment *segment in self.sbSegments) {
+ if ([segment.category isEqualToString:@"poi_highlight"]) {
+ CGFloat previousTime = [self currentVideoMediaTime];
+ [self seekToTime:(CGFloat)segment.startTime];
+
+ if (IS_ENABLED(SBShowNotifications)) {
+ useBackwardIconForButton = YES;
+ NSBundle *bundle = YouModBundle();
+ NSString *message = [bundle localizedStringForKey:@"SB_JUMPED_TO_HIGHLIGHT" value:@"Jumped to highlight" table:nil];
+ NSString *unskipTitle = [bundle localizedStringForKey:@"SB_UNSKIP" value:@"Unskip" table:nil];
+
+ float alertDuration = SBClampedAlertDuration(SBUnskipAlertDuration);
+
+ __weak typeof(self) weakSelf = self;
+ SBSkipNotificationView *pill = [SBSkipNotificationView showInView:sbGetNotificationParent()
+ message:message
+ buttonTitle:unskipTitle
+ action:^{
+ __strong typeof(weakSelf) ss = weakSelf;
+ if (ss) [ss seekToTime:previousTime];
+ }
+ duration:alertDuration];
+ if (pill) {
+ pill.isHighlightPill = YES;
+ self.sbNotificationView = pill;
+ }
+ }
+ break;
+ }
+ }
+}
+
+%end
+
+// SponsorBlock's accent blue, reused for the toggle button's enabled state.
+static UIColor *SBAccentColor() {
+ return [UIColor colorWithRed:0.4 green:0.8 blue:1.0 alpha:1.0];
+}
+
+%ctor {
+ sbSegmentCache = [NSMutableDictionary dictionary];
+ %init;
+
+ // Register the SponsorBlock toggle in the player overlay's custom button row.
+ // sortOrder 100 keeps it right-most (directly under YouTube's settings gear).
+ YMOverlayButtonSpec *toggle = [[YMOverlayButtonSpec alloc] init];
+ toggle.identifier = @"sponsorblock.toggle";
+ toggle.symbolName = @"shield.fill";
+ toggle.tintColor = SBAccentColor();
+ toggle.sortOrder = 100;
+ toggle.isVisible = ^BOOL(YTPlayerViewController *player) {
+ return IS_ENABLED(SBEnabled) && IS_ENABLED(SBShowButton);
+ };
+ toggle.tintProvider = ^UIColor *(YTPlayerViewController *player) {
+ return (player && player.sbEnabledForVideo) ? SBAccentColor() : [UIColor grayColor];
+ };
+ toggle.onTap = ^(YTPlayerViewController *player, UIButton *button) {
+ if (!player) return;
+ BOOL newState = !player.sbEnabledForVideo;
+ player.sbEnabledForVideo = newState;
+ button.tintColor = newState ? SBAccentColor() : [UIColor grayColor];
+
+ NSArray *segments = newState ? (player.sbSegments ?: @[]) : @[];
+ if (newState && segments.count == 0) return;
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"SBSegmentsDidLoad"
+ object:player
+ userInfo:@{@"segments": segments}];
+ };
+ YMRegisterOverlayButton(toggle);
+}
diff --git a/Files/SponsorBlockSettings.x b/Files/SponsorBlockSettings.x
new file mode 100644
index 00000000..6800ef6e
--- /dev/null
+++ b/Files/SponsorBlockSettings.x
@@ -0,0 +1,789 @@
+// SponsorBlockSettings.x — Custom UITableViewController matching YTLite's SponsorBlock UI
+#import "Headers.h"
+#import
+#import
+
+extern UIColor *SBColorFromHex(NSString *hexString);
+
+// Purple accent used to tint the toggle switches and duration sliders on this
+// page. (Distinct from the player overlay button's accent in SponsorBlock.x.)
+static UIColor *SBControlTintColor(void) {
+ return [UIColor colorWithRed:0.6 green:0.2 blue:0.9 alpha:1.0];
+}
+
+// Tag base for a slider row's value label, offset by the row index so each
+// slider can find its own label via viewWithTag:.
+static const NSInteger SBSliderValueLabelTagBase = 100;
+
+// The localization key for a segment action's display name. Shared by the
+// selected-action label and the action picker menu so the two never disagree.
+static NSString *SBActionLocKey(SBSegmentAction action) {
+ switch (action) {
+ case SBSegmentActionAutoSkip: return @"SB_ACTION_AUTO_SKIP";
+ case SBSegmentActionAsk: return @"SB_ACTION_ASK";
+ case SBSegmentActionDisplay: return @"SB_ACTION_DISPLAY";
+ case SBSegmentActionSkipTo: return @"SB_ACTION_SKIP_TO";
+ default: return @"SB_ACTION_DISABLE";
+ }
+}
+
+// One toggle row: the defaults key it controls and its localized title/description
+// keys. This is the single source of truth for the toggle section — the row count,
+// each cell's contents, and the value written on change all read from it, so a row
+// can never display one setting while toggling another.
+@interface SBToggleRow : NSObject
+@property (nonatomic, copy) NSString *key;
+@property (nonatomic, copy) NSString *titleKey;
+@property (nonatomic, copy) NSString *descKey;
+@end
+@implementation SBToggleRow
++ (instancetype)key:(NSString *)key title:(NSString *)titleKey desc:(NSString *)descKey {
+ SBToggleRow *row = [SBToggleRow new];
+ row.key = key; row.titleKey = titleKey; row.descKey = descKey;
+ return row;
+}
+@end
+
+static NSArray *sbToggleRows() {
+ return @[
+ [SBToggleRow key:SBEnabled title:@"SB_ENABLE" desc:@"SB_ENABLE_DESC"],
+ [SBToggleRow key:SBShowButton title:@"SB_SHOW_BUTTON" desc:@"SB_SHOW_BUTTON_DESC"],
+ [SBToggleRow key:SBShowNotifications title:@"SB_SHOW_NOTIFICATIONS" desc:@"SB_SHOW_NOTIFICATIONS_DESC"],
+ [SBToggleRow key:SBSegmentsInPlayer title:@"SB_SEGMENTS_IN_PLAYER" desc:@"SB_SEGMENTS_IN_PLAYER_DESC"],
+ [SBToggleRow key:SBSegmentsInFeed title:@"SB_SEGMENTS_IN_FEED" desc:@"SB_SEGMENTS_IN_FEED_DESC"],
+ [SBToggleRow key:SBSegmentsInMiniPlayer title:@"SB_SEGMENTS_IN_MINIPLAYER" desc:@"SB_SEGMENTS_IN_MINIPLAYER_DESC"],
+ [SBToggleRow key:SBAudioNotification title:@"SB_HAPTIC_FEEDBACK" desc:@"SB_HAPTIC_FEEDBACK_DESC"],
+ [SBToggleRow key:SBShowDuration title:@"SB_SHOW_DURATION" desc:@"SB_SHOW_DURATION_DESC"],
+ ];
+}
+
+static NSString *SBActionName(NSInteger action) {
+ return LOC(SBActionLocKey((SBSegmentAction)action));
+}
+
+static NSString *SBHexFromColor(UIColor *color) {
+ CGFloat r, g, b, a;
+ [color getRed:&r green:&g blue:&b alpha:&a];
+ return [NSString stringWithFormat:@"#%02X%02X%02X", (int)(r * 255), (int)(g * 255), (int)(b * 255)];
+}
+
+#pragma mark - Color Circle View (filled center + rainbow ring)
+
+@interface SBColorCircleView : UIView
+@property (nonatomic, strong) UIColor *fillColor;
+@end
+
+@implementation SBColorCircleView
+
+- (instancetype)initWithFrame:(CGRect)frame color:(UIColor *)color {
+ self = [super initWithFrame:frame];
+ if (self) {
+ self.fillColor = color;
+ self.backgroundColor = [UIColor clearColor];
+ }
+ return self;
+}
+
+- (void)drawRect:(CGRect)rect {
+ CGContextRef ctx = UIGraphicsGetCurrentContext();
+ CGFloat size = MIN(rect.size.width, rect.size.height);
+ CGRect square = CGRectMake((rect.size.width - size) / 2, (rect.size.height - size) / 2, size, size);
+ CGFloat cx = CGRectGetMidX(square), cy = CGRectGetMidY(square);
+ CGFloat ringWidth = 3.0;
+ CGFloat radius = (size - ringWidth) / 2.0;
+
+ // Draw rainbow ring by stroking arc segments at varying hues
+ NSInteger segments = 64;
+ CGFloat anglePerSegment = (2.0 * M_PI) / segments;
+ for (NSInteger i = 0; i < segments; i++) {
+ CGFloat startAngle = i * anglePerSegment - M_PI_2;
+ CGFloat endAngle = startAngle + anglePerSegment + 0.02; // slight overlap to avoid gaps
+ CGFloat hue = (CGFloat)i / segments;
+ UIColor *color = [UIColor colorWithHue:hue saturation:1.0 brightness:1.0 alpha:1.0];
+ CGContextSetStrokeColorWithColor(ctx, color.CGColor);
+ CGContextSetLineWidth(ctx, ringWidth);
+ CGContextAddArc(ctx, cx, cy, radius, startAngle, endAngle, 0);
+ CGContextStrokePath(ctx);
+ }
+
+ // Filled center circle
+ CGRect innerRect = CGRectInset(square, ringWidth + 2, ringWidth + 2);
+ UIBezierPath *innerPath = [UIBezierPath bezierPathWithOvalInRect:innerRect];
+ [self.fillColor setFill];
+ [innerPath fill];
+}
+
+- (void)setFillColor:(UIColor *)fillColor {
+ _fillColor = fillColor;
+ [self setNeedsDisplay];
+}
+
+@end
+
+#pragma mark - SBSettingsViewController
+
+@interface SBSettingsViewController : UIViewController
+- (UITableView *)tableView;
+- (void)setTableView:(UITableView *)tv;
+- (NSString *)activeColorKey;
+- (void)setActiveColorKey:(NSString *)key;
+- (NSIndexPath *)activeColorIndexPath;
+- (void)setActiveColorIndexPath:(NSIndexPath *)ip;
+- (UIColor *)sbTextColor;
+- (UIColor *)sbSecondaryTextColor;
+// Cell builders, also reused by the global settings search (sbSearchRows).
+- (UITableViewCell *)toggleCellForRow:(NSInteger)row tableView:(UITableView *)tableView;
+- (UITableViewCell *)sliderCellForRow:(NSInteger)row tableView:(UITableView *)tableView;
+- (UITableViewCell *)actionCellForCategory:(NSString *)category name:(NSString *)catName tableView:(UITableView *)tableView;
+- (UITableViewCell *)colorCellForCategory:(NSString *)category name:(NSString *)catName tableView:(UITableView *)tableView;
+@end
+
+// Flat list of all SB settings as search rows (declared before use by the per-page
+// filter below; defined in the Settings Search section).
+extern NSArray *sbFlatRowsWithRenderer(SBSettingsViewController *renderer);
+
+static const void *kSBTableViewKey = &kSBTableViewKey;
+static const void *kSBColorKeyKey = &kSBColorKeyKey;
+static const void *kSBColorIndexPathKey = &kSBColorIndexPathKey;
+static const void *kSBPageFilterKey = &kSBPageFilterKey;
+static const void *kSBAllFlatRowsKey = &kSBAllFlatRowsKey;
+
+@implementation SBSettingsViewController
+
+- (UITableView *)tableView { return objc_getAssociatedObject(self, kSBTableViewKey); }
+- (void)setTableView:(UITableView *)tv { objc_setAssociatedObject(self, kSBTableViewKey, tv, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
+- (NSString *)activeColorKey { return objc_getAssociatedObject(self, kSBColorKeyKey); }
+- (void)setActiveColorKey:(NSString *)key { objc_setAssociatedObject(self, kSBColorKeyKey, key, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
+- (NSIndexPath *)activeColorIndexPath { return objc_getAssociatedObject(self, kSBColorIndexPathKey); }
+- (void)setActiveColorIndexPath:(NSIndexPath *)ip { objc_setAssociatedObject(self, kSBColorIndexPathKey, ip, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
+
+// Per-page search state. pageFilter is the active query (nil/empty = not filtering);
+// while filtering, the page collapses to one flat section of matching search rows.
+- (NSString *)pageFilter { return objc_getAssociatedObject(self, kSBPageFilterKey); }
+- (void)setPageFilter:(NSString *)f { objc_setAssociatedObject(self, kSBPageFilterKey, f, OBJC_ASSOCIATION_COPY_NONATOMIC); }
+- (NSArray *)allFlatRows { return objc_getAssociatedObject(self, kSBAllFlatRowsKey); }
+- (void)setAllFlatRows:(NSArray *)r { objc_setAssociatedObject(self, kSBAllFlatRowsKey, r, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
+
+- (BOOL)isFiltering { return self.pageFilter.length > 0; }
+
+- (NSArray *)filteredFlatRows {
+ NSString *q = self.pageFilter;
+ if (q.length == 0) return @[];
+ NSMutableArray *matches = [NSMutableArray array];
+ NSStringCompareOptions opts = NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch;
+ for (YMSearchRow *row in self.allFlatRows) {
+ if ([row.searchText rangeOfString:q options:opts].location != NSNotFound) [matches addObject:row];
+ }
+ return matches;
+}
+
+- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
+ self.pageFilter = [searchController.searchBar.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
+ [self.tableView reloadData];
+}
+
+- (void)viewDidLoad {
+ Class ytStyled = objc_getClass("YTStyledViewController");
+ struct objc_super superStruct = { self, ytStyled ?: [UIViewController class] };
+ ((void (*)(struct objc_super *, SEL))objc_msgSendSuper)(&superStruct, @selector(viewDidLoad));
+
+ self.title = @"SponsorBlock";
+
+ self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleGrouped];
+ self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ self.tableView.delegate = self;
+ self.tableView.dataSource = self;
+ self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
+ self.tableView.estimatedRowHeight = 60;
+ self.tableView.rowHeight = UITableViewAutomaticDimension;
+
+ if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
+ self.tableView.backgroundColor = [%c(YTColor) black3];
+ } else {
+ self.tableView.backgroundColor = [UIColor systemBackgroundColor];
+ }
+
+ [self.view addSubview:self.tableView];
+
+ // Pinned per-page search bar that filters this page's own rows. The flat row
+ // list is built once with self as the renderer (its cells write straight to
+ // NSUserDefaults, so reusing the live VC is safe).
+ self.allFlatRows = sbFlatRowsWithRenderer(self);
+ UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
+ sc.searchResultsUpdater = self;
+ sc.obscuresBackgroundDuringPresentation = NO;
+ sc.searchBar.placeholder = LOC(@"SEARCH");
+ sc.searchBar.tintColor = SBControlTintColor();
+ self.navigationItem.searchController = sc;
+ self.navigationItem.hidesSearchBarWhenScrolling = NO;
+ self.definesPresentationContext = YES;
+}
+
+- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
+ [super traitCollectionDidChange:previousTraitCollection];
+ if (previousTraitCollection.userInterfaceStyle != self.traitCollection.userInterfaceStyle) {
+ self.tableView.backgroundColor = (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark)
+ ? [%c(YTColor) black3]
+ : [UIColor systemBackgroundColor];
+ [self.tableView reloadData];
+ }
+}
+
+- (void)viewWillAppear:(BOOL)animated {
+ Class ytStyled = objc_getClass("YTStyledViewController");
+ struct objc_super superStruct = { self, ytStyled ?: [UIViewController class] };
+ ((void (*)(struct objc_super *, SEL, BOOL))objc_msgSendSuper)(&superStruct, @selector(viewWillAppear:), animated);
+}
+
+- (void)viewDidLayoutSubviews {
+ Class ytStyled = objc_getClass("YTStyledViewController");
+ struct objc_super superStruct = { self, ytStyled ?: [UIViewController class] };
+ ((void (*)(struct objc_super *, SEL))objc_msgSendSuper)(&superStruct, @selector(viewDidLayoutSubviews));
+ YTQTMButton *backButton = [self valueForKey:@"_backButton"];
+
+ if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
+ backButton.tintColor = [UIColor whiteColor];
+ } else {
+ backButton.tintColor = [UIColor blackColor];
+ }
+}
+
+- (UIColor *)navBarForegroundColor {
+ if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
+ return [UIColor whiteColor];
+ }
+ // Light mode: return a concrete colour rather than nil. nil lets YouTube tint the
+ // nav-bar foreground (back chevron + title) with its default appearance, which is
+ // system blue in light mode. labelColor keeps it black in light / white in dark.
+ return [UIColor labelColor];
+}
+
+- (UIColor *)sbTextColor {
+ return (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark)
+ ? [UIColor whiteColor] : [UIColor labelColor];
+}
+
+- (UIColor *)sbSecondaryTextColor {
+ return [UIColor colorWithWhite:0.55 alpha:1.0];
+}
+
+#pragma mark - Sections: 0=Main, 1=Sliders, 2=Segments
+
+- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
+ return self.isFiltering ? 1 : 3; // one flat section of matches while searching
+}
+
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
+ if (self.isFiltering) return self.filteredFlatRows.count;
+ if (section == 0) return sbToggleRows().count; // toggles
+ if (section == 1) return 2; // sliders
+ return sbAllCategories().count * 2; // action + color per category
+}
+
+- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
+ if (self.isFiltering) return nil; // headers dropped in the flat filtered list
+ NSString *title = nil;
+ if (section == 0) title = LOC(@"SB_SECTION_MAIN");
+ else if (section == 2) title = LOC(@"SB_CATEGORIES_HEADER");
+ if (!title) return nil;
+
+ UIView *header = [[UIView alloc] init];
+ UILabel *label = [[UILabel alloc] init];
+ label.text = title;
+ // Match the main YouMod settings section headers (ymSecondaryColor / size 14).
+ label.textColor = [UIColor colorWithWhite:0.55 alpha:1.0];
+ label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightRegular];
+ label.translatesAutoresizingMaskIntoConstraints = NO;
+ [header addSubview:label];
+ [NSLayoutConstraint activateConstraints:@[
+ [label.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:16],
+ [label.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-6],
+ ]];
+ return header;
+}
+
+- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
+ if (self.isFiltering) return 0;
+ if (section == 1) return 16;
+ return 36;
+}
+
+- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
+ if (self.isFiltering) {
+ CGFloat h = self.filteredFlatRows[indexPath.row].cellHeight;
+ return h > 0 ? h : UITableViewAutomaticDimension;
+ }
+ if (indexPath.section == 1) return 70;
+ if (indexPath.section == 2) {
+ BOOL isActionRow = (indexPath.row % 2 == 0);
+ NSInteger catIndex = indexPath.row / 2;
+ if (isActionRow && catIndex > 0) return 64; // extra top spacing between groups
+ return 48;
+ }
+ return UITableViewAutomaticDimension;
+}
+
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
+ if (self.isFiltering) return self.filteredFlatRows[indexPath.row].makeCell(tableView);
+ if (indexPath.section == 0) return [self toggleCellForRow:indexPath.row tableView:tableView];
+ if (indexPath.section == 1) return [self sliderCellForRow:indexPath.row tableView:tableView];
+ return [self segmentCellForRow:indexPath.row tableView:tableView];
+}
+
+#pragma mark - Toggle Cells (Section 0)
+
+- (UITableViewCell *)toggleCellForRow:(NSInteger)row tableView:(UITableView *)tableView {
+ UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil];
+ cell.backgroundColor = [UIColor clearColor];
+ cell.selectionStyle = UITableViewCellSelectionStyleNone;
+ cell.textLabel.textColor = [self sbTextColor];
+ cell.textLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
+ cell.detailTextLabel.textColor = [self sbSecondaryTextColor];
+ cell.detailTextLabel.font = [UIFont systemFontOfSize:13];
+ cell.detailTextLabel.numberOfLines = 0;
+
+ SBToggleRow *def = sbToggleRows()[row];
+
+ cell.textLabel.text = LOC(def.titleKey);
+ cell.detailTextLabel.text = LOC(def.descKey);
+
+ UISwitch *sw = [[UISwitch alloc] init];
+ sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:def.key];
+ sw.onTintColor = SBControlTintColor();
+ sw.tag = row;
+ [sw addTarget:self action:@selector(toggleChanged:) forControlEvents:UIControlEventValueChanged];
+ cell.accessoryView = sw;
+
+ return cell;
+}
+
+- (void)toggleChanged:(UISwitch *)sender {
+ NSArray *rows = sbToggleRows();
+ if (sender.tag < 0 || sender.tag >= (NSInteger)rows.count) return;
+ NSString *key = rows[sender.tag].key;
+ [[NSUserDefaults standardUserDefaults] setBool:sender.on forKey:key];
+}
+
+#pragma mark - Slider Cells (Section 1)
+
+- (UITableViewCell *)sliderCellForRow:(NSInteger)row tableView:(UITableView *)tableView {
+ UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
+ cell.backgroundColor = [UIColor clearColor];
+ cell.selectionStyle = UITableViewCellSelectionStyleNone;
+
+ NSString *title = (row == 0) ? LOC(@"SB_SKIP_ALERT_DURATION") : LOC(@"SB_UNSKIP_ALERT_DURATION");
+ NSString *key = (row == 0) ? SBSkipAlertDuration : SBUnskipAlertDuration;
+ float currentVal = [[NSUserDefaults standardUserDefaults] floatForKey:key];
+ if (currentVal <= 0) currentVal = SBAlertDurationDefault;
+
+ UILabel *titleLabel = [[UILabel alloc] init];
+ titleLabel.text = title;
+ titleLabel.textColor = [self sbSecondaryTextColor];
+ titleLabel.font = [UIFont systemFontOfSize:13];
+ titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
+
+ UISlider *slider = [[UISlider alloc] init];
+ slider.minimumValue = SBAlertDurationMin;
+ slider.maximumValue = SBAlertDurationMax;
+ slider.value = currentVal;
+ slider.minimumTrackTintColor = SBControlTintColor();
+ slider.maximumTrackTintColor = [UIColor colorWithWhite:0.3 alpha:1.0];
+ slider.translatesAutoresizingMaskIntoConstraints = NO;
+ slider.tag = row;
+ [slider addTarget:self action:@selector(sliderChanged:) forControlEvents:UIControlEventValueChanged];
+
+ UILabel *valueLabel = [[UILabel alloc] init];
+ NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init];
+ formatter.allowedUnits = NSCalendarUnitSecond;
+ formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleAbbreviated;
+
+ valueLabel.text = [formatter stringFromTimeInterval:currentVal];
+ valueLabel.textColor = [self sbSecondaryTextColor];
+ valueLabel.font = [UIFont systemFontOfSize:13];
+ valueLabel.textAlignment = NSTextAlignmentRight;
+ valueLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ valueLabel.tag = SBSliderValueLabelTagBase + row;
+
+ [cell.contentView addSubview:titleLabel];
+ [cell.contentView addSubview:slider];
+ [cell.contentView addSubview:valueLabel];
+
+ [NSLayoutConstraint activateConstraints:@[
+ [titleLabel.topAnchor constraintEqualToAnchor:cell.contentView.topAnchor constant:8],
+ [titleLabel.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16],
+
+ [slider.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:8],
+ [slider.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16],
+ [slider.trailingAnchor constraintEqualToAnchor:valueLabel.leadingAnchor constant:-8],
+ [slider.bottomAnchor constraintEqualToAnchor:cell.contentView.bottomAnchor constant:-8],
+
+ [valueLabel.centerYAnchor constraintEqualToAnchor:slider.centerYAnchor],
+ [valueLabel.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16],
+ [valueLabel.widthAnchor constraintEqualToConstant:50],
+ ]];
+
+ return cell;
+}
+
+- (void)sliderChanged:(UISlider *)sender {
+ NSString *key = (sender.tag == 0) ? SBSkipAlertDuration : SBUnskipAlertDuration;
+ int rounded = (int)roundf(sender.value);
+ sender.value = rounded;
+ [[NSUserDefaults standardUserDefaults] setFloat:(float)rounded forKey:key];
+ [[NSUserDefaults standardUserDefaults] synchronize];
+
+ UILabel *valueLabel = (UILabel *)[sender.superview viewWithTag:SBSliderValueLabelTagBase + sender.tag];
+ NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init];
+ formatter.allowedUnits = NSCalendarUnitSecond;
+ formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleAbbreviated;
+
+ valueLabel.text = [formatter stringFromTimeInterval:rounded];
+}
+
+#pragma mark - Segment Cells (Section 2)
+
+- (UITableViewCell *)segmentCellForRow:(NSInteger)row tableView:(UITableView *)tableView {
+ NSInteger catIndex = row / 2;
+ BOOL isColorRow = (row % 2 == 1);
+ NSString *category = sbAllCategories()[catIndex];
+ NSBundle *bundle = YouModBundle();
+ NSString *catLocKey = [NSString stringWithFormat:@"SB_CAT_%@", category];
+ NSString *catName = [bundle localizedStringForKey:catLocKey value:category table:nil];
+
+ if (isColorRow) {
+ return [self colorCellForCategory:category name:catName tableView:tableView];
+ } else {
+ return [self actionCellForCategory:category name:catName tableView:tableView];
+ }
+}
+
+- (UITableViewCell *)actionCellForCategory:(NSString *)category name:(NSString *)catName tableView:(UITableView *)tableView {
+ UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
+ cell.backgroundColor = [UIColor clearColor];
+ cell.selectionStyle = UITableViewCellSelectionStyleNone;
+ cell.textLabel.text = catName;
+ cell.textLabel.textColor = [self sbTextColor];
+ cell.textLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
+
+ BOOL isHighlight = [category isEqualToString:@"poi_highlight"];
+ NSString *actionKey = SB_ACTION_KEY(category);
+
+ UIButton *menuButton = [UIButton buttonWithType:UIButtonTypeSystem];
+ NSInteger currentAction = [[NSUserDefaults standardUserDefaults] integerForKey:actionKey];
+ [menuButton setTitle:SBActionName(currentAction) forState:UIControlStateNormal];
+ [menuButton setTitleColor:[self sbSecondaryTextColor] forState:UIControlStateNormal];
+ menuButton.titleLabel.font = [UIFont systemFontOfSize:15];
+ [menuButton setImage:[UIImage systemImageNamed:@"chevron.up.chevron.down"] forState:UIControlStateNormal];
+ menuButton.tintColor = [self sbSecondaryTextColor];
+
+ NSMutableArray *menuActions = [NSMutableArray array];
+ // Which actions a category may take differs by kind: a highlight can only be
+ // disabled, skipped-to, or displayed, while a regular segment offers
+ // auto-skip / ask instead of skip-to. These are separate option sets, not
+ // duplication — each action's label is resolved through SBActionLocKey.
+ NSArray *actionOptions;
+ if (isHighlight) {
+ actionOptions = @[@(SBSegmentActionDisable), @(SBSegmentActionAsk), @(SBSegmentActionDisplay)];
+ } else {
+ actionOptions = @[@(SBSegmentActionDisable), @(SBSegmentActionAutoSkip), @(SBSegmentActionAsk), @(SBSegmentActionDisplay)];
+ }
+
+ NSBundle *bundle = YouModBundle();
+ for (NSNumber *option in actionOptions) {
+ NSInteger actionVal = [option integerValue];
+ NSString *actionTitle = [bundle localizedStringForKey:SBActionLocKey((SBSegmentAction)actionVal) value:nil table:nil];
+
+ UIAction *action = [UIAction actionWithTitle:actionTitle image:nil identifier:nil handler:^(__kindof UIAction *a) {
+ [[NSUserDefaults standardUserDefaults] setInteger:actionVal forKey:actionKey];
+ [self.tableView reloadData];
+ }];
+ if (actionVal == currentAction) action.state = UIMenuElementStateOn;
+ [menuActions addObject:action];
+ }
+
+ menuButton.menu = [UIMenu menuWithTitle:catName children:menuActions];
+ menuButton.showsMenuAsPrimaryAction = YES;
+ [menuButton sizeToFit];
+ cell.accessoryView = menuButton;
+
+ return cell;
+}
+
+- (UITableViewCell *)colorCellForCategory:(NSString *)category name:(NSString *)catName tableView:(UITableView *)tableView {
+ UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
+ cell.backgroundColor = [UIColor clearColor];
+ cell.selectionStyle = UITableViewCellSelectionStyleNone;
+ cell.textLabel.text = [NSString stringWithFormat:@"%@ %@", catName, LOC(@"SB_SEGMENT_COLOR_SUFFIX")];
+ cell.textLabel.textColor = [self sbTextColor];
+ cell.textLabel.font = [UIFont systemFontOfSize:15];
+
+ NSString *colorKey = SB_COLOR_KEY(category);
+ NSString *hex = [[NSUserDefaults standardUserDefaults] stringForKey:colorKey];
+ UIColor *color = SBColorFromHex(hex);
+
+ SBColorCircleView *circle = [[SBColorCircleView alloc] initWithFrame:CGRectMake(0, 0, 34, 34) color:color];
+ cell.accessoryView = circle;
+
+ return cell;
+}
+
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
+ if (self.isFiltering) {
+ // In the flat filtered list the row carries its own tap handler (colour rows
+ // open the picker); route through it with a reload that refreshes results.
+ YMSearchRow *row = self.filteredFlatRows[indexPath.row];
+ [tableView deselectRowAtIndexPath:indexPath animated:YES];
+ __weak typeof(self) weakSelf = self;
+ if (row.onSelect) row.onSelect(self, ^{ [weakSelf.tableView reloadData]; });
+ return;
+ }
+ if (indexPath.section != 2) return;
+ if (indexPath.row % 2 != 1) return; // only color rows are tappable
+
+ NSInteger catIndex = indexPath.row / 2;
+ NSString *category = sbAllCategories()[catIndex];
+ NSString *colorKey = SB_COLOR_KEY(category);
+
+ self.activeColorKey = colorKey;
+ self.activeColorIndexPath = indexPath;
+
+ UIColorPickerViewController *picker = [[UIColorPickerViewController alloc] init];
+ NSString *catName = [YouModBundle() localizedStringForKey:[NSString stringWithFormat:@"SB_CAT_%@", category] value:category table:nil];
+ picker.title = [NSString stringWithFormat:@"%@ %@", catName, LOC(@"SB_SEGMENT_COLOR_SUFFIX")];
+ NSString *currentHex = [[NSUserDefaults standardUserDefaults] stringForKey:colorKey];
+ if (currentHex) picker.selectedColor = SBColorFromHex(currentHex);
+ picker.supportsAlpha = NO;
+ picker.delegate = self;
+
+ [self presentViewController:picker animated:YES completion:nil];
+}
+
+#pragma mark - UIColorPickerViewControllerDelegate
+
+- (void)colorPickerViewControllerDidFinish:(UIColorPickerViewController *)viewController {
+ UIColor *color = viewController.selectedColor;
+ NSString *hex = SBHexFromColor(color);
+ [[NSUserDefaults standardUserDefaults] setObject:hex forKey:self.activeColorKey];
+ [self.tableView reloadRowsAtIndexPaths:@[self.activeColorIndexPath] withRowAnimation:UITableViewRowAnimationNone];
+}
+
+- (void)colorPickerViewController:(UIColorPickerViewController *)viewController didSelectColor:(UIColor *)color continuously:(BOOL)continuously {
+ if (!continuously) {
+ NSString *hex = SBHexFromColor(color);
+ [[NSUserDefaults standardUserDefaults] setObject:hex forKey:self.activeColorKey];
+ [self.tableView reloadRowsAtIndexPaths:@[self.activeColorIndexPath] withRowAnimation:UITableViewRowAnimationNone];
+ }
+}
+
+#pragma mark - Footer spacing between category groups
+
+- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section {
+ return (section == 2) ? 0 : 16;
+}
+
+- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section {
+ return [[UIView alloc] init];
+}
+
+@end
+
+#pragma mark - Settings Search integration
+
+// Retained delegate for a colour picker opened from a search result. The stock
+// SBSettingsViewController colour flow reloads a specific SB-table index path,
+// which doesn't exist in the search results table; this dedicated delegate writes
+// the chosen colour and calls the search table's own reload instead. It is
+// associated with the presented picker so it lives exactly as long as the picker.
+@interface SBSearchColorDelegate : NSObject
+@property (nonatomic, copy) NSString *colorKey;
+@property (nonatomic, copy) void (^reload)(void);
+@end
+@implementation SBSearchColorDelegate
+- (void)applyColor:(UIColor *)color {
+ [[NSUserDefaults standardUserDefaults] setObject:SBHexFromColor(color) forKey:self.colorKey];
+ if (self.reload) self.reload();
+}
+- (void)colorPickerViewControllerDidFinish:(UIColorPickerViewController *)vc { [self applyColor:vc.selectedColor]; }
+- (void)colorPickerViewController:(UIColorPickerViewController *)vc didSelectColor:(UIColor *)color continuously:(BOOL)continuously {
+ if (!continuously) [self applyColor:color];
+}
+@end
+
+static const void *kSBSearchColorDelegateKey = &kSBSearchColorDelegateKey;
+
+// Build the flat list of every SponsorBlock setting as YMSearchRows, each rendered
+// by the given SBSettingsViewController's own cell builders. Shared by the global
+// settings search (via sbSearchRows) and SponsorBlock's own per-page search filter.
+NSArray *sbFlatRowsWithRenderer(SBSettingsViewController *renderer) {
+ NSMutableArray *rows = [NSMutableArray array];
+ // Weak so a row's makeCell block can't retain the renderer: on the SponsorBlock
+ // page the renderer IS the presented VC, which also retains this row list, so a
+ // strong capture would cycle and leak the VC on every visit. The renderer always
+ // outlives cell rendering (it's the live VC or a retained child), so weak is safe.
+ __weak SBSettingsViewController *renderer_w = renderer;
+
+ // Section 0 — toggles.
+ NSArray *toggles = sbToggleRows();
+ for (NSInteger i = 0; i < (NSInteger)toggles.count; i++) {
+ SBToggleRow *def = toggles[i];
+ YMSearchRow *row = [YMSearchRow new];
+ row.searchText = [NSString stringWithFormat:@"%@ %@", LOC(def.titleKey), LOC(def.descKey)];
+ row.makeCell = ^UITableViewCell *(UITableView *tv) { return [renderer_w toggleCellForRow:i tableView:tv]; };
+ [rows addObject:row];
+ }
+
+ // Section 1 — the two alert-duration sliders.
+ for (NSInteger i = 0; i < 2; i++) {
+ YMSearchRow *row = [YMSearchRow new];
+ row.searchText = (i == 0) ? LOC(@"SB_SKIP_ALERT_DURATION") : LOC(@"SB_UNSKIP_ALERT_DURATION");
+ row.cellHeight = 70;
+ row.makeCell = ^UITableViewCell *(UITableView *tv) { return [renderer_w sliderCellForRow:i tableView:tv]; };
+ [rows addObject:row];
+ }
+
+ // Section 2 — per category: an action picker and a colour circle.
+ NSBundle *bundle = YouModBundle();
+ for (NSString *category in sbAllCategories()) {
+ NSString *catName = [bundle localizedStringForKey:[NSString stringWithFormat:@"SB_CAT_%@", category] value:category table:nil];
+
+ YMSearchRow *actionRow = [YMSearchRow new];
+ actionRow.searchText = catName;
+ actionRow.cellHeight = 48;
+ actionRow.makeCell = ^UITableViewCell *(UITableView *tv) { return [renderer_w actionCellForCategory:category name:catName tableView:tv]; };
+ [rows addObject:actionRow];
+
+ YMSearchRow *colorRow = [YMSearchRow new];
+ colorRow.searchText = [NSString stringWithFormat:@"%@ %@", catName, LOC(@"SB_SEGMENT_COLOR_SUFFIX")];
+ colorRow.cellHeight = 48;
+ colorRow.makeCell = ^UITableViewCell *(UITableView *tv) { return [renderer_w colorCellForCategory:category name:catName tableView:tv]; };
+ colorRow.onSelect = ^(UIViewController *presenter, void (^reload)(void)) {
+ NSString *colorKey = SB_COLOR_KEY(category);
+ UIColorPickerViewController *picker = [[UIColorPickerViewController alloc] init];
+ picker.title = [NSString stringWithFormat:@"%@ %@", catName, LOC(@"SB_SEGMENT_COLOR_SUFFIX")];
+ NSString *currentHex = [[NSUserDefaults standardUserDefaults] stringForKey:colorKey];
+ if (currentHex) picker.selectedColor = SBColorFromHex(currentHex);
+ picker.supportsAlpha = NO;
+
+ SBSearchColorDelegate *delegate = [SBSearchColorDelegate new];
+ delegate.colorKey = colorKey;
+ delegate.reload = reload;
+ picker.delegate = delegate;
+ objc_setAssociatedObject(picker, kSBSearchColorDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+
+ [presenter presentViewController:picker animated:YES completion:nil];
+ };
+ [rows addObject:colorRow];
+ }
+
+ return rows;
+}
+
+NSArray *sbSearchRows(UIViewController *host) {
+ // One renderer builds every SB result cell. It's added as a child VC so the
+ // cells inherit the host's trait collection (light/dark), and its tableView is
+ // pointed at the host's (search results) table so SB's own cell handlers —
+ // e.g. the action picker's [self.tableView reloadData] — refresh the visible
+ // results rather than a table the renderer never presents.
+ SBSettingsViewController *renderer = [[SBSettingsViewController alloc] init];
+ [host addChildViewController:renderer];
+ [renderer didMoveToParentViewController:host];
+ if ([host respondsToSelector:@selector(tableView)]) {
+ renderer.tableView = ((UITableView *(*)(id, SEL))objc_msgSend)(host, @selector(tableView));
+ }
+ return sbFlatRowsWithRenderer(renderer);
+}
+
+#pragma mark - Hook entry point
+
+@interface YTSettingsSectionItemManager (SponsorBlock)
+- (void)updateSponsorBlockSectionWithEntry:(id)entry;
+@end
+
+%hook YTSettingsSectionItemManager
+
+%new(v@:@)
+- (void)updateSponsorBlockSectionWithEntry:(id)entry {
+ YTSettingsViewController *settingsVC = [self valueForKey:@"_settingsViewControllerDelegate"];
+ // Use runtime-registered subclass of YTStyledViewController for YouTube's nav styling
+ Class sbClass = objc_getClass("SBSettingsViewControllerStyled");
+ if (!sbClass) sbClass = [SBSettingsViewController class];
+ // initWithParentResponder: sets up YouTube's DI container (gimme) for nav bar theming
+ id allocated = [sbClass alloc];
+ SBSettingsViewController *sbVC = (SBSettingsViewController *)((id (*)(id, SEL, id))objc_msgSend)(allocated, @selector(initWithParentResponder:), settingsVC);
+ [settingsVC pushViewController:sbVC];
+}
+
+%end
+
+%ctor {
+ // Register SBSettingsViewControllerStyled as a runtime subclass of YTStyledViewController
+ // with all methods from SBSettingsViewController — gives us YouTube's nav bar styling
+ Class ytStyled = %c(YTStyledViewController);
+ if (ytStyled) {
+ Class sbStyled = objc_allocateClassPair(ytStyled, "SBSettingsViewControllerStyled", 0);
+ if (sbStyled) {
+ // Copy all instance methods from our compiled SBSettingsViewController
+ unsigned int count = 0;
+ Method *methods = class_copyMethodList([SBSettingsViewController class], &count);
+ for (unsigned int i = 0; i < count; i++) {
+ SEL sel = method_getName(methods[i]);
+ IMP imp = method_getImplementation(methods[i]);
+ const char *types = method_getTypeEncoding(methods[i]);
+ class_addMethod(sbStyled, sel, imp, types);
+ }
+ free(methods);
+
+ // Copy properties (for @synthesize ivars)
+ unsigned int propCount = 0;
+ objc_property_t *props = class_copyPropertyList([SBSettingsViewController class], &propCount);
+ for (unsigned int i = 0; i < propCount; i++) {
+ unsigned int attrCount = 0;
+ objc_property_attribute_t *attrs = property_copyAttributeList(props[i], &attrCount);
+ class_addProperty(sbStyled, property_getName(props[i]), attrs, attrCount);
+ free(attrs);
+ }
+ free(props);
+
+ // Copy ivars won't work after registration, but properties use associated objects
+ objc_registerClassPair(sbStyled);
+ }
+ }
+
+ // Per-category default action and seek-bar color. Sponsor is the only
+ // category that auto-skips out of the box; the rest are disabled until the
+ // user opts in. The action/color default entries are generated from
+ // sbAllCategories() below so this table stays the sole per-category source.
+ NSDictionary *categoryDefaults = @{
+ @"sponsor": @[@(SBSegmentActionAutoSkip), @"#00D400"],
+ @"intro": @[@(SBSegmentActionDisable), @"#00FFFF"],
+ @"outro": @[@(SBSegmentActionDisable), @"#0202ED"],
+ @"interaction": @[@(SBSegmentActionDisable), @"#FF00F7"],
+ @"selfpromo": @[@(SBSegmentActionDisable), @"#FFFF00"],
+ @"music_offtopic": @[@(SBSegmentActionDisable), @"#FF9900"],
+ @"preview": @[@(SBSegmentActionDisable), @"#0084D6"],
+ @"hook": @[@(SBSegmentActionDisable), @"#395699"],
+ @"poi_highlight": @[@(SBSegmentActionDisable), @"#FF006A"],
+ @"filler": @[@(SBSegmentActionDisable), @"#7300FF"],
+ };
+
+ NSMutableDictionary *defaults = [@{
+ SBEnabled: @YES,
+ SBShowButton: @YES,
+ SBShowNotifications: @YES,
+ SBSegmentsInPlayer: @YES,
+ SBSegmentsInFeed: @YES,
+ SBSegmentsInMiniPlayer: @YES,
+ SBSkipAlertDuration: @(SBAlertDurationDefault),
+ SBUnskipAlertDuration: @(SBAlertDurationDefault),
+ } mutableCopy];
+
+ for (NSString *category in sbAllCategories()) {
+ NSArray *def = categoryDefaults[category];
+ if (!def) continue;
+ defaults[SB_ACTION_KEY(category)] = def[0];
+ defaults[SB_COLOR_KEY(category)] = def[1];
+ }
+
+ [[NSUserDefaults standardUserDefaults] registerDefaults:defaults];
+ %init;
+}
diff --git a/Files/SponsorBlockUI.x b/Files/SponsorBlockUI.x
new file mode 100644
index 00000000..ce36cbee
--- /dev/null
+++ b/Files/SponsorBlockUI.x
@@ -0,0 +1,827 @@
+#import "Headers.h"
+#import
+
+extern BOOL useBackwardIconForButton;
+
+// Range segments render as a filled bar at least this wide so very short segments
+// stay visible. Point segments (poi_highlight) have no duration, so they render as
+// a fixed-width tick centered on their position via the x-offset.
+static const CGFloat SBMarkerMinWidth = 2.0;
+static const CGFloat SBPoiMarkerWidth = 3.0;
+static const CGFloat SBPoiMarkerXOffset = 1.5;
+
+#pragma mark - SBSkipNotificationView Implementation
+
+@implementation SBSkipNotificationView
+
+- (instancetype)initWithFrame:(CGRect)frame {
+ self = [super initWithFrame:frame];
+ if (self) {
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(appDidEnterBackground)
+ name:UIApplicationDidEnterBackgroundNotification
+ object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(appWillEnterForeground)
+ name:UIApplicationWillEnterForegroundNotification
+ object:nil];
+ }
+ return self;
+}
+
+- (void)dealloc {
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+- (void)appDidEnterBackground {
+ if (!self.isPaused) {
+ [self pauseProgress];
+ self.backgroundDate = [NSDate date];
+ }
+}
+
+- (void)appWillEnterForeground {
+ if (self.backgroundDate) {
+ NSTimeInterval timeInBackground = [[NSDate date] timeIntervalSinceDate:self.backgroundDate];
+ self.remainingDuration -= timeInBackground;
+ self.backgroundDate = nil;
+
+ if (self.remainingDuration <= 0) {
+ [self removeFromSuperview];
+ return;
+ } else {
+ CGFloat newScaleX = self.remainingDuration / self.totalDuration;
+ newScaleX = MAX(0.001, MIN(newScaleX, 1.0));
+ self.progressOverlay.transform = CGAffineTransformMakeScale(newScaleX, 1.0);
+ self.progressOverlay.alpha = newScaleX;
+ }
+ }
+ [self resumeProgress];
+}
+
++ (instancetype)showInView:(UIView *)parentView message:(NSString *)message buttonTitle:(NSString *)buttonTitle action:(void (^)(void))action duration:(NSTimeInterval)duration {
+ if (!parentView) return nil;
+
+ for (UIView *sub in [parentView.subviews copy]) {
+ if ([sub isKindOfClass:[SBSkipNotificationView class]]) {
+ SBSkipNotificationView *existing = (SBSkipNotificationView *)sub;
+ if (existing.isHighlightPill) return nil;
+ [existing dismiss];
+ }
+ }
+
+ SBSkipNotificationView *view = [[SBSkipNotificationView alloc] initWithFrame:CGRectZero];
+ view.translatesAutoresizingMaskIntoConstraints = NO;
+ view.clipsToBounds = YES;
+ view.layer.cornerRadius = 22.0;
+ view.onAction = action;
+ view.totalDuration = duration;
+ view.remainingDuration = duration;
+ view.isPaused = NO;
+
+ // Base layer (revealed as progress depletes)
+ view.backgroundColor = [UIColor colorWithWhite:0.08 alpha:1.0];
+
+ // Progress overlay (shrinks from right to left)
+ UIView *progressOverlay = [[UIView alloc] initWithFrame:CGRectZero];
+ progressOverlay.translatesAutoresizingMaskIntoConstraints = YES;
+ progressOverlay.backgroundColor = [UIColor colorWithWhite:0.18 alpha:1.0];
+ progressOverlay.userInteractionEnabled = NO;
+ progressOverlay.layer.anchorPoint = CGPointMake(0, 0.5);
+ progressOverlay.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ view.progressOverlay = progressOverlay;
+ [view addSubview:progressOverlay];
+
+ // Message label
+ UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
+ label.translatesAutoresizingMaskIntoConstraints = NO;
+ label.text = message;
+ label.textColor = [UIColor whiteColor];
+ label.font = [UIFont systemFontOfSize:14.0 weight:UIFontWeightMedium];
+ label.numberOfLines = 2;
+ label.lineBreakMode = NSLineBreakByTruncatingTail;
+ view.messageLabel = label;
+ [view addSubview:label];
+
+ // Icon button (right side)
+ BOOL showButton = (buttonTitle != nil || action != nil);
+ UIButton *button = nil;
+
+ if (showButton) {
+ button = [UIButton buttonWithType:UIButtonTypeCustom];
+ button.translatesAutoresizingMaskIntoConstraints = NO;
+
+ NSString *iconName = useBackwardIconForButton ? @"backward.fill" : @"forward.end.fill";
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightMedium];
+ UIImage *icon = [UIImage systemImageNamed:iconName withConfiguration:config];
+ [button setImage:icon forState:UIControlStateNormal];
+ button.tintColor = [UIColor whiteColor];
+ button.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.15];
+ button.layer.cornerRadius = 16.0;
+ button.clipsToBounds = YES;
+ [button addTarget:view action:@selector(actionButtonTapped) forControlEvents:UIControlEventTouchUpInside];
+ view.actionButton = button;
+ [view addSubview:button];
+ }
+
+ [parentView addSubview:view];
+
+ // Layout: centered horizontally, anchored above tab bar via safe area
+ NSLayoutConstraint *maxWidth = [view.widthAnchor constraintLessThanOrEqualToAnchor:parentView.widthAnchor multiplier:0.85];
+ [NSLayoutConstraint activateConstraints:@[
+ [view.centerXAnchor constraintEqualToAnchor:parentView.centerXAnchor],
+ [view.bottomAnchor constraintEqualToAnchor:parentView.safeAreaLayoutGuide.bottomAnchor constant:-60.0],
+ [view.heightAnchor constraintEqualToConstant:44.0],
+ maxWidth
+ ]];
+
+ // Internal layout
+ if (showButton) {
+ [NSLayoutConstraint activateConstraints:@[
+ [label.leadingAnchor constraintEqualToAnchor:view.leadingAnchor constant:16.0],
+ [label.centerYAnchor constraintEqualToAnchor:view.centerYAnchor],
+ [label.trailingAnchor constraintEqualToAnchor:button.leadingAnchor constant:-10.0],
+
+ [button.trailingAnchor constraintEqualToAnchor:view.trailingAnchor constant:-8.0],
+ [button.centerYAnchor constraintEqualToAnchor:view.centerYAnchor],
+ [button.widthAnchor constraintEqualToConstant:32.0],
+ [button.heightAnchor constraintEqualToConstant:32.0]
+ ]];
+ } else {
+ [NSLayoutConstraint activateConstraints:@[
+ [label.leadingAnchor constraintEqualToAnchor:view.leadingAnchor constant:16.0],
+ [label.centerYAnchor constraintEqualToAnchor:view.centerYAnchor],
+ [label.trailingAnchor constraintEqualToAnchor:view.trailingAnchor constant:-40.0],
+ ]];
+ }
+
+ // Pan gesture for interactive dismissal
+ UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:view action:@selector(handlePan:)];
+ [view addGestureRecognizer:pan];
+
+ // Slide up from below
+ view.transform = CGAffineTransformMakeTranslation(0, 60);
+ view.alpha = 0.0;
+ [UIView animateWithDuration:0.4 delay:0 usingSpringWithDamping:0.85 initialSpringVelocity:0.5 options:UIViewAnimationOptionCurveEaseOut animations:^{
+ view.alpha = 1.0;
+ view.transform = CGAffineTransformIdentity;
+ } completion:^(BOOL finished) {
+ if (finished && duration > 0) {
+ [view startProgressAnimation];
+ }
+ }];
+
+ return view;
+}
+
+- (void)layoutSubviews {
+ [super layoutSubviews];
+ if (self.progressOverlay.layer.animationKeys.count == 0 || self.isPaused) {
+ self.progressOverlay.frame = CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height);
+ }
+}
+
+- (void)startProgressAnimation {
+ if (self.remainingDuration <= 0) return;
+
+ self.progressOverlay.frame = CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height);
+
+ [UIView animateWithDuration:self.remainingDuration delay:0 options:UIViewAnimationOptionCurveLinear animations:^{
+ self.progressOverlay.transform = CGAffineTransformMakeScale(0.001, 1.0);
+ self.progressOverlay.alpha = 0.0;
+ } completion:^(BOOL finished) {
+ if (finished && !self.isPaused && self.superview) {
+ [self dismiss];
+ }
+ }];
+}
+
+- (void)pauseProgress {
+ if (self.isPaused) return;
+ self.isPaused = YES;
+
+ CALayer *presentationLayer = self.progressOverlay.layer.presentationLayer;
+ CGFloat currentScaleX = 1.0;
+ if (presentationLayer) {
+ CATransform3D t = presentationLayer.transform;
+ currentScaleX = t.m11;
+ }
+
+ [self.progressOverlay.layer removeAllAnimations];
+ currentScaleX = MAX(0.001, MIN(currentScaleX, 1.0));
+ self.progressOverlay.transform = CGAffineTransformMakeScale(currentScaleX, 1.0);
+ self.progressOverlay.alpha = currentScaleX;
+ self.remainingDuration = self.totalDuration * currentScaleX;
+}
+
+- (void)resumeProgress {
+ if (!self.isPaused) return;
+ self.isPaused = NO;
+
+ if (self.remainingDuration <= 0) {
+ [self dismiss];
+ return;
+ }
+
+ [UIView animateWithDuration:self.remainingDuration delay:0 options:UIViewAnimationOptionCurveLinear animations:^{
+ self.progressOverlay.transform = CGAffineTransformMakeScale(0.001, 1.0);
+ self.progressOverlay.alpha = 0.0;
+ } completion:^(BOOL finished) {
+ if (finished && !self.isPaused && self.superview) {
+ [self dismiss];
+ }
+ }];
+}
+
+- (void)handlePan:(UIPanGestureRecognizer *)gesture {
+ if (self.alpha < 1.0) {
+ gesture.enabled = NO;
+ gesture.enabled = YES;
+ return;
+ }
+
+ CGPoint translation = [gesture translationInView:self.superview];
+ CGPoint velocity = [gesture velocityInView:self.superview];
+
+ switch (gesture.state) {
+ case UIGestureRecognizerStateBegan:
+ [self pauseProgress];
+ break;
+
+ case UIGestureRecognizerStateChanged:
+ self.transform = CGAffineTransformMakeTranslation(0, translation.y);
+ break;
+
+ case UIGestureRecognizerStateEnded:
+ case UIGestureRecognizerStateCancelled: {
+ CGFloat distanceThreshold = 30.0;
+ CGFloat velocityThreshold = 500.0;
+ BOOL shouldDismiss = (fabs(translation.y) > distanceThreshold) || (fabs(velocity.y) > velocityThreshold);
+
+ if (shouldDismiss) {
+ CGFloat direction = (translation.y < 0) ? -1.0 : 1.0;
+ [self dismissInDirection:direction velocity:fabs(velocity.y)];
+ } else {
+ // Snap back
+ [UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0.5 options:UIViewAnimationOptionBeginFromCurrentState animations:^{
+ self.transform = CGAffineTransformIdentity;
+ } completion:^(BOOL finished) {
+ [self resumeProgress];
+ }];
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+- (void)dismissInDirection:(CGFloat)direction velocity:(CGFloat)velocity {
+ CGFloat offscreenY = direction < 0 ? -(self.frame.size.height + 80) : (self.frame.size.height + 80);
+ CGFloat animDuration = velocity > 500 ? 0.2 : 0.35;
+
+ [UIView animateWithDuration:animDuration delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
+ self.transform = CGAffineTransformMakeTranslation(0, offscreenY);
+ self.alpha = 0.0;
+ } completion:^(BOOL finished) {
+ [self removeFromSuperview];
+ }];
+}
+
+- (void)actionButtonTapped {
+ if (self.onAction) {
+ self.onAction();
+ }
+ [self dismiss];
+}
+
+- (void)dismiss {
+ [self.progressOverlay.layer removeAllAnimations];
+ [UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
+ self.transform = CGAffineTransformMakeTranslation(0, 60);
+ self.alpha = 0.0;
+ } completion:^(BOOL finished) {
+ [self removeFromSuperview];
+ }];
+}
+
++ (instancetype)showSuccessInView:(UIView *)parentView message:(NSString *)message duration:(NSTimeInterval)duration {
+ SBSkipNotificationView *view = [self showInView:parentView message:message buttonTitle:nil action:nil duration:duration];
+ if (view) {
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightMedium];
+ UIImageView *iconView = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.circle.fill" withConfiguration:config]];
+ iconView.tintColor = [UIColor systemGreenColor];
+ iconView.translatesAutoresizingMaskIntoConstraints = NO;
+ [view addSubview:iconView];
+ [NSLayoutConstraint activateConstraints:@[
+ [iconView.trailingAnchor constraintEqualToAnchor:view.trailingAnchor constant:-12.0],
+ [iconView.centerYAnchor constraintEqualToAnchor:view.centerYAnchor],
+ ]];
+ }
+ return view;
+}
+
++ (instancetype)showErrorInView:(UIView *)parentView message:(NSString *)message duration:(NSTimeInterval)duration {
+ SBSkipNotificationView *view = [self showInView:parentView message:message buttonTitle:nil action:nil duration:duration];
+ if (view) {
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightMedium];
+ UIImageView *iconView = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"xmark.circle.fill" withConfiguration:config]];
+ iconView.tintColor = [UIColor systemRedColor];
+ iconView.translatesAutoresizingMaskIntoConstraints = NO;
+ [view addSubview:iconView];
+ [NSLayoutConstraint activateConstraints:@[
+ [iconView.trailingAnchor constraintEqualToAnchor:view.trailingAnchor constant:-12.0],
+ [iconView.centerYAnchor constraintEqualToAnchor:view.centerYAnchor],
+ ]];
+ }
+ return view;
+}
+
+@end
+
+#pragma mark - YMDownloadProgressView
+
+@implementation YMDownloadProgressView
+
++ (instancetype)showInView:(UIView *)parentView message:(NSString *)message cancelAction:(void (^)(void))cancelAction {
+ if (!parentView) return nil;
+
+ // Dismiss any existing download progress pill
+ for (UIView *sub in [parentView.subviews copy]) {
+ if ([sub isKindOfClass:[YMDownloadProgressView class]]) {
+ [(YMDownloadProgressView *)sub dismiss];
+ }
+ }
+
+ YMDownloadProgressView *view = [[YMDownloadProgressView alloc] init];
+ view.onCancel = cancelAction;
+ view.translatesAutoresizingMaskIntoConstraints = NO;
+ view.backgroundColor = [UIColor colorWithWhite:0.12 alpha:1.0];
+ view.layer.cornerRadius = 16.0;
+ view.clipsToBounds = YES;
+ view.layer.borderWidth = 0.5;
+ view.layer.borderColor = [UIColor colorWithWhite:0.25 alpha:1.0].CGColor;
+
+ // Title label
+ UILabel *titleLabel = [[UILabel alloc] init];
+ titleLabel.text = message;
+ titleLabel.textColor = [UIColor whiteColor];
+ titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
+ titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ view.titleLabel = titleLabel;
+ [view addSubview:titleLabel];
+
+ // Subtitle label (speed + size)
+ UILabel *subtitleLabel = [[UILabel alloc] init];
+ subtitleLabel.text = @"";
+ subtitleLabel.textColor = [UIColor colorWithWhite:0.55 alpha:1.0];
+ subtitleLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightRegular];
+ subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ view.subtitleLabel = subtitleLabel;
+ [view addSubview:subtitleLabel];
+
+ // Progress bar
+ UIProgressView *progressBar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
+ progressBar.progress = 0.0;
+ progressBar.trackTintColor = [UIColor colorWithWhite:0.22 alpha:1.0];
+ progressBar.progressTintColor = [UIColor colorWithRed:0.6 green:0.2 blue:0.9 alpha:1.0];
+ progressBar.translatesAutoresizingMaskIntoConstraints = NO;
+ progressBar.layer.cornerRadius = 3.0;
+ progressBar.clipsToBounds = YES;
+ view.progressBar = progressBar;
+ [view addSubview:progressBar];
+
+ // Cancel button
+ UIButton *cancelButton = [UIButton buttonWithType:UIButtonTypeSystem];
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
+ [cancelButton setImage:[UIImage systemImageNamed:@"xmark.circle.fill" withConfiguration:config] forState:UIControlStateNormal];
+ cancelButton.tintColor = [UIColor colorWithWhite:0.45 alpha:1.0];
+ cancelButton.translatesAutoresizingMaskIntoConstraints = NO;
+ [cancelButton addTarget:view action:@selector(cancelButtonTapped) forControlEvents:UIControlEventTouchUpInside];
+ view.cancelButton = cancelButton;
+ [view addSubview:cancelButton];
+
+ // Layout
+ [NSLayoutConstraint activateConstraints:@[
+ [titleLabel.leadingAnchor constraintEqualToAnchor:view.leadingAnchor constant:16],
+ [titleLabel.topAnchor constraintEqualToAnchor:view.topAnchor constant:12],
+ [titleLabel.trailingAnchor constraintEqualToAnchor:cancelButton.leadingAnchor constant:-10],
+
+ [subtitleLabel.leadingAnchor constraintEqualToAnchor:view.leadingAnchor constant:16],
+ [subtitleLabel.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:3],
+ [subtitleLabel.trailingAnchor constraintEqualToAnchor:cancelButton.leadingAnchor constant:-10],
+
+ [progressBar.leadingAnchor constraintEqualToAnchor:view.leadingAnchor constant:16],
+ [progressBar.trailingAnchor constraintEqualToAnchor:view.trailingAnchor constant:-16],
+ [progressBar.topAnchor constraintEqualToAnchor:subtitleLabel.bottomAnchor constant:10],
+ [progressBar.bottomAnchor constraintEqualToAnchor:view.bottomAnchor constant:-14],
+ [progressBar.heightAnchor constraintEqualToConstant:6],
+
+ [cancelButton.trailingAnchor constraintEqualToAnchor:view.trailingAnchor constant:-14],
+ [cancelButton.centerYAnchor constraintEqualToAnchor:titleLabel.centerYAnchor],
+ [cancelButton.widthAnchor constraintEqualToConstant:32],
+ [cancelButton.heightAnchor constraintEqualToConstant:32],
+ ]];
+
+ [parentView addSubview:view];
+
+ // Center horizontally with max width
+ NSLayoutConstraint *centerX = [view.centerXAnchor constraintEqualToAnchor:parentView.centerXAnchor];
+ NSLayoutConstraint *maxWidth = [view.widthAnchor constraintLessThanOrEqualToConstant:360];
+ NSLayoutConstraint *leadingFallback = [view.leadingAnchor constraintGreaterThanOrEqualToAnchor:parentView.leadingAnchor constant:16];
+ NSLayoutConstraint *trailingFallback = [view.trailingAnchor constraintLessThanOrEqualToAnchor:parentView.trailingAnchor constant:-16];
+ NSLayoutConstraint *preferredWidth = [view.widthAnchor constraintEqualToAnchor:parentView.widthAnchor constant:-32];
+ preferredWidth.priority = UILayoutPriorityDefaultHigh;
+
+ [NSLayoutConstraint activateConstraints:@[
+ centerX, maxWidth, leadingFallback, trailingFallback, preferredWidth,
+ [view.bottomAnchor constraintEqualToAnchor:parentView.safeAreaLayoutGuide.bottomAnchor constant:-12],
+ ]];
+
+ // Slide-up animation
+ view.transform = CGAffineTransformMakeTranslation(0, 80);
+ view.alpha = 0;
+ [UIView animateWithDuration:0.35 delay:0 usingSpringWithDamping:0.75 initialSpringVelocity:0.5 options:UIViewAnimationOptionCurveEaseOut animations:^{
+ view.transform = CGAffineTransformIdentity;
+ view.alpha = 1.0;
+ } completion:nil];
+
+ return view;
+}
+
+- (void)updateProgress:(float)progress title:(NSString *)title subtitle:(NSString *)subtitle {
+ self.titleLabel.text = title;
+ self.subtitleLabel.text = subtitle;
+ [self.progressBar setProgress:progress animated:YES];
+}
+
+- (void)cancelButtonTapped {
+ if (self.onCancel) {
+ self.onCancel();
+ [self dismiss];
+ }
+}
+
+- (void)dismiss {
+ if (!self.superview) return;
+ [UIView animateWithDuration:0.25 animations:^{
+ self.transform = CGAffineTransformMakeTranslation(0, 80);
+ self.alpha = 0;
+ } completion:^(BOOL finished) {
+ [self removeFromSuperview];
+ }];
+}
+
+@end
+
+#pragma mark - Marker Repositioning Hooks
+
+// YTModularPlayerBarView - normal player
+%hook YTModularPlayerBarView
+
+- (void)layoutSubviews {
+ %orig;
+ CGFloat barWidth = self.bounds.size.width;
+ if (barWidth <= 0) return;
+
+ // Find reference view for Y
+ UIView *referenceView = nil;
+ for (UIView *sub in self.subviews) {
+ if ([sub isKindOfClass:%c(YTPlayerBarRectangleDecorationView)] ||
+ [sub isKindOfClass:%c(YTPlayerBarProgressDecorationView)]) {
+ referenceView = sub;
+ break;
+ }
+ }
+
+ for (UIView *sub in self.subviews) {
+ if (sub.tag != SBSegmentMarkerTag) continue;
+ NSArray *data = objc_getAssociatedObject(sub, @selector(sbSegmentData));
+ if (!data || data.count < 3) continue;
+
+ CGFloat startFrac = [data[0] floatValue];
+ CGFloat endFrac = [data[1] floatValue];
+ BOOL isPoi = [data[2] boolValue];
+
+ CGFloat x = startFrac * barWidth;
+ CGFloat w = (endFrac - startFrac) * barWidth;
+ if (isPoi) { w = SBPoiMarkerWidth; x = MAX(0, x - SBPoiMarkerXOffset); }
+ else if (w < SBMarkerMinWidth) w = SBMarkerMinWidth;
+
+ sub.frame = CGRectMake(x, referenceView.frame.origin.y, w, referenceView.frame.size.height);
+ }
+
+ // Keep the segment markers above YouTube's decoration views, and the scrubber
+ // dot above the markers, so the stack stays track < markers < dot. YouTube
+ // rebuilds its own decorations on top of ours on each layout (splitting the
+ // progress view per chapter), which would otherwise bury the markers.
+ NSArray *subs = self.subviews;
+ NSMutableArray *markers = [NSMutableArray array];
+ UIView *scrubberDot = nil;
+ @try {
+ scrubberDot = [self valueForKey:@"_scrubberCircle"];
+ } @catch (id ex) {}
+ for (UIView *sub in subs) {
+ if (sub.tag == SBSegmentMarkerTag) {
+ [markers addObject:sub];
+ } else if ([sub isKindOfClass:%c(YTPlayerBarScrubberDotDecorationView)]) {
+ if (!scrubberDot) scrubberDot = sub;
+ }
+ }
+ if (markers.count == 0) return;
+
+ // Desired top group, front-most last: the markers followed by the dot. Reorder
+ // only when the subview tail doesn't already match it, so a settled layout is a
+ // no-op and doesn't trigger a fresh layout pass on every runloop cycle.
+ NSMutableArray *desiredTail = [markers mutableCopy];
+ if (scrubberDot) [desiredTail addObject:scrubberDot];
+ BOOL settled = subs.count >= desiredTail.count;
+ for (NSUInteger i = 0; settled && i < desiredTail.count; i++) {
+ if (subs[subs.count - desiredTail.count + i] != desiredTail[i]) settled = NO;
+ }
+ if (settled) return;
+
+ for (UIView *marker in markers) [self bringSubviewToFront:marker];
+ if (scrubberDot) [self bringSubviewToFront:scrubberDot];
+}
+
+%end
+
+// YTWatchFloatingMiniplayerProgressBarView - miniplayer
+%hook YTWatchFloatingMiniplayerProgressBarView
+- (void)layoutSubviews {
+ %orig;
+ CGFloat barWidth = self.bounds.size.width;
+
+ for (UIView *sub in self.superview.subviews) {
+ if (sub.tag != SBSegmentMarkerTag) continue;
+ NSArray *data = objc_getAssociatedObject(sub, @selector(sbSegmentData));
+ if (!data || data.count < 3) continue;
+
+ CGFloat startFrac = [data[0] floatValue];
+ CGFloat endFrac = [data[1] floatValue];
+ BOOL isPoi = [data[2] boolValue];
+
+ CGFloat x = startFrac * barWidth;
+ CGFloat w = (endFrac - startFrac) * barWidth;
+ if (isPoi) { w = SBPoiMarkerWidth; x = MAX(0, x - SBPoiMarkerXOffset); }
+ else if (w < SBMarkerMinWidth) w = SBMarkerMinWidth;
+
+ sub.frame = CGRectMake(x, self.frame.origin.y, w, self.bounds.size.height);
+ }
+}
+%end
+
+#pragma mark - YTPlayerViewController Hook (Notification Observer)
+
+%group SBObserver
+%hook YTPlayerViewController
+
+- (void)viewDidLoad {
+ %orig;
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:@"SBSegmentsDidLoad" object:self];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(sbSegmentsDidLoad:)
+ name:@"SBSegmentsDidLoad"
+ object:self];
+}
+
+- (void)dealloc {
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:@"SBSegmentsDidLoad" object:self];
+ %orig;
+}
+
+%new
+- (void)sbSegmentsDidLoad:(NSNotification *)notification {
+ [self sbRefreshMarkers:notification.userInfo[@"segments"]];
+}
+
+// Re-resolves the current player bar view fresh and re-creates segment markers
+// on it. Called whenever the player layout might have changed (initial load,
+// fullscreen enter/exit, viewport resize) so markers always live on the
+// currently-visible bar instead of an old detached one.
+%new
+- (void)sbRefreshMarkers:(NSArray *)segments {
+ if (!IS_ENABLED(SBSegmentsInPlayer) && !IS_ENABLED(SBSegmentsInMiniPlayer) && !IS_ENABLED(SBSegmentsInFeed)) return;
+ if (!segments) segments = self.sbSegments;
+
+ CGFloat totalTime = [self currentVideoTotalMediaTime];
+ if (totalTime <= 0) return;
+ CGFloat barWidth;
+ CGFloat h;
+ CGFloat y;
+ // Explicitly nil so the surface branches can rely on "unassigned == nil"
+ // when deciding marker ordering, without depending on ARC zero-init.
+ UIView *mainView = nil;
+ UIView *scrubberDot = nil;
+ UIView *referenceView = nil;
+
+ if ([self.parentViewController isKindOfClass:%c(YTWatchFloatingMiniplayerViewController)] && IS_ENABLED(SBSegmentsInMiniPlayer)) {
+ YTWatchFloatingMiniplayerViewController *miniplayercontroller = (YTWatchFloatingMiniplayerViewController *)self.parentViewController;
+ YTWatchFloatingMiniplayerWithPersistentControlsView *controlsview = (YTWatchFloatingMiniplayerWithPersistentControlsView *)miniplayercontroller.view;
+
+ for (UIView *sub in controlsview.subviews) {
+ for (UIView *sub2 in sub.subviews) {
+ if ([sub2 isKindOfClass:%c(YTWatchFloatingMiniplayerProgressBarView)]) {
+ referenceView = sub2;
+ break;
+ }
+ }
+ if (referenceView) break;
+ }
+
+ mainView = referenceView.superview;
+
+ // Remove old markers
+ for (UIView *sub in [mainView.subviews copy]) {
+ if (sub.tag == SBSegmentMarkerTag) [sub removeFromSuperview];
+ }
+ if (!segments || segments.count == 0) return;
+
+ barWidth = referenceView.bounds.size.width;
+ h = referenceView.bounds.size.height;
+ y = referenceView.frame.origin.y;
+ } else if ([[self activeVideoPlayerOverlay] isKindOfClass:%c(YTMainAppVideoPlayerOverlayViewController)] && IS_ENABLED(SBSegmentsInPlayer)) {
+ YTMainAppVideoPlayerOverlayViewController *overlay = [self activeVideoPlayerOverlay];
+ YTPlayerBarController *barController = [overlay playerBarController];
+ YTInlinePlayerBarContainerView *containerView = barController.playerBar;
+ UIView *playerBar;
+
+ for (UIView *subview in containerView.subviews) {
+ if ([subview isKindOfClass:%c(YTModularPlayerBarView)]) {
+ playerBar = subview;
+ mainView = subview;
+ break;
+ }
+ }
+ if (!playerBar) return;
+
+ // Remove old markers
+ for (UIView *sub in [playerBar.subviews copy]) {
+ if (sub.tag == SBSegmentMarkerTag) [sub removeFromSuperview];
+ }
+
+ if (!segments || segments.count == 0) return;
+
+ barWidth = playerBar.bounds.size.width;
+ if (barWidth <= 0) return;
+
+ @try {
+ scrubberDot = [playerBar valueForKey:@"_scrubberCircle"];
+ } @catch (id ex) {}
+ // Find reference track view for Y position and height
+ for (UIView *sub in playerBar.subviews) {
+ if ([sub isKindOfClass:%c(YTPlayerBarRectangleDecorationView)]) {
+ if (!referenceView) referenceView = sub;
+ } else if ([sub isKindOfClass:%c(YTPlayerBarProgressDecorationView)]) {
+ if (!referenceView) referenceView = sub;
+ } else if ([sub isKindOfClass:%c(YTPlayerBarScrubberDotDecorationView)]) {
+ if (!scrubberDot) scrubberDot = sub;
+ }
+ if (referenceView && scrubberDot) break;
+ }
+ h = referenceView.bounds.size.height;
+ y = referenceView.frame.origin.y;
+ } else if ([[self activeVideoPlayerOverlay] isKindOfClass:%c(YTInlineMutedPlaybackPlayerOverlayViewController)] && IS_ENABLED(SBSegmentsInFeed)) {
+ YTInlineMutedPlaybackPlayerOverlayViewController *viewcon = [self activeVideoPlayerOverlay];
+ YTInlineMutedPlaybackPlayerOverlayView *view = (YTInlineMutedPlaybackPlayerOverlayView *)viewcon.view;
+ UIView *scrub;
+ UIView *playerBar;
+ for (UIView *sub in view.subviews) {
+ if ([sub isKindOfClass:%c(YTInlineMutedPlaybackScrubberView)]) {
+ scrub = sub;
+ mainView = sub;
+ break;
+ }
+ }
+
+ if (!segments || segments.count == 0) return;
+
+ for (UIView *sub in scrub.subviews) {
+ if ([sub isKindOfClass:%c(YTPlayerBarMarkerView)] && sub.frame.origin.y != 0) {
+ playerBar = sub;
+ } else if ([sub isKindOfClass:%c(YTModularPlayerBarView)] && sub.frame.origin.y != 0) {
+ playerBar = sub;
+ mainView = sub;
+ } else if ([sub isKindOfClass:%c(YTInlineMutedPlaybackScrubbingSlider)]) {
+ if ([sub.accessibilityIdentifier isEqualToString:@"id.player.scrubber.slider"]) {
+ scrubberDot = sub;
+ }
+ }
+ if (playerBar && scrubberDot) break;
+ }
+
+ if (!playerBar) return;
+
+ // Remove old markers
+ for (UIView *sub in [mainView.subviews copy]) {
+ if (sub.tag == SBSegmentMarkerTag) [sub removeFromSuperview];
+ }
+
+ if ([mainView isKindOfClass:%c(YTModularPlayerBarView)]) {
+ @try {
+ scrubberDot = [mainView valueForKey:@"_scrubberCircle"];
+ } @catch (id ex) {}
+ // Find reference track view for Y position and height
+ for (UIView *sub in mainView.subviews) {
+ if ([sub isKindOfClass:%c(YTPlayerBarRectangleDecorationView)]) {
+ if (!referenceView) referenceView = sub;
+ } else if ([sub isKindOfClass:%c(YTPlayerBarProgressDecorationView)]) {
+ if (!referenceView) referenceView = sub;
+ } else if ([sub isKindOfClass:%c(YTPlayerBarScrubberDotDecorationView)]) {
+ if (!scrubberDot) scrubberDot = sub;
+ }
+ if (referenceView && scrubberDot) break;
+ }
+ barWidth = playerBar.bounds.size.width;
+ h = referenceView.bounds.size.height;
+ y = referenceView.frame.origin.y;
+ } else {
+ barWidth = playerBar.bounds.size.width;
+ h = playerBar.bounds.size.height;
+ y = playerBar.frame.origin.y;
+ }
+ } else {
+ return;
+ }
+
+ if (!self.sbEnabledForVideo) return;
+
+ for (SBSegment *segment in segments) {
+ SBSegmentAction action = [segment configuredAction];
+ if (action == SBSegmentActionDisable) continue;
+
+ CGFloat startFrac = segment.startTime / totalTime;
+ CGFloat endFrac;
+ if (segment.endTime > totalTime) {
+ endFrac = 1.0;
+ } else {
+ endFrac = segment.endTime / totalTime;
+ }
+ CGFloat x = startFrac * barWidth;
+ CGFloat w = (endFrac - startFrac) * barWidth;
+
+ // poi_highlight is a point, not a range — give it fixed width
+ BOOL isPoi = [segment.category isEqualToString:@"poi_highlight"];
+ if (isPoi) {
+ w = SBPoiMarkerWidth;
+ x = MAX(0, x - SBPoiMarkerXOffset);
+ } else {
+ if (w < SBMarkerMinWidth) w = SBMarkerMinWidth;
+ }
+
+ UIView *marker = [[UIView alloc] initWithFrame:CGRectMake(x, y, w, h)];
+ marker.backgroundColor = [segment segmentColor];
+ marker.userInteractionEnabled = NO;
+ marker.tag = SBSegmentMarkerTag;
+ objc_setAssociatedObject(marker, @selector(sbSegmentData), @[@(startFrac), @(endFrac), @(isPoi)], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+
+ // Insert above the track (main player bar) so the marker paints on it;
+ // the dot is re-fronted after the loop. Miniplayer/feed keep dot-relative
+ // or top ordering.
+ if (referenceView && referenceView.superview == mainView) {
+ [mainView insertSubview:marker aboveSubview:referenceView];
+ } else if (scrubberDot && scrubberDot.superview == mainView) {
+ [mainView insertSubview:marker belowSubview:scrubberDot];
+ } else {
+ [mainView addSubview:marker];
+ [mainView bringSubviewToFront:marker];
+ }
+ }
+ if (scrubberDot) {
+ [mainView bringSubviewToFront:scrubberDot];
+ }
+}
+
+// On fullscreen enter/exit and other layout transitions, YouTube swaps the
+// player bar instance. Re-render markers on the current bar (matches
+// iSponsorBlock's approach). Deferred to the next runloop so YouTube's own
+// layout pass finishes first — otherwise the new bar's bounds.size.width can
+// still be 0 and the refresh early-returns without inserting markers.
+- (void)setPlayerViewLayout:(NSInteger)layout {
+ %orig;
+ __weak typeof(self) weakSelf = self;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [weakSelf sbRefreshMarkers:nil];
+ sbUpdateOverlayInsetForPivotBar();
+ });
+}
+
+- (void)updateViewportSizeProvider {
+ %orig;
+ __weak typeof(self) weakSelf = self;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [weakSelf sbRefreshMarkers:nil];
+ sbUpdateOverlayInsetForPivotBar();
+ });
+}
+
+%end
+%end
+
+#pragma mark - Constructor
+
+%ctor {
+ %init;
+ %init(SBObserver);
+}
\ No newline at end of file
diff --git a/Files/Tabbar.x b/Files/Tabbar.x
index c12a9b49..61f7ed5f 100644
--- a/Files/Tabbar.x
+++ b/Files/Tabbar.x
@@ -1,40 +1,239 @@
#import "Headers.h"
+#import
+
+// Tab icons
+%hook YTAppPivotBarItemStyle
+- (UIImage *)pivotBarItemIconImageWithIconType:(int)type color:(UIColor *)color useNewIcons:(BOOL)isNew selected:(BOOL)isSelected {
+ if (type == 1 || type == 2 || type == 3 || type == 4 || type == 5 || type == 6 || type == 7 || type == 8 || type == 9 || type == 10 || type == 11 || type == 12 || type == 13 || type == 14 || type == 15 || type == 16 || type == 17) {
+ NSString *imageName;
+ if (type == 1) imageName = isSelected ? @"icons/history_selected" : @"icons/history";
+ else if (type == 2) imageName = isSelected ? @"icons/gaming_selected" : @"icons/gaming";
+ else if (type == 3) imageName = isSelected ? @"icons/sports_selected" : @"icons/sports";
+ else if (type == 4) imageName = isSelected ? @"icons/noti_selected" : @"icons/noti";
+ else if (type == 5) imageName = isSelected ? @"icons/news_selected" : @"icons/news";
+ else if (type == 6) imageName = isSelected ? @"icons/music_selected" : @"icons/music";
+ else if (type == 7) imageName = isSelected ? @"icons/watchlater_selected" : @"icons/watchlater";
+ else if (type == 8) imageName = isSelected ? @"icons/playlist_selected" : @"icons/playlist";
+ else if (type == 9) imageName = isSelected ? @"icons/like_selected" : @"icons/like";
+ else if (type == 10) imageName = isSelected ? @"icons/live_selected" : @"icons/live";
+ else if (type == 11) imageName = isSelected ? @"icons/post_selected" : @"icons/post";
+ else if (type == 12) imageName = isSelected ? @"icons/video_selected" : @"icons/video";
+ else if (type == 13) imageName = isSelected ? @"icons/movie_selected" : @"icons/movie";
+ else if (type == 14) imageName = isSelected ? @"icons/course_selected" : @"icons/course";
+ else if (type == 15) imageName = isSelected ? @"icons/minigame_selected" : @"icons/minigame";
+ else if (type == 16) imageName = isSelected ? @"icons/fashion_selected" : @"icons/fashion";
+ else if (type == 17) imageName = isSelected ? @"icons/learning_selected" : @"icons/learning";
+ YTAssetLoader *al = [[%c(YTAssetLoader) alloc] initWithBundle:YouModBundle()];
+ return [al imageNamed:imageName];
+ }
+ return %orig;
+}
+%end
+
+static NSString *ymPivotIDForTabID(NSString *tabID) {
+ if ([tabID isEqualToString:@"home"]) return @"FEwhat_to_watch";
+ if ([tabID isEqualToString:@"shorts"]) return @"FEshorts";
+ if ([tabID isEqualToString:@"create"]) return @"FEuploads";
+ if ([tabID isEqualToString:@"subscriptions"]) return @"FEsubscriptions";
+ if ([tabID isEqualToString:@"library"]) return @"FElibrary";
+ if ([tabID isEqualToString:@"history"]) return [%c(YTIBrowseRequest) browseIDForHistory];
+ if ([tabID isEqualToString:@"gaming"]) return [%c(YTIBrowseRequest) browseIDForGamingDestination];
+ if ([tabID isEqualToString:@"sports"]) return [%c(YTIBrowseRequest) browseIDForSportsDestination];
+ if ([tabID isEqualToString:@"notifications"]) return [%c(YTIBrowseRequest) browseIDForNotificationsInbox];
+ if ([tabID isEqualToString:@"news"]) return @"UCYfdidRxbB8Qhf0Nx7ioOYw"; // FEnews_destination
+ if ([tabID isEqualToString:@"music"]) return @"UC-9-kyTW8ZkZNDHQJ6FgpwQ";
+ if ([tabID isEqualToString:@"watchlater"]) return @"VLWL";
+ if ([tabID isEqualToString:@"playlist"]) return @"FEplaylist_aggregation";
+ if ([tabID isEqualToString:@"like"]) return @"VLLL";
+ if ([tabID isEqualToString:@"live"]) return @"UC4R8DWoMoI7CAwX8_LjQHig";
+ if ([tabID isEqualToString:@"post"]) return @"FEpost_home";
+ if ([tabID isEqualToString:@"video"]) return @"UC3qapbGAd2-S75NkBY3XWww";
+ if ([tabID isEqualToString:@"movie"]) return @"FEstorefront";
+ if ([tabID isEqualToString:@"course"]) return @"FEcourses";
+ if ([tabID isEqualToString:@"minigame"]) return @"FEmini_app_destination";
+ if ([tabID isEqualToString:@"fashion"]) return @"UCrpQ4p1Ql_hG8rKXIKM1MOQ";
+ if ([tabID isEqualToString:@"learning"]) return @"UCtFRv9O2AHqOZjjynzrv-xg";
+ return nil;
+}
+
+static NSInteger ymIconTypeForTabID(NSString *tabID) {
+ if ([tabID isEqualToString:@"history"]) return 1;
+ if ([tabID isEqualToString:@"gaming"]) return 2;
+ if ([tabID isEqualToString:@"sports"]) return 3;
+ if ([tabID isEqualToString:@"notifications"]) return 4;
+ if ([tabID isEqualToString:@"news"]) return 5;
+ if ([tabID isEqualToString:@"music"]) return 6;
+ if ([tabID isEqualToString:@"watchlater"]) return 7;
+ if ([tabID isEqualToString:@"playlist"]) return 8;
+ if ([tabID isEqualToString:@"like"]) return 9;
+ if ([tabID isEqualToString:@"live"]) return 10;
+ if ([tabID isEqualToString:@"post"]) return 11;
+ if ([tabID isEqualToString:@"video"]) return 12;
+ if ([tabID isEqualToString:@"movie"]) return 13;
+ if ([tabID isEqualToString:@"course"]) return 14;
+ if ([tabID isEqualToString:@"minigame"]) return 15;
+ if ([tabID isEqualToString:@"fashion"]) return 16;
+ if ([tabID isEqualToString:@"learning"]) return 17;
+ return 0;
+}
+
+static NSString *ymTitleForTabID(NSString *tabID) {
+ if ([tabID isEqualToString:@"history"]) return LOC(@"HISTORY_TAB");
+ if ([tabID isEqualToString:@"gaming"]) return LOC(@"GAMING_TAB");
+ if ([tabID isEqualToString:@"sports"]) return LOC(@"SPORTS_TAB");
+ if ([tabID isEqualToString:@"notifications"]) return LOC(@"NOTI_TAB");
+ if ([tabID isEqualToString:@"news"]) return LOC(@"NEWS_TAB");
+ if ([tabID isEqualToString:@"music"]) return LOC(@"MUSIC_TAB");
+ if ([tabID isEqualToString:@"watchlater"]) return LOC(@"WATCH_LATER_TAB");
+ if ([tabID isEqualToString:@"playlist"]) return LOC(@"PLAYLIST_TAB");
+ if ([tabID isEqualToString:@"like"]) return LOC(@"LIKE_TAB");
+ if ([tabID isEqualToString:@"live"]) return LOC(@"LIVE_TAB");
+ if ([tabID isEqualToString:@"post"]) return LOC(@"POST_TAB");
+ if ([tabID isEqualToString:@"video"]) return LOC(@"VIDEO_TAB");
+ if ([tabID isEqualToString:@"movie"]) return LOC(@"MOVIE_TAB");
+ if ([tabID isEqualToString:@"course"]) return LOC(@"COURSE_TAB");
+ if ([tabID isEqualToString:@"minigame"]) return LOC(@"MINIGAME_TAB");
+ if ([tabID isEqualToString:@"fashion"]) return LOC(@"FASHION_TAB");
+ if ([tabID isEqualToString:@"learning"]) return LOC(@"LEARNING_TAB");
+ return nil;
+}
%hook YTPivotBarView
- (void)setRenderer:(YTIPivotBarRenderer *)renderer {
- NSMutableArray *items = [renderer itemsArray];
- NSMutableIndexSet *indicesToRemove = [NSMutableIndexSet indexSet];
- // Loop through every item in the bar
- for (NSUInteger i = 0; i < items.count; i++) {
- YTIPivotBarSupportedRenderers *item = items[i];
- NSString *pID = [[item pivotBarItemRenderer] pivotIdentifier];
- NSString *pID2 = [[item pivotBarIconOnlyItemRenderer] pivotIdentifier];
- if ([pID isEqualToString:@"FEwhat_to_watch"] && IS_ENABLED(HideHomeTab)) {
- [indicesToRemove addIndex:i];
- }
- if ([pID isEqualToString:@"FEshorts"] && IS_ENABLED(HideShortsTab)) {
- [indicesToRemove addIndex:i];
- }
- if ([pID2 isEqualToString:@"FEuploads"] && IS_ENABLED(HideCreateButton)) {
- [indicesToRemove addIndex:i];
+ NSArray *savedOrder = [[NSUserDefaults standardUserDefaults] arrayForKey:TabOrder];
+ if (savedOrder.count > 0) {
+ NSMutableArray *items = [renderer itemsArray];
+
+ // Build lookup: pivotIdentifier -> renderer item
+ NSMutableDictionary *lookup = [NSMutableDictionary dictionary];
+ for (YTIPivotBarSupportedRenderers *item in items) {
+ NSString *pID = [[item pivotBarItemRenderer] pivotIdentifier];
+ NSString *pID2 = [[item pivotBarIconOnlyItemRenderer] pivotIdentifier];
+ if (pID) lookup[pID] = item;
+ if (pID2) lookup[pID2] = item;
}
- if ([pID isEqualToString:@"FEsubscriptions"] && IS_ENABLED(HideSubscriptTab)) {
- [indicesToRemove addIndex:i];
+
+ // Build ordered array from saved data
+ NSMutableArray *ordered = [NSMutableArray array];
+ for (NSDictionary *entry in savedOrder) {
+ NSString *tabID = entry[@"id"];
+ BOOL enabled = [entry[@"enabled"] boolValue];
+ if (!enabled) continue;
+
+ NSString *pivotID = ymPivotIDForTabID(tabID);
+ if (!pivotID) continue;
+
+ YTIPivotBarSupportedRenderers *existing = lookup[pivotID];
+ if (existing) {
+ [ordered addObject:existing];
+ } else {
+ // Custom tab not in YouTube's default items — create it
+ NSInteger iconType = ymIconTypeForTabID(tabID);
+ NSString *title = ymTitleForTabID(tabID);
+ if (iconType > 0 && title) {
+ YTIPivotBarSupportedRenderers *newTab = [%c(YTIPivotBarRenderer) pivotSupportedRenderersWithBrowseId:pivotID title:title iconType:iconType];
+ if (newTab) [ordered addObject:newTab];
+ }
+ }
}
+ // Replace items with ordered set
+ [items removeAllObjects];
+ [items addObjectsFromArray:ordered];
}
- // Remove them all at once so the layout doesn't break
- [items removeObjectsAtIndexes:indicesToRemove];
%orig(renderer);
}
%end
// Hide Tab Bar Indicators
%hook YTPivotBarIndicatorView
-- (void)setFillColor:(id)arg1 { IS_ENABLED(HideTabIndi) ? %orig([UIColor clearColor]) : %orig; }
-- (void)setBorderColor:(id)arg1 { IS_ENABLED(HideTabIndi) ? %orig([UIColor clearColor]) : %orig; }
+- (void)setFillColor:(UIColor *)arg1 {
+ UIColor *temp = IS_ENABLED(HideTabIndi) ? [UIColor clearColor] : arg1;
+ %orig(temp);
+}
+- (void)setBorderColor:(UIColor *)arg1 {
+ UIColor *temp = IS_ENABLED(HideTabIndi) ? [UIColor clearColor] : arg1;
+ %orig(temp);
+}
%end
-// Hide Tab Labels
+static NSString *YMExtractYouTubeVideoID(NSString *urlString) {
+ if (!urlString || urlString.length == 0) return nil;
+
+ NSString *cleanString = [urlString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
+
+ if (cleanString.length == 11 && ![cleanString containsString:@"/"] && ![cleanString containsString:@"?"]) {
+ return cleanString;
+ }
+
+ NSString *extractedID = nil;
+ NSURL *url = [NSURL URLWithString:cleanString];
+
+ if (url) {
+ if ([url.host containsString:@"youtu.be"]) {
+ NSString *path = [url.path stringByReplacingOccurrencesOfString:@"/" withString:@""];
+ if (path.length >= 11) {
+ extractedID = [path substringToIndex:11];
+ }
+ } else if ([url.host containsString:@"youtube.com"]) {
+ if ([url.path containsString:@"/shorts/"] || [url.path containsString:@"/live/"] || [url.path containsString:@"/clip/"]) {
+ NSString *lastPath = [url.path lastPathComponent];
+ if ([lastPath containsString:@"?"]) {
+ lastPath = [[lastPath componentsSeparatedByString:@"?"] firstObject];
+ }
+ if (lastPath.length >= 11) {
+ extractedID = [lastPath substringToIndex:11];
+ }
+ } else {
+ NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
+ for (NSURLQueryItem *item in components.queryItems) {
+ if ([item.name isEqualToString:@"v"] && item.value.length >= 11) {
+ extractedID = [item.value substringToIndex:11];
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (!extractedID) {
+ NSError *error = nil;
+ NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"(?:v=|\\/(?:shorts|live|clip)\\Timer|youtu\\.be\\/)([a-zA-Z0-9_-]{11})"
+ options:NSRegularExpressionCaseInsensitive
+ error:&error];
+ NSTextCheckingResult *match = [regex firstMatchInString:cleanString options:0 range:NSMakeRange(0, cleanString.length)];
+ if (match && match.numberOfRanges > 1) {
+ extractedID = [cleanString substringWithRange:[match rangeAtIndex:1]];
+ }
+ }
+
+ return (extractedID && extractedID.length == 11) ? extractedID : nil;
+}
+
+static NSString *gLastOpenedVideoID = nil;
+
+static void YMOpenLinkFromClipboard(UIViewController *presentingVC, BOOL isRuntime) {
+ UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
+
+ if (![pasteboard hasStrings]) return;
+
+ NSString *rawString = [pasteboard.string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
+ NSString *videoID = YMExtractYouTubeVideoID(rawString);
+
+ if (!videoID || videoID.length == 0) return;
+
+ if (gLastOpenedVideoID && [gLastOpenedVideoID isEqualToString:videoID] && IS_ENABLED(AutoOpenLink) && !isRuntime) return;
+
+ NSString *schemeURLString = [NSString stringWithFormat:@"youtube://%@", videoID];
+ NSURL *targetURL = [NSURL URLWithString:schemeURLString];
+
+ if ([[UIApplication sharedApplication] canOpenURL:targetURL]) {
+ gLastOpenedVideoID = [videoID copy];
+ [[UIApplication sharedApplication] openURL:targetURL options:@{} completionHandler:nil];
+ }
+}
+
+static BOOL isGestureRegistered = NO;
+// Hide Tab Labels + long-press on the first tab to open Manage Tabs
%hook YTPivotBarItemView
- (void)setRenderer:(YTIPivotBarRenderer *)renderer {
%orig;
@@ -42,18 +241,116 @@
[self.navigationButton setTitle:@"" forState:UIControlStateNormal];
[self.navigationButton setSizeWithPaddingAndInsets:NO];
}
+ // Attach long-press gesture once per view; the action handler checks the
+ // current pivotIdentifier at fire time, so cell reuse / pivot bar refresh
+ // can rebind the same view to a different tab safely.
+ static const void *kYMContextMenuKey = &kYMContextMenuKey;
+ if (!objc_getAssociatedObject(self, kYMContextMenuKey) && !isGestureRegistered) {
+ UIContextMenuInteraction *interaction = [[UIContextMenuInteraction alloc] initWithDelegate:(id)self];
+ [self addInteraction:interaction];
+ objc_setAssociatedObject(self, kYMContextMenuKey, interaction, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ isGestureRegistered = YES;
+ }
+}
+%new
+- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction configurationForMenuAtLocation:(CGPoint)location {
+ return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil actionProvider:^UIMenu * _Nullable(NSArray * _Nonnull suggestedActions) {
+ UIAction *tabBarAction = [UIAction actionWithTitle:LOC(@"MANAGE_TABS")
+ image:[UIImage systemImageNamed:@"dock.rectangle"]
+ identifier:nil
+ handler:^(__kindof UIAction * _Nonnull action) {
+ YMPresentTabOrderModally(nil);
+ }];
+ UIAction *openLinkAction = [UIAction actionWithTitle:LOC(@"OPEN_LINK")
+ image:[UIImage systemImageNamed:@"link"]
+ identifier:nil
+ handler:^(__kindof UIAction * _Nonnull action) {
+ UIViewController *topVC = YouModTopViewController(nil);
+ YMOpenLinkFromClipboard(topVC, YES);
+ }];
+ return [UIMenu menuWithTitle:@"" children:@[tabBarAction, openLinkAction]];
+ }];
}
%end
// Startup Tab
-BOOL isTabSelected = NO;
+static BOOL isTabSelected = NO;
%hook YTPivotBarViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
+ sbUpdateOverlayInsetForPivotBar();
+ if (IS_ENABLED(ShortsOnly) && !isTabSelected) {
+ [self selectItemWithPivotIdentifier:@"FEshorts"];
+ isTabSelected = YES;
+ return;
+ }
if (!isTabSelected) {
- NSArray *pivotIdentifiers = @[@"FEwhat_to_watch", @"FEshorts", @"FEsubscriptions", @"FElibrary"];
- [self selectItemWithPivotIdentifier:pivotIdentifiers[INTFORVAL(DefaultTab)]]; // Set int here
+ // Build pivot identifiers from enabled tabs (skip Create — matches Settings.x segment logic)
+ NSMutableArray *pivotIdentifiers = [NSMutableArray array];
+ NSArray *savedOrder = [[NSUserDefaults standardUserDefaults] arrayForKey:TabOrder];
+ if (savedOrder.count > 0) {
+ for (NSDictionary *entry in savedOrder) {
+ if (![entry[@"enabled"] boolValue]) continue;
+ NSString *tabID = entry[@"id"];
+ if ([tabID isEqualToString:@"create"]) continue;
+ NSString *pivot = ymPivotIDForTabID(tabID);
+ if (pivot) [pivotIdentifiers addObject:pivot];
+ }
+ }
+ if (pivotIdentifiers.count == 0) {
+ pivotIdentifiers = [@[@"FEwhat_to_watch", @"FEshorts", @"FEsubscriptions", @"FElibrary"] mutableCopy];
+ }
+
+ NSInteger tabIndex = INTFORVAL(DefaultTab);
+ if (tabIndex < 0) tabIndex = 0;
+ if (tabIndex >= (NSInteger)pivotIdentifiers.count) tabIndex = MAX(0, (NSInteger)pivotIdentifiers.count - 1);
+ [self selectItemWithPivotIdentifier:pivotIdentifiers[tabIndex]];
isTabSelected = YES;
}
}
-%end
\ No newline at end of file
+// Translucent tab bar
+- (BOOL)isFrostedPivotBarPermitted {
+ if (INTFORVAL(UseFrostedTabBar) == 1) {
+ return YES;
+ } else if (INTFORVAL(UseFrostedTabBar) == 2) {
+ return NO;
+ }
+ return %orig;
+}
+%end
+
+%hook YTAppDelegate
+- (void)appDidBecomeActive {
+ %orig;
+ if (IS_ENABLED(AutoOpenLink)) {
+ UIViewController *topVC = YouModTopViewController(nil);
+ YMOpenLinkFromClipboard(topVC, NO);
+ }
+}
+%end
+
+// Recompute SB overlay safe-area inset whenever YouTube shows or hides the pivot bar
+// (e.g. entering/exiting fullscreen player). This keeps the SponsorBlock skip pill
+// and download progress pill anchored above the tabbar when visible, and at the
+// device safe-area bottom when the tabbar is hidden.
+%hook YTAppViewController
+- (void)hidePivotBar {
+ %orig;
+ sbUpdateOverlayInsetForPivotBar();
+}
+- (void)showPivotBar {
+ %orig;
+ sbUpdateOverlayInsetForPivotBar();
+}
+%end
+
+%hook YTAppViewControllerImpl
+- (void)hidePivotBar {
+ %orig;
+ sbUpdateOverlayInsetForPivotBar();
+}
+- (void)showPivotBar {
+ %orig;
+ sbUpdateOverlayInsetForPivotBar();
+}
+%end
diff --git a/Files/ThumbnailPreview.x b/Files/ThumbnailPreview.x
new file mode 100644
index 00000000..c7824c60
--- /dev/null
+++ b/Files/ThumbnailPreview.x
@@ -0,0 +1,213 @@
+#import
+#import "Headers.h"
+
+static BOOL isPad() {
+ return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad;
+}
+
+@implementation YouModThumbnailViewController
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ UIView *bgContainer = [[UIView alloc] init];
+ bgContainer.translatesAutoresizingMaskIntoConstraints = NO;
+ bgContainer.clipsToBounds = YES;
+ bgContainer.backgroundColor = [UIColor clearColor];
+ [self.view addSubview:bgContainer];
+
+ UIImageView *bgImageView = [[UIImageView alloc] initWithImage:self.thumbnailImage];
+ bgImageView.contentMode = UIViewContentModeScaleAspectFill;
+ bgImageView.translatesAutoresizingMaskIntoConstraints = NO;
+ [bgContainer addSubview:bgImageView];
+
+ UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemMaterial];
+ UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
+ blurView.translatesAutoresizingMaskIntoConstraints = NO;
+ [bgContainer addSubview:blurView];
+
+ self.scrollView = [[UIScrollView alloc] init];
+ self.scrollView.delegate = self;
+ self.scrollView.clipsToBounds = YES;
+ self.scrollView.minimumZoomScale = 1.0;
+ self.scrollView.maximumZoomScale = 4.0;
+ self.scrollView.showsVerticalScrollIndicator = NO;
+ self.scrollView.showsHorizontalScrollIndicator = NO;
+ self.scrollView.translatesAutoresizingMaskIntoConstraints = NO;
+ [bgContainer addSubview:self.scrollView];
+
+ self.imageView = [[UIImageView alloc] initWithImage:self.thumbnailImage];
+ self.imageView.contentMode = UIViewContentModeScaleAspectFit;
+ self.imageView.translatesAutoresizingMaskIntoConstraints = NO;
+ [self.scrollView addSubview:self.imageView];
+
+ UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
+ doubleTap.numberOfTapsRequired = 2;
+ [self.scrollView addGestureRecognizer:doubleTap];
+
+ UIImageSymbolConfiguration *iconConfig = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightSemibold];
+ UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeCustom];
+ [closeBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:iconConfig] forState:UIControlStateNormal];
+ closeBtn.tintColor = [UIColor whiteColor];
+ closeBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
+ closeBtn.layer.cornerRadius = 25;
+ closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
+ [closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
+ [bgContainer addSubview:closeBtn];
+
+ UIButton *moreBtn = [UIButton buttonWithType:UIButtonTypeCustom];
+ [moreBtn setImage:[UIImage systemImageNamed:@"ellipsis" withConfiguration:iconConfig] forState:UIControlStateNormal];
+ moreBtn.tintColor = [UIColor whiteColor];
+ moreBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
+ moreBtn.layer.cornerRadius = 25;
+ moreBtn.translatesAutoresizingMaskIntoConstraints = NO;
+ [moreBtn addTarget:self action:@selector(moreTapped:) forControlEvents:UIControlEventTouchUpInside];
+ [bgContainer addSubview:moreBtn];
+
+ NSMutableArray *constraints = [NSMutableArray array];
+
+ if (isPad()) {
+ self.view.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
+ bgContainer.layer.cornerRadius = 16;
+
+ [constraints addObject:[bgContainer.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor]];
+ [constraints addObject:[bgContainer.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor]];
+ [constraints addObject:[bgContainer.widthAnchor constraintEqualToAnchor:bgContainer.heightAnchor]];
+ [constraints addObject:[bgContainer.widthAnchor constraintLessThanOrEqualToAnchor:self.view.safeAreaLayoutGuide.widthAnchor multiplier:0.9]];
+ [constraints addObject:[bgContainer.heightAnchor constraintLessThanOrEqualToAnchor:self.view.safeAreaLayoutGuide.heightAnchor multiplier:0.9]];
+
+ NSLayoutConstraint *expandBox = [bgContainer.widthAnchor constraintEqualToConstant:2000];
+ expandBox.priority = UILayoutPriorityDefaultHigh;
+ [constraints addObject:expandBox];
+
+ [constraints addObjectsFromArray:@[
+ [closeBtn.topAnchor constraintEqualToAnchor:bgContainer.topAnchor constant:12],
+ [closeBtn.leadingAnchor constraintEqualToAnchor:bgContainer.leadingAnchor constant:12],
+ [moreBtn.topAnchor constraintEqualToAnchor:bgContainer.topAnchor constant:12],
+ [moreBtn.trailingAnchor constraintEqualToAnchor:bgContainer.trailingAnchor constant:-12],
+ ]];
+ } else {
+ self.view.backgroundColor = [UIColor clearColor];
+ bgContainer.layer.cornerRadius = 0;
+
+ [constraints addObjectsFromArray:@[
+ [bgContainer.topAnchor constraintEqualToAnchor:self.view.topAnchor],
+ [bgContainer.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
+ [bgContainer.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
+ [bgContainer.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor]
+ ]];
+
+ [constraints addObjectsFromArray:@[
+ [closeBtn.topAnchor constraintEqualToAnchor:bgContainer.safeAreaLayoutGuide.topAnchor constant:12],
+ [closeBtn.leadingAnchor constraintEqualToAnchor:bgContainer.safeAreaLayoutGuide.leadingAnchor constant:12],
+ [moreBtn.topAnchor constraintEqualToAnchor:bgContainer.safeAreaLayoutGuide.topAnchor constant:12],
+ [moreBtn.trailingAnchor constraintEqualToAnchor:bgContainer.safeAreaLayoutGuide.trailingAnchor constant:-12],
+ ]];
+ }
+
+ [constraints addObjectsFromArray:@[
+ [bgImageView.topAnchor constraintEqualToAnchor:bgContainer.topAnchor],
+ [bgImageView.bottomAnchor constraintEqualToAnchor:bgContainer.bottomAnchor],
+ [bgImageView.leadingAnchor constraintEqualToAnchor:bgContainer.leadingAnchor],
+ [bgImageView.trailingAnchor constraintEqualToAnchor:bgContainer.trailingAnchor],
+ [blurView.topAnchor constraintEqualToAnchor:bgContainer.topAnchor],
+ [blurView.bottomAnchor constraintEqualToAnchor:bgContainer.bottomAnchor],
+ [blurView.leadingAnchor constraintEqualToAnchor:bgContainer.leadingAnchor],
+ [blurView.trailingAnchor constraintEqualToAnchor:bgContainer.trailingAnchor]
+ ]];
+
+ [constraints addObjectsFromArray:@[
+ [self.scrollView.topAnchor constraintEqualToAnchor:bgContainer.topAnchor],
+ [self.scrollView.bottomAnchor constraintEqualToAnchor:bgContainer.bottomAnchor],
+ [self.scrollView.leadingAnchor constraintEqualToAnchor:bgContainer.leadingAnchor],
+ [self.scrollView.trailingAnchor constraintEqualToAnchor:bgContainer.trailingAnchor]
+ ]];
+
+ [constraints addObjectsFromArray:@[
+ [self.imageView.topAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.topAnchor],
+ [self.imageView.bottomAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.bottomAnchor],
+ [self.imageView.leadingAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.leadingAnchor],
+ [self.imageView.trailingAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.trailingAnchor],
+ [self.imageView.widthAnchor constraintEqualToAnchor:self.scrollView.frameLayoutGuide.widthAnchor],
+ [self.imageView.heightAnchor constraintEqualToAnchor:self.scrollView.frameLayoutGuide.heightAnchor]
+ ]];
+
+ [constraints addObjectsFromArray:@[
+ [closeBtn.widthAnchor constraintEqualToConstant:50],
+ [closeBtn.heightAnchor constraintEqualToConstant:50],
+ [moreBtn.widthAnchor constraintEqualToConstant:50],
+ [moreBtn.heightAnchor constraintEqualToConstant:50],
+ ]];
+
+ [NSLayoutConstraint activateConstraints:constraints];
+
+ UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)];
+ panGesture.delegate = self;
+ [self.view addGestureRecognizer:panGesture];
+}
+
+- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
+ return self.imageView;
+}
+
+- (void)handleDoubleTap:(UITapGestureRecognizer *)gesture {
+ if (self.scrollView.zoomScale > self.scrollView.minimumZoomScale) {
+ [self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES];
+ } else {
+ CGFloat targetZoomScale = 3.0;
+ CGPoint touchPoint = [gesture locationInView:self.imageView];
+ CGFloat zoomWidth = self.scrollView.bounds.size.width / targetZoomScale;
+ CGFloat zoomHeight = self.scrollView.bounds.size.height / targetZoomScale;
+ CGFloat zoomX = touchPoint.x - (zoomWidth / 2.0);
+ CGFloat zoomY = touchPoint.y - (zoomHeight / 2.0);
+ [self.scrollView zoomToRect:CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight) animated:YES];
+ }
+}
+
+- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
+ if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
+ if (self.scrollView.zoomScale > 1.0) return NO;
+ }
+ return YES;
+}
+
+- (void)handlePanGesture:(UIPanGestureRecognizer *)gesture {
+ CGPoint translation = [gesture translationInView:self.view];
+ CGPoint velocity = [gesture velocityInView:self.view];
+
+ if (gesture.state == UIGestureRecognizerStateChanged) {
+ if (translation.y > 0) {
+ self.view.transform = CGAffineTransformMakeTranslation(0, translation.y);
+ if (isPad()) {
+ CGFloat alpha = MAX(0.0, 0.5 - (translation.y / self.view.bounds.size.height));
+ self.view.backgroundColor = [UIColor colorWithWhite:0.0 alpha:alpha];
+ }
+ } else {
+ self.view.transform = CGAffineTransformMakeTranslation(0, translation.y * 0.1);
+ }
+ } else if (gesture.state == UIGestureRecognizerStateEnded || gesture.state == UIGestureRecognizerStateCancelled) {
+ if (translation.y > 150 || velocity.y > 1000) {
+ [self dismissViewControllerAnimated:YES completion:nil];
+ } else {
+ [UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:velocity.y / 1000.0 options:UIViewAnimationOptionCurveEaseOut animations:^{
+ self.view.transform = CGAffineTransformIdentity;
+ if (isPad()) self.view.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
+ } completion:nil];
+ }
+ }
+}
+
+- (void)closeTapped {
+ [self dismissViewControllerAnimated:YES completion:nil];
+}
+
+- (void)moreTapped:(UIButton *)sender {
+ UIActivityViewController *activityVC = [[UIActivityViewController alloc] initWithActivityItems:@[self.thumbnailImage] applicationActivities:nil];
+ if (isPad()) {
+ activityVC.popoverPresentationController.sourceView = sender;
+ activityVC.popoverPresentationController.sourceRect = sender.bounds;
+ }
+ [self presentViewController:activityVC animated:YES completion:nil];
+}
+
+@end
\ No newline at end of file
diff --git a/Files/TranslateText.x b/Files/TranslateText.x
new file mode 100644
index 00000000..4deecf4f
--- /dev/null
+++ b/Files/TranslateText.x
@@ -0,0 +1,434 @@
+#import "Headers.h"
+
+static void YouModTranslateText(NSString *text, NSString *targetLang, void (^completion)(NSString *translatedText, NSError *error)) {
+ if (!text || text.length == 0) {
+ if (completion) completion(@"", nil);
+ return;
+ }
+
+ NSString *encodedText = [text stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
+ NSString *urlString = [NSString stringWithFormat:@"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=%@&dt=t&q=%@", targetLang ?: @"en", encodedText];
+ NSURL *url = [NSURL URLWithString:urlString];
+
+ NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+ if (error || !data) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (completion) completion(nil, error);
+ });
+ return;
+ }
+
+ @try {
+ id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+ if ([json isKindOfClass:[NSArray class]] && [json count] > 0) {
+ NSArray *sentences = json[0];
+ NSMutableString *result = [NSMutableString string];
+ for (id sentence in sentences) {
+ if ([sentence isKindOfClass:[NSArray class]] && [sentence count] > 0) {
+ [result appendString:sentence[0]];
+ }
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (completion) completion(result, nil);
+ });
+ return;
+ }
+ } @catch (NSException *e) {}
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (completion) completion(nil, [NSError errorWithDomain:@"YouModTranslate" code:-1 userInfo:nil]);
+ });
+ }];
+ [task resume];
+}
+
+@implementation YouModLanguagePickerViewController
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ self.view.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
+
+ UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(dismissPicker)];
+ tap.delegate = self;
+ [self.view addGestureRecognizer:tap];
+
+ self.containerView = [[UIView alloc] init];
+ self.containerView.backgroundColor = [UIColor systemBackgroundColor];
+ self.containerView.layer.cornerRadius = 20.0;
+ self.containerView.clipsToBounds = YES;
+ self.containerView.translatesAutoresizingMaskIntoConstraints = NO;
+ [self.view addSubview:self.containerView];
+
+ UILabel *headerLabel = [[UILabel alloc] init];
+ headerLabel.text = LOC(@"LANGUAGE");
+ headerLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
+ headerLabel.textColor = [UIColor systemGrayColor];
+ headerLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ [self.containerView addSubview:headerLabel];
+
+ UIView *headerDivider = [[UIView alloc] init];
+ headerDivider.backgroundColor = [UIColor separatorColor];
+ headerDivider.translatesAutoresizingMaskIntoConstraints = NO;
+ [self.containerView addSubview:headerDivider];
+
+ self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
+ self.tableView.delegate = self;
+ self.tableView.dataSource = self;
+ self.tableView.backgroundColor = [UIColor clearColor];
+ self.tableView.separatorStyle = UITableViewCellSeparatorStyleSingleLine;
+ self.tableView.separatorColor = [UIColor separatorColor];
+ self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
+ [self.containerView addSubview:self.tableView];
+
+ UIButton *cancelButton = [UIButton buttonWithType:UIButtonTypeSystem];
+ [cancelButton setTitle:LOC(@"CANCEL") forState:UIControlStateNormal];
+ [cancelButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
+ cancelButton.backgroundColor = [UIColor systemPurpleColor];
+ cancelButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightBold];
+ cancelButton.layer.cornerRadius = 14.0;
+ cancelButton.clipsToBounds = YES;
+ cancelButton.translatesAutoresizingMaskIntoConstraints = NO;
+ [cancelButton addTarget:self action:@selector(dismissPicker) forControlEvents:UIControlEventTouchUpInside];
+ [self.containerView addSubview:cancelButton];
+
+ NSLayoutConstraint *widthConstraint = [self.containerView.widthAnchor constraintEqualToAnchor:self.view.widthAnchor multiplier:0.8];
+ widthConstraint.priority = UILayoutPriorityDefaultHigh;
+
+ NSLayoutConstraint *heightConstraint = [self.containerView.heightAnchor constraintEqualToAnchor:self.view.heightAnchor multiplier:0.65];
+ heightConstraint.priority = UILayoutPriorityDefaultHigh;
+
+ [NSLayoutConstraint activateConstraints:@[
+ [self.containerView.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
+ [self.containerView.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],
+
+ widthConstraint,
+ [self.containerView.widthAnchor constraintLessThanOrEqualToConstant:320.0],
+
+ heightConstraint,
+ [self.containerView.heightAnchor constraintLessThanOrEqualToConstant:500.0],
+
+ [headerLabel.topAnchor constraintEqualToAnchor:self.containerView.topAnchor constant:14],
+ [headerLabel.leadingAnchor constraintEqualToAnchor:self.containerView.leadingAnchor constant:20],
+ [headerLabel.trailingAnchor constraintEqualToAnchor:self.containerView.trailingAnchor constant:-20],
+
+ [headerDivider.topAnchor constraintEqualToAnchor:headerLabel.bottomAnchor constant:12],
+ [headerDivider.leadingAnchor constraintEqualToAnchor:self.containerView.leadingAnchor],
+ [headerDivider.trailingAnchor constraintEqualToAnchor:self.containerView.trailingAnchor],
+ [headerDivider.heightAnchor constraintEqualToConstant:0.5],
+
+ [self.tableView.topAnchor constraintEqualToAnchor:headerDivider.bottomAnchor],
+ [self.tableView.leadingAnchor constraintEqualToAnchor:self.containerView.leadingAnchor],
+ [self.tableView.trailingAnchor constraintEqualToAnchor:self.containerView.trailingAnchor],
+ [self.tableView.bottomAnchor constraintEqualToAnchor:cancelButton.topAnchor constant:-10],
+
+ [cancelButton.leadingAnchor constraintEqualToAnchor:self.containerView.leadingAnchor constant:16],
+ [cancelButton.trailingAnchor constraintEqualToAnchor:self.containerView.trailingAnchor constant:-16],
+ [cancelButton.bottomAnchor constraintEqualToAnchor:self.containerView.bottomAnchor constant:-14],
+ [cancelButton.heightAnchor constraintEqualToConstant:44]
+ ]];
+}
+
+- (void)viewDidAppear:(BOOL)animated {
+ [super viewDidAppear:animated];
+
+ NSUInteger selectedIndex = [self.codes indexOfObject:self.selectedLangCode];
+ if (selectedIndex != NSNotFound && selectedIndex < self.titles.count) {
+ NSIndexPath *indexPath = [NSIndexPath indexPathForRow:selectedIndex inSection:0];
+ [self.tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionMiddle animated:NO];
+ }
+}
+
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
+ if ([touch.view isDescendantOfView:self.containerView]) {
+ return NO;
+ }
+ return YES;
+}
+
+- (void)dismissPicker {
+ [self dismissViewControllerAnimated:YES completion:nil];
+}
+
+#pragma mark - TableView Delegate & DataSource
+
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
+ return self.titles.count;
+}
+
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
+ static NSString *cellID = @"LangCell";
+ UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellID];
+ if (!cell) {
+ cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellID];
+ cell.backgroundColor = [UIColor clearColor];
+ }
+
+ NSString *name = self.titles[indexPath.row];
+ NSString *code = self.codes[indexPath.row];
+ cell.textLabel.text = name;
+
+ if ([code isEqualToString:self.selectedLangCode]) {
+ cell.textLabel.textColor = [UIColor systemPurpleColor];
+ cell.textLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightBold];
+
+ UIImage *checkImg = [UIImage systemImageNamed:@"checkmark"];
+ cell.imageView.image = [checkImg imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
+ cell.imageView.tintColor = [UIColor systemPurpleColor];
+ } else {
+ cell.textLabel.textColor = [UIColor labelColor];
+ cell.textLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightRegular];
+
+ static UIImage *emptyImage = nil;
+ if (!emptyImage) {
+ UIGraphicsBeginImageContextWithOptions(CGSizeMake(20, 20), NO, 0.0);
+ emptyImage = UIGraphicsGetImageFromCurrentImageContext();
+ UIGraphicsEndImageContext();
+ }
+ cell.imageView.image = emptyImage;
+ }
+
+ return cell;
+}
+
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
+ [tableView deselectRowAtIndexPath:indexPath animated:YES];
+ if (self.onSelectLanguage) {
+ self.onSelectLanguage(self.codes[indexPath.row], self.titles[indexPath.row]);
+ }
+ [self dismissPicker];
+}
+
+@end
+
+@implementation YouModTranslationViewController
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ self.view.backgroundColor = [UIColor systemBackgroundColor];
+ self.title = LOC(@"TRANSLATION");
+
+ if (self.navigationController) {
+ UINavigationBarAppearance *appearance = [[UINavigationBarAppearance alloc] init];
+ [appearance configureWithOpaqueBackground];
+ appearance.backgroundColor = [UIColor systemBackgroundColor];
+ appearance.titleTextAttributes = @{
+ NSForegroundColorAttributeName: [UIColor labelColor],
+ NSFontAttributeName: [UIFont boldSystemFontOfSize:17]
+ };
+ self.navigationController.navigationBar.standardAppearance = appearance;
+ self.navigationController.navigationBar.scrollEdgeAppearance = appearance;
+ }
+
+ UIBarButtonItem *closeItem = [self createEqualBarButtonWithSymbol:@"xmark" action:@selector(closeTapped)];
+ UIBarButtonItem *copyItem = [self createEqualBarButtonWithSymbol:@"doc.on.doc" action:@selector(copyTapped)];
+ UIBarButtonItem *shareItem = [self createEqualBarButtonWithSymbol:@"square.and.arrow.up" action:@selector(shareTapped:)];
+
+ self.navigationItem.rightBarButtonItems = @[closeItem, copyItem, shareItem];
+
+ self.languageTitles = getAllSystemLanguageTitles();
+ self.languageCodes = getAllSystemLanguageValues();
+
+ self.selectedLangCode = @"en";
+ self.selectedLangName = @"English";
+
+ NSUInteger defaultIndex = [self.languageCodes indexOfObject:self.selectedLangCode];
+ if (defaultIndex != NSNotFound && defaultIndex < self.languageTitles.count) {
+ self.selectedLangName = self.languageTitles[defaultIndex];
+ } else if (self.languageTitles.count > 0 && self.languageCodes.count > 0) {
+ self.selectedLangCode = self.languageCodes.firstObject;
+ self.selectedLangName = self.languageTitles.firstObject;
+ }
+
+ UIView *langRowView = [[UIView alloc] init];
+ langRowView.translatesAutoresizingMaskIntoConstraints = NO;
+ langRowView.backgroundColor = [UIColor secondarySystemBackgroundColor];
+ langRowView.layer.cornerRadius = 12;
+ [self.view addSubview:langRowView];
+
+ UILabel *langTitleLabel = [[UILabel alloc] init];
+ langTitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ langTitleLabel.text = LOC(@"LANGUAGE");
+ langTitleLabel.textColor = [UIColor labelColor];
+ langTitleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
+ [langRowView addSubview:langTitleLabel];
+
+ self.langValueLabel = [[UILabel alloc] init];
+ self.langValueLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ self.langValueLabel.userInteractionEnabled = YES;
+
+ UITapGestureRecognizer *langTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(selectLanguageTapped:)];
+ [self.langValueLabel addGestureRecognizer:langTap];
+ [langRowView addSubview:self.langValueLabel];
+ [self updateLanguageLabelText];
+
+ self.reloadButton = [UIButton buttonWithType:UIButtonTypeSystem];
+ self.reloadButton.translatesAutoresizingMaskIntoConstraints = NO;
+ UIImageSymbolConfiguration *reloadConfig = [UIImageSymbolConfiguration configurationWithPointSize:15 weight:UIFontWeightMedium];
+ UIImage *reloadImage = [UIImage systemImageNamed:@"arrow.clockwise" withConfiguration:reloadConfig];
+ [self.reloadButton setImage:reloadImage forState:UIControlStateNormal];
+ self.reloadButton.tintColor = [UIColor systemRedColor];
+ self.reloadButton.hidden = YES;
+ [self.reloadButton addTarget:self action:@selector(performTranslation) forControlEvents:UIControlEventTouchUpInside];
+ [langRowView addSubview:self.reloadButton];
+
+ self.resultTextView = [[UITextView alloc] init];
+ self.resultTextView.translatesAutoresizingMaskIntoConstraints = NO;
+ self.resultTextView.backgroundColor = [UIColor secondarySystemBackgroundColor];
+ self.resultTextView.layer.cornerRadius = 12;
+ self.resultTextView.textColor = [UIColor labelColor];
+ self.resultTextView.font = [UIFont systemFontOfSize:16];
+ self.resultTextView.editable = NO;
+ self.resultTextView.textContainerInset = UIEdgeInsetsMake(12, 12, 12, 12);
+ [self.view addSubview:self.resultTextView];
+
+ UILayoutGuide *guide = self.view.safeAreaLayoutGuide;
+
+ [NSLayoutConstraint activateConstraints:@[
+ [langRowView.topAnchor constraintEqualToAnchor:guide.topAnchor constant:12],
+ [langRowView.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor constant:16],
+ [langRowView.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor constant:-16],
+ [langRowView.heightAnchor constraintEqualToConstant:48],
+
+ [langTitleLabel.leadingAnchor constraintEqualToAnchor:langRowView.leadingAnchor constant:16],
+ [langTitleLabel.centerYAnchor constraintEqualToAnchor:langRowView.centerYAnchor],
+
+ [self.langValueLabel.trailingAnchor constraintEqualToAnchor:langRowView.trailingAnchor constant:-16],
+ [self.langValueLabel.centerYAnchor constraintEqualToAnchor:langRowView.centerYAnchor],
+
+ [self.reloadButton.trailingAnchor constraintEqualToAnchor:self.langValueLabel.leadingAnchor constant:-8],
+ [self.reloadButton.centerYAnchor constraintEqualToAnchor:langRowView.centerYAnchor],
+ [self.reloadButton.widthAnchor constraintEqualToConstant:28],
+ [self.reloadButton.heightAnchor constraintEqualToConstant:28],
+
+ [self.resultTextView.topAnchor constraintEqualToAnchor:langRowView.bottomAnchor constant:12],
+ [self.resultTextView.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor constant:16],
+ [self.resultTextView.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor constant:-16],
+ [self.resultTextView.bottomAnchor constraintEqualToAnchor:guide.bottomAnchor constant:-16]
+ ]];
+
+ [self performTranslation];
+}
+
+- (UIBarButtonItem *)createEqualBarButtonWithSymbol:(NSString *)symbolName action:(SEL)action {
+ UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:15 weight:UIFontWeightMedium];
+ UIImage *image = [UIImage systemImageNamed:symbolName withConfiguration:config];
+ [button setImage:image forState:UIControlStateNormal];
+ button.tintColor = [UIColor labelColor];
+
+ button.translatesAutoresizingMaskIntoConstraints = NO;
+ [NSLayoutConstraint activateConstraints:@[
+ [button.widthAnchor constraintEqualToConstant:32],
+ [button.heightAnchor constraintEqualToConstant:32]
+ ]];
+
+ [button addTarget:self action:action forControlEvents:UIControlEventTouchUpInside];
+ return [[UIBarButtonItem alloc] initWithCustomView:button];
+}
+
+- (void)updateLanguageLabelText {
+ NSString *langName = self.selectedLangName ?: @"";
+ UIColor *purpleColor = [UIColor systemPurpleColor];
+ UIFont *labelFont = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
+
+ NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@ ", langName] attributes:@{
+ NSFontAttributeName: labelFont,
+ NSForegroundColorAttributeName: purpleColor
+ }];
+
+ UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithFont:labelFont];
+ UIImage *symbol = [UIImage systemImageNamed:@"chevron.up.chevron.down" withConfiguration:config];
+
+ if (symbol) {
+ NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
+ attachment.image = [symbol imageWithTintColor:purpleColor renderingMode:UIImageRenderingModeAlwaysOriginal];
+
+ CGFloat fontCapHeight = labelFont.capHeight;
+ CGFloat iconHeight = symbol.size.height;
+ attachment.bounds = CGRectMake(0, (fontCapHeight - iconHeight) / 2.0, symbol.size.width, iconHeight);
+
+ [attrString appendAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]];
+ }
+
+ self.langValueLabel.attributedText = attrString;
+}
+
+- (void)closeTapped {
+ [self dismissViewControllerAnimated:YES completion:nil];
+}
+
+- (void)copyTapped {
+ if (self.resultTextView.text.length > 0 && ![self.resultTextView.text isEqualToString:LOC(@"TRANSLATING")] && ![self.resultTextView.text isEqualToString:LOC(@"TRANSLATE_FAILED")]) {
+ [UIPasteboard generalPasteboard].string = self.resultTextView.text;
+
+ UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
+ [feedback impactOccurred];
+
+ UIView *parent = sbGetNotificationParent();
+ [SBSkipNotificationView showSuccessInView:parent message:LOC(@"COPIED_TO_CLIPBOARD") duration:3.0];
+ }
+}
+
+- (void)shareTapped:(id)sender {
+ if (self.resultTextView.text.length == 0 || [self.resultTextView.text isEqualToString:LOC(@"TRANSLATING")] || [self.resultTextView.text isEqualToString:LOC(@"TRANSLATE_FAILED")]) return;
+
+ UIActivityViewController *activityVC = [[UIActivityViewController alloc] initWithActivityItems:@[self.resultTextView.text] applicationActivities:nil];
+ if (activityVC.popoverPresentationController) {
+ if ([sender isKindOfClass:[UIBarButtonItem class]]) {
+ activityVC.popoverPresentationController.barButtonItem = (UIBarButtonItem *)sender;
+ } else if ([sender isKindOfClass:[UIView class]]) {
+ activityVC.popoverPresentationController.sourceView = (UIView *)sender;
+ activityVC.popoverPresentationController.sourceRect = ((UIView *)sender).bounds;
+ }
+ }
+ [self presentViewController:activityVC animated:YES completion:nil];
+}
+
+- (void)selectLanguageTapped:(UITapGestureRecognizer *)gesture {
+ if (!self.languageTitles || self.languageTitles.count == 0) return;
+
+ YouModLanguagePickerViewController *pickerVC = [[YouModLanguagePickerViewController alloc] init];
+ pickerVC.titles = self.languageTitles;
+ pickerVC.codes = self.languageCodes;
+ pickerVC.selectedLangCode = self.selectedLangCode;
+
+ __weak typeof(self) weakSelf = self;
+ pickerVC.onSelectLanguage = ^(NSString *code, NSString *title) {
+ __strong typeof(weakSelf) strongSelf = weakSelf;
+ if (!strongSelf) return;
+ strongSelf.selectedLangCode = code;
+ strongSelf.selectedLangName = title;
+ [strongSelf updateLanguageLabelText];
+ [strongSelf performTranslation];
+ };
+
+ pickerVC.modalPresentationStyle = UIModalPresentationOverFullScreen;
+ pickerVC.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
+ [self presentViewController:pickerVC animated:YES completion:nil];
+}
+
+- (void)performTranslation {
+ self.reloadButton.hidden = YES;
+ self.resultTextView.text = LOC(@"TRANSLATING");
+ self.resultTextView.textColor = [UIColor systemPurpleColor];
+
+ __weak typeof(self) weakSelf = self;
+ YouModTranslateText(self.originalText, self.selectedLangCode, ^(NSString *translatedText, NSError *error) {
+ __strong typeof(weakSelf) strongSelf = weakSelf;
+ if (!strongSelf) return;
+
+ if (translatedText && translatedText.length > 0) {
+ strongSelf.resultTextView.text = translatedText;
+ strongSelf.resultTextView.textColor = [UIColor labelColor];
+ strongSelf.reloadButton.hidden = YES;
+ } else {
+ strongSelf.resultTextView.text = LOC(@"TRANSLATE_FAILED");
+ strongSelf.resultTextView.textColor = [UIColor systemRedColor];
+ strongSelf.reloadButton.hidden = NO;
+ }
+ });
+}
+
+@end
\ No newline at end of file
diff --git a/Files/YouModPerferences.x b/Files/YouModPerferences.x
index 361ce66c..fc3a4944 100644
--- a/Files/YouModPerferences.x
+++ b/Files/YouModPerferences.x
@@ -3,21 +3,6 @@
#define Prefix @"YouMod"
-static NSBundle *YouModBundle() {
- static NSBundle *bundle = nil;
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- NSString *tweakBundlePath = [[NSBundle mainBundle] pathForResource:Prefix ofType:@"bundle"];
- if (tweakBundlePath)
- bundle = [NSBundle bundleWithPath:tweakBundlePath];
- else
- bundle = [NSBundle bundleWithPath:[NSString stringWithFormat:PS_ROOT_PATH_NS(@"/Library/Application Support/%@.bundle"), Prefix]];
- });
- return bundle;
-}
-
-#define LOC(x) [YouModBundle() localizedStringForKey:x value:nil table:nil]
-
@implementation YouModPrefsManager
+ (instancetype)sharedManager {
@@ -57,6 +42,7 @@ static NSBundle *YouModBundle() {
YTAlertView *alertView = [%c(YTAlertView) infoDialog];
alertView.title = LOC(@"ERROR");
alertView.subtitle = LOC(@"ERROR_INVALID_FILE");
+ alertView.shouldDismissOnBackgroundTap = YES;
[alertView show];
return;
}
@@ -81,16 +67,21 @@ static NSBundle *YouModBundle() {
YTAlertView *alertView = [%c(YTAlertView) infoDialog];
alertView.title = LOC(@"ERROR");
alertView.subtitle = LOC(@"ERROR_NO_KEYS_IMPORT");
+ alertView.shouldDismissOnBackgroundTap = YES;
[alertView show];
return;
}
[defaults synchronize];
// Success Alert with Restart
YTAlertView *alertView = [%c(YTAlertView) confirmationDialogWithAction:^{
- exit(0);
+ [[UIApplication sharedApplication] performSelector:@selector(suspend)];
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+ exit(0);
+ });
} actionTitle:LOC(@"YES")];
alertView.title = LOC(@"IMPORT_DONE");
alertView.subtitle = LOC(@"APPLY_DESC"); // "Restart required"
+ alertView.shouldDismissOnBackgroundTap = YES;
[alertView show];
}
@@ -107,6 +98,7 @@ static NSBundle *YouModBundle() {
YTAlertView *alertView = [%c(YTAlertView) infoDialog];
alertView.title = LOC(@"ERROR");
alertView.subtitle = LOC(@"ERROR_NO_KEYS_EXPORT");
+ alertView.shouldDismissOnBackgroundTap = YES;
[alertView show];
return;
}
@@ -134,18 +126,23 @@ static NSBundle *YouModBundle() {
}
[defaults setBool:YES forKey:AutoClearCache];
[defaults setBool:YES forKey:YTPremiumLogo];
- [defaults setBool:YES forKey:HideCreateButton];
[defaults setBool:YES forKey:HideCastButtonNav];
[defaults setBool:YES forKey:HideCastButtonPlayer];
[defaults setBool:YES forKey:BackgroundPlayback];
- [defaults setBool:YES forKey:OldQualityPicker];
- [defaults setBool:YES forKey:DownloadManager];
- [defaults setBool:YES forKey:DownloadSaveToPhotos];
+ [defaults setBool:YES forKey:DisableHints];
+ [defaults setInteger:1 forKey:GestureActivationArea];
+ [defaults setInteger:1 forKey:LeftSideGesture];
+ [defaults setInteger:2 forKey:RightSideGesture];
+ [defaults setInteger:1 forKey:GestureHUDSize];
[defaults synchronize];
- exit(0);
+ [[UIApplication sharedApplication] performSelector:@selector(suspend)];
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
+ exit(0);
+ });
} actionTitle:LOC(@"YES")];
alertView.title = LOC(@"WARNING");
alertView.subtitle = LOC(@"RESETDEFAULT");
+ alertView.shouldDismissOnBackgroundTap = YES;
[alertView show];
}
diff --git a/Files/YouModSettings.x b/Files/YouModSettings.x
new file mode 100644
index 00000000..a4ca8448
--- /dev/null
+++ b/Files/YouModSettings.x
@@ -0,0 +1,1472 @@
+// YouModSettings.x — Reusable UIKit-based sub-page for YouMod settings sections
+#import "Headers.h"
+#import
+#import