All notable changes to SmartTube are documented here.
- TOS-compliant player is now the default on iOS — playback uses an embedded YouTube IFrame player (WKWebView) with native SwiftUI controls, replacing the AVPlayer-based stream-extraction pipeline on iOS. Removes the entire class of playback failures caused by CDN/geo blocks, BotGuard/PO-token issues, and HLS/DASH manifest-parsing errors
- Comments added to the player's "..." overflow menu on iOS
- Existing iOS installs are migrated automatically to the new default player; the experimental
useTOSPlayerOnIOStoggle has been removed - Video quality/ABR on iOS is now controlled entirely by YouTube's own player — manual quality and "max resolution" selection no longer apply on iOS
- Speed and "..." menu controls repositioned next to the back button on iOS
- Mini-player — video now keeps playing when minimized instead of stopping
- Playback speed pill stuck showing "Normal (1x)" after a speed change
- Audio occasionally cutting to silent shortly after auto-unmute
- Full-screen player no longer lingers after minimize/stop
- Safe-area inset double-counted on the back button
- BotGuard PO token pipeline — complete WKWebView-based
pot=token extraction with websafe base64 fallback; token injected into adaptive streaming requests to satisfy YouTube's enhanced bot-detection - AVC-only codec pinning — "Prefer H.264" toggle in player settings pins DASH and HLS streams to AVC variants for maximum compatibility (#206)
- Serbian & Croatian localizations — two new languages added; 19 missing translation keys filled across all existing languages
- HLS audio rendition proxying —
#EXT-X-MEDIAaudio playlist URLs rewritten toytwebhls://so the local proxy correctly forwards cookies to rendition segments
- 60 s
loadTracksstall on SABR streams — SABR stream detection added;c=TVHTML5URLs bypass the stall-prone audio-track load path (#204) - Dubbed audio broken via WKWebView — HLS proxy now fetches the per-quality master URL rather than the generic master, restoring dubbed tracks (#205, #205b)
- WKWebView cookies not forwarded to HLS proxy — all
WKWebViewcookies, including thegooglevideo.comdomain, are now included in local proxy requests (#207) - 8 s
loadTracksstall —rqh=1adaptive composition skipped when nogooglevideo.comcookies are present, eliminating the hang (#208) - WKWebView HLS proxy silently skipped — early
rqh=1guard removed; proxy is now attempted regardless of cookie state at startup (#209) - Quality switch failed after muxed fallback — fresh WKWebView extraction is now triggered whenever a quality change follows a muxed-only fallback (#210)
- Stale WKWebView HLS URL used — cached URL validity probed before constructing
AVPlayerItem; expired URLs trigger immediate re-extraction (#211) - Quality button always visible — changed from hidden-when-empty to disabled-when-empty; button remains visible but non-interactive when no quality options are available (#186)
- Audio tracks not loaded after WKWebView HLS —
loadAudioTracks(from:)now called inside thetryWebViewHLSreadyToPlayhandler
- DASH adaptive quality switching — selecting a quality tier for DASH streams rebuilds
AVMutableCompositionwith the matching video track; a pending quality label is shown during the switch; the initial load is capped to the display's native resolution; VP9 variants excluded for device compatibility - macOS (Mac Catalyst) support —
NSViewRepresentableAVPlayerLayer renders in the macOS window; App Sandbox, Hardened Runtime, andPrivacyInfo.xcprivacy(network/API usage declarations) added;SUPPORTED_PLATFORMSupdated; macOS build succeeds - TV client attestation token —
fetchAttestationTokeninjected into the TVHTML5 client authentication payload to satisfy YouTube's enhanced bot-detection for signed-in sessions - "Remove from Watch Later" action — video cards inside the Watch Later playlist show a destructive remove option in the context menu; cards outside the playlist continue to show "Save to Watch Later"
- Stats for Nerds added to the player overflow menu on all platforms
- tvOS settings tab required double-click to open —
MainTVTabViewTabViewnow uses a selection binding; a single remote press selects the tab - tvOS video description and comments panels could not be closed —
isAnyOverlayVisiblenow includes both panel states so the player no longer intercepts the Menu button; each panel has its own.focusScope+.onExitCommand - tvOS quality preference reset to Auto on every launch —
SettingsStore.init()was unconditionally overwritingpreferredQualityat startup - tvOS overflow menu text low contrast — focused row highlight changed from
white.opacity(0.15)togray.opacity(0.35) - Playlist auto-advance not working from Search results —
SearchViewnow populatesCurrentQueueStorebefore starting playback; macOS compact-grid path also fixed - HLS manifest cache TTL raised from 5 min to 30 min; cache entry correctly invalidated on a 403 recovery path to prevent stale variant URLs
- Audio language selector in iOS fullscreen player — speed, quality, and audio-track pill buttons in the quick-access row below the scrub bar for one-tap access without opening the overflow menu
- tvOS player overflow menu expanded — Captions and Audio Track rows added; Audio Only toggle available on tvOS; picker overlays no longer restricted to iOS
- tvOS UI test coverage — six new test suites: Settings (7 tests), Home Feed (5 tests), Library Navigation (8 tests), Player Controls (9 tests), Playback Regression (5 tests), Audio & Caption Pickers (6 tests)
- Library Subscriptions chip shortened to "Subs"
- No audio after quality change in fullscreen — quality-reload paths now call
loadAudioTracks(from:)on every newAVPlayerItem - No audio when quality is not set to Auto — audio track configuration was bypassed in manual quality selection paths
- All audio tracks showing as "Original" —
isOriginaldetection useshasMediaCharacteristic(.isMainProgramContent)withDEFAULT=YESas fallback; per-track logging added - Caption track not remembered between videos — last selected caption language persisted in
AppSettingsand re-applied on load - Captions obscuring the scrub bar — caption overlay repositioned above the progress bar
- Player controls hiding too quickly in fullscreen — auto-hide timeout increased by 50%
- Play/pause and navigation button tap targets too small — enlarged from 44 pt to 68 pt
- 1440p quality not applied on initial load — HLS variant fetch and
applyQualityPreferenceadded to all fallback entry points - tvOS settings missing seek-interval picker and preferred audio language selector —
#if !os(tvOS)guard removed from both pickers - tvOS long-press triggering both context menu and playback —
onSelectclosure added toVideoCardView;.onTapGestureco-located with.contextMenuso the gesture arbiter suppresses playback on long-press - tvOS settings navigation bar hidden after scroll-down-then-up —
.toolbar(.hidden)guard changed from#if os(iOS)to#if !os(macOS) - tvOS SponsorBlock skip button not focusable —
isSkipToastActivegate routes focus to the skip button when a segment is detected and restores player focus on dismiss - Audio track selector missing from iOS overflow menu —
moreMenuAudioTrackRowrestored (accidentally removed in a prior refactor) - Watch Later playlist playing videos in wrong order —
CurrentQueueStore.replaceAll(with:)now called before starting playback in all playlist entry points - Hide Shorts not filtering Subscriptions feed — three detection gaps fixed:
parseVideoRendererShorts signals, RSS feed detection via playlist enrichment, History view was incorrectly filtering non-Shorts - Intermittent "first video cannot be played" on launch — stale iOS-client preload cache now invalidated before a fresh client fetch in the muxed-only fallback path
- "Original Track" missing from tvOS preferred audio language picker
- Firebase network hardening: VPN/IP-block banner suppresses "Try Again" button; Android muxed-only fallthrough no longer records a non-fatal;
NSURLError -999(cancelled) added to transient-suppression list; request timeout raised from 20 s to 30 s
- Quick-access player controls (iOS) — speed, quality, audio-track, and sleep-timer pill buttons below the scrub bar for one-tap access without opening the overflow menu
- iCloud sync for local data — subscriptions, watch state, current queue, and RSS feeds sync via
NSUbiquitousKeyValueStore; opt-in toggle in Settings → General - Video publish date in search results — upload age label shown in grid and compact card layouts for search results and playlist views
- Shorts auto-pagination — the home feed Shorts section automatically loads a second page when the initial batch is below 6 (iPhone) or 8 (tvOS)
- Downloads screen — Library → Downloads lists locally downloaded videos; tap to play offline, swipe to delete
- OrientationManager crash on iOS 26 (EXC_BREAKPOINT, Crashlytics 1bce7ef1) —
requestGeometryUpdateerror handler was inheriting@MainActorisolation and invoked on a background GCD queue on iOS 26+; extracted as an explicitnonisolatedfile-scope function - Watch history not updating for signed-in users —
PlaybackViewModel's ownInnerTubeAPIinstance was not receiving token refreshes;WatchtimeTrackerwas sending anonymous pings - Streaming spinner not dismissing after video loads —
isLoading = falsemoved into the.readyToPlayobserver; audio-only path also fixed - App launch interrupting background audio — premature
AVAudioSession.setActive(true)removed fromPlaybackViewModel.init() - Shorts not loading on cold launch —
fetchShortsandfetchShortsMorenow usepostTV()(TVHTML5 client) to match the TV OAuth token scope; WEB client was returning HTTP 400 - Mini-player overlapping bottom tab bar (iPhone portrait) — mini-player now rendered as
overlay(alignment: .bottom)above theTabViewsafe area;TabBarBottomInsetKeypreference captures the actual tab bar height - 16 QPB-identified bugs:
extractNumberundercounting K/M/B view-count suffixes;viewCountalwaysnilin all renderers; fallback paths (retryWithFallbackPlayer,retryWith403Recovery,retryWithAdaptiveComposition) droppingplayerInfo/availableFormats/availableCaptions;loadAsyncrace on superseded tasks;pingTrackingURLsilently discarding errors;evictAuthSensitiveDataskippingVideoDiskCache; auth token captured at prefetch-task creation time (stale after refresh);WatchtimeTrackerusing stale URLs post-eviction;phase2Taskskipped in all fallback paths;tryLoadAudioURLunconditionally resetting audio-only mode on any error;itemObserverTaskrace on item replacement;endObserverTasknot restarted in audio-only mode;parsePlaylistVideoRenderernot implemented
- Safari Web Extension — new
SafariExtensiontarget (manifest.json+content.js) intercepts YouTube watch, Shorts, youtu.be, and Music URLs in Safari and redirects them tosmarttube://video/<id>without any user tap;YouTubeLinkHandlerextended to recognisemusic.youtube.com/watch?v=URLs so the extension and the app URL handler stay in sync;SafariExtensionURLCoverageTests(7 tests) verify every manifest match pattern - Auto quality caps at display native resolution — when
preferredQuality == .auto,PlaybackViewModel+Fallback.qualityCapVideoURLnow callsdisplayMaxVideoHeight()(returnsmin(nativeBounds.width, nativeBounds.height)on UIKit, 1080 on tvOS) and passes the result aspreferredMaxHeighttoselectBestVideoFormat; initial DASH composition and HLSpreferredMaximumResolutionhints use the same value; previously Auto unconditionally picked the highest available format regardless of screen pixel density
- DASH manual quality switching broken for many videos —
PlaybackQualityManager.reloadDASHItemwas resolving the video URL fromavailableFormats(populated by the TV auth / TVHTML5 client), whose adaptive entries use SABR-protocol URLs thatAVURLAsset.loadTrackscannot open (AVFoundation error −11828); quality switches now resolve the video URL fromplayerInfo.formats(populated by the AndroidVR client, which returns standard CDN MPEG-4 URLs); aselectBestVideoFormat(from:preferredMaxHeight:)helper matches the requested quality tier to the closest available itag; the TVAuthavailableFormatsset is retained for the picker display only - DASH quality switch silently rebuilding at wrong resolution —
reloadDASHItemusedselectBestVideoFormatwithout verifying the resolved height satisfied the request; when the requested tier (e.g. 480p) was absent fromplayerInfo.formats,selectBestVideoFormatfell back to the highest available format (e.g. 720p) andAVMutableCompositionsucceeded at that height whileselectedFormatstill reflected 480p; amatchedFmt.height <= fmt.heightguard now rejects the fallback result and falls through to theavailableFormatsURL for proper error-recovery when the tier is absent fromplayerInfo.formats - Quality picker showing 144p/240p only for DASH videos — TVAuth adaptive format entries had
height = 0because TVHTML5 SABR responses omit theheightfield;parseFormatsnow extracts the height from thequalityLabelstring (e.g."720p"→ 720) whenheight == 0, so all adaptive tiers appear correctly in the picker - AndroidVR client returning
LOGIN_REQUIREDbot-detection error —postAndroidVRnow includes theX-Goog-Visitor-Idheader populated from thevisitorDatafield of the preceding TVAuth/playerresponse; the sharedURLSession(carrying YouTube authentication cookies from TV sign-in) is used instead of an ephemeral session; together these signals satisfy YouTube's bot-detection check for the AndroidVR client - Crash on age-restricted / region-locked videos (1,048 iOS + 130 tvOS events, issue NW-3) — TV authenticated client sometimes returns a
PlayerInfowithhlsURL = niland no usable adaptive streams (muxed-only TVHTML5 URL);PlaybackViewModel+Loadingnow checkstvInfo.hlsURL == nil && bestAdaptiveVideoURL == nilimmediately after the TV fetch and falls straight tofetchPlayerInfoAndroidbefore creating anAVPlayerItem, eliminating theAVFoundationErrorDomain -11828crash;TVClientHLSNilFallbackTests(8 tests) added - Audio-only mode silently stalling on playback start —
tryLoadAudioURL(_:userAgent:)now callssetupAudioItemObserver(_:)beforeplayer.replaceCurrentItem(with:), wiring upitemObserverTaskto catch.failedstatus (resetsisAudioOnlyMode = falseand propagates the error) and.readyToPlay(callsloadAudioTracks(from:)); previously a failed audio item had no observer and the player hung silently;AudioOnlyModeUITests(testAudioOnlyModeOpensVideoWithoutError) added - Mini player X button sometimes restoring fullscreen —
fullScreenBindingsetter inRootViewwas callingplayerState.minimize()whenevercurrentVideocleared tonil; becausestop()setspresentation = .hiddenasynchronously, the setter raced and re-promoted state to.miniPlayer; setter is now a no-op (theLandscapeFullScreenCoverUIKit coordinator already manages dismissal) - Audio track selection not working after fallback recovery —
audioSelectionGroupandaudioOptionsByIDwere never refreshed when the Android fallback player, adaptive composition fallback, or 403-recovery path created a newAVPlayerItem;retryWithFallbackPlayer,retryWithAdaptiveComposition, andretryWith403RecoveryinPlaybackViewModel+Fallbacknow callloadAudioTracks(from:)in their.readyToPlayobservers, matching the existing pattern in the normal load and quality-change paths - More menu overflowing and unusable in landscape mode (GitHub issue #45, contributor say4n) —
.frame(maxHeight: 520)was too tall for compact vertical size class;moreMenuOverlaynow uses 320 pt max-height inverticalSizeClass == .compact, adds.safeAreaPadding(.horizontal)plus 36 pt extra horizontal padding when landscape, keeping the menu within the live area and scrollable to the Cancel row;player.moreMenu.scrollViewaccessibility identifier added - "Hide Shorts" setting not filtering Shorts in Search, Library, and Channel views (GitHub issue #41) —
AppSettings.hideShortswas applied only inHomeViewandBrowseView;SearchViewandLibraryViewnow injectSettingsStorevia@Environmentand filter results before passing toVideoGridSection;ChannelView.filteredVideosextended to apply the same predicate;RSSFeedsView.videoListalso updated for consistency;HideShortsFilterTests(6 tests) added - Audio-only mode silently stalling on playback start —
tryLoadAudioURL(_:userAgent:)now callssetupAudioItemObserver(_:)beforeplayer.replaceCurrentItem(with:), wiring upitemObserverTaskto catch.failedstatus (resetsisAudioOnlyMode = falseand propagates the error) and.readyToPlay(callsloadAudioTracks(from:)); previously a failed audio item had no observer and the player hung silently;AudioOnlyModeUITests(testAudioOnlyModeOpensVideoWithoutError) added - Mini player X button sometimes restoring fullscreen —
fullScreenBindingsetter inRootViewwas callingplayerState.minimize()whenevercurrentVideocleared tonil; becausestop()setspresentation = .hiddenasynchronously, the setter raced and re-promoted state to.miniPlayer; setter is now a no-op (theLandscapeFullScreenCoverUIKit coordinator already manages dismissal) - Audio track selection not working after fallback recovery —
audioSelectionGroupandaudioOptionsByIDwere never refreshed when the Android fallback player, adaptive composition fallback, or 403-recovery path created a newAVPlayerItem;retryWithFallbackPlayer,retryWithAdaptiveComposition, andretryWith403RecoveryinPlaybackViewModel+Fallbacknow callloadAudioTracks(from:)in their.readyToPlayobservers, matching the existing pattern in the normal load and quality-change paths - More menu overflowing and unusable in landscape mode (GitHub issue #45, contributor say4n) —
.frame(maxHeight: 520)was too tall for compact vertical size class;moreMenuOverlaynow uses 320 pt max-height inverticalSizeClass == .compact, adds.safeAreaPadding(.horizontal)plus 36 pt extra horizontal padding when landscape, keeping the menu within the live area and scrollable to the Cancel row;player.moreMenu.scrollViewaccessibility identifier added - "Hide Shorts" setting not filtering Shorts in Search, Library, and Channel views (GitHub issue #41) —
AppSettings.hideShortswas applied only inHomeViewandBrowseView;SearchViewandLibraryViewnow injectSettingsStorevia@Environmentand filter results before passing toVideoGridSection;ChannelView.filteredVideosextended to apply the same predicate;RSSFeedsView.videoListalso updated for consistency;HideShortsFilterTests(6 tests) added
- Landscape lock button (iOS) — rotation icon in the player top bar locks orientation in landscape; the "Landscape Always Play" toggle was removed from Settings
- Audio-only button in player controls (iOS) — waveform icon in the bottom player bar toggles audio-only mode; the Settings toggle was removed; tvOS retains the overflow menu row
- Toast on mode switches — "Audio-Only Mode" / "Video Mode" notification shown when toggling playback mode
- DeArrow titles and thumbnails — cached branding applied in
VideoCardViewgrid and compact layouts and tocurrentVideoon load; respects thedeArrowEnabledsetting
- Mini player "X" button not stopping audio —
AVAudioSession.setActive(false)called inPlaybackViewModel.stop()on dismiss - Overflow menu too wide and large in portrait —
.font(.subheadline)applied to all rows; max width constrained to 80% of screen width - Portrait player next/back buttons had small tap targets — enlarged to 68 pt with circular background matching other control buttons
- Shorts incorrectly included in playlists —
displayVideosfilter added toPlaylistView - Video not reloading after stop and replay —
itemObserverTaskandendObserverTasknow cancelled instop() - Preferred audio language ignored on AI-dubbed videos —
DEFAULT=YESHLS track priority corrected; device language preference evaluated before YouTube's marked default - Shorts visible despite "Hide Shorts" being enabled —
parseLockupViewModelguard relaxed to correctly detectreelWatchEndpointasisShort = true - Pagination failing on transient network errors — all 5 pagination entry points wrapped with
retryWithBackoff(up to 3 attempts, 1–2 s exponential delay)
- Audio cutting out when manually changing video resolution —
reloadHLSItemandreloadHLSItemH264CappedinPlaybackViewModel+Qualitycreated a newAVPlayerItemon quality switch but never calledloadAudioTracks(from:);audioSelectionGroupandaudioOptionsByIDstayed stale from the old item soselectAudioTrack()silently no-opped and AVPlayer fell back to muted defaults; one-line fix addsloadAudioTracks(from: item)in both.readyToPlayhandlers - Audio quality improved to match official YouTube app —
AVPlayerItem.audioTimePitchAlgorithmdefaulted to.timeDomainwhich introduces subtle artefacts at normal playback speeds; changed to.spectralinPlaybackViewModel+Loadingwhen theAVPlayerItemis created (both HLS and adaptive paths) - tvOS video quality stuck at perceived ~420p despite 2160p setting —
fetchHLSVariantURLs()unconditionally replaced HEVC variants with H.264 for all platforms (added originally for Simulator compatibility); on tvOS HEVC is fully supported and YouTube lists it first in the manifest, so H.264 selection forced lower perceived quality; codec preference is now guarded#if !os(tvOS)so tvOS keeps the first (HEVC) variant while iOS/macOS still prefer H.264;preferredPeakBitRatehints added alongsidepreferredMaximumResolutioninreloadHLSItemand initial load;PlaybackQualityTests(10 tests) added - Duplicate video cards causing blank cells in Home and Subscriptions feeds — four separate deduplication gaps allowed duplicate
Video.idvalues intoForEacharrays: (1)fetchMoreVideos(.home)returnedflatMap(\.videos)with no dedup; (2)HomeViewModel.loadMorecapturedexistingIdsonce before the append loop so intra-newVideosduplicates slipped through; (3)BrowseViewModel.mergeIntoFirstGrouphad the same static-set gap; (4)fetchNextPage(.home)appended new groups without cross-group ID filtering; all four fixed with a growing-set pattern (var seen = Set<String>(); filter { seen.insert($0.id).inserted });mergedVideoscomputed property gains a safety-net pass;HomeFeedNoDuplicatesUITests(3 tests) added - tvOS centre-zone double-tap and d-pad focus failures in player UI tests — five UI tests were failing:
ToastModifierhad no accessibility identifier so toast queries raced against 2 s expiry;ProgressViewspinner absorbed tap gestures;SettingsStoreleaked persisted UserDefaults state between tests; SponsorBlock auto-seek disrupted player gesture tests;testLandscapeAlwaysPlayBackButtonReturnsHomeusedguardinsidedefer(compile-valid but never ran on error path); fixes:.accessibilityIdentifier("player.toast")on toast text;.allowsHitTesting(false)on spinner overlay;--uitesting-reset-settingslaunch-arg handler inSettingsStore.init; accessibility identifiers on SponsorBlock picker/NavigationLink;testDoubleTapCentreZoneTogglesFitFillconverted from live Home feed to--uitesting-deeplink-video=fixed video ID to eliminate feed-timing flakiness;guard→ifin defer block
- Shorts section on home screen —
ShortsCardView(portrait 9:16 thumbnail with dark gradient title overlay and duration badge) andShortsRowView(horizontalLazyHStack, ~120 pt wide on iPhone) render above the main grid;HomeViewModelpartitionsmergedVideosintohomeVideos(non-Shorts) andhomeShortsVideosat the computed-property level with no extra network call; Shorts are identified byVideo.isShortset at parse time fromreelWatchEndpoint,TILE_STYLE_YTLR_SHORTS, orparseReelItemRenderer - Per-device YouTube recommendations setting —
InnerTubeAPI.visitorData(previously declared but never populated) is now extracted fromresponseContexton each browse/search response and injected into subsequent requests viamakeBody(includeVisitorData: true); differentvisitorDataper device produces different recommendation graphs per device; toggle in Settings → Interface; disabled resetsvisitorData = nilon the next browse call
- Local Subscription Management — follow/unfollow channels without a Google account; feeds backed by
LocalSubscriptionStore(actor, UserDefaults persistence) andLocalSubscriptionFeedService(RSS fetch with InnerTube fallback); channels sorted alphabetically, feed videos sorted newest-first YouTubeRSSParser— XML-based RSS parser for YouTube channel feeds (https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID) using FoundationXMLParser; background refresh viaLocalSubscriptionFeedCache- RSS Feeds feature — users can add arbitrary YouTube channel RSS feed URLs;
RSSFeedInfomodel +RSSFeedStoreactor (JSON in UserDefaults);RSSFeedsViewModelfetches all active feeds concurrently withwithTaskGroup, deduplicates by video ID, sorts newest-first;RSSFeedsView(list with toolbar add button),AddRSSFeedView(sheet),ManageRSSFeedsView(delete/toggle); Share Extension detects RSS URLs and writespendingRSSFeedURLsto shared UserDefaults app group forAppEntryto drain on launch;RSSFeedStoreTestsunit tests - Audio-only playback mode —
PlaybackViewModel+AudioOnlyprovidesloadAudioOnlyItemIfEnabled()with a three-step chain: (1) iOS-clientbestAdaptiveAudioURL(zero extra network cost), (2)fetchPlayerInfoAndroidVR()usingANDROID_VRclient (nameID 28, version 1.65.10, Oculus UA, no PO Token required), (3) silent HLS fallback withisAudioOnlyMode = falsereset; live streams excluded; thumbnail overlay shown viaaudioOnlyThumbnailOverlayinPlayerView+Lifecycle; quality picker hidden in audio-only mode; "Audio Only" toggle in Settings (iOS) - Preferred Audio Language setting —
Pickerin Settings → Player (iOS only) with options: System Default, English, Spanish, French, German, Japanese, Korean, Portuguese, Chinese (Simplified), Original Track;autoSelectAudioTrack()priority updated: saved explicit language →"original"sentinel selects HLSDEFAULT=YEStrack → exact language code match → prefix match (e.g."en"matches"en-US") → English fallback → tracks.first;AudioTrackSelectionTestsextended (6 new tests) - Picture-in-Picture (iOS) — PiP session management in
PlaybackViewModel; toggle in Settings - "Landscape Always Play" setting — auto-rotate to landscape when a video starts on iPhone
- poToken groundwork —
PoTokenProviderprotocol;PlayerInfo.applyingPoToken(_:)appends&pot=<token>to all format/HLS/DASH URLs;InnerTubeAPIstorespoToken/poTokenVideoId/poTokenExpiry;makeBody(includePoToken:)injectsserviceIntegrityDimensions.poToken;ServerPoTokenProvider(developer tool, hidden behindpoTokenServiceURLsetting) POSTs{"videoId":"..."}and expects{"token":"..."};PoTokenInjectionTests(6 tests) - VPN "cannot play video" hardening —
APIError.ipBlocked(String)added;InnerTubeAPI+Player.parsePlayerInfodetects VPN/proxy/bot keywords inplayabilityStatus.reasonand throws.ipBlockedinstead of.unavailable;PlaybackViewModel+Loadingshort-circuits the retry storm on.ipBlocked(one TV-auth attempt for signed-in users; Android fallback skipped);NWPathMonitorinInnerTubeAPIresetsvisitorData = nilon VPN connect/disconnect; inert "Force IPv4 (VPN users)" toggle in Settings;CrashlyticsLoggerrecordsvpn_ip_block = truenon-fatal;IPBlockDetectionTests(12 tests) - VideoPreloadCache — advanced caching (Phases E–K):
- Phase E — Progressive
loadAsync:loadAsyncsplit into Phase 1 (critical path: cache consume →fetchPlayerInforetry chain → AVPlayer setup →isLoading = false) and Phase 2 (.utilityTask running concurrently: SponsorBlock cache-miss fetch,nextInfo,endCards,trackingURLs, neighbour prefetch);phase2Taskcancelled inload()andstop();ProgressiveLoadPhase2Tests(7 tests) - Phase F — Stale-while-revalidate (SWR):
CachedVideoData.DataTypeenum (.nextInfo,.endCards,.sponsorSegments,.deArrowBranding);staleFields: Set<DataType>returned byconsume(); stale values returned immediately (non-nil) while Phase 2 revalidates in background;VideoPreloadCacheTTLTests(16 tests) - Phase G — Priority prefetch queue:
PrefetchPriorityenum (.speculative,.visible,.immediate,.userFocused);[PrefetchRequest]queue bounded at 20 items; overflow evicts lowest-priority item; worker pool of 5 slots (WiFi) / 2 (cellular);VideoCardViewpasses.visible; neighbour prefetch passes.speculative;PrefetchQueueTests(5 tests) - Phase H — In-flight coalescing:
inFlightPlayerFetches: [String: Task<PlayerInfo?, Never>]onVideoPreloadCache;getOrFetchPlayerInfo(videoId:)returns existing in-flight task or creates a new one;loadAsynccoalesces against in-flight prefetch before falling to its own fetch;InFlightCoalescingTests(4 tests) - Phase I — TTL tuning:
nextInfoTTL5 min → 20 min;sponsorTTL1 h → 2 h;deArrowTTL1 h → 4 h;endCardsTTL1 h → 4 h - Phase J — Disk persistence:
VideoDiskCachewritesnextInfo,endCards,sponsorSegments,deArrowBrandingas JSON underCaches/st-video-cache/<videoId>-<dataType>.json; LRU eviction at 20 MB;playerInfoandtrackingURLsnever written (CDN/auth-sensitive);Codableadded toNextInfo,EndCard,EndCard.Style,Chapter,DeArrowService.BrandingInfo;VideoDiskCacheTests(7 tests) - Phase K — Network-aware throttling:
NWPathMonitorinVideoPreloadCache; offline → 0 workers (pauses all prefetches); cellular/constrained → 2 workers,playerInfo+nextInfo+sponsorSegmentsonly; WiFi → 5 workers, all data types;networkCapandallowedPrefetchDataTypescomputed properties
- Phase E — Progressive
YouTubeRSSParserTests,LocalSubscriptionStoreTests,LocalSubscriptionFeedServiceTestsunit tests
- Shorts player section feed sometimes not visible when test starts — added explicit wait for section feed before asserting
- Landscape playback for iOS —
OrientationManager+LandscapeAwareHostingControllerreplace SwiftUI's portrait-locked hosting controller so UIKit acceptsrequestGeometryUpdate(.landscape)while the player is on screen - tvOS PlayerView (
PlayerView+tvOS) — full d-pad navigation withTVPlayerControlfocus model; Siri Remote play/pause, seek, menu/back handling - Now Playing — lock screen and Dynamic Island metadata, artwork, and transport controls via
MPNowPlayingInfoCenterandMPRemoteCommandCenter - Playback quality selection — manual format override with
PlaybackViewModel+Quality; HLS variant URL fetching; toast confirmation viaToastModifier - Previous/next video navigation — history stack in
PlaybackViewModel+Navigation;playNext()/playPrevious() - Caption track selection — VTT fetch and live cue overlay in
PlaybackViewModel+Captions - Sleep timer — countdown task in
PlaybackViewModel+SleepTimer - Like/Dislike actions —
PlaybackViewModel+LikeDislike - Stats for Nerds overlay —
PlaybackViewModel+StatsForNerds PlayerView+OverlaysandPlayerView+PickerOverlays— player UI extracted into focused extension filesToastModifier— self-dismissing pill message (auto-clears binding after 2 s)ScrollOffsetPreserver— saves and restores LibraryView scroll position across tab switchesVideoDownloadUITests— UI test for both download methods: (A) player more menu → "Download to Gallery" using--uitesting-deeplink-video=JhCjw57u8mQlaunch arg; (B) video card long-press context menu → "Download to Gallery";player.moreMenu.downloadButtonaccessibility identifier added to download button inPlayerView+Overlays- Updated app icon (dark variant added)
PlaybackViewModelsplit into 14 focused extensions (Auth, AudioTracks, Captions, Controls, ControlsVisibility, Fallback, LikeDislike, Loading, Navigation, NowPlaying, Observers, Quality, SleepTimer, SponsorBlock, StatsForNerds)InnerTubeAPI+VideoRenderers.swift(~1,100 lines) split intoInnerTubeAPI+VideoGroupRows.swift(multi-shelf home row parsing),InnerTubeAPI+VideoRendererParsers.swift(individual renderer parsers:parseTileRenderer,parseLockupViewModel,parseReelItemRenderer,parseVideoRenderer), andInnerTubeAPI+VideoGroupFlat.swift(flat video group fallback parsing); all other files kept under 1,000 lines- Enhanced error handling and retry logic for failed stream requests
- BrowseViewModel recommended-video fetch deduplicates results
- Improved focus management for picker overlays on tvOS
- Subscriptions feed not strictly sorted in chronological order — YouTube returns each page sorted newest-first, but
BrowseViewModel.mergeIntoFirstGroupandHomeViewModel.loadMoreappended pages without global re-sort, so videos from page 2 appeared out of position relative to page 1;videoGroups[0].videosis now re-sorted bypublishedAtdescending after eachmergeIntoFirstGroupcall (subscriptions case) and after eachloadMoreappend inHomeViewModel;SubscriptionsSortTests(pagination merge test) added
- Localisation —
Localizable.xcstringsstring catalog covering the full app InnerTubeAPIProtocol— protocol abstraction overInnerTubeAPIenabling mock injection in testsViewModelLogger— structured per-category logging routed to Crashlytics- Sign-in UI: one-tap "Open Activation Page" button (opens pre-filled URL); "Or scan from another device" QR divider section
- Sign-in progress guard in
AuthService— prevents concurrent device-code flows - Comprehensive unit tests:
WebVTTParserTests,VideoStateStoreTests,ViewModelTests,VideoPreloadCacheTTLTests,SearchFilterUITests,YouTubeLinkHandlerTests - UI test suites: Channel, Library (History / Playlists / Subscriptions), Player controls, Recommended chip pagination, Search, Settings, Shorts, Audio track selection
- GitHub issue templates (bug report, feature request)
- Updated Privacy Policy
- Various internal refactors for readability and maintainability
- Login failures requiring multiple retries on iPhone —
AuthServicegainedretryWithBackoff<T>(): up to 3 attempts with 1–10 s exponential delay on transientURLErrorcodes (timeout, connection lost, offline, SSL);requestDeviceCode,fetchUserInfo,validAccessToken,refreshAccessTokenall wrapped; permanent OAuth errors (invalid_grant,invalid_client,unauthorized_client) are still propagated immediately without retry;YouTubeClientCredentialsFetcher.fetchFromYouTuberetries twice before falling back to hardcoded credentials;URLSessioninInnerTubeAPI.initnow useswaitsForConnectivity = true, 20 s request timeout, 60 s resource timeout - SponsorBlock causing video playback to stall — race condition between time observer ticks and async seek callback; debounce guard (
sponsorSkipDebounceTask) prevents redundant seeks when multiple ticks fire on the same segment; seek-in-flight guard skips sponsor check while a seek is pending; exact seek (tolerance = zero) used for segment endpoints instead of fuzzy tolerance; segment-near-end threshold widened from 0.5 s to 2.0 s of video duration to prevent clamping to last frame; buffer-status verification logs warning when player is.waitingfor >2 s after seek - Subscriptions feed showing videos out of chronological order —
InnerTubeAPI+Browse.fetchSubscriptions()now sorts each page's videos bypublishedAtdescending on arrival;parseGuideChannels()andparseSubscribedChannels()now sort channels alphabetically, matchingLocalSubscriptionStore's existing order - Apple TV fast-forward and rewind buttons showing white squares and not responding — SF Symbol images in
seekButtonandplayPauseButtonrendered as white-on-white on tvOS focus engine due toscaleEffect+shadow+buttonStyle(.plain)interaction; fixed with.renderingMode(.original)+.foregroundStyle(.white); reduced shadow intensity; Siri Remote gen 1 edge-tap left/right swipe gestures wired toseekRelativewhen controls are visible - Audio track language defaulting to wrong language for AI-dubbed videos — when no
DEFAULT=YEStrack is present in the HLS manifest,index == 0was incorrectly markedisOriginal = true; for AI-dubbed videos YouTube often lists the dubbed track first, causing the wrong track to be auto-selected;isOriginalis now only set whengroup.defaultOption == option(explicit HLSDEFAULT=YES); auto-selection waterfall reordered: (1) user's saved language preference, (2) HLSDEFAULT=YESoriginal, (3) English track, (4) device locale, (5) first track - Videos unable to play / "reload page" error on iOS 18.7.2 and iOS 26 — hardcoded
"iOS 18_3_2"User-Agent and client version string caused YouTube to detect a version mismatch on newer OS versions and returnUNPLAYABLEor cipher-protected streams; User-Agent now usesUIDevice.systemVersiondynamically; SponsorBlock segment fetch moved to a detached backgroundTaskafterAVPlayerItemis.readyToPlayso it no longer blocks stream setup on slow connections
- Home feed staleness check —
HomeViewModel.refreshIfStale(threshold:)reloads shelves when content is older than 15 minutes InnerTubeAPI: authenticated playback tracking URLs (fetchAuthenticatedTrackingURLs), TV-client endpoint (postTV), section-date and relative-date parsing- Home feed fallback to popular videos when watch history is empty
- HomeView replaced shelf rows with
VideoGridSectiongrid layout VideoCardViewlayout and thumbnail improvements
- Android-client HLS fallback in
PlaybackViewModel— retries with Android credentials when the iOS HLS manifest returns a 404 due to IP-binding; last attempted URL stamped into Crashlytics non-fatal reports VideoPlaybackRegressionUITests— UI test coverage for core playback flows
VideoPreloadCachekeeps itsInnerTubeAPIaccess-token in sync with the signed-in session
VideoPreloadCache— background prefetch and cache of video stream data keyed by video IDWatchtimeTracker— reports playback position metrics to YouTube's watchtime endpointInnerTubeAPIKeySwiftUI environment key — all views receiveInnerTubeAPIvia@Environment(\.innerTubeAPI)instead of constructor injection
- Updated InnerTube client version strings
- Initial open source release of SmartTube for iPhone, iPad, macOS, and Apple TV
- Audio track selection — loads alternate HLS renditions (dubbed/translated tracks) from the manifest; auto-selects by device locale; persisted in
AppSettings - tvOS d-pad navigation in the player — custom
TVPlayerControlenum; directional seek, play/pause, and back without SwiftUI focus engine - tvOS Settings: Ko-fi and GitHub QR code sheets
- Firebase dSYM copy script for crash symbolication
CrashlyticsLoggerintegration
AuthService: concurrent sign-in guard; automatic sign-out on permanent OAuth failures (invalid_grant,invalid_client,unauthorized_client); device code expiration clamped at server-reportedexpiresInVideoDownloadServicedownload-session and background-task code restricted to iOS with#if os(iOS)guardsPlaybackViewModel: foreground/background audio session handling (handleForeground()/handleBackground())