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..aab05d5d 100644
--- a/Files/Ads.x
+++ b/Files/Ads.x
@@ -9,30 +9,61 @@ static BOOL isProductList(YTICommand *command) {
return NO;
}
-NSString *getAdString(NSString *description) {
- for (NSString *str in @[
- @"brand_promo",
- @"carousel_footered_layout",
- @"carousel_headered_layout",
- @"eml.expandable_metadata",
- @"feed_ad_metadata",
- @"full_width_portrait_image_layout",
- @"full_width_square_image_layout",
- @"landscape_image_wide_button_layout",
- @"post_shelf",
- @"product_carousel",
- @"product_engagement_panel",
- @"product_item",
- @"shopping_carousel",
- @"shopping_item_card_list",
- @"statement_banner",
- @"square_image_layout",
- @"text_image_button_layout",
- @"text_search_ad",
- @"video_display_full_layout",
- @"video_display_full_buttoned_layout"
- ])
+static NSString *getPostString(NSString *description) {
+ static NSArray *postStrings = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ postStrings = @[
+ @"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"
+ ];
+ });
+ for (NSString *str in postStrings) {
if ([description containsString:str]) return str;
+ }
+ return nil;
+}
+
+static NSString *getAdString(NSString *description) {
+ static NSArray *adStrings = nil;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ adStrings = @[
+ @"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",
+ @"product_engagement_panel",
+ @"product_item",
+ @"shopping_carousel",
+ @"shopping_item_card_list",
+ @"statement_banner",
+ @"square_image_layout",
+ @"text_image_button_layout",
+ @"text_search_ad",
+ @"video_display_full_layout",
+ @"video_display_full_buttoned_layout"
+ ];
+ });
+ for (NSString *str in adStrings) {
+ if ([description containsString:str]) return str;
+ }
return nil;
}
@@ -42,16 +73,32 @@ static BOOL isAdRenderer(YTIElementRenderer *elementRenderer, int kind) {
}
NSString *description = [elementRenderer description];
NSString *adString = getAdString(description);
- if (adString) {
- return YES;
- }
+ if (adString) return YES;
return NO;
}
static NSMutableArray *filteredArray(NSArray *array) {
+ const BOOL hideShorts = IS_ENABLED(HideShortsShelf);
+ const BOOL keepShortsSub = IS_ENABLED(KeepShortsSubscript);
+ const BOOL hideFeedPost = IS_ENABLED(HideFeedPost);
+ const BOOL hidePlayables = IS_ENABLED(HidePlayables);
+ const BOOL hideHoriShelf = IS_ENABLED(HideHoriShelf);
+ const BOOL hideCommuGuide = IS_ENABLED(HideCommuGuide);
+ const BOOL hideGenMusic = IS_ENABLED(HideGenMusicShelf);
+ const BOOL hideSurveys = IS_ENABLED(HideSurveys);
+ const BOOL hideComments = IS_ENABLED(HideCommentsSection);
+
NSMutableArray *newArray = [array mutableCopy];
NSIndexSet *removeIndexes = [newArray indexesOfObjectsPassingTest:^BOOL(YTIItemSectionRenderer *sectionRenderer, NSUInteger idx, BOOL *stop) {
if ([sectionRenderer isKindOfClass:%c(YTIShelfRenderer)]) {
+ NSString *description = [sectionRenderer description];
+ if ([description containsString:@"community-tab-chip-posts-section"]) return NO;
+ if (hideShorts) {
+ if (keepShortsSub && [description containsString:@"subscriptions-shorts-shelf-item"]) return NO;
+ else if ([description containsString:@"shorts_video_cell.eml"]) return YES;
+ else if ([description containsString:@"shelf_header.eml"] && [description containsString:@"youtube_shorts_24_cairo"]) return YES;
+ }
+ if (hideFeedPost && getPostString(description) != nil) return YES;
YTIShelfSupportedRenderers *content = ((YTIShelfRenderer *)sectionRenderer).content;
YTIHorizontalListRenderer *horizontalListRenderer = content.horizontalListRenderer;
NSMutableArray *itemsArray = horizontalListRenderer.itemsArray;
@@ -60,20 +107,71 @@ 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;
+ else if ([desc containsString:@"UNLIMITED"] && [desc containsString:@"SPunlimited"]) {
+ [indexesToRemove addIndex:idx];
+ if (lastCellDividerIndex != NSNotFound) {
+ [indexesToRemove addIndex:lastCellDividerIndex];
+ lastCellDividerIndex = NSNotFound;
+ }
+ }
+ }];
+ [contentsArray removeObjectsAtIndexes:indexesToRemove];
+ return NO;
+ }
+
+ const BOOL isShortsShelf = [description containsString:@"shorts_shelf.eml"];
+ const BOOL isHistory = [description containsString:@"history-shorts-shelf-item"];
+ const BOOL isShortsOverlay = [description containsString:@"video_lockup_overlay"];
+ if (hideShorts && keepShortsSub) {
+ if (isShortsShelf && ![description containsString:@"subscriptions-shorts-shelf-item"] && !isHistory) return YES;
+ else if (isShortsOverlay) return YES;
+ } else if (hideShorts) {
+ if (isShortsShelf && !isHistory) return YES;
+ else if (isShortsOverlay) return YES;
+ }
+
+ if ([description containsString:@"horizontal_shelf.eml"]) {
+ if (hidePlayables && [description containsString:@"FEmini_app_destination"]) return YES;
+ if (hideHoriShelf && ![description containsString:@"UCYfdidRxbB8Qhf0Nx7ioOYw"] && ![description containsString:@"FElibrary"] && ![description containsString:@"mini_game_card.eml"] && ![description containsString:@"FEplaylist_aggregation"]) {
+ return YES;
+ }
+ }
+
+ if (hideCommuGuide && ([description containsString:@"community_guidelines.eml"] || [description containsString:@"channel_guidelines_entry_banner.eml"])) {
+ return YES;
+ }
+
+ if (hideFeedPost && getPostString(description) != nil) return YES;
+ if (hideGenMusic && [description containsString:@"feed_nudge.eml"]) return YES;
+ if (hideSurveys && [description containsString:@"in_feed_survey.eml"]) return YES;
+ if (hideComments && [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) {
+ YTIElementRenderer *elementRenderer = sectionSupportedRenderers.elementRenderer;
+ return isAdRenderer(elementRenderer, 3);
+ }];
+ [contentsArray removeObjectsAtIndexes:removeContentsArrayIndexes];
+ }
+ YTIItemSectionSupportedRenderers *firstObject = [contentsArray firstObject];
+ YTIElementRenderer *elementRenderer = firstObject.elementRenderer;
+ if (isAdRenderer(elementRenderer, 2)) return YES;
}
- if (![sectionRenderer isKindOfClass:%c(YTIItemSectionRenderer)])
- return NO;
- NSMutableArray *contentsArray = sectionRenderer.contentsArray;
- if (contentsArray.count > 1) {
- NSIndexSet *removeContentsArrayIndexes = [contentsArray indexesOfObjectsPassingTest:^BOOL(YTIItemSectionSupportedRenderers *sectionSupportedRenderers, NSUInteger idx2, BOOL *stop2) {
- YTIElementRenderer *elementRenderer = sectionSupportedRenderers.elementRenderer;
- return isAdRenderer(elementRenderer, 3);
- }];
- [contentsArray removeObjectsAtIndexes:removeContentsArrayIndexes];
- }
- YTIItemSectionSupportedRenderers *firstObject = [contentsArray firstObject];
- YTIElementRenderer *elementRenderer = firstObject.elementRenderer;
- return isAdRenderer(elementRenderer, 2);
+ return NO;
}];
[newArray removeObjectsAtIndexes:removeIndexes];
return newArray;
@@ -102,11 +200,17 @@ static NSMutableArray *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,10 +300,23 @@ 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 = isPad() ? @"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,123 @@ 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, 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 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 YTIPlayerResponse *YouModPlayerDataForPlayer(YTPlayerViewController *player) {
+ YTPlayerResponse *response;
+ if ([player respondsToSelector:@selector(contentPlayerResponse)]) {
+ response = player.contentPlayerResponse;
+ } else {
+ response = player.playerResponse;
+ }
+ YTIPlayerResponse *playerData = response.playerData;
+ return playerData;
}
-static NSString *YouModVideoIDForPlayer(YTPlayerViewController *player) {
- NSString *videoID = [player contentVideoID];
- if (videoID.length == 0)
- videoID = [player currentVideoID];
- return videoID;
+static NSArray *YouModCaptionTracksForPlayer(YTPlayerViewController *player) {
+ 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 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 YTIVideoDetails *YouModVideoDetailsForPlayer(YTPlayerViewController *player) {
+ YTIPlayerResponse *ires = YouModPlayerDataForPlayer(player);
+ return ires.videoDetails;
}
-// 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 NSString *YouModAuthorForPlayer(YTPlayerViewController *player) {
+ YTIVideoDetails *details = YouModVideoDetailsForPlayer(player);
+ return details.author;
}
-static id YouModPlayerDataForPlayer(YTPlayerViewController *player) {
- id response = YouModPlayerResponsesForPlayer(player).firstObject;
- id playerData = YouModObjectFromSelector(response, @selector(playerData));
- return playerData ?: response;
+static NSString *YouModTitleForPlayer(YTPlayerViewController *player) {
+ YTIVideoDetails *details = YouModVideoDetailsForPlayer(player);
+ return details.title;
}
-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 NSString *YouModDescriptionForPlayer(YTPlayerViewController *player) {
+ YTIVideoDetails *details = YouModVideoDetailsForPlayer(player);
+ return details.shortDescription;
}
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;
+ YTIPlayerResponse *playerData = YouModPlayerDataForPlayer(player);
+ YTIStreamingData *streamingData = playerData.streamingData;
+ return streamingData.adaptiveFormatsArray;
}
-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);
+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 +728,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 +739,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)])
@@ -1465,22 +759,28 @@ static YTPlayerViewController *YouModPlayerFromViewController(UIViewController *
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 NSURL *YouModThumbnailURL(YTPlayerViewController *player) {
+ if (!player) return nil;
+ YTIVideoDetails *details = YouModVideoDetailsForPlayer(player);
+ YTIThumbnailDetails *thumbmain = details.thumbnail;
+ YTIThumbnailDetails_Thumbnail *bestThumbnail = nil;
+ NSUInteger maxPixels = 0;
+
+ for (YTIThumbnailDetails_Thumbnail *thumb in thumbmain.thumbnailsArray) {
+ NSUInteger pixels = (NSUInteger)thumb.width * (NSUInteger)thumb.height;
+ if (pixels > maxPixels) {
+ maxPixels = pixels;
+ bestThumbnail = thumb;
+ }
+ }
+
+ return [NSURL URLWithString:bestThumbnail.URL];
}
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 +803,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) {
+ if (isPad()) {
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 +871,80 @@ 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];
+}
+
+// SABR progress with a speed + size subtitle. When the total is known (the formats'
+// contentLength, in self.totalBytes) the percentage and subtitle track downloaded/total
+// like the direct/server path; otherwise they fall back to the segment fraction + a
+// downloaded-so-far figure. Reuses the "X.X MB/s · Y.Y MB" style.
+- (void)updateSABRProgressTitle:(NSString *)title progress:(float)progress bytesDownloaded:(unsigned long long)bytesDownloaded {
+ NSTimeInterval elapsed = [NSDate timeIntervalSinceReferenceDate] - self.downloadStartTime;
+ double downloadedMB = (double)bytesDownloaded / 1048576.0;
+ double speedMBps = elapsed > 0 ? (downloadedMB / elapsed) : 0;
+
+ NSString *subtitle;
+ if (self.totalBytes > 0) {
+ // Real percentage from bytes; subtitle shows downloaded / total.
+ progress = fminf(fmaxf((float)bytesDownloaded / (float)self.totalBytes, 0.0f), 1.0f);
+ double totalMB = (double)self.totalBytes / 1048576.0;
+ subtitle = [NSString stringWithFormat:@"%.1f MB/s · %.1f / %.1f MB", speedMBps, downloadedMB, totalMB];
+ } else {
+ // Total unknown → segment-fraction % + downloaded-so-far.
+ subtitle = [NSString stringWithFormat:@"%.1f MB/s · %.1f MB", speedMBps, downloadedMB];
+ }
+
+ NSString *displayTitle = [NSString stringWithFormat:@"%@ - %ld%%", title, (long)lrintf(progress * 100.0f)];
+ [self.progressPill updateProgress:progress title:displayTitle subtitle:subtitle];
}
- (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];
+ [YMSABR cancelCurrent];
+
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 +972,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 +1036,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 +1045,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 +1069,26 @@ 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];
+ // On-device SABR path. Checked first: on modern YouTube the format URLs are empty
+ // (media flows via SABR), so the URL check below would otherwise abort.
+ if (INTFORVAL(DownloadMethod) == DownloadMethodOnDevice) {
+ [self startSABRVideoDownloadWithVideoFormat:videoFormat audioFormat:audioFormat fileName:fileName presenter:presenter];
+ return;
+ }
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 +1096,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 (INTFORVAL(DownloadMethod) == DownloadMethodServer) {
+ 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 +1130,119 @@ 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;
- }
-
+// On-device SABR download: fetch the chosen mp4 video + m4a audio itags via the SABR
+// engine (which captures/replays the app's own signed request), then hand the two
+// elementary files to the existing muxer. Additive — the direct/server paths above
+// are untouched.
+- (void)startSABRVideoDownloadWithVideoFormat:(YouModMediaFormat *)videoFormat audioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter {
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];
+ // Known total from the formats' contentLength (present even on 21.29 where the
+ // stream URL is empty) → the pill can show a real % + total size. 0 if unknown.
+ self.totalBytes = videoFormat.contentLength + audioFormat.contentLength;
+ [self showProgressWithTitle:LOC(@"DOWNLOADING_VIDEO") presenter:presenter];
+ unsigned long long durationMs = videoFormat.durationMs ?: audioFormat.durationMs;
__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;
+ [YMSABR downloadVideoItag:videoFormat.itag audioItag:audioFormat.itag
+ progress:^(float fraction, unsigned long long bytesDownloaded) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ [self updateSABRProgressTitle:LOC(@"DOWNLOADING_VIDEO") progress:fraction bytesDownloaded:bytesDownloaded];
}
- [self completeWithFileURL:fileURL isVideo:YES presenter:presenter];
- }];
+ completion:^(NSURL *videoURL, NSURL *audioURL, NSString *err) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ if (err || !videoURL || !audioURL) {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:20 userInfo:@{NSLocalizedDescriptionKey: err ?: LOC(@"DOWNLOAD_FAILED")}]];
+ return;
+ }
+ self.videoTempURL = videoURL; // so cleanupTemporaryFiles removes them afterwards
+ self.audioTempURL = audioURL;
+ [self mergeVideoURL:videoURL audioURL:audioURL fileName:fileName outputExtension:@"mp4" durationMs:durationMs 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];
+// On-device SABR audio-only download: fetch the chosen m4a audio itag via SABR, then
+// remux the fragmented-mp4 track into a clean .m4a with AVFoundation (passthrough, no
+// re-encode). No half-length trim: SABR delivers a properly segmented, complete track.
+- (void)startSABRAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter {
+ self.active = YES;
+ self.cancelled = NO;
+ self.completedBytes = 0;
+ // Known total from the format's contentLength (see video path). 0 if unknown.
+ self.totalBytes = audioFormat.contentLength;
+ [self showProgressWithTitle:LOC(@"DOWNLOADING_AUDIO") presenter:presenter];
+
+ NSURL *finalURL = YouModUniqueFileURL(fileName, @"m4a");
+ __weak typeof(self) weakSelf = self;
+ [YMSABR downloadAudioItag:audioFormat.itag
+ progress:^(float fraction, unsigned long long bytesDownloaded) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ [self updateSABRProgressTitle:LOC(@"DOWNLOADING_AUDIO") progress:fraction bytesDownloaded:bytesDownloaded];
+ }
+ completion:^(NSURL *audioURL, NSString *err) {
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self || self.cancelled) return;
+ if (err || !audioURL) {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:21 userInfo:@{NSLocalizedDescriptionKey: err ?: LOC(@"DOWNLOAD_FAILED")}]];
+ return;
+ }
+ self.audioTempURL = audioURL; // so cleanupTemporaryFiles removes it afterwards
+ [self exportSABRAudioURL:audioURL toURL:finalURL 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);
+// Remux a SABR audio track (fragmented mp4) to a clean m4a container, full length.
+- (void)exportSABRAudioURL:(NSURL *)audioURL toURL:(NSURL *)outputURL presenter:(UIViewController *)presenter {
+ [self updateProgressTitle:LOC(@"FINA_VIDEO") progress:0.985f];
+ AVURLAsset *asset = [AVURLAsset URLAssetWithURL:audioURL options:nil];
+ AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:asset presetName:AVAssetExportPresetAppleM4A];
+ if (!exportSession) {
+ [self failWithError:[NSError errorWithDomain:@"YouMod" code:22 userInfo:@{NSLocalizedDescriptionKey: LOC(@"DOWNLOAD_FAILED")}]];
return;
}
- [self startDirectAudioDownloadWithAudioFormat:audioFormat fileName:fileName videoID:videoID outputFormat:outputFormat presenter:presenter];
+ exportSession.outputURL = outputURL;
+ exportSession.outputFileType = AVFileTypeAppleM4A;
+ self.exporter = exportSession;
+
+ __weak typeof(self) weakSelf = self;
+ [exportSession exportAsynchronouslyWithCompletionHandler:^{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ __strong typeof(weakSelf) self = weakSelf;
+ if (!self) return;
+ self.exporter = nil;
+ if (self.cancelled || exportSession.status == AVAssetExportSessionStatusCancelled) return;
+ if (exportSession.status == AVAssetExportSessionStatusCompleted) {
+ [self completeWithFileURL:outputURL isVideo:NO presenter:presenter];
+ } else {
+ [self failWithError:exportSession.error ?: [NSError errorWithDomain:@"YouMod" code:23 userInfo:@{NSLocalizedDescriptionKey: LOC(@"DOWNLOAD_FAILED")}]];
+ }
+ });
+ }];
}
-- (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)startAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID {
+ if (self.active) {
+ YouModSendToast(LOC(@"ALREADY_DOWNLOADING"));
+ return;
+ }
+ [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 {
- NSURL *audioURL = [NSURL URLWithString:audioFormat.urlString];
- if (!audioURL) {
- YouModSendToast(@"No audio URL found", presenter);
+- (void)startDirectAudioDownloadWithAudioFormat:(YouModMediaFormat *)audioFormat fileName:(NSString *)fileName presenter:(UIViewController *)presenter videoID:(NSString *)vidID {
+ [self cleanupTemporaryFiles];
+ // On-device SABR path. Checked first: on modern YouTube the format URLs are empty
+ // (media flows via SABR), so the URL check below would otherwise abort.
+ if (INTFORVAL(DownloadMethod) == DownloadMethodOnDevice) {
+ [self startSABRAudioDownloadWithAudioFormat:audioFormat fileName:fileName presenter:presenter];
return;
}
- outputFormat = outputFormat ?: YouModDefaultAudioOutputFormat();
- if (!outputFormat.supported) {
- YouModSendToast([NSString stringWithFormat:@"%@ not supported", outputFormat.title ?: @"Format"], presenter);
+ NSURL *audioURL = [NSURL URLWithString:audioFormat.urlString];
+ if (!audioURL) {
+ YouModSendError(LOC(@"NO_AUDIO_URL"));
return;
}
@@ -1859,104 +1250,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 (INTFORVAL(DownloadMethod) == DownloadMethodServer) {
+ [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 +1369,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 +1380,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 +1398,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 +1437,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 YouModDownloadThumbnail(NSString *videoID, UIViewController *presenter) {
- NSURL *thumbnailURL = YouModThumbnailURLForVideoID(videoID);
+static void YouModShowThumbnailViewer(YTPlayerViewController *player, UIViewController *presenter) {
+ NSURL *thumbnailURL = YouModThumbnailURL(player);
+ 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 = UIModalPresentationFormSheet;
+ viewerVC.modalTransitionStyle = UIModalTransitionStyleCoverVertical;
+
+ [presenter presentViewController:viewerVC animated:YES completion:nil];
+ });
+ }] resume];
+}
+
+static void YouModCopyThumbnail(YTPlayerViewController *player, UIViewController *presenter) {
+ NSURL *thumbnailURL = YouModThumbnailURL(player);
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;
+ }
+ [[UIPasteboard generalPasteboard] setImage:image];
+ YouModSendSuccess(LOC(@"COPIED_TO_CLIPBOARD"));
+ });
+ }] resume];
+}
+
+static void YouModDownloadThumbnail(YTPlayerViewController *player, UIViewController *presenter) {
+ NSURL *thumbnailURL = YouModThumbnailURL(player);
+ 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;
}
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 +1671,211 @@ 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;
+ }
+
+ // Skip the audio-track chooser for a single format, or the server path (which
+ // can't fetch a chosen track). Direct and on-device SABR both honor the choice.
+ if (audioFormats.count == 1 || INTFORVAL(DownloadMethod) == DownloadMethodServer) {
+ 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) {
+static void YouModShowThumbnailSheet(YTPlayerViewController *player, UIViewController *presenter, UIView *sender) {
+ NSMutableArray *items = [NSMutableArray array];
+
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SAVE_THUMBNAIL") subtitle:nil icon:YouModYTIconImage(57, NO, nil) handler:^{
+ YouModDownloadThumbnail(player, presenter);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"SHOW_THUMBNAIL") subtitle:nil icon:YouModYTIconImage(208, NO, nil) handler:^{
+ YouModShowThumbnailViewer(player, presenter);
+ }]];
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_THUMBNAIL") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModCopyThumbnail(player, presenter);
+ }]];
+
+ YouModPresentMenu(nil, items, presenter, sender);
+}
+
+static void YouModShowDownloadManager(YTPlayerViewController *player, UIViewController *presenter, UIView *sender, BOOL isShorts) {
if (!player) {
- YouModSendToast(@"Open a video before using the download manager.", presenter);
+ YouModSendError(LOC(@"OPEN_VID_BEFORE"));
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);
+
+ 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:@"Download captions" subtitle:@"Save subtitles as VTT" icon:YouModIconImage(637) handler:^{
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"DOWNLOAD_CAPTIONS") subtitle:nil icon:YouModYTIconImage(50, NO, nil) 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:LOC(@"THUMBNAIL_OPTIONS") subtitle:nil icon:YouModYTIconImage(367, NO, nil) handler:^{
+ YouModShowThumbnailSheet(player, presenter, sender);
}]];
- [items addObject:[YouModMenuItem itemWithTitle:@"Copy video information" subtitle:@"Copy title and URL" icon:YouModIconImage(250) handler:^{
- YouModCopyVideoInfo(player, presenter);
+ [items addObject:[YouModMenuItem itemWithTitle:LOC(@"COPY_VID_INFO") subtitle:nil icon:YouModYTIconImage(250, NO, nil) handler:^{
+ YouModShowCopyVideoInfoSheet(player, presenter, sender);
}]];
- 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(player, items, presenter, sender);
}
-%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 {
@@ -2384,3 +1922,351 @@ 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]) {
+ ASDisplayNode *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;
+ }
+
+ for (id obj in node.yogaChildren) {
+ if ([obj isKindOfClass:elmTextClass] && [[obj description] containsString:@"id.comment.content.label"]) {
+ NSAttributedString *text = [obj valueForKey:@"_attributedText"];
+ resultText = text.string;
+ 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 = isDarkMode(view) ? [%c(YTColor) black3] : [%c(YTColor) white1];
+ 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,84 @@
#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
+#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 SABRDownload @"YouModSABRDownload"
+#define DownloadMethod @"YouModDownloadMethod" // index into the "Download method" picker
+#define DownloadMethodDirect 0 // YouTube's built-in stream URLs
+#define DownloadMethodServer 1 // external server (triggerSilentDownload…)
+#define DownloadMethodOnDevice 2 // on-device SABR engine
+
#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 +113,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 +182,111 @@
#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 HideShortsTopbar @"YouModHideShortsTopbar"
+#define HideShortsSubbar @"YouModHideShortsSubbar"
+#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 SBButtonKey @"YouModSBButtonKey"
+
+#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 +294,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 +339,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 +359,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 +512,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 +524,268 @@ 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;
+- (YTIThumbnailDetails *)thumbnail;
+@end
+
+@interface YTDataUtils : NSObject
++ (instancetype)generateClientSideNonce;
+@end
+
+@interface YCHAsyncLiveChatCollectionViewController : UIViewController
+@end
+
+@interface YTStartupAnimationViewController : UIViewController
+@end
+
+@interface YTWatchFloatingMiniplayerBadgeView : UIView
+@end
+
+@interface YTReelTopBarView : UIView
+@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);
+extern BOOL isDarkMode(UIView *view);
+extern BOOL isPad();
+
+#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;
+- (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
+
+// On-device SABR downloader (SABRDownload.x). Produces two elementary files (video
+// mp4 + audio m4a) for the existing muxer; progress/completion on the main queue.
+@interface YMSABR : NSObject
++ (void)downloadVideoItag:(int)videoItag audioItag:(int)audioItag
+ progress:(void (^)(float fraction, unsigned long long bytesDownloaded))progress
+ completion:(void (^)(NSURL *videoURL, NSURL *audioURL, NSString *err))completion;
++ (void)downloadAudioItag:(int)audioItag
+ progress:(void (^)(float fraction, unsigned long long bytesDownloaded))progress
+ completion:(void (^)(NSURL *audioURL, NSString *err))completion;
++ (void)cancelCurrent;
@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..cd8aa00b
--- /dev/null
+++ b/Files/OverlayButtons.x
@@ -0,0 +1,548 @@
+#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 YMOverlayButtonGap = 7.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) : 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..45854cd2 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 && IS_ENABLED(SBButtonKey)) {
+ 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];
+ }
+
+ 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 (![durationLabel.text containsString:formattedEndTime]) {
- durationLabel.text = [durationLabel.text stringByAppendingString:[NSString stringWithFormat:@" • %@", formattedEndTime]];
+ 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,28 @@ 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;
+ playerViewController.YouModHoldGesture.delegate = playerViewController;
+ [pv addGestureRecognizer:playerViewController.YouModHoldGesture];
+ }
}
%orig;
}
@@ -442,45 +697,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;
+
+ // Adjust the gesture view if the engagement panel is on
+ 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 +800,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 +816,606 @@ 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;
- } else {
- controlType = 0; // Center area
+ CGPoint velocity = [panGestureRecognizer velocityInView:self.view];
+ BOOL isHorizontal = fabs(velocity.x) > fabs(velocity.y);
+
+ if (isHorizontal && IS_ENABLED(SeekOnOverlay)) {
+ currentPanMode = 2;
+ } else if (!isHorizontal && IS_ENABLED(GestureControls)) {
+ 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);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [[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);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ 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;
+ controlType = 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 {
+ 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;
+ }
+
+ CGPoint startLocation = [tapGestureRecognizer locationInView:self.view];
+ CGFloat fullWidth = self.view.bounds.size.width;
+ 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) {
+ CGFloat remainingWidth = fullWidth - panelWidth;
+ if (startLocation.x > remainingWidth) return;
+ }
+ }
+ }
+
+ 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 (self.YouModHoldGesture && (self.YouModHoldGesture.state == UIGestureRecognizerStateBegan || self.YouModHoldGesture.state == UIGestureRecognizerStateChanged)) {
+ return;
+ }
+ 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) {
+ return (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) ?
+ [UIColor colorWithWhite:0.1 alpha:0.95] : [UIColor colorWithWhite:0.95 alpha:0.95];
+ }];
+
+ if (!self.YouModSpeedToastView) {
+ self.YouModSpeedToastView = [[UIView alloc] init];
+ self.YouModSpeedToastView.clipsToBounds = YES;
+ self.YouModSpeedToastView.alpha = 0.0;
+
+ self.YouModSpeedToastLabel = [[UILabel alloc] init];
+ self.YouModSpeedToastLabel.textAlignment = NSTextAlignmentCenter;
+ 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;
+
+ 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;
+
+ CGSize maxLabelSize = CGSizeMake(self.playerView.bounds.size.width * 0.8, CGFLOAT_MAX);
+ CGSize textSize = [self.YouModSpeedToastLabel sizeThatFits:maxLabelSize];
+
+ CGFloat paddingX = 24.0;
+ CGFloat paddingY = 10.0;
+ CGFloat toastWidth = fmaxf(textSize.width + paddingX, 140.0);
+ CGFloat toastHeight = textSize.height + paddingY;
+
+ self.YouModSpeedToastView.frame = CGRectMake(0, 0, toastWidth, toastHeight);
+ self.YouModSpeedToastView.layer.cornerRadius = toastHeight / 2.0;
+ self.YouModSpeedToastLabel.frame = self.YouModSpeedToastView.bounds;
+
+ self.YouModSpeedToastView.center = CGPointMake(self.playerView.bounds.size.width / 2.0, 36.0);
+ self.YouModSpeedToastView.layer.zPosition = 999;
+ [self.playerView bringSubviewToFront:self.YouModSpeedToastView];
+
+ [UIView animateWithDuration:0.2 animations:^{
+ self.YouModSpeedToastView.alpha = 1.0;
+ }];
+}
+
+%new
+- (void)YouModHoldToSpeed:(UILongPressGestureRecognizer *)gesture {
+ 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;
+ }
+
+ CGPoint touchLocation = [gesture locationInView:self.view];
+ CGFloat fullWidth = self.view.bounds.size.width;
+ 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) {
+ CGFloat remainingWidth = fullWidth - panelWidth;
+ if (touchLocation.x > remainingWidth) return;
+ }
+ }
+ }
+
+ 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(GlobalSavedNormalRate);
+
+ 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/SABRDownload.x b/Files/SABRDownload.x
new file mode 100644
index 00000000..f1b9d4a6
--- /dev/null
+++ b/Files/SABRDownload.x
@@ -0,0 +1,742 @@
+// SABRDownload.x — On-device SABR download (opt-in, behind the SABRDownload toggle).
+//
+// Modern YouTube (21.29) ships no direct stream URL; media flows via SABR (server-
+// driven adaptive bitrate) over the UMP byte protocol. This downloads on-device by
+// CAPTURING the app's own live, fully-signed `videoplayback` request (which already
+// carries the session's auth / PoToken in its URL) and REPLAYING a modified copy of
+// it — selecting the mp4 itags we want, then reading the returned UMP media parts to
+// disk — instead of reconstructing a SABR session from scratch. The two elementary
+// files are handed to YouMod's existing muxer for the final mp4.
+//
+// Additive: gated on SABR_ENABLED(), off by default. Touches none of the existing
+// (direct / server) download paths.
+//
+// Protocol field numbers follow LuanRT/googlevideo (cross-checked against
+// coletdjnz/yt-dlp-ytse and Epic0001/YTKACE) but are pinned to what YouTube 21.29
+// actually sends; see docs/specs/2026-07-29-sabr-ondevice-download-final.md. They are
+// version-sensitive — every wire field is a named constant below so a YouTube update
+// can be re-mapped in one place.
+
+#import "Headers.h"
+
+// Active only when the "Download method" setting is On-device (SABR). The capture
+// hook must share this gate with the download routing in Download.x, or it won't
+// capture the request the engine later needs.
+#define SABR_ENABLED() (INTFORVAL(DownloadMethod) == DownloadMethodOnDevice)
+
+// Serial queue guarding all shared engine state (capture globals, per-download
+// bookkeeping). Capture hooks and network completions fire on arbitrary threads and
+// hop here before touching shared state.
+static dispatch_queue_t SABRQueue(void) {
+ static dispatch_queue_t q; static dispatch_once_t o;
+ dispatch_once(&o, ^{ q = dispatch_queue_create("youmod.sabr", DISPATCH_QUEUE_SERIAL); });
+ return q;
+}
+
+#pragma mark - UMP parser
+
+// UMP varint: leading 1-bits of the first byte give total byte count (1..5).
+static NSUInteger SABRReadUMPVarint(const uint8_t *b, NSUInteger len, NSUInteger pos, uint64_t *out) {
+ if (pos >= len) return 0;
+ uint8_t pfx = b[pos]; int size = 1;
+ if (pfx >= 0xF0) size = 5; else if (pfx >= 0xE0) size = 4; else if (pfx >= 0xC0) size = 3; else if (pfx >= 0x80) size = 2;
+ if (pos + size > len) return 0;
+ uint64_t r = 0; int sh = 0;
+ if (size != 5) { sh = 8 - size; r = pfx & ((1u << sh) - 1); }
+ for (int i = 1; i < size; i++) { r |= ((uint64_t)b[pos + i]) << sh; sh += 8; }
+ *out = r; return (NSUInteger)size;
+}
+
+// UMP part type IDs (LuanRT/googlevideo ump_part_id.proto).
+typedef NS_ENUM(NSInteger, SABRPartType) {
+ SABRPartMediaHeader = 20,
+ SABRPartMedia = 21,
+ SABRPartMediaEnd = 22,
+ SABRPartNextRequestPolicy = 35,
+ SABRPartFormatInit = 42, // FORMAT_INITIALIZATION_METADATA
+ SABRPartRedirect = 43, // SABR_REDIRECT (new URL)
+ SABRPartError = 44, // SABR_ERROR
+ SABRPartReload = 46, // RELOAD_PLAYER_RESPONSE
+ SABRPartContextUpdate = 57, // SABR_CONTEXT_UPDATE
+ SABRPartStreamProtection = 58, // STREAM_PROTECTION_STATUS
+};
+
+// SABR_REDIRECT (part 43) payload: #1 = the replacement videoplayback URL (string).
+static const uint64_t kSABRRedirectURL = 1;
+
+// Parse a UMP stream, invoking `handler` per part with (type, payload). Returns
+// bytes consumed. Payload for MEDIA parts still includes the leading headerId
+// varint (caller strips it).
+static NSUInteger SABRParseUMP(NSData *data, void (^handler)(uint64_t type, const uint8_t *payload, NSUInteger size)) {
+ const uint8_t *b = (const uint8_t *)data.bytes; NSUInteger len = data.length, pos = 0;
+ while (pos < len) {
+ uint64_t type = 0, size = 0;
+ NSUInteger c1 = SABRReadUMPVarint(b, len, pos, &type); if (!c1) break; pos += c1;
+ NSUInteger c2 = SABRReadUMPVarint(b, len, pos, &size); if (!c2) break; pos += c2;
+ if (pos + size > len) break;
+ if (handler) handler(type, b + pos, (NSUInteger)size);
+ pos += (NSUInteger)size;
+ }
+ return pos;
+}
+
+#pragma mark - SABR protobuf field map
+
+// VideoPlaybackAbrRequest top-level fields.
+static const uint64_t kSABRReqClientAbrState = 1; // keep verbatim; only #28 player_time overridden
+static const uint64_t kSABRReqSelectedFormats = 2; // "initialized" echo list (empty until init metadata arrives)
+static const uint64_t kSABRReqBufferedRanges = 3; // what we already hold (drives advancement)
+static const uint64_t kSABRReqPlayerTimeMs = 4; // top-level player time
+static const uint64_t kSABRReqUstreamerConfig = 5; // ustreamer/policy blob; also carries the available-format list
+static const uint64_t kSABRReqPreferredAudio = 16; // preferred_audio_format_ids — the AUDIO download target
+static const uint64_t kSABRReqPreferredVideo = 17; // preferred_video_format_ids — the VIDEO download target
+// (#19 streamer_context is kept verbatim via the copy-everything-else path.)
+
+static const uint64_t kSABRAbrStatePlayerTimeMs = 28; // ClientAbrState.player_time_ms
+
+// #5 -> #1 -> #6 repeated FormatId = the full available-format list (source of lastModified + xtags).
+static const uint64_t kSABRAvailInner = 1; // #5.#1
+static const uint64_t kSABRAvailList = 6; // #5.#1.#6 repeated FormatId
+
+// FormatId { #1 itag, #2 last_modified, #3 xtags }.
+static const uint64_t kSABRFmtItag = 1;
+static const uint64_t kSABRFmtLastMod = 2;
+static const uint64_t kSABRFmtXtags = 3;
+
+// BufferedRange { #1 formatId, #2 start_time_ms, #3 duration_ms, #4 start_segment_index,
+// #5 end_segment_index, #6 TimeRange } ; TimeRange { #1 start, #2 duration, #3 timescale }.
+static const uint64_t kSABRBufFormatId = 1;
+static const uint64_t kSABRBufStartMs = 2;
+static const uint64_t kSABRBufDurationMs = 3;
+static const uint64_t kSABRBufStartSeg = 4;
+static const uint64_t kSABRBufEndSeg = 5;
+static const uint64_t kSABRBufTimeRange = 6;
+static const uint64_t kSABRTimeRangeStart = 1;
+static const uint64_t kSABRTimeRangeDur = 2;
+static const uint64_t kSABRTimeRangeScale = 3;
+static const uint64_t kSABRMsPerSecond = 1000; // ms timebase (also the timescale we emit)
+
+// MediaHeader (UMP part 20) fields. On 21.29 the scalar start/duration (#11/#12) are
+// absent and their values come from the #15 TimeRange instead.
+static const uint64_t kSABRHdrHeaderId = 1;
+static const uint64_t kSABRHdrItag = 3;
+static const uint64_t kSABRHdrIsInit = 8;
+static const uint64_t kSABRHdrSequence = 9;
+static const uint64_t kSABRHdrStartMs = 11; // segment start (ms), scalar — when present
+static const uint64_t kSABRHdrDurationMs = 12; // segment duration (ms), scalar — when present
+static const uint64_t kSABRHdrFormatId = 13; // fallback FormatId (has itag/xtags)
+static const uint64_t kSABRHdrContentLength = 14;
+static const uint64_t kSABRHdrTimeRange = 15; // TimeRange { startTicks, durationTicks, timescale }
+static const uint64_t kSABRTRStartTicks = 1;
+static const uint64_t kSABRTRDurationTicks = 2;
+static const uint64_t kSABRTRTimescale = 3;
+
+// FormatInitializationMetadata (UMP part 42) — a track's total length. #1 is the
+// video_id string; the FormatId is at #2.
+static const uint64_t kSABRInitFormatId = 2; // FormatId { itag, lastModified, xtags }
+static const uint64_t kSABRInitEndTimeMs = 3; // total duration ms
+static const uint64_t kSABRInitEndSegment = 4; // last segment index
+
+// Cap on request round-trips per download (safety backstop, never hit in practice).
+static const int kSABRMaxRequests = 400;
+// Bail after this many CONSECUTIVE rounds with no new media (a policy-only round is
+// normal; a run of them means the download is genuinely stuck).
+static const int kSABRMaxEmptyRounds = 4;
+
+#pragma mark - Protobuf helpers (base-128 varint; distinct from the UMP varint above)
+
+// Protobuf wire types (low 3 bits of a field key).
+static const int kProtoWireVarint = 0;
+static const int kProtoWire64Bit = 1;
+static const int kProtoWireLengthDelimited = 2;
+static const int kProtoWire32Bit = 5;
+
+// Read a base-128 varint. Returns bytes consumed (0 on malformed/truncated input).
+static NSUInteger SABRReadProtoVarint(const uint8_t *b, NSUInteger len, NSUInteger pos, uint64_t *out) {
+ uint64_t v = 0; int sh = 0; NSUInteger start = pos;
+ while (pos < len) {
+ uint8_t by = b[pos++];
+ v |= ((uint64_t)(by & 0x7f)) << sh;
+ if (!(by & 0x80)) { if (out) *out = v; return pos - start; }
+ sh += 7; if (sh >= 64) return 0;
+ }
+ return 0;
+}
+
+static void SABRAppendProtoVarint(NSMutableData *d, uint64_t v) {
+ uint8_t buf[10]; int n = 0;
+ do { uint8_t by = v & 0x7f; v >>= 7; if (v) by |= 0x80; buf[n++] = by; } while (v);
+ [d appendBytes:buf length:n];
+}
+static void SABRAppendVarintField(NSMutableData *d, uint64_t field, uint64_t v) {
+ SABRAppendProtoVarint(d, (field << 3) | 0); SABRAppendProtoVarint(d, v);
+}
+static void SABRAppendBytesField(NSMutableData *d, uint64_t field, NSData *bytes) {
+ SABRAppendProtoVarint(d, (field << 3) | 2); SABRAppendProtoVarint(d, bytes.length); [d appendData:bytes];
+}
+
+// Walk ONE level of a protobuf message. handler receives each field's number, wire
+// type, the byte span of the whole field (tag..end), and its payload span. Return NO
+// from handler to stop early. Returns YES only if the buffer parsed cleanly
+// end-to-end (a malformed field aborts with NO).
+static BOOL SABRIterateTopLevel(NSData *data, BOOL (^handler)(uint64_t field, int wire, NSUInteger fieldStart, NSUInteger fieldEnd, NSUInteger payloadStart, NSUInteger payloadLen)) {
+ const uint8_t *b = (const uint8_t *)data.bytes; NSUInteger len = data.length, pos = 0;
+ while (pos < len) {
+ NSUInteger fieldStart = pos;
+ uint64_t key = 0; NSUInteger kc = SABRReadProtoVarint(b, len, pos, &key); if (!kc) return NO; pos += kc;
+ uint64_t field = key >> 3; int wire = key & 0x7;
+ NSUInteger payloadStart = pos, payloadLen = 0;
+ if (wire == kProtoWireVarint) { uint64_t v; NSUInteger c = SABRReadProtoVarint(b, len, pos, &v); if (!c) return NO; payloadLen = c; pos += c; }
+ else if (wire == kProtoWire64Bit) { if (pos + 8 > len) return NO; payloadLen = 8; pos += 8; }
+ else if (wire == kProtoWireLengthDelimited) { uint64_t l; NSUInteger c = SABRReadProtoVarint(b, len, pos, &l); if (!c || pos + c + l > len) return NO; payloadStart = pos + c; payloadLen = (NSUInteger)l; pos += c + l; }
+ else if (wire == kProtoWire32Bit) { if (pos + 4 > len) return NO; payloadLen = 4; pos += 4; }
+ else return NO;
+ if (handler && !handler(field, wire, fieldStart, pos, payloadStart, payloadLen)) return YES;
+ }
+ return YES;
+}
+
+// Read one varint field's value from a message (first match). Returns whether found.
+static BOOL SABRReadVarintField(NSData *msg, uint64_t field, uint64_t *out) {
+ const uint8_t *b = (const uint8_t *)msg.bytes; NSUInteger len = msg.length;
+ __block BOOL got = NO; __block uint64_t val = 0;
+ SABRIterateTopLevel(msg, ^BOOL(uint64_t f, int w, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ if (f == field && w == kProtoWireVarint) { uint64_t v = 0; if (SABRReadProtoVarint(b, len, ps, &v)) { val = v; got = YES; return NO; } }
+ return YES;
+ });
+ if (got && out) *out = val;
+ return got;
+}
+
+// Return a copy of `msg` with varint `field` set to `value` (replacing an existing
+// one in place, or appended if absent). Non-target fields are copied byte-for-byte.
+static NSData *SABRSetVarintField(NSData *msg, uint64_t field, uint64_t value) {
+ const uint8_t *b = (const uint8_t *)msg.bytes;
+ NSMutableData *out = [NSMutableData data];
+ __block BOOL replaced = NO;
+ BOOL ok = SABRIterateTopLevel(msg, ^BOOL(uint64_t f, int w, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ if (f == field && w == kProtoWireVarint) { SABRAppendVarintField(out, field, value); replaced = YES; }
+ else [out appendBytes:b + fs length:fe - fs];
+ return YES;
+ });
+ if (!ok) return [msg copy]; // don't corrupt a body we can't parse
+ if (!replaced) SABRAppendVarintField(out, field, value);
+ return out;
+}
+
+#pragma mark - Available formats (#5) → {lastModified, xtags} for a chosen itag
+
+// A resolved download target: itag + the lastModified/xtags copied verbatim from #5.
+@interface YMSABRFormat : NSObject
+@property (nonatomic, assign) uint64_t itag;
+@property (nonatomic, assign) uint64_t lastModified;
+@property (nonatomic, strong) NSData *xtags; // may be empty; always emitted (matches the real client)
+@property (nonatomic, assign) BOOL found;
+@end
+@implementation YMSABRFormat @end
+
+// Parse a FormatId message → {itag, lastModified, xtags}.
+static YMSABRFormat *SABRParseFormatId(NSData *fmt) {
+ YMSABRFormat *r = [YMSABRFormat new]; r.xtags = [NSData data];
+ const uint8_t *b = (const uint8_t *)fmt.bytes; NSUInteger len = fmt.length;
+ SABRIterateTopLevel(fmt, ^BOOL(uint64_t f, int w, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ if (f == kSABRFmtItag && w == kProtoWireVarint) { uint64_t v = 0; if (SABRReadProtoVarint(b, len, ps, &v)) r.itag = v; }
+ else if (f == kSABRFmtLastMod && w == kProtoWireVarint) { uint64_t v = 0; if (SABRReadProtoVarint(b, len, ps, &v)) r.lastModified = v; }
+ else if (f == kSABRFmtXtags && w == kProtoWireLengthDelimited) { r.xtags = [fmt subdataWithRange:NSMakeRange(ps, pl)]; }
+ return YES;
+ });
+ return r;
+}
+
+// Resolve a download target for `itag` from the available-format list (#5.#1.#6).
+// Some itags appear twice — a plain entry and an xtags-tagged variant (distinct
+// formats, not versions). We prefer the xtags variant because that's what the app
+// itself selects (our capture played 251 with xtags), and we copy {itag,
+// lastModified, xtags} as one consistent triple so server-side matching lines up.
+static YMSABRFormat *SABRResolveFormat(NSData *body, uint64_t itag) {
+ YMSABRFormat *result = [YMSABRFormat new];
+ SABRIterateTopLevel(body, ^BOOL(uint64_t f5, int w5, NSUInteger fs, NSUInteger fe, NSUInteger ps5, NSUInteger pl5) {
+ if (f5 != kSABRReqUstreamerConfig || w5 != kProtoWireLengthDelimited) return YES;
+ NSData *avail = [body subdataWithRange:NSMakeRange(ps5, pl5)];
+ SABRIterateTopLevel(avail, ^BOOL(uint64_t f1, int w1, NSUInteger fs1, NSUInteger fe1, NSUInteger ps1, NSUInteger pl1) {
+ if (f1 != kSABRAvailInner || w1 != kProtoWireLengthDelimited) return YES;
+ NSData *inner = [avail subdataWithRange:NSMakeRange(ps1, pl1)];
+ SABRIterateTopLevel(inner, ^BOOL(uint64_t f6, int w6, NSUInteger fs6, NSUInteger fe6, NSUInteger ps6, NSUInteger pl6) {
+ if (f6 != kSABRAvailList || w6 != kProtoWireLengthDelimited) return YES;
+ YMSABRFormat *fmt = SABRParseFormatId([inner subdataWithRange:NSMakeRange(ps6, pl6)]);
+ if (fmt.itag == itag) {
+ // Keep the first match, but upgrade to an entry that carries xtags.
+ if (!result.found || (result.xtags.length == 0 && fmt.xtags.length > 0)) {
+ result.itag = fmt.itag; result.lastModified = fmt.lastModified;
+ result.xtags = fmt.xtags; result.found = YES;
+ }
+ }
+ return YES;
+ });
+ return YES;
+ });
+ return NO; // only the first #5
+ });
+ return result;
+}
+
+// Encode a FormatId submessage { #1 itag, #2 lastModified, #3 xtags }. xtags is
+// always emitted (empty string if none) — matches YTKACE / the real client.
+static NSData *SABREncodeFormatId(YMSABRFormat *fmt) {
+ NSMutableData *d = [NSMutableData data];
+ SABRAppendVarintField(d, kSABRFmtItag, fmt.itag);
+ SABRAppendVarintField(d, kSABRFmtLastMod, fmt.lastModified);
+ SABRAppendBytesField(d, kSABRFmtXtags, fmt.xtags ?: [NSData data]);
+ return d;
+}
+
+// Encode a BufferedRange reporting the REAL extent just received for a track:
+// segments [startSeg..endSeg] spanning [startMs, startMs+durationMs). Reporting the
+// true extent (not player_time) is essential — overstating it makes the server think
+// the track is fully buffered and stop sending its remaining segments.
+static NSData *SABREncodeBufferedRange(YMSABRFormat *fmt, uint64_t startSeg, uint64_t endSeg, uint64_t startMs, uint64_t durationMs) {
+ NSMutableData *tr = [NSMutableData data];
+ SABRAppendVarintField(tr, kSABRTimeRangeStart, startMs);
+ SABRAppendVarintField(tr, kSABRTimeRangeDur, durationMs);
+ SABRAppendVarintField(tr, kSABRTimeRangeScale, kSABRMsPerSecond);
+
+ NSMutableData *br = [NSMutableData data];
+ SABRAppendBytesField(br, kSABRBufFormatId, SABREncodeFormatId(fmt));
+ SABRAppendVarintField(br, kSABRBufStartMs, startMs);
+ SABRAppendVarintField(br, kSABRBufDurationMs, durationMs);
+ SABRAppendVarintField(br, kSABRBufStartSeg, startSeg);
+ SABRAppendVarintField(br, kSABRBufEndSeg, endSeg);
+ SABRAppendBytesField(br, kSABRBufTimeRange, tr);
+ return br;
+}
+
+#pragma mark - Response part decoders (MEDIA_HEADER / FORMAT_INIT)
+
+// Decoded MEDIA_HEADER (UMP part 20): maps a headerId to a format + segment.
+@interface YMSABRMediaHeader : NSObject
+@property (nonatomic, assign) uint64_t headerId;
+@property (nonatomic, assign) uint64_t itag;
+@property (nonatomic, assign) uint64_t sequence;
+@property (nonatomic, assign) uint64_t startMs;
+@property (nonatomic, assign) uint64_t durationMs;
+@property (nonatomic, assign) uint64_t contentLength;
+@property (nonatomic, assign) BOOL isInit;
+@end
+@implementation YMSABRMediaHeader @end
+
+// Convert a TimeRange tick count to milliseconds (rounded).
+static uint64_t SABRTicksToMs(uint64_t ticks, uint64_t timescale) {
+ if (timescale == 0) return 0;
+ return (uint64_t)(((double)ticks / (double)timescale) * (double)kSABRMsPerSecond + 0.5);
+}
+
+static YMSABRMediaHeader *SABRDecodeMediaHeader(const uint8_t *payload, NSUInteger size) {
+ NSData *msg = [NSData dataWithBytesNoCopy:(void *)payload length:size freeWhenDone:NO];
+ YMSABRMediaHeader *h = [YMSABRMediaHeader new];
+ const uint8_t *b = (const uint8_t *)msg.bytes; NSUInteger len = msg.length;
+ SABRIterateTopLevel(msg, ^BOOL(uint64_t f, int w, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ if (f == kSABRHdrHeaderId && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.headerId = v; }
+ else if (f == kSABRHdrItag && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.itag = v; }
+ else if (f == kSABRHdrIsInit && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.isInit = (v != 0); }
+ else if (f == kSABRHdrSequence && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.sequence = v; }
+ else if (f == kSABRHdrStartMs && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.startMs = v; }
+ else if (f == kSABRHdrDurationMs && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.durationMs = v; }
+ else if (f == kSABRHdrContentLength && w == kProtoWireVarint){ uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v)) h.contentLength = v; }
+ else if (f == kSABRHdrFormatId && w == kProtoWireLengthDelimited && h.itag == 0) { // fallback itag from nested FormatId
+ YMSABRFormat *fmt = SABRParseFormatId([msg subdataWithRange:NSMakeRange(ps, pl)]);
+ if (fmt.itag) h.itag = fmt.itag;
+ }
+ else if (f == kSABRHdrTimeRange && w == kProtoWireLengthDelimited) { // TimeRange — the start/duration source on 21.29
+ NSData *tr = [msg subdataWithRange:NSMakeRange(ps, pl)];
+ uint64_t startTicks = 0, durTicks = 0, timescale = 0;
+ SABRReadVarintField(tr, kSABRTRStartTicks, &startTicks);
+ SABRReadVarintField(tr, kSABRTRDurationTicks, &durTicks);
+ SABRReadVarintField(tr, kSABRTRTimescale, ×cale);
+ if (timescale > 0) {
+ if (h.durationMs == 0 && durTicks) h.durationMs = SABRTicksToMs(durTicks, timescale);
+ if (h.startMs == 0 && startTicks) h.startMs = SABRTicksToMs(startTicks, timescale);
+ }
+ }
+ return YES;
+ });
+ return h;
+}
+
+// FORMAT_INITIALIZATION_METADATA (part 42): the authoritative end-of-track marker.
+static void SABRDecodeFormatInit(const uint8_t *payload, NSUInteger size, uint64_t *itag, uint64_t *endSeg, uint64_t *endTimeMs) {
+ NSData *msg = [NSData dataWithBytesNoCopy:(void *)payload length:size freeWhenDone:NO];
+ const uint8_t *b = (const uint8_t *)msg.bytes; NSUInteger len = msg.length;
+ __block uint64_t it = 0;
+ SABRIterateTopLevel(msg, ^BOOL(uint64_t f, int w, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ if (f == kSABRInitFormatId && w == kProtoWireLengthDelimited) { YMSABRFormat *fmt = SABRParseFormatId([msg subdataWithRange:NSMakeRange(ps, pl)]); if (fmt.itag) it = fmt.itag; }
+ else if (f == kSABRInitEndSegment && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v) && endSeg) *endSeg = v; }
+ else if (f == kSABRInitEndTimeMs && w == kProtoWireVarint) { uint64_t v=0; if (SABRReadProtoVarint(b,len,ps,&v) && endTimeMs) *endTimeMs = v; }
+ return YES;
+ });
+ if (itag) *itag = it;
+}
+
+#pragma mark - Per-track download state
+
+// Accumulates one track's media (keyed by ITAG, not headerId — the init segment and
+// media segments of a track arrive under different headerIds). Written to `fileURL`.
+@interface YMSABRTrack : NSObject
+@property (nonatomic, strong) YMSABRFormat *format;
+@property (nonatomic, strong) NSURL *fileURL;
+@property (nonatomic, strong) NSFileHandle *handle;
+@property (nonatomic, assign) uint64_t downloadedMs; // real total downloaded duration (sum of segment durations)
+@property (nonatomic, assign) uint64_t lastSequence; // highest segment index written
+// Per-round window of NEW segments (for the buffered_range we report each round —
+// the reference sends a delta window, not a cumulative 0..N range).
+@property (nonatomic, assign) uint64_t roundStartMs;
+@property (nonatomic, assign) uint64_t roundDurationMs;
+@property (nonatomic, assign) uint64_t roundFirstSeq;
+@property (nonatomic, assign) uint64_t roundLastSeq;
+@property (nonatomic, assign) BOOL roundHasMedia;
+@property (nonatomic, assign) uint64_t endSegment; // from FORMAT_INIT (0 = unknown)
+@property (nonatomic, assign) uint64_t endTimeMs; // from FORMAT_INIT (0 = unknown)
+@property (nonatomic, assign) BOOL initWritten;
+@property (nonatomic, assign) BOOL complete;
+@property (nonatomic, assign) unsigned long long bytesWritten;
+@end
+@implementation YMSABRTrack @end
+
+#pragma mark - Capture layer (permanent): the app's live signed videoplayback request
+
+// Captured request material — tracks the MOST-RECENT videoplayback request, i.e. the
+// currently watched video, so switching videos re-captures the new one. Capturing
+// mid-playback is safe: the engine rebuilds the buffered ranges (#3) and selected
+// formats from scratch and never depends on the captured body's own ranges.
+// Touched only on SABRQueue.
+static NSURL *gCapURL;
+static NSData *gCapPlainBody; // pre-Brotli body (from -HTTPBody)
+static NSDictionary *gCapHeaders;
+static NSTimeInterval gCapExpire; // from the URL's expire= param
+static BOOL gSABRCancel; // set to abort the active download loop (touched on SABRQueue)
+
+static NSTimeInterval SABRExpireFromURL(NSURL *url) {
+ for (NSURLQueryItem *item in [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO].queryItems)
+ if ([item.name isEqualToString:@"expire"]) return item.value.doubleValue;
+ return 0;
+}
+
+%hook HAMDataLoadRequest
+- (NSURLRequest *)buildURLRequest {
+ NSURLRequest *r = %orig;
+ if (!SABR_ENABLED()) return r;
+ @try {
+ NSString *host = r.URL.host ?: @""; NSString *path = r.URL.path ?: @"";
+ if ([host containsString:@"googlevideo"] && [path containsString:@"videoplayback"] &&
+ [r.HTTPMethod isEqualToString:@"POST"]) {
+ id me = self;
+ NSData *plain = nil; @try { plain = [me HTTPBody]; } @catch (id ex) {}
+ NSURL *url = r.URL;
+ NSData *plainCopy = [plain copy];
+ NSMutableDictionary *hdrs = [r.allHTTPHeaderFields mutableCopy] ?: [NSMutableDictionary dictionary];
+ dispatch_async(SABRQueue(), ^{
+ // Most-recent-wins: always track the latest videoplayback request (the
+ // current video), so switching videos captures the new one.
+ gCapURL = url; gCapPlainBody = plainCopy; gCapHeaders = hdrs; gCapExpire = SABRExpireFromURL(url);
+ });
+ }
+ } @catch (id ex) {}
+ return r;
+}
+%end
+
+#pragma mark - Request builder
+
+// Build one request body targeting `videoFmt` + `audioFmt`:
+// #16 = preferred audio FormatId, #17 = preferred video FormatId (the real
+// download targets — sent every request).
+// #2 = the "initialized" echo list (empty until formats initialize).
+// #3 = buffered ranges we already hold (empty = stream from start).
+// #1.#28 + #4 = player_time_ms (advances the stream).
+// #1 / #5 / #19 kept byte-for-byte (auth/session/config stay intact).
+// `bufferedRanges` / `selectedFormats` are pre-encoded FormatId/BufferedRange
+// submessages (nil/empty on the first request).
+static NSData *SABRBuildRequestBody(NSData *orig, YMSABRFormat *videoFmt, YMSABRFormat *audioFmt,
+ uint64_t playerTimeMs, NSArray *bufferedRanges,
+ NSArray *selectedFormats) {
+ const uint8_t *b = (const uint8_t *)orig.bytes;
+ NSMutableData *out = [NSMutableData data];
+ BOOL parsed = SABRIterateTopLevel(orig, ^BOOL(uint64_t field, int wire, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ // Drop the fields we rebuild; copy everything else verbatim.
+ if (field == kSABRReqSelectedFormats || field == kSABRReqBufferedRanges ||
+ field == kSABRReqPreferredAudio || field == kSABRReqPreferredVideo ||
+ field == kSABRReqPlayerTimeMs) return YES;
+ if (field == kSABRReqClientAbrState && wire == 2) {
+ // Override player_time_ms (#28) inside the ClientAbrState, keep the rest.
+ NSData *state = SABRSetVarintField([orig subdataWithRange:NSMakeRange(ps, pl)], kSABRAbrStatePlayerTimeMs, playerTimeMs);
+ SABRAppendBytesField(out, kSABRReqClientAbrState, state);
+ return YES;
+ }
+ [out appendBytes:b + fs length:fe - fs];
+ return YES;
+ });
+ if (!parsed) return nil;
+
+ SABRAppendVarintField(out, kSABRReqPlayerTimeMs, playerTimeMs);
+ if (audioFmt) SABRAppendBytesField(out, kSABRReqPreferredAudio, SABREncodeFormatId(audioFmt));
+ if (videoFmt) SABRAppendBytesField(out, kSABRReqPreferredVideo, SABREncodeFormatId(videoFmt));
+ for (NSData *sel in selectedFormats) SABRAppendBytesField(out, kSABRReqSelectedFormats, sel);
+ for (NSData *br in bufferedRanges) SABRAppendBytesField(out, kSABRReqBufferedRanges, br);
+ return out;
+}
+
+#pragma mark - Download engine (permanent)
+
+// One network round-trip: POST `body` uncompressed to the captured signed URL,
+// return the raw UMP response. Called ON SABRQueue (it reads gCapHeaders); the
+// NSURLSession completion runs on an arbitrary queue, so the caller's completion
+// block must hop back to SABRQueue before touching shared state.
+static void SABRPostOnce(NSURL *url, NSData *body, void (^completion)(NSData *data, NSHTTPURLResponse *http, NSError *err)) {
+ NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url];
+ req.HTTPMethod = @"POST";
+ req.HTTPBody = body;
+ [gCapHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *k, NSString *v, BOOL *stop) {
+ if ([k caseInsensitiveCompare:@"Content-Encoding"] == NSOrderedSame) return; // no Brotli
+ if ([k caseInsensitiveCompare:@"Content-Length"] == NSOrderedSame) return; // session sets it
+ [req setValue:v forHTTPHeaderField:k];
+ }];
+ NSURLSession *s = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration]];
+ [[s dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
+ completion(data, [resp isKindOfClass:NSHTTPURLResponse.class] ? (NSHTTPURLResponse *)resp : nil, err);
+ }] resume];
+}
+
+// Parse one UMP response, routing media to whichever track (by itag) it belongs to.
+// Writes init segments (once) + media segments, updates each track's lastSequence,
+// downloaded duration, per-round window, and FORMAT_INIT markers, and reports any
+// redirect URL / reload flag via out-params.
+static void SABRIngestResponse(NSData *data, NSDictionary *tracks,
+ NSString **redirectURL, BOOL *reload) {
+ NSMutableDictionary *headers = [NSMutableDictionary dictionary]; // headerId -> header
+ __block NSString *redirect = nil; __block BOOL sawReload = NO;
+
+ // Reset each track's per-round window; it's rebuilt from the real headers below.
+ for (NSNumber *itag in tracks) {
+ YMSABRTrack *t = tracks[itag];
+ t.roundHasMedia = NO; t.roundStartMs = 0; t.roundDurationMs = 0; t.roundFirstSeq = 0; t.roundLastSeq = 0;
+ }
+
+ SABRParseUMP(data, ^(uint64_t type, const uint8_t *payload, NSUInteger size) {
+ if (type == SABRPartMediaHeader) {
+ YMSABRMediaHeader *h = SABRDecodeMediaHeader(payload, size);
+ headers[@(h.headerId)] = h;
+ } else if (type == SABRPartMedia && size > 0) {
+ uint64_t hid = 0; NSUInteger hc = SABRReadUMPVarint(payload, size, 0, &hid);
+ if (hc == 0 || hc > size) return;
+ YMSABRMediaHeader *h = headers[@(hid)];
+ YMSABRTrack *track = h ? tracks[@(h.itag)] : nil;
+ if (!track) return; // media for a format we're not collecting
+ if (h.isInit) {
+ if (!track.initWritten) { [track.handle writeData:[NSData dataWithBytes:payload + hc length:size - hc]]; track.bytesWritten += size - hc; track.initWritten = YES; }
+ return;
+ }
+ // A segment can arrive more than once (same sequence, different headerId)
+ // when pacing changes; only sequences beyond the high-water mark are new.
+ // Writing a duplicate would corrupt the elementary stream.
+ if (h.sequence <= track.lastSequence) return;
+ [track.handle writeData:[NSData dataWithBytes:payload + hc length:size - hc]];
+ track.bytesWritten += size - hc;
+ } else if (type == SABRPartMediaEnd && size > 0) {
+ YMSABRMediaHeader *h = headers[@(payload[0])];
+ YMSABRTrack *track = h ? tracks[@(h.itag)] : nil;
+ if (!track || h.isInit) return;
+ if (h.sequence <= track.lastSequence) return; // duplicate segment: don't double-count
+ track.lastSequence = h.sequence;
+ track.downloadedMs += h.durationMs; // real total from #15-derived duration
+ // Extend this round's contiguous window with the segment's real timing.
+ if (!track.roundHasMedia) { track.roundHasMedia = YES; track.roundStartMs = h.startMs; track.roundFirstSeq = h.sequence; }
+ track.roundDurationMs += h.durationMs;
+ track.roundLastSeq = h.sequence;
+ } else if (type == SABRPartFormatInit) {
+ uint64_t it = 0, endSeg = 0, endMs = 0;
+ SABRDecodeFormatInit(payload, size, &it, &endSeg, &endMs);
+ YMSABRTrack *track = tracks[@(it)];
+ if (track) { if (endSeg) track.endSegment = endSeg; if (endMs) track.endTimeMs = endMs; }
+ } else if (type == SABRPartRedirect) {
+ NSData *msg = [NSData dataWithBytesNoCopy:(void *)payload length:size freeWhenDone:NO];
+ SABRIterateTopLevel(msg, ^BOOL(uint64_t f, int w, NSUInteger fs, NSUInteger fe, NSUInteger ps, NSUInteger pl) {
+ // -copy detaches from the transient no-copy payload.
+ if (f == kSABRRedirectURL && w == kProtoWireLengthDelimited) redirect = [[[NSString alloc] initWithData:[msg subdataWithRange:NSMakeRange(ps, pl)] encoding:NSUTF8StringEncoding] copy];
+ return NO;
+ });
+ } else if (type == SABRPartReload) {
+ sawReload = YES;
+ }
+ });
+
+ if (redirectURL) *redirectURL = redirect;
+ if (reload) *reload = sawReload;
+}
+
+// A track is complete once it has reached its last segment (authoritative), or its
+// real downloaded duration has reached the total (belt-and-suspenders).
+static BOOL SABRTrackDone(YMSABRTrack *track) {
+ if (!track.endSegment && !track.endTimeMs) return NO; // no markers yet → not done
+ if (track.endSegment && track.lastSequence >= track.endSegment) return YES;
+ if (track.endTimeMs && track.downloadedMs + 500 >= track.endTimeMs) return YES;
+ return NO;
+}
+
+// Open a fresh unique temp file for a track (unique per download so a stale or
+// concurrent run can't collide/overwrite).
+static YMSABRTrack *SABRMakeTrack(YMSABRFormat *fmt, NSString *ext) {
+ NSString *name = [NSString stringWithFormat:@"sabr_%llu_%@.%@", fmt.itag, [NSUUID UUID].UUIDString, ext];
+ NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:name];
+ [[NSFileManager defaultManager] createFileAtPath:path contents:nil attributes:nil];
+ YMSABRTrack *t = [YMSABRTrack new];
+ t.format = fmt;
+ t.fileURL = [NSURL fileURLWithPath:path];
+ t.handle = [NSFileHandle fileHandleForWritingAtPath:path];
+ return t;
+}
+
+// Orchestrator: download the video itag + audio itag together (both requested every
+// round; media routed to a file per track by itag) until every track reaches its
+// last segment, then call `completion(videoURL, audioURL, err)` on the main queue.
+// Pass videoItag == 0 for an audio-only download (videoURL is then nil).
+// NB: named SABRRunDownload, not SABRDownload — the latter is a settings-key macro.
+static void SABRRunDownload(uint64_t videoItag, uint64_t audioItag,
+ void (^progress)(float fraction, unsigned long long bytesDownloaded),
+ void (^completion)(NSURL *videoURL, NSURL *audioURL, NSString *err)) {
+ dispatch_async(SABRQueue(), ^{
+ if (!gCapURL || !gCapPlainBody.length) {
+ dispatch_async(dispatch_get_main_queue(), ^{ completion(nil, nil, @"No request captured yet — play the video for a few seconds first."); });
+ return;
+ }
+ BOOL wantVideo = videoItag != 0;
+ YMSABRFormat *videoFmt = wantVideo ? SABRResolveFormat(gCapPlainBody, videoItag) : nil;
+ YMSABRFormat *audioFmt = SABRResolveFormat(gCapPlainBody, audioItag);
+ if ((wantVideo && !videoFmt.found) || !audioFmt.found) {
+ NSString *videoState = !wantVideo ? @"n/a" : (videoFmt.found ? @"ok" : @"missing");
+ dispatch_async(dispatch_get_main_queue(), ^{ completion(nil, nil, [NSString stringWithFormat:@"format not available (video %llu:%@, audio %llu:%@)", videoItag, videoState, audioItag, audioFmt.found?@"ok":@"missing"]); });
+ return;
+ }
+
+ gSABRCancel = NO;
+ YMSABRTrack *videoTrack = wantVideo ? SABRMakeTrack(videoFmt, @"mp4") : nil;
+ YMSABRTrack *audioTrack = SABRMakeTrack(audioFmt, @"m4a");
+ // Ordered [video?, audio]; `mainTrack` (first = video when present, else audio)
+ // drives player_time, matching the reference's "drive off the main format".
+ NSArray *trackList = wantVideo ? @[videoTrack, audioTrack] : @[audioTrack];
+ YMSABRTrack *mainTrack = trackList.firstObject;
+ NSMutableDictionary *tracks = [NSMutableDictionary dictionary];
+ for (YMSABRTrack *t in trackList) tracks[@(t.format.itag)] = t;
+
+ __block int requestCount = 0;
+ __block int emptyRounds = 0; // consecutive rounds with no new media (SABR may send policy-only rounds)
+ __block BOOL finished = NO;
+ __block NSURL *currentURL = gCapURL;
+ // Recursive loop across async network callbacks: hold the block in a heap box
+ // and recurse through it (a direct capture is an ARC retain-cycle error);
+ // `finish` empties the box to free the loop.
+ NSMutableArray *box = [NSMutableArray arrayWithObject:[NSNull null]];
+ void (^callRound)(void) = ^{ id r = box.firstObject; if (r && r != [NSNull null]) ((void (^)(void))r)(); };
+ void (^finish)(NSString *) = ^(NSString *err) {
+ if (finished) return; // idempotent: never double-close / double-complete
+ finished = YES;
+ [box removeAllObjects];
+ for (YMSABRTrack *t in trackList) [t.handle closeFile];
+ if (err) { // failed/cancelled → don't leave partial files behind
+ for (YMSABRTrack *t in trackList) [[NSFileManager defaultManager] removeItemAtURL:t.fileURL error:nil];
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (err) completion(nil, nil, err);
+ else completion(videoTrack.fileURL, audioTrack.fileURL, nil);
+ });
+ };
+ void (^round)(void) = ^{
+ if (gSABRCancel) { finish(@"cancelled"); return; }
+ if (requestCount++ >= kSABRMaxRequests) { finish(@"exceeded request cap"); return; }
+
+ // Each requested format stays in its preferred field (#16/#17) for the whole
+ // session; a finished track is silenced only by its buffered_ranges (which
+ // tell the server it is fully buffered). Removing a completed format from
+ // #16/#17 instead would change the request shape and stall the other track.
+ NSMutableArray *buffered = [NSMutableArray array];
+ NSMutableArray *selected = [NSMutableArray array];
+ // Report each track's REAL per-round window (the delta of segments received
+ // last round: real start/duration/first/last from #15 time_range), as the
+ // reference does — never player_time. Overstating a track's buffered extent
+ // makes the server treat it as complete and stop sending its tail segments.
+ for (YMSABRTrack *t in trackList) {
+ if (t.roundHasMedia) {
+ [buffered addObject:SABREncodeBufferedRange(t.format, t.roundFirstSeq, t.roundLastSeq, t.roundStartMs, t.roundDurationMs)];
+ }
+ if (t.lastSequence > 0) [selected addObject:SABREncodeFormatId(t.format)];
+ }
+ // Drive player_time off the main track's real downloaded total (video when
+ // present, else audio). The playhead stays at/ahead of each track's buffered
+ // edge and pulls the remaining segments forward.
+ uint64_t driveTime = mainTrack.downloadedMs;
+ NSData *body = SABRBuildRequestBody(gCapPlainBody, videoFmt, audioFmt, driveTime, buffered, selected);
+ if (!body) { finish(@"failed to build request body"); return; }
+
+ SABRPostOnce(currentURL, body, ^(NSData *data, NSHTTPURLResponse *http, NSError *err) {
+ dispatch_async(SABRQueue(), ^{
+ if (err || !http) { finish([NSString stringWithFormat:@"network error: %@", err.localizedDescription ?: @"no response"]); return; }
+ if (http.statusCode != 200 || !data.length) { finish([NSString stringWithFormat:@"HTTP %ld (%lu bytes)", (long)http.statusCode, (unsigned long)data.length]); return; }
+
+ NSMutableDictionary *beforeSeq = [NSMutableDictionary dictionary];
+ for (YMSABRTrack *t in trackList) beforeSeq[@(t.format.itag)] = @(t.lastSequence);
+ NSString *redirect = nil; BOOL reload = NO;
+ SABRIngestResponse(data, tracks, &redirect, &reload);
+
+ if (reload) { finish(@"session expired (RELOAD) — replay the video and try again"); return; }
+ if (redirect.length) currentURL = [NSURL URLWithString:redirect] ?: currentURL;
+
+ BOOL allDone = YES; float fracSum = 0; unsigned long long bytesTotal = 0;
+ for (YMSABRTrack *t in trackList) {
+ t.complete = SABRTrackDone(t);
+ if (!t.complete) allDone = NO;
+ fracSum += t.endTimeMs ? MIN(1.0f, (float)t.downloadedMs / (float)t.endTimeMs) : 0;
+ bytesTotal += t.bytesWritten;
+ }
+ if (progress) progress(fracSum / (float)trackList.count, bytesTotal);
+ if (allDone) { finish(nil); return; }
+ // Stall guard keys off SEQUENCE advancement, not raw bytes: a request
+ // that can't make progress still returns the same segment (nonzero
+ // bytes, no new sequence), so a bytes check would never trip. Bail
+ // after several CONSECUTIVE rounds where no INCOMPLETE track advanced.
+ BOOL advanced = NO;
+ for (YMSABRTrack *t in trackList)
+ if (!t.complete && t.lastSequence > beforeSeq[@(t.format.itag)].unsignedLongLongValue) advanced = YES;
+ emptyRounds = advanced ? 0 : (emptyRounds + 1);
+ if (emptyRounds >= kSABRMaxEmptyRounds) { finish(@"stalled — no forward progress for several rounds"); return; }
+ callRound();
+ });
+ });
+ };
+ box[0] = round;
+ round();
+ });
+}
+
+#pragma mark - Public entry (called from Download.x)
+
+// Download the chosen mp4 video itag + m4a audio itag on-device via SABR, producing
+// two elementary files. `progress` (0..1) and `completion` are always delivered on
+// the main queue. The caller hands the two files to its existing muxer.
+@implementation YMSABR
++ (void)downloadVideoItag:(int)videoItag audioItag:(int)audioItag
+ progress:(void (^)(float fraction, unsigned long long bytesDownloaded))progress
+ completion:(void (^)(NSURL *videoURL, NSURL *audioURL, NSString *err))completion {
+ SABRRunDownload((uint64_t)videoItag, (uint64_t)audioItag,
+ ^(float f, unsigned long long bytes) { if (progress) dispatch_async(dispatch_get_main_queue(), ^{ progress(f, bytes); }); },
+ completion); // SABRRunDownload already delivers completion on the main queue
+}
++ (void)downloadAudioItag:(int)audioItag
+ progress:(void (^)(float fraction, unsigned long long bytesDownloaded))progress
+ completion:(void (^)(NSURL *audioURL, NSString *err))completion {
+ // videoItag 0 → audio-only; deliver just the audio file.
+ SABRRunDownload(0, (uint64_t)audioItag,
+ ^(float f, unsigned long long bytes) { if (progress) dispatch_async(dispatch_get_main_queue(), ^{ progress(f, bytes); }); },
+ ^(NSURL *videoURL, NSURL *audioURL, NSString *err) { completion(audioURL, err); });
+}
++ (void)cancelCurrent {
+ dispatch_async(SABRQueue(), ^{ gSABRCancel = YES; }); // loop aborts at its next round
+}
+@end
diff --git a/Files/Settings.x b/Files/Settings.x
index f2967084..83981f82 100644
--- a/Files/Settings.x
+++ b/Files/Settings.x
@@ -4,29 +4,33 @@
#define TweakName @"YouMod"
-#define LOC(x) [tweakBundle localizedStringForKey:x value:nil table:nil]
+#define YMLOC(x) [YouModBundle() localizedStringForKey:x value:nil table:nil]
#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)
static const NSInteger TweakSection = 'ytmo';
+@interface YMSettingsItem : NSObject
+- (instancetype)visibleWhenKey:(NSString *)key equals:(NSInteger)value; // show row only while INTFORVAL(key)==value
+@end
+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 +45,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;
}
@@ -95,13 +79,12 @@ static NSString *GetCacheSize() { // YTLite - @dayanch96
%new(v@:@)
- (void)updateYouModSectionWithEntry:(id)entry {
NSMutableArray *sectionItems = [NSMutableArray array];
- NSBundle *tweakBundle = YouModBundle();
Class YTSettingsSectionItemClass = %c(YTSettingsSectionItem);
YTSettingsViewController *settingsViewController = [self valueForKey:@"_settingsViewControllerDelegate"];
// 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 +93,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 +105,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 +116,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 +126,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 +161,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),
+ YMPicker(YMLOC(@"DOWNLOAD_METHOD"), YMLOC(@"DOWNLOAD_METHOD_DESC"), DownloadMethod, (@[YMLOC(@"METHOD_DIRECT"), YMLOC(@"METHOD_SERVER"), YMLOC(@"METHOD_ONDEVICE")]), 0),
+ [YMPicker(YMLOC(@"DOWNLOAD_SERVER"), YMLOC(@"CHOOSE_DOWNLOAD_SERVER"), DownloadServerIndex, (@[YMLOC(@"SERVER_EUROPRE1"), YMLOC(@"SERVER_ASIA1")]), 0) visibleWhenKey:DownloadMethod equals:1],
+ 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 +201,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 +217,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 +238,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 +261,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) visibleWhenKey:AudioTrack equals:2],
+ [YMToggle(YMLOC(@"NO_AUTO_DUBBED"), YMLOC(@"NO_AUTO_DUBBED_DESC"), NoDubbedAudioTrack) visibleWhenKey:AudioTrack equals:2],
+ 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) visibleWhenKey:CaptionTrack equals:2],
+ [YMToggle(YMLOC(@"DISABLES_CAPTION_TRACK"), YMLOC(@"DISABLES_CAPTION_TRACK_DESC"), DisablesCaptionTrack) visibleWhenKey:CaptionTrack equals:2],
+ 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) visibleWhenKey:HideNextAndPrevButtons equals:0],
+ 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) visibleWhenKey:ShowExtraTimeRemaining equals:1],
+ 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) visibleWhenKey:SkipBackwardEnabled equals:1],
+ YMToggle(YMLOC(@"SKIP_FORWARD"), YMLOC(@"SKIP_FORWARD_DESC"), SkipForwardEnabled),
+ [YMSlider(YMLOC(@"FORWARD_SECONDS"), nil, ForwardSeconds, 5, 60, 5, 10) visibleWhenKey:SkipForwardEnabled equals:1],
+ 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) visibleWhenKey:GestureControls equals:1],
+ [YMPicker(YMLOC(@"LEFT_SIDE_GESTURE"), nil, LeftSideGesture, (@[YMLOC(@"GESTURE_NONE"), YMLOC(@"GESTURE_BRIGHTNESS"), YMLOC(@"GESTURE_VOLUME"), YMLOC(@"GESTURE_SPEED")]), 1) visibleWhenKey:GestureControls equals:1],
+ [YMPicker(YMLOC(@"RIGHT_SIDE_GESTURE"), nil, RightSideGesture, (@[YMLOC(@"GESTURE_NONE"), YMLOC(@"GESTURE_BRIGHTNESS"), YMLOC(@"GESTURE_VOLUME"), YMLOC(@"GESTURE_SPEED")]), 2) visibleWhenKey:GestureControls equals:1],
+ [YMToggle(YMLOC(@"GESTURE_HUD"), YMLOC(@"GESTURE_HUD_DESC"), GestureHUD) visibleWhenKey:GestureControls equals:1],
+ [YMPicker(YMLOC(@"GESTURE_HUD_SIZE"), YMLOC(@"GESTURE_HUD_SIZE_DESC"), GestureHUDSize, (@[YMLOC(@"SMALL"), YMLOC(@"NORMAL"), YMLOC(@"LARGE"), YMLOC(@"EXTRALARGE"), YMLOC(@"MAX")]), 1) visibleWhenKey:GestureHUD equals:1],
+ [YMPicker(YMLOC(@"GESTURE_HUD_POSITION"), YMLOC(@"GESTURE_HUD_POSITION_DESC"), GestureHUDPosition, (@[YMLOC(@"TOP"), YMLOC(@"MIDDLE"), YMLOC(@"BOTTOM")]), 0) visibleWhenKey:GestureHUD equals:1],
+ 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 +350,23 @@ 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_TOPBAR"), YMLOC(@"HIDE_SHORTS_TOPBAR_DESC"), HideShortsTopbar),
+ YMToggle(YMLOC(@"HIDE_SHORTS_SUBBAR"), YMLOC(@"HIDE_SHORTS_SUBBAR_DESC"), HideShortsSubbar),
+ 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 +376,53 @@ 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"};
+ YTAssetLoader *assetLoader = [[%c(YTAssetLoader) alloc] initWithBundle:YouModBundle()];
+
+ 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 +432,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 +481,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 +603,15 @@ 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,
+ SBButtonKey: @YES,
+ DisableHints: @YES,
+ RewindSeconds: @10.0,
+ ForwardSeconds: @10.0,
}];
%init;
}
diff --git a/Files/Shorts.x b/Files/Shorts.x
index d73cbb24..8cc33ece 100644
--- a/Files/Shorts.x
+++ b/Files/Shorts.x
@@ -8,6 +8,7 @@
- (BOOL)iosEnableShortsPlayerVideoQuality { return IS_ENABLED(EnablesShortsQuality) ? YES : %orig; }
- (BOOL)iosEnableShortsPlayerVideoQualityRestartVideo { return IS_ENABLED(EnablesShortsQuality) ? YES : %orig; }
- (BOOL)iosEnableSimplerTitleInShortsVideoQualityPicker { return IS_ENABLED(EnablesShortsQuality) ? YES : %orig; }
+- (BOOL)enablePlayerBarForVerticalVideoWhenControlsHiddenInFullscreen { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
%end
// Always show Shorts seekbar
@@ -16,11 +17,6 @@
- (BOOL)shouldEnablePlayerBarOnlyOnPause { return IS_ENABLED(ShowShortsSeekbar) ? NO : %orig; }
%end
-%hook YTReelPlayerViewController
-- (BOOL)shouldAlwaysEnablePlayerBar { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
-- (BOOL)shouldEnablePlayerBarOnlyOnPause { return IS_ENABLED(ShowShortsSeekbar) ? NO : %orig; }
-%end
-
%hook YTReelPlayerViewControllerSub
- (BOOL)shouldAlwaysEnablePlayerBar { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
- (BOOL)shouldEnablePlayerBarOnlyOnPause { return IS_ENABLED(ShowShortsSeekbar) ? NO : %orig; }
@@ -31,6 +27,260 @@
- (BOOL)mobileShortsTablnlinedExpandWatchOnDismiss { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
%end
-%hook YTHotConfig
-- (BOOL)enablePlayerBarForVerticalVideoWhenControlsHiddenInFullscreen { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
-%end
\ No newline at end of file
+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
+- (BOOL)shouldAlwaysEnablePlayerBar { return IS_ENABLED(ShowShortsSeekbar) ? YES : %orig; }
+- (BOOL)shouldEnablePlayerBarOnlyOnPause { return IS_ENABLED(ShowShortsSeekbar) ? NO : %orig; }
+- (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
+
+%hook YTReelTopBarView
+- (void)layoutSubviews {
+ %orig;
+ if (IS_ENABLED(HideShortsTopbar)) {
+ if (self.superview) {
+ [self removeFromSuperview];
+ }
+ } else if (IS_ENABLED(HideShortsSubbar)) {
+ UIView *subbar = [self valueForKey:@"_pausedStateCarouselView"];
+ if (subbar && subbar.superview) {
+ [subbar removeFromSuperview];
+ }
+ }
+}
+%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.YouModExitShortsOnlyGesture.delegate = (id)self;
+ [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..6eb452cd
--- /dev/null
+++ b/Files/SponsorBlock.x
@@ -0,0 +1,594 @@
+#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;
+- (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.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) || !IS_ENABLED(SBButtonKey) || 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 IS_ENABLED(SBButtonKey) ? SBAccentColor() : [UIColor grayColor];
+ };
+ toggle.onTap = ^(YTPlayerViewController *player, UIButton *button) {
+ if (!player) return;
+ BOOL newState = !IS_ENABLED(SBButtonKey);
+ [[NSUserDefaults standardUserDefaults] setBool:newState forKey:SBButtonKey];
+ 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..1c1e9307
--- /dev/null
+++ b/Files/SponsorBlockUI.x
@@ -0,0 +1,827 @@
+#import "Headers.h"
+#import