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 either probes a dedicated Direct Play start URL or uses the production universal-transcode decision and start.m3u8. Both lanes then load one app-owned AVPlayer and retain 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
else Plex Direct Play / Maximum copy
UI->>PC: construct and start with item + session
PC->>Server: direct-play probe
Server-->>PC: copy decision
PC->>AV: dedicated direct-play start.m3u8 at 0
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 playback uses two start paths. Do not collapse them into a single
“decision, then production start.m3u8” recipe.
- Direct Play / Maximum copy. When the selected quality is Direct Play /
Maximum, there is no subtitle burn, and Dolby Vision is not forcing a
transcode, the controller first sends
directPlayProbeRequest(). If PMS will copy video, it commits the dedicateddirectPlayStartM3U8URL(), preflights that playlist, and arms one playback-time fallback to production HLS. That copy-lane start omitsoffset=: the session starts at 0 and the playhead is restored with a client seek. Puttingoffset=on a copy session was observed to emit#EXT-X-START:TIME-OFFSETand then abandon the sole variant. - Production decision /
start.m3u8. Capped quality rungs, Maximum (HLS), subtitle burn, and DV-forced transcodes skip the dedicated probe and use the production universal-transcode decision plusstart.m3u8. Capped transcodes keepoffset=priming so a deep resume does not wait on an unproduced segment.
Quality settings can force a capped transcode; Direct Play / Maximum preserves the user's no-cap/copy intent and is not silently converted to a lower-quality transcode after a stall.
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.
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.
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 Direct Play / Maximum. Jellyfin/Emby use an 8-second head start only for a transcoded stream with a nonzero resume or reopen target; progressive/direct streams and zero-offset remote starts attach without that prewarm. All outcomes are soft and AVPlayer still gets a chance to load.- After that MediaBrowser transcode 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 and progressive/direct streams skip both the prewarm and the proxy. - 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 lane: Jellyfin/Emby remote transcodes
use 45 seconds, other Direct Play / Maximum-selected paths use 90 seconds, and all remaining
paths 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 Direct Play / Maximum choice to a capped transcode. 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.
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.
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¶
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.
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. Jellyfin and Emby first detach the old item, negotiate and attach the replacement, then defer the prior session and active-encoding cleanup so old resource loads cannot race the new playlist.
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 Jellyfin or Emby 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.
- Preserve each backend lane's replacement order: Plex stops the superseded in-place transcode before replacement, while Jellyfin/Emby detach the old item, attach the replacement, and only then schedule deferred prior active-encoding cleanup.
- 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.