Playback architecture¶
Video playback is split by backend, then converges on one app-owned PlaybackController.
The controller owns its AVPlayer, progress reporting, diagnostics, seeking, recovery, and
teardown. Presenter views own the AVPlayerLayer instances that display that player:
CustomPlayerView hosts PlayerLayerView in a window, and the Cinema attachment hosts another
presenter for the same live controller. The retired AVPlayerViewController path is not part of
the current architecture.
Every controller is constructed from one typed PlaybackSessionSource: .plex carries Plex
server authority, .mediaBrowser carries the negotiated Jellyfin/Emby stream together with its
reopener, progress context, and cleanup callback, and .offline carries the local file and side
assets. The controller never infers its lane from independent optional URLs, tokens, or callbacks.
Each item replacement advances a playback generation. Observer callbacks, notifications,
timers, artwork/metadata loads, reconnect watchdogs, and other queued work capture that
generation and re-check it on the main actor through PlaybackLifecycleCallbackSink and
VideoPlaybackLifecyclePolicy. Removing an observer is cleanup, not proof that a callback
already queued for the old item was cancelled.
sequenceDiagram
accTitle: Backend playback startup
accDescr: Jellyfin and Emby negotiate a stream before constructing the playback controller. Plex and MediaBrowser lanes validate an available video-copy path for Original and ask before falling back to video encoding; explicit transcoded qualities authorize encoding. Each lane then loads one app-owned AVPlayer and retains lane-specific progress and cleanup.
participant UI
participant Backend
participant PC as PlaybackController
participant AV as AVPlayer
participant Server
alt Jellyfin or Emby
UI->>Backend: initial PlaybackInfo negotiation
Backend->>Server: authenticated PlaybackInfo request
Server-->>Backend: stream URL + session metadata
Backend-->>UI: negotiated remote stream + callbacks
UI->>PC: construct with negotiated stream
opt MediaBrowser Original HLS copy
PC->>Server: validate explicit video-copy primary playlist
opt copy unavailable or video transform required
PC->>UI: ask before video encoding
UI-->>PC: approve or decline
end
end
else Plex Original (Direct Stream) copy
UI->>PC: construct and start with item + session
PC->>Server: directPlay=0, directStream=1 decision
Server-->>PC: video-copy decision or encoding required
opt encoding required or copy fails
PC->>UI: ask for video-encoding consent
UI-->>PC: approve or decline
end
PC->>AV: authorized start.m3u8 at copy or encode intent
else Plex capped, HLS-max, burn, or DV-forced
UI->>PC: construct and start with item + session
PC->>Server: production decision and start.m3u8
Server-->>PC: decision + stream URL
end
PC->>AV: create and replace player item
PC->>Server: progress / heartbeat as needed
PC->>Server: lane-specific cleanup on replacement or teardown
Plex¶
Plex uses the universal HLS endpoint with distinct video-copy and video-encoding intent.
The client-first changes are under active acceptance (see the repository plan
docs/plans/2026-09-06-client-first-playback.md);
control-plane success is not a claim that live rendering or every platform has passed.
- Original (Direct Stream). Negotiate with
directPlay=0anddirectStream=1, and require an explicit videocopy/directplaydecision before fetching the media start. Container remux and audio conversion are allowed; this HLS route is not byte-for-byte original-file playback. Copy starts omitoffset=and restore the playhead through a client seek. The rejected literaldirectPlay=1HLS start is no longer the app's Original playback route. - Consent before video encoding. An unknown/encoding decision, subtitle burn, Dolby Vision safety requirement, or failed copy rendition stops the previous job and offers a controller-owned choice. Decline starts no encoder. Approval is scoped to the current item and generation; stop and explicit quality changes clear it. Approved fallback disables Direct Stream and primes the saved resume offset. It is not a persistent preference or an automatic retry loop.
- Explicit capped / Maximum (Transcode). These quality choices authorize encoding without
a second prompt. Maximum (Transcode) disables Direct Stream rather than recreating the copy
rendition. Capped/approved encoding retains
offset=priming for deep resume.
For a single HDR variant on an SDR display, the bounded media-playlist selector can open its same-origin child directly after master prewarming. It rejects alternate media tracks, multiple variants, and cross-origin children, and never rewrites encoded color metadata. Actual HDR/SDR presentation and seek acceptance remain open; preparation alone is insufficient.
Automatic bitrate downshift still does not turn Original into an unapproved capped encode. Transport activity can defer the stall watchdog for one additional normal interval, not indefinitely. Genuine play/resume, pause, and teardown reset that waiting window.
The profile and quality parameters are load-bearing. TranscodeRequest uses the built-in
Plex profile name Generic plus explicit profile-extra directives. Unknown or missing
profile names can make PMS return HTTP 400, while the previously tried Safari profile
regressed high-bitrate 10-bit HEVC. Do not change these values casually.
Jellyfin¶
DetailPlaybackLauncher orchestrates Jellyfin's initial PlaybackInfo negotiation through
JellyfinBrowseService before PlaybackController is constructed. The controller receives the
resolved stream and callbacks as one typed MediaBrowser session for later reopens, progress, and
cleanup. The app adapts
the native open result to the neutral MediaBrowser carrier at the app boundary,
preserves required request headers, and reports Sessions/Playing,
Sessions/Playing/Progress, and Sessions/Playing/Stopped with the current play session,
media source, method, and absolute position ticks. A reopen that mints a new session must
replace that progress context.
Jellyfin Original is now guarded before any media segment request: compatible H.264/HEVC
sources may use explicit VideoCodec=copy with fMP4 segments while audio is copied or
converted. The controller selects the validated same-origin primary copy child, not the
HDR master whose additional SDR variants can force a video encoder. Unknown decisions,
required video transforms, and unhandled alternate renditions stop and ask rather than
silently encoding or discarding a track. Maximum (Transcode) explicitly forces video encoding.
An approval travels only through the captured, current-session reopener; stale generations
cannot approve another playback request. These changes remain under live acceptance.
Jellyfin VOD playlists describe the full timeline. Both copy and encoded playback use a
client seek on readiness; StartTimeTicks must not reach dynamic segment requests. Copy
playback is not treated as server-primed merely because it owns an FFmpeg audio/remux job.
Verified Jellyfin video-copy HLS reopens keep automatic waiting enabled with the same
12-second buffer target. Disabling it reproduced a rate-zero paused hold after both HDR10
and P7 seeks; the scoped exception passed inspected native seek checks. Approved video
encoding, paused-loading settings, user pause intent and session authority are unchanged.
See the bounded acceptance evidence.
Emby¶
Emby playback uses its own MediaBrowser-family lane. Like Jellyfin, DetailPlaybackLauncher
orchestrates its initial PlaybackInfo request through EmbyBrowseService before controller
construction; the resulting stream and reopen/cleanup callbacks are then supplied to the
controller. It reports progress through the same neutral app progress seam, but with Emby's
request dialect. POST /Sessions/Playing/Stopped reports
playback state only; it does not stop an encoder. A source whose open result says it used
server encoding must also call Emby's active-encoding delete endpoint. Keep those two teardown
operations separate.
Emby Original uses a server-accepted static/direct-play result when available instead of
silently accepting video encoding. If that file stalls, one bounded retry requests video-copy HLS with AAC audio
conversion before asking to encode video. Ordinary copy HLS uses Emby's m4s dialect, validates
the same-origin primary child, and preserves session and track authority. H.264 static-file
recovery instead requests self-contained MPEG-TS segments with video copy and AAC; this
avoids the dynamic fragmented-MP4 delivery failure observed in the Mac acceptance control.
The recovery child uses the full VOD timeline, strips StartTimeTicks from master and child,
and performs its initial resume and subsequent seeks natively. It bypasses the legacy
master-playlist prewarmer/proxy and retains the server session for cleanup. Explicit
full-timeline resume must not depend on the clock still being near zero after track setup.
Known HEVC 10-bit SDR copy-compatible HLS also uses a full timeline and explicit native resume/seek, but retains fragmented MP4 rather than switching to MPEG-TS. Capped sources must have a known bitrate within the ceiling and explicit copy-compatible server reasons; unknown facts, transforms, and HDR are excluded. This avoids the reproduced visual corruption on the offset-primed quality-reopen path, without asserting a shared cause with the H.264 starvation issue (evidence and limits).
Verified Emby copy HLS reopens retain their 12-second buffer target but keep automatic waiting enabled, so buffer exhaustion does not strand AVPlayer at rate zero. Verified Jellyfin copy reopens use the same exception; approved video-encode settings remain unchanged. AC-3 copy transport also uses AAC as a narrow delivery workaround. Unknown transforms and subtitle burn require consent; Maximum explicitly authorizes video encoding. This fallback is video-copy Original (Direct Stream), not byte-for-byte original-file Direct Play. Native-file starvation itself remains unresolved; the scoped recovery passed Mac playback and deep-seek acceptance.
A reopened result arriving after cancellation must still stop its exact server session.
stopAndWaitIgnoringCancellation() isolates that cleanup from the cancelled preparation
task; playback-stopped reporting alone does not establish encoder cleanup.
Jellyfin and Emby share DTOs, quality/progress policy, and app-facing carriers, not a single
wire implementation. See BACKENDS.md for the exact boundary.
HLS startup and failure detection¶
AVFoundation applies hard per-media-file startup deadlines. A large first HLS segment can
miss those deadlines even when the server and link are otherwise healthy; a one-variant
playlist then has no alternate rendition left. The failure shape also differs: Plex can
remain .unknown while the decisive codes appear only in AVPlayerItem.errorLog(), whereas
a MediaBrowser item may become .failed.
Current invariants:
HLSSessionPrewarmeris lane-specific rather than a universal HLS prerequisite. Plex uses its full 20-second budget only when the selected quality is Original (Direct Stream). Outside native full-timeline lanes, Emby gives nonzero transcode resume/reopen targets an 8-second head start. Absent/zero-resume AV1 transcodes instead warm the same session for up to 20 seconds without the proxy (evidence and limits). Jellyfin skips legacy priming and client-seeks its VOD timeline. All prewarm outcomes are soft and AVPlayer still gets a chance to load.- After the nonzero-offset Emby prewarm, the controller stands up
MediaSessionProxyto stripstarttimeticksfrom the playlist and inject a playlist start-time offset, then attaches AVPlayer to the loopback URL. If proxy standup fails, it falls back to the original remote URL. Zero-offset starts skip the proxy; progressive/direct streams skip both. - Failure handling scans the complete error log for the startup-deadline/variant-removal codes; a notification can cover more than its last appended event.
- A startup-deadline abandonment gets at most one automatic warm retry. The allowance is
re-armed by explicit user intent such as Retry, a quality/audio reload, or a new seek,
not by a transient
.playingcallback. Exhaustion produces the visible Retry surface. - Preparation and reconnect watchdogs are each 20 seconds. The preparation watchdog covers
attach after a poisoned
start.m3u8that produces neither an AVPlayer failure nor a usefultimeControlStatustransition. The reconnect watchdog covers in-flight recovery that replaces the player item. Both are progress-deferred so a slow-but-working prime is not false-failed.
Buffering and stalls¶
Plex universal-transcode playlists are live-ish while their server session is open, even
for video-copy routes. In steady-state remote HLS playback, Labstream enables
canUseNetworkResourcesForLiveStreamingWhilePaused; otherwise pause-to-buffer can stop
network loading completely. An explicit out-of-buffer HLS seek temporarily uses the bounded
reopen/buffer path instead. Jellyfin/Emby progressive direct streams are ordinary VOD.
HLS buffer-ahead values advance by completed segment, so a high-bitrate stream can remain at 0 seconds for a while and then jump by the segment duration. That staircase is not itself a stall. Diagnostics distinguish active transfer from a stale/idle observed-bitrate sample.
Network loss frequently leaves AVPlayer waiting with an empty buffer without changing the
item to .failed. Stall deadlines depend on the active route: Jellyfin/Emby remote transcodes
use 45 seconds; the no-cap Original (Direct Stream) watchdog uses 90 seconds except for Emby's
direct-file (Direct Play) route, which uses 15 seconds; all remaining paths also use 15 seconds.
For every network-backed stream—Plex, Jellyfin, or Emby—growth in
transferred bytes or loaded range at expiry rearms the watchdog instead of failing a
slow-but-working prime. Local-file playback does not use this deferral. Once progress stops, the
controller uses the startup error log when available and otherwise surfaces a recoverable
network/capacity message.
Client-driven adaptive bitrate is an optional Settings feature and is default-off. When enabled, it can reopen supported capped Plex or MediaBrowser streams at bounded rungs after a sustained stall and later upshift after healthy playback. It never silently converts an explicit Original (Direct Stream) choice to a capped transcode. Maximum (Transcode) is an explicit upper cap; when adaptive bitrate is enabled, it may move among bounded transcode rungs below that cap. The requested forward-buffer duration remains a hint, not a guarantee; a full server throttle window, a single-rendition copy stream, or AVPlayer's realized buffer is not itself an adaptive bitrate ladder.
Seeking and restart budgets¶
The custom scrubber owns user seek intent. RemoteSeekModePolicy selects native in-buffer
or copy-lane seeking versus a backend/session reopen. Out-of-buffer server-encoded seeks are
debounced into one settled final-target rebuild instead of restarting for every drag sample.
- Plex streaming-copy HLS seeks natively within its full-timeline playlist; do not kill and re-mint that copy session merely to seek.
- Reopen/rebuild paths capture the live playhead, detach stale work, negotiate the final target, and hold the scrubber until the replacement item lands or fails.
- A successful client resume-seek completion explicitly resumes the player when its item and lifecycle are still current and the user has not requested pause. Applying the saved playback speed alone preserves a paused player and cannot restore playback after a seek.
FinalTargetRebuildPolicyandSeekRestartBudgetprevent concurrent/unbounded restart pipelines. When the budget is exhausted, recovery stops and the user gets Retry rather than a hidden server-hammering loop.- Playhead evidence is carried as typed
PlaybackPositionSamples rather than parallel millisecond/timestamp/source/near-zero fields.PlaybackPositionSnapshotcaptures the chosen restart target and all evidence together;PlaybackSeekHoldowns its target, latest generation, and 12-second deadline so a stale completion cannot release a newer seek. - Explicit seek-to-zero samples remain authoritative. Unintended near-zero clocks observed while replacing an item are suppressed when newer meaningful evidence exists, and terminal reporting uses the same typed evidence without regressing to a detached item's transient zero.
- Track, quality, explicit-Retry, and adaptive-bitrate replacements enter the controller through
a typed
PlaybackRestartIntent. Its value-onlyPlaybackRestartPlanis reason-specific: every intent resets the final target, rearms the startup-deadline retry, and tears down observers; only.explicitRetryalso clears the visible error and resets ABR. Callers must not assume every restart clears failure state. - Every intentional Plex in-place restart that supersedes a transcode (quality/audio reload, Retry, or final-target rebuild) stops the old job first with a bounded wait before requesting the replacement under the reused session id.
- Jellyfin/Emby replacement is deliberately ordered differently: detach the old
AVPlayerItem, negotiate and attach the replacement item, then defer the prior active-encoding stop. If the backend reused the same play-session id, skip that prior stop so it cannot tear down the new stream. A consent boundary instead awaits the old session stop before showing the choice, and cleans up any rejected newly negotiated session without attaching it.
Audio and subtitle selection¶
Player pickers consume one validated PlaybackTrackSnapshot: its rows and selected typed ID are
captured together, so a stale or magic numeric selection cannot describe a different list. Every
row carries exactly one mechanism—AVFoundation, Plex stream, MediaBrowser stream, or offline
sidecar—and subtitle Off is a lane-specific typed choice. Plex's 0 and MediaBrowser's -1 Off
values exist only in the controller's backend adapter immediately next to wire-facing requests.
Offline subtitle menus read only the downloaded track metadata. The controller opens and parses the selected SRT/VTT sidecar off the main actor when the viewer chooses it; opening the menu no longer parses every sidecar up front. Offline subtitle and metadata-audio selections are generation-fenced: a later track or Off choice invalidates stale async parse/PUT completions before they can change the active track, preference, overlay, or restart the stream. Plex's account-sticky audio PUTs also run through a serialized latest-intent tail: an in-flight mutation finishes before the newest choice is sent, while superseded queued choices are skipped, making the newest intent the final server mutation as well as the final local selection.
Subtitle delivery risk and caption styling are separate typed policies. SubtitleBurnRiskPolicy
maps backend evidence (including Plex's decision response and MediaBrowser transcode reasons) to
none, uncertain, or confirmed burn/transcode risk; only confirmed new risk interrupts selection
with a consequence-and-alternatives confirmation. SubtitleStyleCapabilityPolicy independently
decides whether the selected route can use an Apple caption appearance profile, is an app-rendered
offline sidecar, or is server/image rendered and therefore cannot be restyled.
The playback-owned caption appearance controller observes system Media Accessibility changes,
applies profiles system-wide only after explicit selection, and uses AVPlayerLayer's native
profile preview on OS 26.4 and later. Preview is stopped before layer replacement and on picker,
item, playback, and Cinema teardown. The app-owned offline subtitle overlay mirrors the active
system profile's supported font, size, colors, opacity, edge, window, and corner settings.
Local/offline playback¶
Completed downloads play from local file URLs. Local playback has no remote progress stream,
server session, or transcode cleanup path. Its playhead is persisted on the offline record,
and its typed offline session still shares player UI, diagnostics, chapters/subtitles, Cinema,
and error surfaces with remote playback. Transport-status presentation is source-aware:
PlaybackTransportPresentationPolicy maps AVPlayer's shared waiting state to local-preparation
wording for .localFile sessions instead of remote buffering language, while stall-watchdog
mechanics remain shared with remote playback.
HDR and Dolby Vision¶
The Stream signal row distinguishes observed AVFoundation signaling from unverified copy/encode intent. A source HDR label, compatible base layer, or server decision alone cannot establish decoded HDR or display light output. See the native HDR investigation for bounded platform evidence.
Display capability is live state, not a property of the media file. The controller observes AVFoundation HDR-eligibility changes for the current item's lifetime (including while paused), refreshes on the diagnostics tick even after stream inspection is conclusive, and re-reads eligibility after asynchronous inspection so an old sample cannot win a display change. On macOS the player-layer host samples its own window's screen, observes screen moves and screen-parameter changes, and removes its subscriptions when detached. Stats shows a separate Display row: AVPlayer eligibility, player-screen capability, and current EDR headroom. Potential EDR above 1 indicates a capable screen; current headroom of 1 alone does not make that screen SDR-only. Missing screen information is shown as unknown, not HDR output proof. The Plex single-variant startup policy uses the known player-screen capability rather than assuming a device-wide positive answer applies to every monitor. If the view has not attached, it falls back to AVFoundation's device-wide eligibility. A later display change updates facts only: it never silently requests video encoding, restarts playback, or promises a dynamic Dolby Vision/HDR10+ output mode. Physical mixed-display/hot-plug acceptance remains required.
Source classification and runtime observation are deliberately separate. PMSKit maps each
backend's available stream metadata into VideoHDRMetadata; PlaybackHDRProbe later reads
AVFoundation tracks and format descriptions after segments load. Stats may describe the
source as HDR10+ only when backend metadata can distinguish it, and must not claim that
AVPlayer rendered HDR10+ dynamic metadata. Bit depth alone is not HDR evidence.
Dolby Vision Profile 5 has no compatible base layer. With experimental DV signalling off
(the default), DolbyVisionPlaybackPolicy forces a tone-map transcode where the backend is
known to handle untagged P5, and blocks Emby rather than accepting a successful-looking but
incorrectly colored encode. A guard-forced transcode has a transport-progress-aware
first-frame deadline and a DV-specific failure surface.
P7 HDR10 initialization normalization remains a default-off, DEBUG macOS-only experiment with explicit source/copy authority and strict playlist/initialization admission. The older automatic Original-quality P7 rewrite is superseded, not an enabled Release path. See the P7 decoder investigation for evidence and remaining hardware gates. AVPlayer's observed transfer bitrate on a loopback path measures local delivery, not upstream network speed; Stats marks it unavailable.
Experimental DV signalling remains default-off and changes two separate decisions when enabled.
First, it defers the fallback-less Profile 5 safety gate so the experimental copy lane can be
attempted. Separately, server capability advertising and HLS master-playlist injection are enabled
only for the exact eligible item, and actual injection remains limited to Profile 8 streams with
a known compatible base layer. Profile 5 never receives SUPPLEMENTAL-CODECS injection. Do not
broaden either policy without device and bitstream verification.
SharePlay on visionOS¶
The visionOS App owns one live app-lifetime WatchTogetherCoordinator and injects it into both
the main window and Custom Cinema. The local player is not coordinated merely because a
GroupSession exists: the participant must resolve and launch the exact local item first. A
surface-owned attachment-maintenance loop then binds that controller's
AVPlayerPlaybackCoordinator to the active session and reattaches whenever the group-session
generation or AVPlayerItem changes.
The window-to-Cinema handoff preserves the same PlaybackController and SharePlay session. The
window presenter disappearing during that handoff does not leave the activity; the Cinema
scaffold takes over attachment maintenance. A genuine player close, Cinema exit, active
browse-session identity change (backend, server, user, or auth session), or invalidated group
session leaves or clears participation. Payload privacy and participant-local resolution are
documented in System integration.
System media ownership¶
visionOS video uses a controller-owned VideoNowPlayingCoordinator backed by a scoped
MPNowPlayingSession(players:). It is created as a player item loads and stays active through
in-controller item replacement. Controller stop, a surfaced playback failure, or EOF without
autoplay tears it down. Metadata is published on each AVPlayerItem, including best-effort
Plex-authenticated or cached offline artwork when those inputs are available, and the session's
commands route play, pause, skip, and absolute seeks back through PlaybackController.
The app-lifetime ArtworkPipeline supplies music/video system Now Playing, ordinary posters,
offline rows, offline player art, and AVPlayerItem external metadata on platforms where that API
is available, from the same exact authenticated/local flight and cost-cache boundary. macOS
publishes Now Playing artwork through VideoNowPlayingCore rather than AVPlayerItem.externalMetadata.
Completed image values cross the immutable
CGImage-backed DecodedImage boundary; original encoded bytes are retained only for
AVMetadataItem artwork, and AppKit/UIKit images are created only at native publication bridges.
Video and visionOS metadata completion additionally requires the exact descriptor, pipeline,
playback generation, and current AVPlayerItem; stale success/failure callbacks cannot overwrite a
replacement item.
The chapter info tab is the deliberate exception: AVKit hosts it in an independent
UIHostingController without the app's injected pipeline or a stable requested-pixel contract, so
RequestBackedChapterImage remains request-backed. BIF and sprite-sheet providers, Emby generated
per-position frames, Emby online/offline chapter fallback, and the player nearest-frame cache remain
provider-scoped time-indexed exceptions rather than ArtworkPipeline consumers. Authenticated
requests use the nonpersistent side-asset transport, and their leaf caches are memory-only and
bounded by both byte cost and entry count. The Chapters panel additionally holds a panel-session-scoped ChapterThumbnailImageCache — a
MainActor cost-bounded LRU (48 images / 48 MB) keyed by opaque side-asset request digest — that
keeps decoded thumbnails warm across lazy card reuse and releases all pixels when the panel closes
or under memory pressure. Sprite sheets and final scrub previews cross a detached,
eager ImageIO decode boundary before entering provider or MainActor cache state. A parsed BIF retains
one backing payload, maps safe offline files, and normal seek lookup copies only the selected frame;
the source-compatible frames accessor materializes all payloads only when explicitly read.
Largest-real-BIF and tile-sheet peak-RSS measurement is not a current release gate; the caches
remain bounded by the policies described above.
Those paths use DecodedImage at their image boundary, but that conversion is not shared-pipeline
migration.
iOS/iPadOS and macOS use a separate process-wide lease model. The mobile platform coordinator wraps
VideoNowPlayingCore alongside PiP/AirPlay behavior, while the Mac player owns the core directly.
The core acquires an identity-guarded video lease on the app-lifetime
SystemMediaSessionCoordinator owned by MusicPlayerController. Video temporarily supersedes
music's Now Playing and remote commands; releasing video restores the most recent surviving music
owner, and stale artwork or teardown cannot clear a newer owner. visionOS video does not use this
lease path. tvOS compiles neither VideoNowPlayingCore nor the visionOS
VideoNowPlayingCoordinator; it does not publish video Now Playing through either path.
Playback explanation¶
Stats for Nerds shows a compact Why line from PlaybackExplanation: one lane headline and
at most two useful reasons, with provenance (backend-reported, app-requested, or inferred).
The normal player must not show raw backend reason arrays, IDs, or URLs. Emby Profile 5 is
blocked rather than forced through a tone-map, so an Emby no-fallback DV open should refuse; it
is not a requested tone-map explanation.
Cinema ownership¶
Cinema is an app-owned visionOS immersive presentation, not AVKit expanded playback. It
hosts the same PlayerLayerView and CustomPlayerChrome in one RealityView attachment. The
attachment is scaled from its measured visualBounds; hard-coding points-to-meters density
can make a present and hit-testable surface effectively invisible.
The visionOS App retains the active controller in CustomCinemaSessionStore. The immersive
space presents that controller; it does not own it. Dropping the controller when the window
dismisses is a regression. CinemaTransitionCoordinator is the sole
presentation-transition owner: its pure reducer generation-fences open, appear, window-detach,
dismiss, and disappear callbacks. Explicit Exit, Crown/system dismissal, EOF, and Up Next all
converge on one exact-once finalizer ordered as SharePlay leave, controller stop, return routing,
main-window open, and retained-session clear. CinemaExitRouting preserves the originating
tab/item, resolved next item, or offline destination without introducing a server fetch for an
offline return. The player window detaches only after both the platform open result and the exact
immersive generation's appearance have succeeded; a failed or missing appearance leaves it in
place. Window disappearance during the accepted handoff is not an exit and preserves the same
controller, AVPlayer, and audio path. Stale scaffold generations cannot bind player callbacks,
maintain SharePlay attachment, tick, or finalize. Do not restore a hidden second player or
reintroduce an AVKit-only control surface.
Restart and cleanup principles¶
sequenceDiagram
accTitle: Backend replacement and cleanup ordering
accDescr: A Plex in-place restart first bounds and awaits the superseded transcode stop, detaches its now-dead item, starts the replacement session, and attaches the new item. Emby detaches the old item and awaits acknowledged prior-session cleanup before negotiating its replacement; unconfirmed cleanup blocks replacement. Jellyfin detaches, replaces, then defers prior cleanup.
participant PC as PlaybackController
participant AV as AVPlayer
participant Backend
participant Server
alt Plex in-place restart
PC->>Server: stop superseded transcode, bounded and awaited
PC->>AV: detach old item
PC->>Backend: decide and start replacement stream
Backend->>Server: universal-transcode requests
PC->>AV: attach replacement item
else Emby reopen
PC->>AV: pause and detach old item
PC->>Backend: await exact prior-session stop
Backend->>Server: bounded active-encoding cleanup
Backend-->>PC: request acknowledgement
alt acknowledged
PC->>Backend: negotiate replacement after older transactions finish
PC->>AV: attach replacement item
else unconfirmed
PC->>PC: retain cleanup authority and surface Retry failure
end
else Jellyfin reopen
PC->>AV: pause and detach old item
PC->>Backend: request replacement at target
Backend->>Server: authenticated PlaybackInfo request
Backend-->>PC: replacement stream and cleanup callback
PC->>AV: attach replacement item
PC->>Backend: schedule deferred prior-session cleanup
Backend->>Server: progress stop and active-encoding cleanup as applicable
end
- Restart player items rather than mutating a stale AVPlayer item in place when the server route changes.
- Relative jumps compose against the outstanding seek target before an accepted live clock. Once that hold clears, live time resumes precedence; an old pending-resume value must not override normal advancement. Generation fencing prevents an older completion clearing a newer hold.
- Preserve each backend lane's replacement order: Plex stops the superseded in-place transcode before replacement. Emby detaches the old item, awaits its exact-session stop acknowledgement, and serializes replacement negotiation through superseded-result cleanup. A failed stop retains retry authority and blocks a new encoder; terminal cleanup has only one bounded retry. Jellyfin continues to attach the replacement before deferring prior active-encoding cleanup. This Emby ordering addresses a reproduced overlapping-session failure; acknowledgement is not proof of encoder-process exit.
- Surfaced failure also ends the current attempt: invalidate callbacks, cancel preparation, detach the player item, and stop its exact backend session without waiting for Close. The shared error surface uses the stable playback codes, not raw server text. Explicit Retry joins pending cleanup before replacing the attempt and preserves trustworthy resume position, quality, approved consent, and user pause intent.
- Terminal stop detaches the player item before issuing server-stop requests so paused HLS
resource loading cannot continue against a stopped session. UI teardown remains non-blocking;
named probes join the controller-owned stop requests before publishing their final report.
Completed requests alone do not prove server workers exited.
MediaBrowser
playback.remote_stop_requested/playback.remote_stop_finisheddiagnostics correlate a hashed play-session ID and report request acknowledgement only. Jellyfin can recreate a job from an already accepted HLS request while its stop endpoint drains the previously snapshotted job; see the bounded cleanup investigation. Do not infer worker absence from acknowledgement or add unbounded cleanup retries. - Keep Plex transcode stop, MediaBrowser progress-stop, and MediaBrowser active-encoding cleanup as distinct operations.
- Treat cleanup failures as non-fatal where the user-visible playback path can continue.
- Keep diagnostic fields shape-level and redacted: no full URLs, tokens, hosts, titles, or filenames.