diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05614d2f..91c226f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,8 @@ on: - '*.md' - 'feather.json' - '.gitignore' + pull_request: + branches: [ "main" ] workflow_dispatch: inputs: repo: @@ -33,10 +35,13 @@ jobs: - name: Checkout YouMod uses: actions/checkout@v7 with: - ref: ${{ inputs.branch || github.ref_name }} + ref: ${{ inputs.branch || github.ref }} repository: ${{ inputs.repo || github.repository }} path: YouMod + - name: Check feed regressions + run: python3 YouMod/Tests/check_feed_regressions.py + - name: Install dependencies run: brew install make ldid @@ -192,6 +197,10 @@ jobs: ls -lh YouTube_YouMod.ipa + - name: Update Feather manifest + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + run: | + cd ${{ github.workspace }} IPA_SIZE=$(stat -f%z YouTube_YouMod.ipa 2>/dev/null || stat -c%s YouTube_YouMod.ipa) echo "IPA size: $IPA_SIZE" python3 -c " @@ -252,6 +261,7 @@ jobs: overwrite: true - name: Create or update release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: softprops/action-gh-release@v3 with: tag_name: v${{ env.YOUMOD_VERSION }} diff --git a/Files/DeArrow.x b/Files/DeArrow.x index f8e98857..55af4383 100644 --- a/Files/DeArrow.x +++ b/Files/DeArrow.x @@ -5,9 +5,10 @@ // Thumbnails: https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID={videoID}&time={timestamp} static NSString * const kYMDeArrowUpdatedNotification = @"YouModDeArrowUpdatedNotification"; -static NSString *currentInlinePreviewVideoID = nil; -static NSString *YouModFindVideoIDFromView(UIView *view); + +@interface ELMImageNode : ASNetworkImageNode +@end @interface ASTextNode (YouMod) - (void)setNeedsDisplay; @@ -19,7 +20,7 @@ static NSString *YouModFindVideoIDFromView(UIView *view); @implementation YouModDeArrowButton - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { - CGRect hitBounds = CGRectInset(self.bounds, -12.0, -12.0); + CGRect hitBounds = CGRectInset(self.bounds, -9.0, -9.0); return CGRectContainsPoint(hitBounds, point); } @end @@ -54,6 +55,7 @@ static BOOL YouModIsLikelyBadgeOrMetadata(NSString *str) { @property (nonatomic, strong) NSCache *brandingCache; @property (nonatomic, strong) NSCache *titleToVideoIDCache; @property (nonatomic, strong) NSMutableSet *inFlightRequests; +@property (nonatomic, strong) NSCache *requestDates; @property (nonatomic, strong) NSMutableSet *toggledOriginalVideoIDs; + (instancetype)sharedInstance; - (void)fetchBrandingForVideoID:(NSString *)videoID completion:(void (^)(NSDictionary *branding))completion; @@ -87,6 +89,8 @@ static BOOL YouModIsLikelyBadgeOrMetadata(NSString *str) { _titleToVideoIDCache = [[NSCache alloc] init]; _titleToVideoIDCache.countLimit = 1000; _inFlightRequests = [NSMutableSet set]; + _requestDates = [[NSCache alloc] init]; + _requestDates.countLimit = 500; _toggledOriginalVideoIDs = [NSMutableSet set]; } return self; @@ -178,15 +182,15 @@ static NSString *YouModFormatDeArrowTitle(NSString *origTitle) { - (void)registerOriginalTitle:(NSString *)title forVideoID:(NSString *)videoID { if (!title || title.length == 0 || !videoID || videoID.length == 0) return; [_titleToVideoIDCache setObject:videoID forKey:title]; - NSMutableDictionary *entry = (NSMutableDictionary *)[_brandingCache objectForKey:videoID]; + NSMutableDictionary *entry = [[_brandingCache objectForKey:videoID] mutableCopy]; if (!entry) { entry = [NSMutableDictionary dictionary]; entry[@"videoID"] = videoID; entry[@"originalTitle"] = [title copy]; - [_brandingCache setObject:entry forKey:videoID]; } else if (!entry[@"originalTitle"]) { entry[@"originalTitle"] = [title copy]; } + [_brandingCache setObject:[entry copy] forKey:videoID]; } - (NSString *)videoIDForTitle:(NSString *)title { @@ -281,17 +285,19 @@ static NSString *YouModFormatDeArrowTitle(NSString *origTitle) { } NSDictionary *cached = [_brandingCache objectForKey:videoID]; - if (cached && (cached[@"title"] != nil || cached[@"thumbnailURL"] != nil)) { + if ([cached[@"fetched"] boolValue]) { if (completion) completion(cached); return; } @synchronized (_inFlightRequests) { - if ([_inFlightRequests containsObject:videoID]) { + NSDate *lastRequest = [_requestDates objectForKey:videoID]; + if ([_inFlightRequests containsObject:videoID] || (lastRequest && -lastRequest.timeIntervalSinceNow < 60)) { if (completion) completion(nil); return; } [_inFlightRequests addObject:videoID]; + [_requestDates setObject:[NSDate date] forKey:videoID]; } NSString *urlString = [NSString stringWithFormat:@"https://sponsor.ajay.app/api/branding?videoID=%@", videoID]; @@ -333,6 +339,7 @@ static NSString *YouModFormatDeArrowTitle(NSString *origTitle) { NSDictionary *existing = [self.brandingCache objectForKey:videoID]; NSMutableDictionary *branding = existing ? [existing mutableCopy] : [NSMutableDictionary dictionary]; branding[@"videoID"] = videoID; + branding[@"fetched"] = @YES; // Parse Titles NSArray *titles = json[@"titles"]; @@ -392,7 +399,7 @@ static NSString *YouModFormatDeArrowTitle(NSString *origTitle) { branding[@"timestamp"] = bestTimestamp; branding[@"thumbnailURL"] = candidateURL; } - [self.brandingCache setObject:branding forKey:videoID]; + [self.brandingCache setObject:[branding copy] forKey:videoID]; dispatch_async(dispatch_get_main_queue(), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:kYMDeArrowUpdatedNotification @@ -486,11 +493,18 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { %hook ASNetworkImageNode - (void)setURL:(NSURL *)url resetToDefault:(BOOL)reset { + NSURL *failed = objc_getAssociatedObject(self, "kYMDeArrowFailedURL"); + if (failed && [url isEqual:failed]) url = objc_getAssociatedObject(self, "kYMDeArrowOrigURLKey"); + if (!url || (!YouModExtractDeArrowVideoID(url.absoluteString) && ![url.host isEqualToString:@"dearrow-thumb.ajay.app"])) { + objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(self, "kYMDeArrowOrigURLKey", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } if (!IS_ENABLED(DeArrowEnabled) || !IS_ENABLED(DeArrowReplaceThumbnails) || !url) { %orig(url, reset); return; } + if ([self.URL isEqual:url]) return; NSString *urlStr = url.absoluteString; NSString *videoID = YouModExtractDeArrowVideoID(urlStr); if (!videoID || videoID.length != 11) { @@ -501,32 +515,13 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); objc_setAssociatedObject(self, "kYMDeArrowOrigURLKey", url, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - // Propagate videoID to parent nodes - ASDisplayNode *cur = self.supernode; - while (cur) { - objc_setAssociatedObject(cur, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - cur = cur.supernode; - } - - if ([self respondsToSelector:@selector(view)]) { - UIView *v = [self performSelector:@selector(view)]; - if (v) { - objc_setAssociatedObject(v, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - UIView *p = v.superview; - while (p) { - objc_setAssociatedObject(p, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - p = p.superview; - } - } - } - if ([[YouModDeArrowManager sharedInstance] isOriginalToggledForVideoID:videoID]) { %orig(url, reset); return; } NSString *deArrowURL = [[YouModDeArrowManager sharedInstance] thumbnailURLForVideoID:videoID]; - if (deArrowURL.length > 0) { + if (deArrowURL.length > 0 && ![failed.absoluteString isEqualToString:deArrowURL]) { %orig([NSURL URLWithString:deArrowURL], reset); return; } @@ -555,11 +550,6 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { - (void)didLoad { %orig; - NSString *videoID = objc_getAssociatedObject(self, "kYMDeArrowVideoIDKey"); - if (videoID && [self respondsToSelector:@selector(view)]) { - UIView *v = [self performSelector:@selector(view)]; - if (v) objc_setAssociatedObject(v, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(youmod_onDeArrowImageNotification:) name:kYMDeArrowUpdatedNotification object:nil]; } @@ -587,6 +577,19 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { %end +// ELMImageNode is the actual downloader delegate in YouTube 21.38.2. +%hook ELMImageNode +- (void)imageNode:(ASNetworkImageNode *)node didFailWithError:(NSError *)error { + NSURL *original = objc_getAssociatedObject(node, "kYMDeArrowOrigURLKey"); + if (original && [node.URL.host isEqualToString:@"dearrow-thumb.ajay.app"]) { + objc_setAssociatedObject(node, "kYMDeArrowFailedURL", node.URL, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [node setURL:original resetToDefault:NO]; + return; + } + %orig; +} +%end + // UIKit image view hook %hook YTImageView @@ -635,6 +638,7 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { - (void)didMoveToWindow { %orig; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kYMDeArrowUpdatedNotification object:nil]; if (self.window) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(youmod_onDeArrowYTImageNotification:) name:kYMDeArrowUpdatedNotification object:nil]; } else { @@ -666,26 +670,6 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { %end -// Protobuf model thumbnail fallback -%hook YTIThumbnailDetails_Thumbnail -- (NSString *)URL { - NSString *origURL = %orig; - if (!IS_ENABLED(DeArrowEnabled) || !IS_ENABLED(DeArrowReplaceThumbnails)) { - return origURL; - } - NSString *videoID = YouModExtractDeArrowVideoID(origURL); - if (!videoID || videoID.length != 11) return origURL; - - NSString *deArrowURL = [[YouModDeArrowManager sharedInstance] thumbnailURLForVideoID:videoID]; - if (deArrowURL) { - return deArrowURL; - } - - [[YouModDeArrowManager sharedInstance] prefetchBrandingForVideoID:videoID]; - return origURL; -} -%end - #pragma mark - Title Hooks across All Feed & Video Renderers // Home / subscriptions main feed video renderer @@ -893,22 +877,16 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { } NSString *videoID = objc_getAssociatedObject(self, "kYMDeArrowVideoIDKey"); - if (!videoID) { - ASDisplayNode *cur = self.supernode; - while (cur && !videoID) { - videoID = objc_getAssociatedObject(cur, "kYMDeArrowVideoIDKey"); - cur = cur.supernode; - } + NSAttributedString *previous = objc_getAssociatedObject(self, "kYMDeArrowOrigAttrKey"); + NSString *replacement = [[YouModDeArrowManager sharedInstance] titleForVideoID:videoID]; + if (videoID && ![curStr isEqualToString:previous.string] && ![curStr isEqualToString:replacement]) { + videoID = nil; + objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(self, "kYMDeArrowOrigAttrKey", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } if (!videoID) { videoID = [[YouModDeArrowManager sharedInstance] videoIDForTitle:curStr]; } - if (!videoID && [self respondsToSelector:@selector(view)]) { - UIView *v = [self performSelector:@selector(view)]; - if (v && v.superview) { - videoID = YouModFindVideoIDFromView(v.superview); - } - } if (videoID && videoID.length == 11) { objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); @@ -940,14 +918,6 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { - (void)youmod_onDeArrowTextNotification:(NSNotification *)note { NSString *notifVideoID = note.userInfo[@"videoID"]; NSString *myVideoID = objc_getAssociatedObject(self, "kYMDeArrowVideoIDKey"); - if (!myVideoID && self.supernode) { - ASDisplayNode *cur = self.supernode; - while (cur && !myVideoID) { - myVideoID = objc_getAssociatedObject(cur, "kYMDeArrowVideoIDKey"); - cur = cur.supernode; - } - if (myVideoID) objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", myVideoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } if (!myVideoID || ![myVideoID isEqualToString:notifVideoID]) return; NSAttributedString *origAttr = objc_getAssociatedObject(self, "kYMDeArrowOrigAttrKey"); @@ -960,21 +930,7 @@ static NSString *YouModExtractDeArrowVideoID(NSString *urlStr) { [mod.mutableString setString:title]; [self setAttributedText:mod]; [self setNeedsDisplay]; - if ([self respondsToSelector:@selector(view)]) { - UIView *v = [self performSelector:@selector(view)]; - [v setNeedsDisplay]; - [v setNeedsLayout]; - } - } -} -%end - -// Player response trigger to prefetch branding as soon as a video starts -%hook YTPlayerViewController -- (void)setPlayerResponse:(YTIPlayerResponse *)response { - %orig; - if (IS_ENABLED(DeArrowEnabled) && response.videoDetails.videoId.length > 0) { - [[YouModDeArrowManager sharedInstance] prefetchBrandingForVideoID:response.videoDetails.videoId]; + [self setNeedsLayout]; } } %end @@ -1018,28 +974,25 @@ static void YouModUpdateOverflowIndicator(UIButton *indBtn, NSString *videoID) { } } +static void YouModCollectCardNodes(ASDisplayNode *node, NSMutableArray *textNodes, NSMutableArray *imageNodes) { + if (!node) return; + if ([node isKindOfClass:%c(ASTextNode)]) [textNodes addObject:node]; + if ([node isKindOfClass:%c(ASNetworkImageNode)]) [imageNodes addObject:node]; + NSArray *children = node.yogaChildren.count ? node.yogaChildren : node.subnodes; + for (ASDisplayNode *child in children) { + YouModCollectCardNodes(child, textNodes, imageNodes); + } +} + static void YouModCollectNodesFromView(UIView *view, NSMutableArray *textNodes, NSMutableArray *imageNodes) { - if (!view) return; - id node = nil; - if ([view respondsToSelector:@selector(node)]) { - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - node = [view performSelector:@selector(node)]; - #pragma clang diagnostic pop - } - if (!node) { - @try { node = [view valueForKey:@"asyncdisplaykit_node"]; } @catch (id ex) {} - } - if (node) { - if ([node isKindOfClass:%c(ASTextNode)]) { - [textNodes addObject:node]; - } else if ([node isKindOfClass:%c(ASNetworkImageNode)]) { - [imageNodes addObject:node]; + if ([view respondsToSelector:@selector(keepalive_node)]) { + ASDisplayNode *node = [(id)view keepalive_node]; + if (node) { + YouModCollectCardNodes(node, textNodes, imageNodes); + return; } } - for (UIView *sub in view.subviews) { - YouModCollectNodesFromView(sub, textNodes, imageNodes); - } + for (UIView *child in view.subviews) YouModCollectNodesFromView(child, textNodes, imageNodes); } static ASTextNode *YouModFindTitleNode(NSArray *textNodes) { @@ -1077,6 +1030,11 @@ static ASNetworkImageNode *YouModFindThumbnailNode(NSArray *imageNodes, NSString for (id node in imageNodes) { if (![node isKindOfClass:%c(ASNetworkImageNode)]) continue; ASNetworkImageNode *inNode = (ASNetworkImageNode *)node; + NSString *knownID = objc_getAssociatedObject(inNode, "kYMDeArrowVideoIDKey"); + if (knownID.length == 11) { + if (outVideoID) *outVideoID = knownID; + return inNode; + } NSURL *u = nil; if ([inNode respondsToSelector:@selector(URL)]) { u = [inNode URL]; @@ -1115,375 +1073,123 @@ static ASNetworkImageNode *YouModFindThumbnailNode(NSArray *imageNodes, NSString NSString *videoID = objc_getAssociatedObject(button, "kYMDeArrowVideoIDKey"); if (!videoID || videoID.length == 0) return; - BOOL isOriginal = [[YouModDeArrowManager sharedInstance] toggleDeArrowForVideoID:videoID]; - + [[YouModDeArrowManager sharedInstance] toggleDeArrowForVideoID:videoID]; UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; - [feedback prepare]; [feedback impactOccurred]; - - UIView *container = button.superview; - if (container) { - NSMutableArray *textNodes = [NSMutableArray array]; - NSMutableArray *imageNodes = [NSMutableArray array]; - YouModCollectNodesFromView(container, textNodes, imageNodes); - - ASTextNode *tn = YouModFindTitleNode(textNodes); - if (tn) { - NSAttributedString *origAttr = objc_getAssociatedObject(tn, "kYMDeArrowOrigAttrKey") ?: tn.attributedText; - if (isOriginal) { - [tn setAttributedText:origAttr]; - } else { - NSString *deTitle = [[YouModDeArrowManager sharedInstance] titleForVideoID:videoID]; - if (deTitle.length > 0) { - NSMutableAttributedString *mod = [[NSMutableAttributedString alloc] initWithAttributedString:origAttr]; - [mod.mutableString setString:deTitle]; - [tn setAttributedText:mod]; - } - } - [tn setNeedsDisplay]; - if ([tn respondsToSelector:@selector(view)]) { - [[tn view] setNeedsDisplay]; - } - } - - NSString *extractedVID = nil; - ASNetworkImageNode *inNode = YouModFindThumbnailNode(imageNodes, &extractedVID); - if (inNode) { - if (isOriginal) { - NSURL *origURL = objc_getAssociatedObject(inNode, "kYMDeArrowOrigURLKey"); - if (origURL) [inNode setURL:origURL resetToDefault:YES]; - } else { - NSString *deThumb = [[YouModDeArrowManager sharedInstance] thumbnailURLForVideoID:videoID]; - if (deThumb.length > 0) { - [inNode setURL:[NSURL URLWithString:deThumb] resetToDefault:YES]; - } - } - [inNode setNeedsDisplay]; - if ([inNode respondsToSelector:@selector(view)]) { - [[inNode view] setNeedsDisplay]; - } - } - } - + // The notification updates the bound title and image; no second reload. YouModUpdateOverflowIndicator(button, videoID); } @end -// Robust helper to extract video ID from an ASDisplayView or its hierarchy -static NSString *YouModFindVideoIDFromView(UIView *view) { - if (!view) return nil; - NSString *vID = objc_getAssociatedObject(view, "kYMDeArrowVideoIDKey"); - if (vID.length == 11) return vID; - - id node = nil; - if ([view respondsToSelector:@selector(node)]) { - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - node = [view performSelector:@selector(node)]; - #pragma clang diagnostic pop - } - if (!node) { - @try { node = [view valueForKey:@"asyncdisplaykit_node"]; } @catch (id ex) {} - } - if (node) { - NSString *nodeVID = objc_getAssociatedObject(node, "kYMDeArrowVideoIDKey"); - if (nodeVID.length == 11) return nodeVID; - if ([node respondsToSelector:@selector(URL)]) { - NSURL *u = [node performSelector:@selector(URL)]; - NSString *extracted = YouModExtractDeArrowVideoID(u.absoluteString); - if (extracted.length == 11) return extracted; - } - } +// Keep the toggle in the menu column. This column is outside both the title +// and thumbnail in home, compact channel, and notification cards. +static CGRect YouModDeArrowButtonFrame(CGRect bounds, CGRect menu) { + CGFloat size = 26.0; + CGFloat x = CGRectGetMidX(menu) - size / 2.0; + CGFloat y = CGRectGetMaxY(menu) + 10.0; + CGRect frame = CGRectMake(x, y, size, size); + return CGRectContainsRect(bounds, frame) ? frame : CGRectZero; +} - for (UIView *sub in view.subviews) { - NSString *subVID = objc_getAssociatedObject(sub, "kYMDeArrowVideoIDKey"); - if (subVID.length == 11) return subVID; - id subNode = nil; - if ([sub respondsToSelector:@selector(node)]) { - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - subNode = [sub performSelector:@selector(node)]; - #pragma clang diagnostic pop - } - if (!subNode) { - @try { subNode = [sub valueForKey:@"asyncdisplaykit_node"]; } @catch (id ex) {} - } - if (subNode) { - NSString *snVID = objc_getAssociatedObject(subNode, "kYMDeArrowVideoIDKey"); - if (snVID.length == 11) return snVID; - if ([subNode respondsToSelector:@selector(URL)]) { - NSURL *u = [subNode performSelector:@selector(URL)]; - NSString *extracted = YouModExtractDeArrowVideoID(u.absoluteString); - if (extracted.length == 11) return extracted; - } - } +static UIView *YouModDeArrowCard(UIView *menu, NSMutableArray *texts, NSMutableArray *images, NSString **videoID) { + // Stop at the collection cell; never scan another card or the entire feed. + for (UIView *card = menu.superview; card; card = card.superview) { + if ([card isKindOfClass:[UIScrollView class]]) break; + [texts removeAllObjects]; + [images removeAllObjects]; + YouModCollectNodesFromView(card, texts, images); + YouModFindThumbnailNode(images, videoID); + if (*videoID) return card; + if ([card isKindOfClass:[UICollectionViewCell class]]) break; } - - return YouModExtractDeArrowVideoID([view description]); + return nil; } %hook _ASDisplayView - (void)didMoveToWindow { %orig; - if (!self.window) { - [[NSNotificationCenter defaultCenter] removeObserver:self name:kYMDeArrowUpdatedNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kYMDeArrowUpdatedNotification object:nil]; + UIButton *button = objc_getAssociatedObject(self, "kYMDeArrowButton"); + if (!self.window || !IS_ENABLED(DeArrowEnabled)) { + [button removeFromSuperview]; return; } - if (!IS_ENABLED(DeArrowEnabled)) return; - if (YouModIsOverflowButtonView(self)) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(youmod_onDeArrowNotification:) name:kYMDeArrowUpdatedNotification object:nil]; + [self setNeedsLayout]; } } - (void)layoutSubviews { %orig; - if (!IS_ENABLED(DeArrowEnabled)) return; - if (!YouModIsOverflowButtonView(self)) return; - - UIView *superv = self.superview; - if (!superv) return; - - // Target container view: expand beyond narrow button wrapper if needed - UIView *container = superv; - if (container.frame.size.width < 50 && container.superview) { - container = container.superview; - } - - NSMutableArray *textNodes = [NSMutableArray array]; - NSMutableArray *imageNodes = [NSMutableArray array]; - YouModCollectNodesFromView(container, textNodes, imageNodes); - - NSString *videoID = objc_getAssociatedObject(self, "kYMDeArrowVideoIDKey"); - if (!videoID) { - NSString *extractedVID = nil; - YouModFindThumbnailNode(imageNodes, &extractedVID); - if (extractedVID.length == 11) videoID = extractedVID; - } - if (!videoID) { - videoID = YouModFindVideoIDFromView(container); - } - if (!videoID || videoID.length != 11) return; - - objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(container, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - UIButton *indBtn = (UIButton *)[container viewWithTag:0xDEA222]; - if (!indBtn) { - indBtn = [YouModDeArrowButton buttonWithType:UIButtonTypeCustom]; - indBtn.tag = 0xDEA222; - indBtn.userInteractionEnabled = YES; - indBtn.exclusiveTouch = YES; - indBtn.layer.cornerRadius = 13.0; - indBtn.clipsToBounds = YES; - [indBtn addTarget:[YouModDeArrowSwapHandler sharedHandler] action:@selector(handleSwapButtonTap:) forControlEvents:UIControlEventTouchUpInside]; - [container addSubview:indBtn]; - } - - objc_setAssociatedObject(indBtn, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - CGRect rectInContainer = [self convertRect:self.bounds toView:container]; - CGFloat btnSize = 26.0; - CGFloat btnX = rectInContainer.origin.x - btnSize - 6.0; - CGFloat btnY = rectInContainer.origin.y + (rectInContainer.size.height - btnSize) / 2.0; - - // Check if candidate position overlaps with a compact thumbnail (e.g. notification row) - CGRect thumbRectInContainer = CGRectZero; - NSString *extractedVID = nil; - ASNetworkImageNode *inNode = YouModFindThumbnailNode(imageNodes, &extractedVID); - if (inNode) { - if ([inNode respondsToSelector:@selector(view)] && [inNode view]) { - thumbRectInContainer = [[inNode view] convertRect:[inNode view].bounds toView:container]; - } else { - thumbRectInContainer = inNode.frame; - } + UIButton *button = objc_getAssociatedObject(self, "kYMDeArrowButton"); + if (!self.window || !IS_ENABLED(DeArrowEnabled) || !YouModIsOverflowButtonView(self)) { + [button removeFromSuperview]; + return; } - - CGRect candidateBtnFrame = CGRectMake(btnX, btnY, btnSize, btnSize); - BOOL overlapsThumbnail = (thumbRectInContainer.size.width > 0 && CGRectIntersectsRect(candidateBtnFrame, thumbRectInContainer)); - - if (overlapsThumbnail) { - // Notification row layout: compact thumbnail is right next to 3-dots menu! - // Place DeArrow button in the open right margin below the 3-dots button, or above, or left of thumbnail - CGFloat belowY = CGRectGetMaxY(rectInContainer) + 6.0; - CGFloat colX = rectInContainer.origin.x + (rectInContainer.size.width - btnSize) / 2.0; - if (belowY + btnSize <= container.bounds.size.height - 2.0) { - btnX = colX; - btnY = belowY; - } else { - CGFloat aboveY = rectInContainer.origin.y - btnSize - 6.0; - if (aboveY >= 2.0) { - btnX = colX; - btnY = aboveY; - } else { - btnX = thumbRectInContainer.origin.x - btnSize - 6.0; - btnY = thumbRectInContainer.origin.y + (thumbRectInContainer.size.height - btnSize) / 2.0; - } - } - } else { - // Feed card layout: ensure title text does not overlap the DeArrow button - ASTextNode *tn = YouModFindTitleNode(textNodes); - if (tn && [tn respondsToSelector:@selector(view)]) { - UIView *titleView = [tn view]; - if (titleView) { - CGRect tInCont = [titleView convertRect:titleView.bounds toView:container]; - if (CGRectGetMaxX(tInCont) > btnX - 6.0) { - CGRect tFrame = titleView.frame; - CGFloat diff = CGRectGetMaxX(tInCont) - (btnX - 6.0); - tFrame.size.width = MAX(50.0, tFrame.size.width - diff); - titleView.frame = tFrame; - titleView.clipsToBounds = YES; - } - } - } + NSMutableArray *texts = [NSMutableArray array]; + NSMutableArray *images = [NSMutableArray array]; + NSString *videoID = nil; + UIView *card = YouModDeArrowCard(self, texts, images, &videoID); + if (!card) { + [button removeFromSuperview]; + return; } - - indBtn.frame = CGRectMake(btnX, btnY, btnSize, btnSize); - [container bringSubviewToFront:indBtn]; - - YouModUpdateOverflowIndicator(indBtn, videoID); - - // Apply DeArrow to title node and thumbnail node if available - ASTextNode *tn = YouModFindTitleNode(textNodes); - if (tn) { - objc_setAssociatedObject(tn, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (!objc_getAssociatedObject(tn, "kYMDeArrowOrigAttrKey")) { - objc_setAssociatedObject(tn, "kYMDeArrowOrigAttrKey", tn.attributedText, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [[YouModDeArrowManager sharedInstance] registerOriginalTitle:tn.attributedText.string forVideoID:videoID]; - } - BOOL isOriginal = [[YouModDeArrowManager sharedInstance] isOriginalToggledForVideoID:videoID]; - if (!isOriginal && IS_ENABLED(DeArrowReplaceTitles)) { - NSString *deTitle = [[YouModDeArrowManager sharedInstance] titleForVideoID:videoID]; - if (deTitle.length > 0 && ![tn.attributedText.string isEqualToString:deTitle]) { - NSAttributedString *origAttr = objc_getAssociatedObject(tn, "kYMDeArrowOrigAttrKey") ?: tn.attributedText; - NSMutableAttributedString *mod = [[NSMutableAttributedString alloc] initWithAttributedString:origAttr]; - [mod.mutableString setString:deTitle]; - [tn setAttributedText:mod]; - [tn setNeedsDisplay]; - if ([tn respondsToSelector:@selector(view)]) [[tn view] setNeedsDisplay]; - } - } + // A metadata wrapper may contain the image but be shorter than the row. + CGRect menu = [self convertRect:self.bounds toView:card]; + CGRect frame = YouModDeArrowButtonFrame(card.bounds, menu); + while (CGRectIsEmpty(frame) && card.superview && ![card isKindOfClass:[UICollectionViewCell class]] && + ![card.superview isKindOfClass:[UIScrollView class]]) { + card = card.superview; + menu = [self convertRect:self.bounds toView:card]; + frame = YouModDeArrowButtonFrame(card.bounds, menu); + } + if (CGRectIsEmpty(frame)) { + [button removeFromSuperview]; + return; } + if (!button) { + button = [YouModDeArrowButton buttonWithType:UIButtonTypeCustom]; + button.layer.cornerRadius = 13; + button.accessibilityLabel = @"Toggle DeArrow original title and thumbnail"; + [button addTarget:[YouModDeArrowSwapHandler sharedHandler] action:@selector(handleSwapButtonTap:) forControlEvents:UIControlEventTouchUpInside]; + objc_setAssociatedObject(self, "kYMDeArrowButton", button, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_COPY_NONATOMIC); + objc_setAssociatedObject(button, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_COPY_NONATOMIC); + if (button.superview != card) [card addSubview:button]; + if (!CGRectEqualToRect(button.frame, frame)) button.frame = frame; + YouModUpdateOverflowIndicator(button, videoID); - if (inNode) { - objc_setAssociatedObject(inNode, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (!objc_getAssociatedObject(inNode, "kYMDeArrowOrigURLKey") && [inNode respondsToSelector:@selector(URL)]) { - objc_setAssociatedObject(inNode, "kYMDeArrowOrigURLKey", [inNode URL], OBJC_ASSOCIATION_RETAIN_NONATOMIC); + ASTextNode *title = YouModFindTitleNode(texts); + if (title) { + NSString *oldID = objc_getAssociatedObject(title, "kYMDeArrowVideoIDKey"); + if (![oldID isEqualToString:videoID]) { + objc_setAssociatedObject(title, "kYMDeArrowOrigAttrKey", title.attributedText, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(title, "kYMDeArrowVideoIDKey", videoID, OBJC_ASSOCIATION_COPY_NONATOMIC); + [[YouModDeArrowManager sharedInstance] registerOriginalTitle:title.attributedText.string forVideoID:videoID]; } - BOOL isOriginal = [[YouModDeArrowManager sharedInstance] isOriginalToggledForVideoID:videoID]; - if (!isOriginal && IS_ENABLED(DeArrowReplaceThumbnails)) { - NSString *deThumb = [[YouModDeArrowManager sharedInstance] thumbnailURLForVideoID:videoID]; - if (deThumb.length > 0) { - [inNode setURL:[NSURL URLWithString:deThumb] resetToDefault:YES]; - [inNode setNeedsDisplay]; - if ([inNode respondsToSelector:@selector(view)]) [[inNode view] setNeedsDisplay]; + if (IS_ENABLED(DeArrowReplaceTitles)) { + NSString *replacement = [[YouModDeArrowManager sharedInstance] titleForVideoID:videoID]; + if (replacement.length && ![title.attributedText.string isEqualToString:replacement]) { + NSMutableAttributedString *text = [title.attributedText mutableCopy]; + [text.mutableString setString:replacement]; + title.attributedText = text; } } } - + // Image nodes handle branding notifications themselves. Never reload here. [[YouModDeArrowManager sharedInstance] prefetchBrandingForVideoID:videoID]; } %new -- (void)youmod_onDeArrowNotification:(NSNotification *)notif { - NSString *notifVideoID = notif.userInfo[@"videoID"]; - NSString *myVideoID = objc_getAssociatedObject(self, "kYMDeArrowVideoIDKey"); - if (!myVideoID && self.superview) { - myVideoID = YouModFindVideoIDFromView(self.superview); - if (myVideoID) objc_setAssociatedObject(self, "kYMDeArrowVideoIDKey", myVideoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - if (!myVideoID || ![myVideoID isEqualToString:notifVideoID]) return; - - UIView *superv = self.superview; - UIView *container = superv; - if (container.frame.size.width < 50 && container.superview) { - container = container.superview; - } - if (!container) return; - - UIButton *indBtn = (UIButton *)[container viewWithTag:0xDEA222]; - if (indBtn) { - YouModUpdateOverflowIndicator(indBtn, myVideoID); - } - - BOOL isOriginal = [[YouModDeArrowManager sharedInstance] isOriginalToggledForVideoID:myVideoID]; - if (isOriginal) return; - - NSMutableArray *textNodes = [NSMutableArray array]; - NSMutableArray *imageNodes = [NSMutableArray array]; - YouModCollectNodesFromView(container, textNodes, imageNodes); - - if (IS_ENABLED(DeArrowReplaceTitles)) { - ASTextNode *tn = YouModFindTitleNode(textNodes); - if (tn) { - NSString *deTitle = [[YouModDeArrowManager sharedInstance] titleForVideoID:myVideoID]; - if (deTitle.length > 0 && ![tn.attributedText.string isEqualToString:deTitle]) { - NSAttributedString *origAttr = objc_getAssociatedObject(tn, "kYMDeArrowOrigAttrKey") ?: tn.attributedText; - NSMutableAttributedString *mod = [[NSMutableAttributedString alloc] initWithAttributedString:origAttr]; - [mod.mutableString setString:deTitle]; - [tn setAttributedText:mod]; - if ([tn respondsToSelector:@selector(view)]) { - UIView *tv = [tn view]; - [tv setNeedsDisplay]; - if (indBtn && tv) { - CGRect tInCont = [tv convertRect:tv.bounds toView:container]; - if (CGRectGetMaxX(tInCont) > indBtn.frame.origin.x - 6.0) { - CGRect tFrame = tv.frame; - CGFloat diff = CGRectGetMaxX(tInCont) - (indBtn.frame.origin.x - 6.0); - tFrame.size.width = MAX(50.0, tFrame.size.width - diff); - tv.frame = tFrame; - tv.clipsToBounds = YES; - } - } - } - } - } - } - - if (IS_ENABLED(DeArrowReplaceThumbnails)) { - NSString *extractedVID = nil; - ASNetworkImageNode *inNode = YouModFindThumbnailNode(imageNodes, &extractedVID); - if (inNode) { - NSString *deThumb = [[YouModDeArrowManager sharedInstance] thumbnailURLForVideoID:myVideoID]; - if (deThumb.length > 0) { - [inNode setURL:[NSURL URLWithString:deThumb] resetToDefault:YES]; - [inNode setNeedsDisplay]; - if ([inNode respondsToSelector:@selector(view)]) [[inNode view] setNeedsDisplay]; - } - } - } -} - -%end - -#pragma mark - Inline Muted Playback Tracking - -// Track active preview video ID -%hook YTInlineMutedPlaybackScrubberViewController - -- (void)setActiveSingleVideoObservable:(YTSingleVideoController *)singleVideoController { - %orig; - if (singleVideoController) { - @try { - NSString *vID = [singleVideoController valueForKey:@"_videoId"]; - if (!vID && [singleVideoController respondsToSelector:@selector(contentVideoID)]) { - vID = [singleVideoController performSelector:@selector(contentVideoID)]; - } - if (!vID && [singleVideoController respondsToSelector:@selector(videoId)]) { - vID = [singleVideoController performSelector:@selector(videoId)]; - } - if (vID.length > 0) { - currentInlinePreviewVideoID = [vID copy]; - if (self.view.superview) { - objc_setAssociatedObject(self.view.superview, "kYMDeArrowVideoIDKey", currentInlinePreviewVideoID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - } - } @catch (id ex) {} +- (void)youmod_onDeArrowNotification:(NSNotification *)note { + if ([note.userInfo[@"videoID"] isEqual:objc_getAssociatedObject(self, "kYMDeArrowVideoIDKey")]) { + [self setNeedsLayout]; } } - %end %ctor { diff --git a/Files/Player.x b/Files/Player.x index ef87d9dd..9e425a1f 100644 --- a/Files/Player.x +++ b/Files/Player.x @@ -776,42 +776,40 @@ static void YouModAddEndTime(YTInlinePlayerBarContainerView *playerbar, YTPlayer %end -%hook YTInlineMutedPlaybackScrubberViewController -- (void)setActiveSingleVideoObservable:(YTSingleVideoController *)singleVideoController { - if (singleVideoController) { - objc_setAssociatedObject(singleVideoController, "kYMIsInlinePlayback", @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (IS_ENABLED(FeedPreviewSoundOn) && !IS_ENABLED(AutoFeedMute)) { - [singleVideoController setMuted:NO]; - } - } - %orig; -} -%end - -%hook YTInlineMutedPlaybackPlayerOverlayViewController -- (BOOL)inlinePlaybackUnmutedAtStart { - if (IS_ENABLED(FeedPreviewSoundOn) && !IS_ENABLED(AutoFeedMute)) { - return YES; - } - return %orig; -} -- (void)loadView { - %orig; - if (IS_ENABLED(FeedPreviewCCDisabled)) { - @try { - if ([self.view respondsToSelector:@selector(setCaptionsActive:)]) { - [(id)self.view setCaptionsActive:NO]; - } - UIView *captionBtn = [self.view valueForKey:@"_captionButton"]; - if ([captionBtn respondsToSelector:@selector(setSelected:)]) { - [captionBtn performSelector:@selector(setSelected:) withObject:@NO]; - } - captionBtn.hidden = NO; - } @catch (id ex) {} - } -} -%end - +// Initialize the native preference once per preview. The native getter must +// remain live: it is read again after taps to synchronize the audio icon. +@interface NSObject (YouModInlineAudioState) +- (void)setInlinePlaybackUnmutedAtStart:(BOOL)unmuted; +- (void)setInlinePlaybackCaptionHidden:(BOOL)hidden; +@end + +%hook YTInlineMutedPlaybackPlayerOverlayViewController +- (void)setActiveSingleVideo:(YTSingleVideoController *)video { + NSString *videoID = video.singleVideo.videoId; + NSString *previousID = objc_getAssociatedObject(self, "kYMPreviewVideoID"); + BOOL newVideo = videoID.length && ![videoID isEqualToString:previousID]; + objc_setAssociatedObject(self, "kYMPreviewVideoID", videoID, OBJC_ASSOCIATION_COPY_NONATOMIC); + if (newVideo) { + @try { + id monitor = [self valueForKey:@"_globalStateMonitor"]; + if (IS_ENABLED(FeedPreviewSoundOn) && !IS_ENABLED(AutoFeedMute) && [monitor respondsToSelector:@selector(setInlinePlaybackUnmutedAtStart:)]) { + [monitor setInlinePlaybackUnmutedAtStart:YES]; + } + if (IS_ENABLED(FeedPreviewCCDisabled) && [monitor respondsToSelector:@selector(setInlinePlaybackCaptionHidden:)]) { + [monitor setInlinePlaybackCaptionHidden:YES]; + } + } @catch (id ex) {} + } + %orig; + if (newVideo && IS_ENABLED(FeedPreviewSoundOn) && !IS_ENABLED(AutoFeedMute)) { + [video setMuted:NO]; + if ([self.viewIfLoaded respondsToSelector:@selector(setAudioSoundOn:)]) { + [(id)self.viewIfLoaded setAudioSoundOn:YES]; + } + } +} +%end + // Exit Fullscreen on Finish %hook YTWatchFlowController - (BOOL)shouldExitFullScreenOnFinish { return IS_ENABLED(AutoExitFullScreen) ? YES : %orig; } diff --git a/Files/ReturnYouTubeDislike.x b/Files/ReturnYouTubeDislike.x index d2001f76..f2685c60 100644 --- a/Files/ReturnYouTubeDislike.x +++ b/Files/ReturnYouTubeDislike.x @@ -4,7 +4,7 @@ // API: https://returnyoutubedislikeapi.com/votes?videoId={videoId} static NSString * const kYMReturnDislikeNotification = @"YouModReturnDislikeNotification"; -static NSString *currentActiveVideoID = nil; +static __weak YTPlayerViewController *currentWatchPlayer = nil; static NSString *YouModFormatVoteCount(NSInteger count) { if (count < 0) return @"0"; @@ -20,6 +20,7 @@ static NSString *YouModFormatVoteCount(NSInteger count) { @interface YouModRYDManager : NSObject @property (nonatomic, strong) NSCache *votesCache; @property (nonatomic, strong) NSMutableSet *inFlightRequests; +@property (nonatomic, strong) NSCache *requestDates; + (instancetype)sharedInstance; - (void)fetchVotesForVideoID:(NSString *)videoID completion:(void (^)(NSDictionary *votes))completion; - (NSDictionary *)cachedVotesForVideoID:(NSString *)videoID; @@ -42,6 +43,8 @@ static NSString *YouModFormatVoteCount(NSInteger count) { _votesCache = [[NSCache alloc] init]; _votesCache.countLimit = 200; _inFlightRequests = [NSMutableSet set]; + _requestDates = [[NSCache alloc] init]; + _requestDates.countLimit = 200; } return self; } @@ -64,11 +67,13 @@ static NSString *YouModFormatVoteCount(NSInteger count) { } @synchronized (_inFlightRequests) { - if ([_inFlightRequests containsObject:videoID]) { + NSDate *lastRequest = [_requestDates objectForKey:videoID]; + if ([_inFlightRequests containsObject:videoID] || (lastRequest && -lastRequest.timeIntervalSinceNow < 60)) { if (completion) completion(nil); return; } [_inFlightRequests addObject:videoID]; + [_requestDates setObject:[NSDate date] forKey:videoID]; } NSString *urlString = [NSString stringWithFormat:@"https://returnyoutubedislikeapi.com/votes?videoId=%@", videoID]; @@ -108,6 +113,10 @@ static NSString *YouModFormatVoteCount(NSInteger count) { } NSDictionary *voteData = (NSDictionary *)json; + if (![voteData[@"likes"] isKindOfClass:[NSNumber class]] || ![voteData[@"dislikes"] isKindOfClass:[NSNumber class]]) { + if (completion) completion(nil); + return; + } [self.votesCache setObject:voteData forKey:videoID]; dispatch_async(dispatch_get_main_queue(), ^{ @@ -151,40 +160,13 @@ static NSString *YouModFormatVoteCount(NSInteger count) { - (void)relayoutNode; @end -@interface ELMNodeFactory (RYD) -+ (instancetype)sharedInstance; -- (id)nodeWithElement:(id)element materializationContext:(const void *)context; -@end - -@interface ASDisplayNode (YouModRYDYoga) -- (void)addYogaChild:(id)child; -- (NSArray *)yogaChildren; -- (id)closestViewController; -@end - -@interface ASCollectionView (RYD) -@property (nonatomic, assign) BOOL hasDislikeIntent; -@property (nonatomic, assign) BOOL isProbablyVideoDescriptionHeaderPanel; -@end - #pragma mark - Hooks static NSString *YouModGetCurrentVideoID(void) { - if (currentActiveVideoID && currentActiveVideoID.length > 0) return currentActiveVideoID; - if (YouModCurrentPlayerViewController) { - @try { - YTIPlayerResponse *resp = [YouModCurrentPlayerViewController valueForKey:@"playerResponse"]; - NSString *vid = resp.videoDetails.videoId; - if (vid.length > 0) return vid; - } @catch (id e) {} - } - return nil; + return currentWatchPlayer.contentVideoID; } static NSString *getVideoId(ASDisplayNode *containerNode) { - NSString *current = YouModGetCurrentVideoID(); - if (current && current.length > 0) return current; - UIViewController *vc = [containerNode closestViewController]; if (![vc isKindOfClass:%c(YTWatchNextResultsViewController)]) { UIViewController *parentViewController; @@ -220,45 +202,31 @@ static NSString *getVideoId(ASDisplayNode *containerNode) { NSString *vid = [pvc contentVideoID]; if (vid.length > 0) return vid; } @catch (id ex) {} - return nil; + return YouModGetCurrentVideoID(); } -// Capture current video ID from player -%hook YTPlayerViewController -- (void)setPlayerResponse:(YTIPlayerResponse *)response { - %orig; - if (response.videoDetails.videoId.length > 0) { - currentActiveVideoID = [response.videoDetails.videoId copy]; - if (IS_ENABLED(ReturnYouTubeDislike)) { - [[YouModRYDManager sharedInstance] fetchVotesForVideoID:currentActiveVideoID completion:nil]; - } - } +// These lifecycle selectors are present in YouTube 21.38.2. The old +// setPlayerResponse:/loadVideoWithPlaybackData: hooks are no longer called. +static void YouModWatchVideoChanged(YTPlayerViewController *player) { + if (player.isInlinePlaybackActive || [player.activeVideoPlayerOverlay isKindOfClass:%c(YTInlineMutedPlaybackPlayerOverlayViewController)]) return; + NSString *videoID = player.contentVideoID; + if (!videoID.length) return; + currentWatchPlayer = player; + if (!IS_ENABLED(ReturnYouTubeDislike)) return; + [[YouModRYDManager sharedInstance] fetchVotesForVideoID:videoID completion:nil]; + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kYMReturnDislikeNotification object:nil userInfo:@{@"videoID": videoID}]; + }); } -- (void)loadVideoWithPlaybackData:(id)data { +%hook YTPlayerViewController +- (void)viewDidAppear:(BOOL)animated { %orig; - NSString *vID = nil; - @try { - vID = [data valueForKey:@"videoId"]; - } @catch (id ex) {} - if (vID.length > 0) { - currentActiveVideoID = [vID copy]; - if (IS_ENABLED(ReturnYouTubeDislike)) { - [[YouModRYDManager sharedInstance] fetchVotesForVideoID:currentActiveVideoID completion:nil]; - } - } + YouModWatchVideoChanged(self); } -%end - -%hook YTSingleVideoController -- (void)setPlayerResponse:(YTIPlayerResponse *)response { +- (void)playbackController:(id)controller didActivateNewPlaybackWithContentVideo:(id)video { %orig; - if (response.videoDetails.videoId.length > 0) { - currentActiveVideoID = [response.videoDetails.videoId copy]; - if (IS_ENABLED(ReturnYouTubeDislike)) { - [[YouModRYDManager sharedInstance] fetchVotesForVideoID:currentActiveVideoID completion:nil]; - } - } + YouModWatchVideoChanged(self); } %end @@ -303,373 +271,149 @@ static NSString *getVideoId(ASDisplayNode *containerNode) { %end -static int overrideNodeCreation = 0; +@interface ASDisplayNode (YouModVoteRemoval) +- (void)removeFromSupernode; +@end -%hook ELMNodeFactory +// Texture's ASDimension ABI (unit: NSInteger, value: CGFloat). +typedef struct { NSInteger unit; CGFloat value; } YMVoteDimension; +@interface ASLayoutElementStyleYoga (YouModVotes) +- (void)setMinWidth:(YMVoteDimension)width; +- (void)setFlexDirection:(unsigned char)direction; +@end -- (Class)classForElement:(id)element materializationContext:(const void *)context { - switch (overrideNodeCreation) { - case 1: - return %c(YTRollingNumberNode); - case 2: - return %c(ELMTextNode); - default: - return %orig; - } +// Both current action-bar designs can contain only an icon. Add a measured +// Texture text node so Yoga reserves space instead of drawing outside a button. +static BOOL YouModIsVoteButton(ASDisplayNode *node) { + return [node.accessibilityIdentifier isEqualToString:@"id.video.like.button"] || + [node.accessibilityIdentifier isEqualToString:@"id.video.dislike.button"]; } -%end - -static ELMContainerNode *YouModFindNodeWithIdentifier(ASDisplayNode *root, NSString *targetId) { - if (!root) return nil; - if ([root.accessibilityIdentifier isEqualToString:targetId]) { - return (ELMContainerNode *)root; - } - for (ASDisplayNode *child in root.yogaChildren) { - ELMContainerNode *found = YouModFindNodeWithIdentifier(child, targetId); - if (found) return found; +static ASDisplayNode *YouModFindVoteText(ASDisplayNode *node, NSUInteger depth) { + if ([node isKindOfClass:%c(ASTextNode)] || [node isKindOfClass:%c(YTRollingNumberNode)]) return node; + if (depth == 0) return nil; + for (ASDisplayNode *child in node.yogaChildren) { + ASDisplayNode *text = YouModFindVoteText(child, depth - 1); + if (text) return text; } return nil; } -%hook ASCollectionView - -%property (nonatomic, assign) BOOL hasDislikeIntent; - -- (ELMCellNode *)nodeForItemAtIndexPath:(NSIndexPath *)indexPath { - ELMCellNode *node = %orig; - if (!IS_ENABLED(ReturnYouTubeDislike)) return node; - - // Strictly limit RYD modification to the video action bar! - // Comments, search, feeds, and channels return immediately without touching Yoga trees. - if (![self.accessibilityIdentifier isEqualToString:@"id.video.scrollable_action_bar"]) { - return node; - } - - ELMContainerNode *likeNode = YouModFindNodeWithIdentifier(node, @"id.video.like.button"); - ELMContainerNode *dislikeNode = YouModFindNodeWithIdentifier(node, @"id.video.dislike.button"); - - if (!likeNode) { - ASDisplayNode *containerNode = node; - if ([containerNode isKindOfClass:%c(ELMCellNode)]) { - while (containerNode.yogaChildren.count == 1 || containerNode.yogaChildren.count == 2) { - if (containerNode.yogaChildren.count == 2) { - ASDisplayNode *first = [containerNode.yogaChildren firstObject]; - if ([first.accessibilityIdentifier isEqualToString:@"id.video.like.button"]) { - likeNode = (ELMContainerNode *)first; - dislikeNode = (ELMContainerNode *)[containerNode.yogaChildren lastObject]; - break; - } - containerNode = containerNode.yogaChildren[1]; - } else { - containerNode = [containerNode.yogaChildren firstObject]; - } - } +static void YouModUpdateVoteNode(ASDisplayNode *button) { + if (!YouModIsVoteButton(button)) return; + BOOL likes = [button.accessibilityIdentifier isEqualToString:@"id.video.like.button"]; + BOOL enabled = IS_ENABLED(ReturnYouTubeDislike) && (likes ? IS_ENABLED(RYDShowLikes) : IS_ENABLED(RYDShowDislikes)); + ASTextNode *added = objc_getAssociatedObject(button, "kYMVoteText"); + if (!enabled) { + if (added) { + [added.yogaParent removeYogaChild:added]; + [added removeFromSupernode]; + objc_setAssociatedObject(button, "kYMVoteText", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [button setNeedsLayout]; } - } - - if (likeNode) { - @try { - if (!dislikeNode) { - dislikeNode = YouModFindNodeWithIdentifier(node, @"id.video.dislike.button"); - } - NSString *videoId = getVideoId(node); - if (videoId.length == 0) videoId = YouModGetCurrentVideoID(); - if (videoId.length == 0) return node; - - id targetNode = nil; - if (likeNode.yogaChildren.count >= 2) { - targetNode = likeNode.yogaChildren[1]; - } - if (!targetNode) { - for (ASDisplayNode *child in likeNode.yogaChildren) { - if ([child isKindOfClass:%c(YTRollingNumberNode)] || [child isKindOfClass:%c(ELMTextNode)]) { - targetNode = child; - break; - } - for (ASDisplayNode *grandchild in child.yogaChildren) { - if ([grandchild isKindOfClass:%c(YTRollingNumberNode)] || [grandchild isKindOfClass:%c(ELMTextNode)]) { - targetNode = grandchild; - break; - } - } - if (targetNode) break; - } - } - - __strong YTRollingNumberNode *likeRollingNumberNode = [targetNode isKindOfClass:%c(YTRollingNumberNode)] ? (YTRollingNumberNode *)targetNode : nil; - __strong ELMTextNode *likeTextNode = [targetNode isKindOfClass:%c(ELMTextNode)] ? (ELMTextNode *)targetNode : nil; - - __strong YTRollingNumberNode *dislikeRollingNumberNode = nil; - __strong ELMTextNode *dislikeTextNode = nil; - - if (dislikeNode) { - for (ASDisplayNode *dChild in dislikeNode.yogaChildren) { - if ([dChild isKindOfClass:%c(YTRollingNumberNode)]) dislikeRollingNumberNode = (YTRollingNumberNode *)dChild; - else if ([dChild isKindOfClass:%c(ELMTextNode)]) dislikeTextNode = (ELMTextNode *)dChild; - } - - if (!dislikeRollingNumberNode && !dislikeTextNode && targetNode) { - if (likeRollingNumberNode) { - id elementContext = [likeRollingNumberNode valueForKey:@"_context"]; - overrideNodeCreation = 1; - dislikeRollingNumberNode = [[%c(ELMNodeFactory) sharedInstance] nodeWithElement:likeRollingNumberNode.element materializationContext:&elementContext]; - overrideNodeCreation = 0; - dislikeRollingNumberNode.updatedCount = @"..."; - dislikeRollingNumberNode.updatedCountNumber = @(0); - if ([dislikeRollingNumberNode respondsToSelector:@selector(updateRollingNumberView)]) { - [dislikeRollingNumberNode updateRollingNumberView]; - } - [dislikeNode addYogaChild:dislikeRollingNumberNode]; - if (dislikeRollingNumberNode.view && dislikeNode.view) { - [dislikeNode.view addSubview:dislikeRollingNumberNode.view]; - } - } else if (likeTextNode) { - id elementContext = [likeTextNode valueForKey:@"_context"]; - overrideNodeCreation = 2; - dislikeTextNode = [[%c(ELMNodeFactory) sharedInstance] nodeWithElement:likeTextNode.element materializationContext:&elementContext]; - overrideNodeCreation = 0; - NSMutableAttributedString *mDis = [[NSMutableAttributedString alloc] initWithAttributedString:likeTextNode.attributedText]; - [mDis.mutableString setString:@"..."]; - dislikeTextNode.attributedText = mDis; - [dislikeNode addYogaChild:dislikeTextNode]; - if (dislikeTextNode.view && dislikeNode.view) { - [dislikeNode.view addSubview:dislikeTextNode.view]; - } - } - } - } - - self.hasDislikeIntent = YES; - [[YouModRYDManager sharedInstance] fetchVotesForVideoID:videoId completion:^(NSDictionary *votes) { - if (!votes) return; - NSInteger dislikes = [votes[@"dislikes"] integerValue]; - NSString *dislikeCount = YouModFormatVoteCount(dislikes); - NSInteger likes = [votes[@"likes"] integerValue]; - NSString *likeCount = YouModFormatVoteCount(likes); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (IS_ENABLED(RYDShowLikes)) { - if (likeRollingNumberNode) { - likeRollingNumberNode.updatedCount = likeCount; - likeRollingNumberNode.updatedCountNumber = @(likes); - if ([likeRollingNumberNode respondsToSelector:@selector(updateRollingNumberView)]) [likeRollingNumberNode updateRollingNumberView]; - if ([likeRollingNumberNode respondsToSelector:@selector(relayoutNode)]) [likeRollingNumberNode relayoutNode]; - } else if (likeTextNode) { - NSMutableAttributedString *mLike = [[NSMutableAttributedString alloc] initWithAttributedString:likeTextNode.attributedText]; - [mLike.mutableString setString:likeCount]; - likeTextNode.attributedText = mLike; - } - } - if (IS_ENABLED(RYDShowDislikes)) { - if (dislikeRollingNumberNode) { - dislikeRollingNumberNode.updatedCount = dislikeCount; - dislikeRollingNumberNode.updatedCountNumber = @(dislikes); - if ([dislikeRollingNumberNode respondsToSelector:@selector(updateRollingNumberView)]) [dislikeRollingNumberNode updateRollingNumberView]; - if ([dislikeRollingNumberNode respondsToSelector:@selector(relayoutNode)]) [dislikeRollingNumberNode relayoutNode]; - } else if (dislikeTextNode) { - NSMutableAttributedString *mDis = [[NSMutableAttributedString alloc] initWithAttributedString:dislikeTextNode.attributedText]; - [mDis.mutableString setString:dislikeCount]; - dislikeTextNode.attributedText = mDis; - } - } - }); - }]; - } @catch (id ex) {} - } - - return node; -} - -%end - -#pragma mark - _ASDisplayView & Button Fallbacks - -// Helpers to apply votes -static void YouModApplyRYDVotes(_ASDisplayView *view, NSDictionary *votes, NSString *iden) { - if (!view || !votes) return; - dispatch_async(dispatch_get_main_queue(), ^{ - view.clipsToBounds = NO; - if (view.superview) view.superview.clipsToBounds = NO; - - if ([iden isEqualToString:@"id.video.dislike.button"]) { - NSInteger dislikes = [votes[@"dislikes"] integerValue]; - NSString *dislikesText = YouModFormatVoteCount(dislikes); - - BOOL updatedExisting = NO; - for (UIView *sub in view.subviews) { - if ([sub isKindOfClass:objc_getClass("YTRollingNumberView")]) { - updatedExisting = YES; - break; - } - if ([sub respondsToSelector:@selector(node)]) { - id subNode = [sub performSelector:@selector(node)]; - if ([subNode isKindOfClass:%c(ELMTextNode)] || [subNode isKindOfClass:%c(ASTextNode)]) { - NSAttributedString *orig = [subNode attributedText]; - NSMutableAttributedString *m = orig ? [orig mutableCopy] : [[NSMutableAttributedString alloc] initWithString:dislikesText]; - [m.mutableString setString:dislikesText]; - [subNode setAttributedText:m]; - [sub setNeedsDisplay]; - updatedExisting = YES; - break; - } - } - } - - if (!updatedExisting) { - UILabel *lbl = [view viewWithTag:0xD1571CE]; - if (!lbl) { - lbl = [[UILabel alloc] init]; - lbl.tag = 0xD1571CE; - lbl.font = [UIFont systemFontOfSize:12 weight:UIFontWeightMedium]; - lbl.textColor = [UIColor whiteColor]; - lbl.textAlignment = NSTextAlignmentLeft; - [view addSubview:lbl]; - } - lbl.text = dislikesText; - [lbl sizeToFit]; - - CGFloat iconWidth = 24.0; - CGFloat padding = 4.0; - lbl.frame = CGRectMake(iconWidth + padding, (view.bounds.size.height - lbl.bounds.size.height) / 2.0, lbl.bounds.size.width, lbl.bounds.size.height); - - CGRect f = view.frame; - CGFloat reqWidth = iconWidth + padding + lbl.bounds.size.width + 8.0; - if (f.size.width < reqWidth) { - CGFloat diff = reqWidth - f.size.width; - f.size.width = reqWidth; - view.frame = f; - if (view.superview) { - CGRect pf = view.superview.frame; - pf.size.width += diff; - view.superview.frame = pf; - } - } - } - } else if ([iden isEqualToString:@"id.video.like.button"] && IS_ENABLED(RYDShowLikes)) { - NSInteger likes = [votes[@"likes"] integerValue]; - NSString *likesText = YouModFormatVoteCount(likes); - for (UIView *sub in view.subviews) { - if ([sub respondsToSelector:@selector(node)]) { - id subNode = [sub performSelector:@selector(node)]; - if ([subNode isKindOfClass:%c(ELMTextNode)] || [subNode isKindOfClass:%c(ASTextNode)]) { - NSAttributedString *orig = [subNode attributedText]; - NSMutableAttributedString *m = orig ? [orig mutableCopy] : [[NSMutableAttributedString alloc] initWithString:likesText]; - [m.mutableString setString:likesText]; - [subNode setAttributedText:m]; - [sub setNeedsDisplay]; - } - } else if ([sub isKindOfClass:[UILabel class]] && sub.tag != 0xD1571CE) { - UILabel *likeLbl = (UILabel *)sub; - if (likeLbl.text.length > 0 && ![likeLbl.text isEqualToString:likesText]) { - likeLbl.text = likesText; - } - } - } - } - }); -} - -static void YouModApplyRYDVotesToButton(YTQTMButton *btn, NSDictionary *votes, NSString *iden) { - if (!btn || !votes) return; - dispatch_async(dispatch_get_main_queue(), ^{ - if ([iden isEqualToString:@"id.video.dislike.button"]) { - NSInteger dislikes = [votes[@"dislikes"] integerValue]; - NSString *dislikesText = YouModFormatVoteCount(dislikes); - [btn setTitle:dislikesText forState:UIControlStateNormal]; - [btn setTitle:dislikesText forState:UIControlStateSelected]; - } else if ([iden isEqualToString:@"id.video.like.button"] && IS_ENABLED(RYDShowLikes)) { - NSInteger likes = [votes[@"likes"] integerValue]; - NSString *likesText = YouModFormatVoteCount(likes); - [btn setTitle:likesText forState:UIControlStateNormal]; - [btn setTitle:likesText forState:UIControlStateSelected]; - } - }); -} - -// Update main player action bar buttons (_ASDisplayView) -%hook _ASDisplayView - -- (void)didMoveToWindow { - %orig; - if (!self.window) { - [[NSNotificationCenter defaultCenter] removeObserver:self name:kYMReturnDislikeNotification object:nil]; return; } - if (!IS_ENABLED(ReturnYouTubeDislike)) return; - - NSString *iden = self.accessibilityIdentifier; - if (![iden isEqualToString:@"id.video.dislike.button"] && ![iden isEqualToString:@"id.video.like.button"]) return; - - self.clipsToBounds = NO; - if (self.superview) self.superview.clipsToBounds = NO; - - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(youmod_onDislikeNotification:) name:kYMReturnDislikeNotification object:nil]; - - NSString *videoID = YouModGetCurrentVideoID(); - if (!videoID) return; - + NSString *videoID = getVideoId(button); + if (!videoID.length) return; NSDictionary *votes = [[YouModRYDManager sharedInstance] cachedVotesForVideoID:videoID]; - if (votes) { - YouModApplyRYDVotes(self, votes, iden); + if (!votes) [[YouModRYDManager sharedInstance] fetchVotesForVideoID:videoID completion:nil]; + id number = votes[likes ? @"likes" : @"dislikes"]; + NSString *text = [number isKindOfClass:[NSNumber class]] ? YouModFormatVoteCount([number integerValue]) : @"—"; + + // In the new non-scrollable bar, the hit target and icon are siblings. + ASDisplayNode *container = button.yogaChildren.count ? button : button.yogaParent; + if (!container) return; + ASDisplayNode *countNode = added ?: YouModFindVoteText(container, 4); + if (!countNode) { + added = [[%c(ASTextNode) alloc] init]; + added.accessibilityIdentifier = likes ? @"youmod.like.count" : @"youmod.dislike.count"; + added.style.spacingBefore = 4; + added.style.flexShrink = 0; + objc_setAssociatedObject(button, "kYMVoteIconWidth", @(MAX(24, container.calculatedSize.width)), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [container.style setFlexDirection:1]; // ASStackLayoutDirectionHorizontal + [container addYogaChild:added]; + added.layerBacked = container.isLayerBacked; + [container addSubnode:added]; + objc_setAssociatedObject(button, "kYMVoteText", added, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + countNode = added; + } + if ([countNode isKindOfClass:%c(YTRollingNumberNode)]) { + YTRollingNumberNode *rolling = (id)countNode; + if (![rolling.updatedCount isEqualToString:text]) { + rolling.updatedCount = text; + rolling.updatedCountNumber = [number isKindOfClass:[NSNumber class]] ? number : @0; + [rolling updateRollingNumberView]; + if ([rolling respondsToSelector:@selector(relayoutNode)]) [rolling relayoutNode]; + } } else { - [[YouModRYDManager sharedInstance] fetchVotesForVideoID:videoID completion:^(NSDictionary *fetchedVotes) { - if (fetchedVotes) { - YouModApplyRYDVotes(self, fetchedVotes, iden); - } + ASTextNode *label = (id)countNode; + UIColor *color = isDarkMode([container closestViewController].viewIfLoaded) ? UIColor.whiteColor : UIColor.blackColor; + NSAttributedString *value = [[NSAttributedString alloc] initWithString:text attributes:@{ + NSFontAttributeName: [UIFont systemFontOfSize:12 weight:UIFontWeightMedium], + NSForegroundColorAttributeName: color }]; + if (![label.attributedText isEqualToAttributedString:value]) { + label.attributedText = value; + if (added) { + CGFloat iconWidth = [objc_getAssociatedObject(button, "kYMVoteIconWidth") doubleValue]; + [container.style setMinWidth:(YMVoteDimension){1, iconWidth + 4 + ceil(value.size.width)}]; + } + [container setNeedsLayout]; + } } } -- (void)layoutSubviews { +%hook ELMContainerNode +- (void)didLoad { %orig; - if (!IS_ENABLED(ReturnYouTubeDislike)) return; - UILabel *lbl = [self viewWithTag:0xD1571CE]; - if (lbl) { - self.clipsToBounds = NO; - if (self.superview) self.superview.clipsToBounds = NO; - CGFloat iconWidth = 24.0; - CGFloat padding = 4.0; - lbl.frame = CGRectMake(iconWidth + padding, (self.bounds.size.height - lbl.bounds.size.height) / 2.0, lbl.bounds.size.width, lbl.bounds.size.height); - } + if (!YouModIsVoteButton(self)) return; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kYMReturnDislikeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(youmod_updateVoteCount:) name:kYMReturnDislikeNotification object:nil]; + YouModUpdateVoteNode(self); +} +- (void)didEnterVisibleState { + %orig; + if (!YouModIsVoteButton(self)) return; + // Texture visibility callbacks can arrive on a background thread. + dispatch_async(dispatch_get_main_queue(), ^{ YouModUpdateVoteNode(self); }); } - %new -- (void)youmod_onDislikeNotification:(NSNotification *)note { - NSString *videoID = note.userInfo[@"videoID"]; - if (!videoID || ![videoID isEqualToString:YouModGetCurrentVideoID()]) return; - NSDictionary *votes = note.userInfo[@"votes"]; - YouModApplyRYDVotes(self, votes, self.accessibilityIdentifier); +- (void)youmod_updateVoteCount:(NSNotification *)note { + YouModUpdateVoteNode(self); } - %end -// Fallback for button controls using YTQTMButton -%hook YTQTMButton +@interface YTQTMButton (YouModVoteTitle) +- (void)youmod_updateVoteTitle:(NSNotification *)note; +@end +// Older UIKit action buttons still use their native title layout. +%hook YTQTMButton - (void)didMoveToWindow { %orig; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kYMReturnDislikeNotification object:nil]; if (!self.window) return; + NSString *identifier = self.accessibilityIdentifier; + if (![identifier isEqualToString:@"id.video.like.button"] && ![identifier isEqualToString:@"id.video.dislike.button"]) return; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(youmod_updateVoteTitle:) name:kYMReturnDislikeNotification object:nil]; + [self youmod_updateVoteTitle:nil]; +} +%new +- (void)youmod_updateVoteTitle:(NSNotification *)note { if (!IS_ENABLED(ReturnYouTubeDislike)) return; - - NSString *iden = self.accessibilityIdentifier; - if (![iden isEqualToString:@"id.video.dislike.button"] && ![iden isEqualToString:@"id.video.like.button"]) return; - + BOOL likes = [self.accessibilityIdentifier isEqualToString:@"id.video.like.button"]; + if (!(likes ? IS_ENABLED(RYDShowLikes) : IS_ENABLED(RYDShowDislikes))) return; NSString *videoID = YouModGetCurrentVideoID(); - if (!videoID) return; - NSDictionary *votes = [[YouModRYDManager sharedInstance] cachedVotesForVideoID:videoID]; - if (votes) { - YouModApplyRYDVotesToButton(self, votes, iden); - } else { - [[YouModRYDManager sharedInstance] fetchVotesForVideoID:videoID completion:^(NSDictionary *fetchedVotes) { - if (fetchedVotes) { - YouModApplyRYDVotesToButton(self, fetchedVotes, iden); - } - }]; + id number = votes[likes ? @"likes" : @"dislikes"]; + if (![number isKindOfClass:[NSNumber class]]) { + [[YouModRYDManager sharedInstance] fetchVotesForVideoID:videoID completion:nil]; + return; } + NSString *title = YouModFormatVoteCount([number integerValue]); + [self setTitle:title forState:UIControlStateNormal]; + [self setTitle:title forState:UIControlStateSelected]; } - %end #pragma mark - Protobuf Model Hooks diff --git a/Tests/DEVICE_SMOKE_TEST.md b/Tests/DEVICE_SMOKE_TEST.md new file mode 100644 index 00000000..b12e5a31 --- /dev/null +++ b/Tests/DEVICE_SMOKE_TEST.md @@ -0,0 +1,8 @@ +Test with YouTube 21.38.2 and YouMod 2.1.6. These are device checks, not results claimed by the automated test. + +- Home: confirm one DeArrow toggle below each three-dot menu; tap both controls independently. Swap a title/thumbnail twice, then scroll away and back. +- MrBeast channel Videos: repeat for Latest/Popular/Oldest and horizontal shelves. Scroll rapidly for several minutes, open/close a video and comments, and verify no freeze or wrong-video swaps after cell reuse. +- Notifications: confirm the toggle stays in the menu column, outside the thumbnail and notification text, including grouped uploads. +- Previews: sound starts on; mute/unmute five times on the same preview. Scroll to a new preview and verify sound starts on again. Captions start off and remain manually switchable. +- Watch page: both like and dislike counts appear beside their icons, including the compact action bar shown in the report. Change videos quickly; counts must follow the watched video. Test light/dark mode and both count preferences. +- Network failure: block DeArrow/RYD briefly. Original thumbnails remain usable; unavailable votes show a dash rather than invented zeroes. Restore connectivity and re-enter the card after a minute. diff --git a/Tests/check_feed_regressions.py b/Tests/check_feed_regressions.py new file mode 100644 index 00000000..7d137e66 --- /dev/null +++ b/Tests/check_feed_regressions.py @@ -0,0 +1,86 @@ +"""Run: python3 Tests/check_feed_regressions.py (C compiler required). + +Execute the production placement helper against screenshot-derived card bounds. +Source checks also guard the known request/layout loops and frozen audio getter. +On-device Texture layout and playback still require the accompanying smoke test. +""" +from pathlib import Path +import os +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[1] +dearrow = (root / 'Files/DeArrow.x').read_text() +player = (root / 'Files/Player.x').read_text() +ryd = (root / 'Files/ReturnYouTubeDislike.x').read_text() +start = dearrow.index('static CGRect YouModDeArrowButtonFrame(') +end = dearrow.index('\n}\n', start) + 3 +helper = dearrow[start:end] +stubs = r''' +#include +typedef double CGFloat; +typedef struct { double x, y; } CGPoint; +typedef struct { double width, height; } CGSize; +typedef struct { CGPoint origin; CGSize size; } CGRect; +#define CGRectZero ((CGRect){{0,0},{0,0}}) +#define CGRectMake(x,y,w,h) ((CGRect){{x,y},{w,h}}) +#define CGRectGetMidX(r) ((r).origin.x + (r).size.width / 2) +#define CGRectGetMaxX(r) ((r).origin.x + (r).size.width) +#define CGRectGetMaxY(r) ((r).origin.y + (r).size.height) +static int CGRectContainsRect(CGRect b, CGRect r) { + return r.origin.x >= b.origin.x && r.origin.y >= b.origin.y && + CGRectGetMaxX(r) <= CGRectGetMaxX(b) && CGRectGetMaxY(r) <= CGRectGetMaxY(b); +} +static int intersects(CGRect a, CGRect b) { + return a.origin.x < CGRectGetMaxX(b) && b.origin.x < CGRectGetMaxX(a) && + a.origin.y < CGRectGetMaxY(b) && b.origin.y < CGRectGetMaxY(a); +} +''' +cases = r''' +int main(void) { + // Bounds, overflow menu, thumbnail, title: home, channel, notifications, + // narrow phone, landscape, and RTL notification layout. + CGRect cases[][4] = { + {{{0,0},{393,310}}, {{342,226},{44,24}}, {{0,0},{393,221}}, {{55,229},{283,64}}}, + {{{0,0},{393,98}}, {{363,0},{30,24}}, {{16,0},{156,88}}, {{188,0},{169,82}}}, + {{{0,0},{393,114}}, {{363,0},{30,24}}, {{251,0},{112,64}}, {{58,0},{181,94}}}, + {{{0,0},{320,98}}, {{290,0},{30,24}}, {{16,0},{128,72}}, {{155,0},{129,84}}}, + {{{0,0},{844,100}}, {{800,0},{44,24}}, {{16,0},{160,90}}, {{188,0},{606,90}}}, + {{{0,0},{393,114}}, {{0,0},{30,24}}, {{30,0},{112,64}}, {{150,0},{181,94}}}, + }; + for (unsigned i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) { + CGRect frame = YouModDeArrowButtonFrame(cases[i][0], cases[i][1]); + assert(frame.size.width == 26 && frame.size.height == 26); + assert(CGRectContainsRect(cases[i][0], frame)); + for (int j = 1; j < 4; j++) assert(!intersects(frame, cases[i][j])); + // The expanded 44pt hit target must not steal overflow menu taps. + CGRect hit = CGRectMake(frame.origin.x-9, frame.origin.y-9,44,44); + assert(!intersects(hit, cases[i][1])); + } + CGRect frame = YouModDeArrowButtonFrame(CGRectMake(0,0,30,24), CGRectMake(0,0,30,24)); + assert(frame.size.width == 0); // Clipped wrapper: caller must ascend to the card. +} +''' +with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / 'layout.c' + binary = Path(tmp) / 'layout' + source.write_text(stubs + helper + cases) + subprocess.run([os.environ.get('CC', 'cc'), '-Wall', '-Werror', str(source), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + +layout = dearrow.split('- (void)layoutSubviews {', 1)[1].split('%new', 1)[0] +assert 'setURL:' not in layout, 'Layout must not restart thumbnail downloads' +assert 'fetched"] boolValue' in dearrow and 'branding[@"fetched"] = @YES' in dearrow +assert 'lastRequest.timeIntervalSinceNow < 60' in dearrow +assert '%hook YTIThumbnailDetails_Thumbnail' not in dearrow, 'Preserve original thumbnail identity' +assert 'keepalive_node' in dearrow and 'node.subnodes' in dearrow, 'Include layer-backed home cards' +image_hook = dearrow.split('%hook ASNetworkImageNode', 1)[1].split('%end', 1)[0] +assert '@selector(view)' not in image_hook, 'Never force a view for a layer-backed image node' +assert 'titleView.frame =' not in dearrow, 'Do not fight native title layout' +assert '- (BOOL)inlinePlaybackUnmutedAtStart {' not in player, 'Leave audio getter live after taps' +assert 'setInlinePlaybackUnmutedAtStart:YES' in player and 'if (newVideo' in player +assert '[container addYogaChild:added]' in ryd and '[container.style setMinWidth:' in ryd +assert 'view.frame =' not in ryd and 'pf.size.width += diff' not in ryd +assert 'didActivateNewPlaybackWithContentVideo:' in ryd and 'currentWatchPlayer.contentVideoID' in ryd +assert '%hook ELMContainerNode' in ryd, 'Both action bars need counts, independent of collection ID' +print('PASS: six card layouts, clipping, hit targets, and feed/audio/count regression guards') diff --git a/control b/control index 57edd661..a344be52 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: dev.water888.youmod Name: YouMod -Version: 2.1.5 +Version: 2.1.6 Architecture: iphoneos-arm Description: A lightweight YouTube enhancer Maintainer: Tonwalter888, G Rohit