The verbose toggles (MPV, remote control) kept working with the master
"Enable Logging" toggle off: the settings UI hides them, but their
stored values - verboseMPVLogging is even iCloud-synced - were still
honored at runtime, producing console output and enabling the periodic
MPV stats collection while logging appeared disabled.
Enable Logging now takes precedence everywhere:
- LoggingService gates its DEBUG-build OSLog console output on the
master toggle (it previously ran before the isEnabled guard, so the
Xcode console was never silent)
- MPVLogging requires loggingEnabled && verboseMPVLogging, which also
gates the 10s playback stats task via MPVLogging.verboseEnabled
- rcDebug in LocalNetworkService and RemoteControlCoordinator requires
the master toggle alongside verboseRemoteControlLogging
Build 264 stutters for a split second every ~10 seconds. The periodic
playback-stats task fetches ~30 debug properties in one ~190ms block on
mpvQueue, and MPVClient.render() wrapped mpv_render_context_render in
mpvQueue.sync - so the render thread queued behind the fetch and stalled
~190ms, at exactly the stats interval. Build 261 predates the stats
task, which is why it was unaffected.
libmpv's render API is documented thread-safe relative to the client
API, so render-context calls no longer go through mpvQueue:
- New renderContextLock protects renderContext lifecycle vs use;
render(), renderWithDepth(), renderSoftware(), reportSwap(),
shouldRenderUpdateFrame() and hasRenderContext use it
- Render-update callbacks dispatch on a dedicated renderEventQueue so
frame-ready notifications can't queue behind property/command work
- destroyRenderContext takes mpvQueue then the lock, still excluding
in-flight renders during teardown
- The stats task skips its property fetch on iOS/tvOS unless verbose
MPV logging is on (macOS keeps it for the render watchdog)
- performRender slow-frame warnings now break down render vs present
time and report frame-delivery gaps for future triage
Hide the toggle on macOS, where apps are never suspended and playback
always continues regardless of the setting. Pause playback on iOS when
backgrounding with the setting disabled — the audio background mode
previously kept mpv playing, making the off state a no-op.
Move the continue-playing policy into PlayerService and make the
backend's background render pause unconditional (PiP-gated only):
it is a GPU optimization that should not depend on the setting.
Also ungate the player sheet visibility handlers, which manage
foreground mini player rendering unrelated to background playback.
Audio-only streams never report a video size, so the aspect ratio of
the last played video stuck to the player area indefinitely once audio
mode was enabled. Reset it in play() when the selected stream is
audio-only, and let the macOS player window fall back to 16:9 instead
of keeping the previous lock.
Downloads bypass stream selection and reach play() as ready-made local
file streams, so audio mode never affected them. Apply the mode at that
choke point instead: swap a local video stream for the separately
downloaded audio track, or play a muxed file with the video track
disabled (vid set per-load in MPVClient, never toggled live). The
transform is bidirectional so stored audio-only queue/history entries
restore full video when the mode is off, and setAudioMode now reloads
downloaded content at the current position in both directions.
CloudKit record names carry a source scope suffix so the same channel
or video ID can exist under different sources, but the apply path
matched local entities by bare ID: incoming records could merge into an
entity from a different scope, and a remote deletion of one scope
deleted every local entity with that ID and cascaded scoped deletions
for all other scopes back to CloudKit.
- Match local entities on the full mapper-derived record name when
merging incoming records, materializing records for upload, and
applying remote deletions.
- Remote deletions now remove only the matching entity and no longer
queue CloudKit deletions (no echo, no cross-scope cascade).
- Insert dedupe for CloudKit-applied records is scope-aware, so a
same-ID record from a different source inserts instead of being
silently dropped. Remotely added bookmarks also update the
fast-lookup cache immediately.
- When conflict resolution during a fetch keeps newer local data, the
merge result is queued for upload; previously the server (and other
devices) kept the stale version unless the entity was edited again.
Strict timestamp comparisons prevent re-upload ping-pong between
devices.
The recent-channel resolver copied avatarURLString/providerName and the
recent-playlist resolver copied authorID/providerName - fields that do
not exist in the records the mapper writes - while missing the real
ones (thumbnailURLString, subscriberCount, isVerified, videoCount). As
a result, when the local side won a conflict, those fields silently
kept the older server values.
Also drop the notificationsEnabled preservation in the subscription
resolver: the subscription mapper never writes that field since
notification preferences moved to their own record type.
Naturally finished videos only saved their 100% progress locally
(play() deliberately skips the sync-on-switch when videoEndedNaturally
is set), so the watched state never reached other devices in the
autoplay flow. Save completion through updateWatchProgress, which
queues the iCloud sync.
Also queue the current playback position when the app enters
background, so backgrounding mid-video hands the position off to other
devices instead of only syncing on explicit stop or video switch.
Register pending changes with the engine state instead of keeping
app-managed record arrays, and materialize records from local data at
send time in nextRecordZoneChangeBatch. This fixes several data-loss
paths:
- Pending changes now persist inside the engine state serialization,
so saves queued during the debounce window (or while offline) survive
app termination. Legacy record-name keys are migrated once.
- Transient CloudKit errors (zoneBusy, serviceUnavailable, rate limits,
network failures, limitExceeded, batchRequestFailed) re-register the
change for engine-scheduled retry instead of silently dropping it.
- Conflicts are resolved against the exact record that was sent; the
resolved record (carrying the server change tag) is cached and takes
precedence at the next send, and any newer local edit evicts it.
- Queue methods are now synchronous, removing a task-reordering race
where a fast delete/re-add could end up deleting the re-added entity.
- Records are built fresh at send time, so queued changes no longer
upload stale field snapshots, and batch size limits are handled by
the framework.
Persistent per-platform mode that plays only the audio track to save
bandwidth and speed up loads. When enabled, stream selection picks the
best audio-only stream (preferred language, codec, bitrate) and never
fetches the video URL; toggling mid-video reloads at the current
position using the quality-switch path (live vid=no toggling is avoided
due to A/V desync).
- Toggle row in the player quality/settings sheet on iOS, macOS and
tvOS, next to lock controls
- New audioMode control button type (red when active) for player
controls and mini player layouts
- Picking a video quality explicitly turns the mode off; audio track
picks keep it on and now work while audio-only is playing
- Stream URL refresh preserves audio-only playback instead of
resurrecting video
- Queue preloads and history entries resolved before a toggle are
discarded so selection re-runs with the current mode
- tvOS player shows the video thumbnail instead of a black screen
during audio-only playback
- PiP button hidden for audio-only streams
On macOS 27 beta, mpv's coreaudio AO fails to initialize (channel layout
set rejected with paramErr -50). The existing ao=coreaudio,avfoundation
fallback restored audio, but exposed an mpv bug: init() registers a HAL
hotplug listener (AudioObjectAddPropertyListener on the system object,
with the raw ao pointer as user data) before the step that fails, and
the coreaudio_error path never unregisters it. mpv then frees the ao
and falls back to avfoundation, leaving a dangling listener per
playback start. The next audio device change (AirPods connect,
sleep/wake device republish) invokes the listener with the freed
pointer and crashes with a use-after-free SIGSEGV on the
HALC_ProxyNotification Call Listener Queue (observed in build 262;
faulting address was reused "texture_" shader string memory).
Gate the AO order by OS version: on macOS 27+ use
avfoundation,coreaudio so the failing coreaudio init never runs; older
macOS keeps the native coreaudio first with avfoundation as fallback.
Update the read-only defaults mirror in MPV options settings to match.
Views without an explicit background sat on the translucent window
material, which picks up a wallpaper tint (bluish in dark mode) and
clashed with views drawing windowBackgroundColor, making the video
info header gradient fade visibly mismatch. Add opaqueWindowBackground()
and apply it centrally in NavigationDestinationHandlerModifier to both
stack roots and pushed destinations, plus VideoInfoView for
presentations outside navigation stacks.
Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
When "Play in a separate window" is disabled, the expanded player was a
native sheet attached to the main window. AppKit refuses fullscreen with
an attached sheet, so pressing F swapped the sheet for an in-window
overlay before fullscreening — a visible two-step transition.
Present the inline player as the full-window overlay from the start, so
fullscreen is just the window's native toggle. This removes the whole
sheet-swap machinery (toggleSheetFullScreen with its detach timing,
SheetWindowResizer, sheet sizing helpers).
- beginInlineOverlay/endInlineOverlay hide/restore the main window
toolbar and mirror the window's fullscreen state into the renamed
isMacInlinePlayerFullScreen flag via NSWindow notifications, so exits
through Esc/green button/Mission Control stay in sync
- Collapsing exits fullscreen only if it was entered for the video
- Esc in the windowed overlay collapses to the mini bar via a new
onCollapse callback (replacing the sheet's default Esc-dismiss)
- ContentView registers its hosting window (MainContentWindowReader) so
the overlay targets the right window even when the setting is toggled
while the Settings window has focus
macOS 27 beta's AUHAL rejects mpv's channel-layout property with paramErr
(-50), so the coreaudio AO fails to initialize and playback is silent.
Change the macOS ao option from "coreaudio" to "coreaudio,avfoundation"
so mpv self-heals: older macOS keeps using coreaudio unchanged, while the
beta drops to avfoundation (AVSampleBufferAudioRenderer) instead of playing
no sound.
The fullscreen button did nothing in sheet mode: AppKit ignores
toggleFullScreen on a window attached as a sheet, and the old fallback
targeted the key window — the sheet itself. Two other strategies were
ruled out first: fullscreening the sheet's parent is silently refused
while any sheet is attached, and resizing the sheet's backing window to
screen size NaN-asserts inside AppKit's sheet positioning even without
animation.
Working approach: swap the sheet for a plain overlay in the main window
and native-fullscreen the now sheet-free main window.
- isMacPlayerFullScreenOverlay on NavigationCoordinator dismisses the
sheet (presentation binding excludes it; the setter ignores the
dismissal write-back) and mounts ExpandedPlayerSheet as a black-backed
overlay in ContentView
- ExpandedPlayerWindowManager fullscreens the parent after the sheet
detaches, verifies AppKit accepted it (rolls back if refused), hides
the window toolbar (NSWindow keeps it as a strip above the overlay
otherwise) and restores it on exit
- a didExitFullScreen observer restores the sheet on any exit path
(button, Esc, green button, Window menu); closing the player while
fullscreen exits the main window's fullscreen too
- scheduleSharedViewAdoptionRetry on entry re-homes the shared render
view once the dismissing sheet's window loses visibility — its steal
is declined mid-dismissal and nothing else retried, leaving the
overlay black until the next video load
SPUUpdater is a main-thread-only API; calling resetUpdateCycle() from a
detached utility task violated Sparkle's threading contract and raised
a Swift 6 concurrency error (non-Sendable SPUUpdater captured in a
detached task). A plain main-actor Task keeps the deferral that avoids
running the feed-cache work synchronously inside the didSet.
Previously CKSyncEngine was created with nil state serialization on
every launch, replaying the entire zone change history (hundreds of
merge/delete log lines and re-downloads each start). Now the saved
state is loaded so launches only fetch changes since the last session.
To make incremental sync safe:
- Clear persisted state once when the record schema version increases,
so records previously skipped as unsupported get re-delivered after
an app update.
- Handle remote zone deletion in fetchedDatabaseChanges by recreating
the zone and re-uploading local data, instead of leaving sync dead.
Account change and manual Refresh Sync still clear the state and force
a full fetch as before.
Closing the player window via its close button left isPlayerCollapsing
stuck true (windowShouldClose has no animation completion to clear it),
so the mini capsule preview kept mounting its render container and
grabbed the shared render view on the next expand - the video rendered
into the 44x26 thumbnail while the player window stayed black.
- Clear isPlayerCollapsing on the next runloop in windowShouldClose
- Give containers in the tracked player window priority to claim the
shared view (even from a visible owner), and refuse steals from the
visible player window by outside containers
- Prefer the largest container in the player window during recovery so
thumbnail-sized surfaces are never picked
- Add a watchdog misplaced-view check in MPVBackend that re-attaches
the render view when it lives outside the visible player window
- Include container bounds and tracked-window flag in attach/decline
logs
In separate-window mode, hide() keeps the ordered-out window's SwiftUI
hierarchy alive (nilling contentViewController crashes AVKit), and its
updateNSView unconditionally re-attached the shared MPVOGLView -
stealing it into a window CoreAnimation never composites. The layer's
silent skip-render fallback then consumed every frame flag, so video
stayed black (climbing vo drops) until app restart.
- Decline stealing the shared view from a visible container into a
non-visible one; owner-initiated transfers bypass the guard
- Only transfer to containers in visible windows or the window still
tracked by ExpandedPlayerWindowManager; park the view otherwise and
reclaim it when a container gains a window
- Log skip-render frame consumption (rate-limited) and track render
health counters (draws/skips/dropped draws)
- Add a watchdog to the playback stats task that detects a stalled
video output and re-attaches the view to a visible container
- Don't mount player layouts during zero-size layout passes
- Add macOS-specific entitlements via CODE_SIGN_ENTITLEMENTS[sdk=macosx*]:
the iOS aps-environment key is silently dropped from macOS exports,
leaving distribution builds without a push entitlement (no CloudKit
sync pushes). Release now uses Yattee-macOS.entitlements with
com.apple.developer.aps-environment = production.
- Release-DeveloperID uses Yattee-macOS-DeveloperID.entitlements, adding
the Sparkle mach-lookup temporary exceptions (-spks/-spki) required
for the updater to install in a sandboxed app.
- Default Sparkle updater to the beta channel while only beta releases
exist, so testers receive updates without toggling Advanced settings.
- Set NSHumanReadableCopyright for the app target.
Queue items carry a pre-resolved stream, so play() takes the
provided-stream shortcut and skips the full API fetch that populates
storyboards. The follow-up loadOnlineStreams() call fetched the response
containing them but dropped storyboards on the floor.
Keep the storyboards in loadOnlineStreams() and apply them when none are
set (preserving local storyboards for downloaded playback), and bail out
if the current video changed during the fetch.
The PiP restore handler only expanded the player when mini player
video was disabled, which is the iOS behavior where playback can
continue in the mini player. On macOS the mini player is not a
restore target, so always reopen the player window; iOS keeps the
existing logic.
MPVBackend.stop() clears the PiP bridge callbacks on macOS to prevent
crashes during window close, but the backend is reused across videos
and setupPiPIfNeeded is guarded by isPiPSetUp, so the callbacks were
never wired again. The next PiP session then started without notifying
PlayerService (no onPiPStatusChanged), leaving the player window
visible alongside the PiP window, both playing.
Extract the callback wiring into wirePiPBridgeCallbacks() and re-run it
in startPiP() and in setupPiPIfNeeded when already set up.
Switching videos during active PiP left the window at the old video's
size with white space around the new content, for two reasons:
- MPV reports the new video's width and height as separate property
events while the old values are kept across switches, so a transient
mixed size (new width with old height) could reach the PiP capture
path and AVKit sized the window from that frame. Coalesce the PiP
capture-size/aspect update with a short debounce so only settled
dimensions are used.
- On macOS nothing told AVKit about the new aspect: unlike iOS, the
bridge never flushed the sample buffer renderer on an aspect change
during active PiP, and AVKit doesn't resize the macOS PiP window on
its own. Flush the renderer, reset the format description, and
resize the PiP window to the new aspect ratio explicitly.
The PiP-active guard that skips expanding the player when opening a new
video was compiled iOS-only, so on macOS a browser deep link always
re-showed the hidden player window instead of playing the new video in
the existing PiP window. Extend the guard in both openVideo overloads
and the Handoff path to macOS.
Since 1d9c5e3b performRender feeds swap timing to mpv via
mpv_render_context_report_swap, but only after presentRenderbuffer -
which is skipped while PiP is active. Once swap reports stop mid-stream,
display-vdrop's vsync estimation goes stale and mpv drops nearly every
frame (vo drop counter climbing ~19/s on a 24fps stream), turning PiP
into a slideshow.
The frame is genuinely displayed during PiP via the
AVSampleBufferDisplayLayer, so report the swap on that path too.
The expanded player window hidden for PiP is reused on restore, so its
MPVContainerNSView kept a stale weak reference to the shared render view
that the mini player preview had taken during PiP. On restore the mini
preview released the view as an orphan and the reused container skipped
re-attaching it because its guard only compared view identity.
- Skip re-attach in setPlayerView only when the view is still actually
our subview, matching the iOS container's stolen-view handling
- Transfer the shared render view to another living container on unmount
instead of orphaning it, since SwiftUI is not guaranteed to re-run
updateNSView on a hidden window's content after restore
- Force a repaint when restoring the window hidden for PiP; forced draws
requested while ordered out are dropped and the layer is pull-based
With Atmos "Continuous Audio Output" enabled, some HDMI routes report
32 output channels, which mpv's audiounit AO cannot open - playback
stays silent while other (AVPlayer-based) apps work fine.
Update MPVKit to 0.41.0-n8.1.2, which builds mpv's avfoundation AO
(AVSampleBufferAudioRenderer, previously macOS-only) for iOS/tvOS
(mpvkit/MPVKit#73), and prefer it on tvOS via ao=avfoundation,audiounit
so mpv still falls back to audiounit if it fails to initialize. iOS and
macOS audio outputs are unchanged.
The MPVKit pin must be exactVersion: SwiftPM sorts the pre-release
identifier below 0.41.0, so a range starting at 0.41.0 never resolves
to it. Note this tag also bumps FFmpeg from 8.0 to n8.1.1.
1d9c5e3b removed the main-thread EAGLContext bind from setupAsync so
the context is never left current on two threads. But libmpv's
mpv_render_context_create requires a current GL context on the calling
thread (it probes glGetString(GL_VERSION)), so render context creation
failed with MPV_ERROR_UNSUPPORTED (-18) and every video played with no
--vo ("No render context set") on real iOS/tvOS devices.
Bind the context around the createRenderContext call and unbind right
after. The bind is scoped to setup, before the display link starts and
before any frame is rendered, so the never-left-current invariant that
protects the A10X fence fix (#947/#949) still holds.
Verified on Apple TV 4K (3rd gen): createRenderContext succeeds and
video frames render.
Claude-Session: https://claude.ai/code/session_01UEXP5f6F4S1zLdY8fdVuLJ
The macOS CAOpenGLLayer is pull-based: after the shared render view is
re-parented into a reopened player window, a single forced repaint is
issued. draw() consumed the forceDraw flag before validating the
viewport, so when that repaint landed while the layer was still 0x0
(the expanded window builds its content a runloop late), the request
was swallowed. With mpv paused during load, nothing repainted again,
leaving the video permanently black with working audio.
- Validate viewport/framebuffer in draw() before consuming flags;
on failure keep the forced draw armed and retry (bounded, 5x50ms)
- Repaint from setFrameSize on the first non-zero layout while a
forced draw is still pending
- Drop the stale fbo=1 fallback; render only into the validated
live framebuffer binding
- clearToBlack: take the CGL lock and skip glClear when no
framebuffer is bound - clearing FBO 0 on this core-profile context
latched GL_INVALID_FRAMEBUFFER_OPERATION, which libmpv reported on
every load
- Add diagnostics for dropped draws and the first-frame render
Apple TV 4K 1st gen on tvOS 26 crashes to the home screen with a
"blocked by IOFence" gpuEvent at playback start (#949) and stutters
with a starved demuxer cache (#947). Defensive fixes in the shared
iOS/tvOS OpenGL path:
- Drain in-flight GPU work (glFinish) before destroying the
renderbuffer, so a pending presentRenderbuffer fence can't hold the
CAEAGLLayer IOSurface being torn down or reallocated
- Never leave the EAGLContext current on the main thread: framebuffer
create/destroy and the background glFinish now unbind on exit, and
setup no longer binds the context on main at all
- Disable retained backing on tvOS; it exists only for iOS PiP frame
capture and adds per-frame IOSurface fence pressure
- Report swaps to mpv after each present so display-vdrop (the tvOS
default video-sync) gets swap-timing feedback, matching macOS
Add verbose-gated diagnostics for remote triage: playback stats every
10s (cache fill/rate, dropped frames, hwdec, avsync) and a rate-limited
slow-frame warning in performRender.
Addresses #949 and #947
The player video surface is a single shared MPVOGLView that is re-parented
between the mini-bar container (main window) and the expanded-player sheet
(a separate sheet window). When presenting the sheet, the new container
attaches the shared view before the old mini-bar container tears down, so
the old container's viewWillMove(toSuperview: nil) was ripping the shared
view out of its new home via removeFromSuperview() — leaving it with no
window and a black surface. Symmetric, so it also blanked the mini-bar
preview after collapsing.
Only detach the shared view if it still belongs to the container
(superview === self). Also force a repaint on window reattach so a paused
frame redraws, and extend the onAppear resumeRendering to macOS.
Drop the dashEnabled toggle and its plumbing. Stream auto-selection now
keeps DASH as a candidate but ranks it strictly below progressive and
HLS formats, so DASH is only chosen when it is the only format available
(including live-stream fallback after HLS). The manual quality selector
hides DASH entirely on iOS/tvOS/macOS. Reconciles BackendSwitcher, which
previously treated HLS/DASH equally, to use the same DASH-last ranking.
The Test Connection buttons only probed API/server reachability, which
was misleading: a green result said nothing about whether videos would
actually play. Remove both the remote-server and WebDAV bandwidth test
buttons and all code exclusive to them (testBandwidth, BandwidthTestResult,
and orphaned localization keys). Add-time connectivity validation for
SMB/WebDAV sources is retained.
Legacy v1 instances without an account had no import path after the
switch to explicit account review. Surface them in a separate "Sources"
section of the legacy import view where they can be added with a single
tap (no sign-in) or removed, mirroring the accounts flow. Instances tied
to an account stay in the Accounts section and are not duplicated.
Detection now gates the entry points and first-launch prompt on any
legacy data (accounts or sources), not accounts alone.
The manual account-import flow made the old silent instance-import
pipeline unreachable. Drop the orphaned parsing, reachability checks,
batch import, result/error types, and the old import row view, plus the
httpClient dependency they were the only consumer of. Also remove the
LegacyAccount.password field, which was parsed but never read.
MPVOpenGLLayer called fatalError() when CGLChoosePixelFormat failed for
both the 10-bit and 8-bit (kCGLPFAAccelerated) formats, killing the app at
launch on machines without a usable GPU such as virtual machines.
Add a non-accelerated software pixel format as a last resort and make the
whole OpenGL init path failable: createPixelFormat/createContext return nil
(with an error log) instead of aborting, MPVOpenGLLayer.init becomes init?,
and MPVOGLView holds an optional layer, throwing openGLSetupFailed from
setup(with:) when OpenGL is unavailable. That error is already handled by
MPVBackend.setupMPVAsync, so the app now launches cleanly and only logs that
video playback is unavailable.
Surface audio-delay (±10/±100 ms) and video-sync-mode controls behind an
"A/V Sync" navigation row in Advanced settings rather than inline, keeping the
Advanced page uncluttered. Audio delay applies live to the running MPV
instance; sync mode takes effect on next playback.
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord'
from the @MainActor sync engine hopped off the main actor and read live
SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong
executor. SwiftData models are bound to the main ModelContext and are not
thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS.
Make the mapper @MainActor (it must touch main-isolated models regardless) and
drop the now-redundant awaits. Fixes the crash for all synced model types.
Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
The 'resumed' guard flag was set inside a dispatched MainActor Task rather than
synchronously in the connection state handler. Two states arriving in quick
succession (e.g. .ready then .failed) could both pass the guard before either
Task ran, resuming the continuation twice and trapping. Claim the continuation
synchronously on the serial queue before dispatching the side effects.
Fixes a SIGTRAP seen in TestFlight build 261.
completeMultiFileDownload captured an index via firstIndex, then awaited a
detached file-size calculation. Another concurrently-completing download could
mutate activeDownloads during that suspension, leaving the index stale and
crashing in Array.remove(at:) with an out-of-bounds index. Remove the download
by identity instead.
Fixes a SIGTRAP seen in TestFlight build 261 (5 reports).
SwiftUI evaluates a @State default-value autoclosure more than once,
keeping only the first instance but still running the side effects of
the discarded ones. Each AppEnvironment() built its own DownloadManager,
and each DownloadManager registered a background URLSession under the
same identifier. The download task started on the surviving instance's
session, but iOS delivered the completion delegate callback to the other
(leaked) instance, whose activeDownloads was empty - so the finished
file was dropped and the progress spinner spun forever at 0 KB.
- Make AppEnvironment a process-wide singleton (static let shared) and
reference it from the App's @State, guaranteeing exactly one instance
(and one background session, one DataManager/CloudKit stack).
- Make DownloadManager.setDownloadSettings idempotent: only create the
background session when none exists, so a re-entrant call never
invalidateAndCancels the live session and kills in-flight downloads.
Cellular changes already route through refreshCellularAccessSetting().
Player settings on tvOS showed the previous video's tracks after queue
advance because availableStreams was never cleared on a video change and
playQueuedVideo only seeded a single pre-resolved stream. Reset the list
on a new video, then fetch the full streams in the background for global
videos, and repoint state.currentStream/currentAudioStream to the
refreshed entries so the picker checkmarks land on the playing tracks.
Lets the Apple TV switch its HDMI output to match the playing video's
frame rate and dynamic range via AVDisplayManager.preferredDisplayCriteria,
driven from MPV's container-fps and video-params/gamma. Two opt-in toggles
(default off) live under Playback → Display on tvOS; both are no-ops on
other platforms. Anchor an AVKit class symbol so the linker keeps AVKit
linked — Swift only autolinks AVFoundation here, and without AVKit the
UIWindow.avDisplayManager category isn't loaded at runtime.
If the MPV backend was retrying a failed load and the user switched to
another video before retries exhausted, the eventual error was published
to the player UI even though that video was no longer active. Guard the
catch block with a loadingVideoID check so stale errors are dropped.
Render subscriber count, Subscribe button, and (for subscribed
channels) description in the tvOS loading state instead of just
avatar + name + spinner. Seed the in-memory author cache when
navigating to a channel from a video so the first-time channel
view has a name and avatar to display immediately.
When the same video was already loaded (typically paused), opening it
again via the URL scheme, a deep link, or a remote-control loadVideo
command did nothing — the player just stayed paused. Now the same-video
early-return path resumes playback if paused and seeks to the supplied
startTime, so timestamps from URLs and remotes are honoured even when
the video is already loaded.
URLRouter gains a parseTimestamp helper that reads t/time/start query
params in plain-seconds and YouTube-style (1h2m3s) forms, and the deep
link handler now forwards that timestamp through to openVideo.