AetherEngine

superuser404notfound活跃维护

面向 iOS、tvOS、macOS 和 visionOS 的媒体播放引擎,用 FFmpeg 解封装、VideoToolbox 等硬解,支持直播时移、杜比全景声与杜比视界,UI 由你自己写。

Media player engine for iOS, tvOS, macOS and visionOS. FFmpeg demux, VideoToolbox / dav1d / libavcodec decode, live TV with DVR timeshift and hardware deinterlace, Dolby Atmos passthrough, lossless TrueHD and DTS:X height channels, HDR10 / HDR10+ / Dolby Vision / HLG, text + bitmap + teletext subtitles, and an audio-only path. You ship the UI.

7.62.0发布于 2026-11-09 07:51 UTC409star +14 / 7 天132fork同步于 2026-11-10 06:03 UTC

怎么上手

README 里没有找到可直接复制的安装命令

README 里没有找到可直接复制的安装命令,请到 README 或官方文档查看安装方式。

硬件要求按 README 关键词推断
支持 Apple Silicon
下载:源码 zip·GitHub Releases(安装包)本站不镜像安装包

项目说明

这个项目的说明还在生成中,先参考下面「最近发版」和 README。

最近发版

5 个版本7.62.0
  1. 7.62.07.62.0 - DTS:X IMAX heights, live DVR byte budget, PQ PGS palettes最新正式版2026-11-09 · 2026-11-09 07:51 UTC

    Added

    • DTS:X IMAX tracks keep their heights (#744). The alternate DTS:X presentations (FFmpeg labels some "DTS-HD MA + DTS:X IMAX") carry the same four fixed heights as the standard presentation, after one to five object channels, and were refused and played as their 7.1 bed. FFmpegBuild 3.14.0's x_heights now decodes their height channel set too, while the object channels are never decoded and stay in the bed where the encoder folded them. DTSXObjectAudioDecoder renders them through the same bed regression as standard DTS:X. The profiles fold their heights into the bed at different gains, so the fold comes from the decoder's new x_presentation (the profile, never the stream's metadata): D0 at unity, D1 and D3 at -3 dB. D4, whose fold has not been measured, is still refused and plays as its 7.1 bed. On Bad Boys for Life (D0) every frame of a 60 s excerpt decoded its heights, the bed bit-identical to the plain 7.1 decode; the session log now reads DTS-HD MA track is DTS:X 7.1.4 for it.
    • LoadOptions.liveDVRByteBudget lifts the software live DVR ring past 2 GiB (#1005). The ring that backs timeshift on the software route was capped at 2 GiB whatever dvrWindowSeconds asked for, about 50 minutes of 5.7 Mbps broadcast MPEG-2, so a host playing a recording still in progress as a live source lost its start. An explicit budget now replaces the 2 GiB default; the quarter-of-free-space clamp, the claims of other sessions and the 64 MiB floor still apply, and nil keeps today's sizing.

    Fixed

    • PGS subtitles of an HDR UHD Blu-ray render white instead of grey (#1006). On a PQ source the disc codes the PGS palette in BT.2100 PQ, and the engine published it as sRGB, so a ~294-nit white came out as 62 % grey. On a PQ session each palette entry is now converted once per display set: back to Y'CbCr through the BT.709 matrix pgssubdec applied, to BT.2020 R'G'B', through the PQ EOTF, against the BT.2408 graphics white of 203 nits, to BT.709 primaries and clipped to sRGB. Embedded tracks on both routes and .sup sidecars are covered; HLG, SDR and every other bitmap codec are untouched.

    Dependencies

    • FFmpegBuild 3.14.0 (.upToNextMinor(from: "3.14.0")): the dca decoder's x_heights also decodes the heights of DTS:X's alternate presentations, and x_presentation names the presentation they came from. Same n8.1.3 otherwise.

    New API: LoadOptions.liveDVRByteBudget. Extended CI and the Xcode 26.3 build are green on the release commit.

    Full changelog: https://github.com/superuser404notfound/AetherEngine/compare/7.61.1...7.62.0

  2. 7.61.17.61.1 - Display switch hold after a restart and a real rate switch, DVB cue end rounding2026-11-08 · 2026-11-08 16:07 UTC
    查看更新说明

    Fixed

    • A restart while the previous stop's display switch is still running no longer starts on the old rate (AE#950). A restart within about 2 s of stop() wrote its criteria while the reset's switch back was still in flight, announced no start of its own, and that switch's modeSwitchEnd settled the gate with the panel still at the old rate (50.001 Hz on the report). apply() now records whether a switch was in flight at the write; the gate waits that switch out, takes its end off the record and gives the write's own switch the full 1000 ms start grace.
    • A real refresh-rate switch now holds play 1000 ms after its end (AE#950). tvOS posts modeSwitchEnd before every sink in the HDMI chain has re-locked, so one fresh start in four still flashed noise on an LG C3 after a correctly observed end, with identical engine logs for clean and noisy starts. An engine rate-only write whose panel read another rate before it now waits 1000 ms, measured from the end, before releasing play.
    • A DVB bitmap cue whose start rounds down no longer closes at its own packet (AE#1001). A stored packet is rebuilt on a 1/1000 time base, so a decoded cue start is its PTS rounded to the millisecond while the store keeps the exact PTS. The end alignment searched strictly after the rounded start, so a start rounded down (6.976167 decoded as 6.976) found its own packet as the successor and the set showed for 0.167 ms instead of the 2.193 s up to its clear. The successor lookup now starts one reconstruction tick past the cue start. Verified with aetherctl on the reporter's fixture: cue 0 at 6.98 to 9.17 (was 6.98 to 6.98).

    No API changes. Extended CI and the Xcode 26.3 build are green on the release commit.

    Full changelog: https://github.com/superuser404notfound/AetherEngine/compare/7.61.0...7.61.1

  3. 7.61.07.61.0 - FEL curve fold (DolbyVisionFelCurves), LibDovi 2.2.02026-11-08 · 2026-11-08 13:28 UTC
    查看更新说明

    Added

    • FEL curve fold (DolbyVisionFelCurves trait, off by default). A Profile 7 FEL source on the native route no longer loses its enhancement layer outright in the Profile 8.1 conversion. The conversion now keeps the stream's own mapping (the disc's luma polynomial and MMR chroma, mode 1 then mode 2, as an MEL source already passes), so the platform's licensed Dolby Vision decoder applies it, and the engine folds the enhancement layer's luma residual into the luma curve per scene. FelCurveLookahead holds back one GOP between demuxer and producer (every packet, in read order), decodes both layers at the first frame of each scene (VideoToolbox, from the GOP's keyframe), samples 100 000 positions, fits the mean inverse-quantised residual over base-layer luma by least squares on the stream's own pivots, and adds it to every frame's luma polynomial. A stream that never raises scene_refresh_flag is refitted at every keyframe. The engine never applies a mapping to a pixel and never forms a composed picture.

      Delta E ITP median against the composed FEL picture, whole picture, on three UHD Blu-ray samples: 2.46 to 1.49, 8.90 to 0.81 (a dark title) and 4.83 to 2.10 (a title with no scene marks and a new mapping every frame). Local detail (12-bit refinement, grain) stays out of reach. On macOS AVPlayerLayer shows the stream from aetherctl serve pixel-identical to the folded RPUs injected offline. OptionalComponent.dolbyVisionFelCurves; aetherctl felcurves <url> <out.rpu> runs the fold over a whole file. Design and measurements in Dolby Vision FEL › Curve fold.

    Changed

    • LibDovi 2.2.0 (dovi_rpu_set_polynomial_mapping, one additive C entry point; same dolby_vision 3.4.0).
    • The docs recommend DolbyVisionFelCurves over DualLayerDolbyVision for distributed apps. The composer route evaluates the RPU's MMR chroma prediction and the enhancement layer on every pixel, steps covered by live Dolby patents; the curve fold leaves every evaluation of the stream's mapping to the platform's licensed decoder and keeps everything the native route has (Picture in Picture, AirPlay, scrub stills, subtitles).

    Notes for hosts

    • Enable the fold with traits: ["DolbyVisionFelCurves"] on the package dependency. There is no load option: a build with the trait folds every FEL source on the native route. Without the trait nothing changes.

    Full diff: https://github.com/superuser404notfound/AetherEngine/compare/7.60.0...7.61.0

  4. 7.60.07.60.0 - Play gate holds .loading, isAwaitingDisplaySwitch, stop off the main thread, ad-seam cutter, shaping heap fix2026-11-08 · 2026-11-08 12:27 UTC
    查看更新说明

    Added

    • isAwaitingDisplaySwitch (AE#1000). True while a loaded session holds its start for the display: the post-load play gate is waiting for a mode switch to settle. The first frame can already be up and startupProgress reads .presenting there, so this is the signal that the wait belongs to the panel, and a host that keeps its spinner off a switch during .preparingDisplay can keep it off here too. Cleared when the gate ends and at every teardown.

    Fixed

    • An autostarting native or software load no longer reads as paused while it waits for a display switch (AE#1000). Item readiness settled state to .paused before the post-load play gate, so with Match Content on playbackPhase went loading, paused, loading, playing, and the .paused lasted the whole switch (2.3 s on the report). A host cannot tell that from a viewer's pause, so its pause UI flashed on every start that changed the TV's mode. Readiness now settles .paused only for a paused mount (autoplay = false), the rule loadRemoteHLS already followed, so a load that is going to play stays .loading until it does. The same holds for an engine rebuild that resumes playing. A seek issued during the gate now lands right after the start instead of before it, as it already did on the remote-HLS bypass.
    • Audio shaping no longer corrupts the heap when it builds a downmix matrix. swr_build_matrix2 with a positive rematrix_volume rescales a full 64 x 64 grid at the caller's stride, while the engine hands it a buffer sized for the source and output channels only (12 coefficients for 5.1 to stereo). That read-modify-write ran kilobytes past the buffer and could overwrite another thread's freed block, which showed up as an intermittent memory corruption of free block abort in CI. The matrix is now built with a volume of 0, which skips the rescale and yields the same coefficients. Found with Guard Malloc at strict size.
    • Stopping after a long watch no longer holds the main thread (AE#998). The engine's currentItem observer asked KVO for the new value, and the Swift overlay decodes that value through a runtime cast before the closure runs. Inside the replaceCurrentItem of a stop after a few minutes of playback, that cast missed the conformance cache and scanned every conformance record in the process, 0.4 to 0.6 s on main in the reporter's samples, while a short watch paid a few milliseconds. Every engine KVO observer now reads the observed object instead and requests no decoded old or new value, which removes the cast from the path entirely.
    • Ad seams on HLS live channels, rounds 2 and 3 (AE#996). A PES carrying several AAC frames with a timestamp on the first only taught the timestamp synthesizer a stride sixteen frames wide, so the audio axis ran away from the picture after the audio followed an ad onto its own PID. The stride is now learned per packet. Separately, a seam whose outgoing video PES arrived truncated wedged the live cutter: the replay guard held that packet as a damaged boundary, the clock restart behind it reset the guard, and the producer went on buffering every later packet as part of an overlap that no longer existed, until the no-cut watchdog retuned 10 s later, at the same seam on every retune. The seam now forwards the held packets before the guard starts over.
  5. 7.59.07.59.0 - Bitmap subtitle memory follows the host's offset range, ad-seam audio, E-AC-3 Atmos on the tvOS software host2026-11-06 · 2026-11-06 21:34 UTC
    查看更新说明

    Added

    • maximumSubtitleOffsetSeconds: bitmap subtitle memory follows the host's offset range (AE#929). Decoded PGS/DVB/DVD images were kept for a fixed 60 s behind the playhead and decoded 60 s ahead of it, so a host-side offset of up to a minute always found its cue. The 64 MiB budget never touched those 120 s, and on a dense track they alone ran well past it: on the reporter's Apple TV the PNG-backed history they compare against stayed 111 MB smaller. A host now declares the largest offset it applies, and both edges follow it, never below 15 s and the lead never past 60 s. The default stays 60, so nothing changes until a host sets it; a host without an offset control sets 0. With 0 on a fixture with a 4 MB image every 3 s, the retained images drop from 165 MB to the 64.5 MB budget (malloc 202 to 86 MB), and a seek back still shows its landing cue in the next tick. Text tracks are unaffected. aetherctl play --subtitle-offset-reach sets it and prints the retained bitmap bytes on every tick.

    Fixed

    • A finished MPEG-TS HLS playlist that AVPlayer refuses now plays in software (AE#990). An H.264 archive with five reference frames failed with CoreMediaErrorDomain 1718449215 on iOS and tvOS, and nothing offered the session to libavcodec: the AE#561 escalation excluded the remote-HLS bypass, and the bypass ignored preferredDecodePath = .software. A finite MPEG-TS playlist loaded with .software (directly or through the AE#154 reroute) now plays on the software host off the seekable TS ingest, start position and seeks included, and the AE#561 escalation on a finite bypass is that same rebuild. Other playlists mount AVPlayer once more and surface its failure; a live bypass keeps its own reroutes.
    • PGS subtitles no longer go missing after a backward seek into a stretch a forward restart skipped (AE#941, rounds 2 and 3). The drain held at a hole in the stored packets for at most 20 ticks, which run on wall time and kept counting while a seek or a rebuffer held the playhead still, then decoded across the hole and never came back for the sets the refill harvested (reported: 50 to 68 s lost). The hold now ends only when the far side of the hole comes within 2 s of the playhead. A pump restart that continues its run also carries its coverage up to the seam instead of leaving a hole no run fills, and a side-reader reposition that did not complete no longer anchors coverage. Measured on a 70 Mbit/s M2TS fixture: the set at 67.42 s was lost in 5 of 6 runs before, 0 of 6 after.
    • Ad-stitched live HLS keeps its sound and stops looping at ad seams (AE#996). Two separate faults on free ad-supported channels (MediaTailor, Xumo, Amagi). First, a clock restart behind an EXT-X-DISCONTINUITY was read as a source replay: FFmpeg's 33-bit wrap correction turns a restart at 2 s into 2^33 + 180000, the #405 axis-reset check took that for a restarted origin, and the pump exited for a reopen. One channel reopened six times in 9 s onto the same bumper. On segmented live ingest that shape is now an ordinary seam and goes through the timeline rebase; a continuous transport stream keeps the #405 reading, and a replay after a reconnect is still caught by the exact payload match. Second, some ad servers carry the ad's audio on a PID of its own (programme 0x1e2, ad 0x101, video on one PID throughout). The producer was bound to one audio stream index, so after a seam every packet of the other PID was dropped as foreign and every segment was cut without sound until a later break happened to bring the bound PID back. The producer now follows the audio to a new stream when the bound one has gone quiet across a video seam (or for a full second without one) and the new stream's ADTS frames match the track's AudioSpecificConfig; it logs audio follows the source to stream N. Stream-copy ADTS AAC only; a bridged track or another codec logs not followed once

用户点评

登录用户的真实使用记录,可按版本和硬件筛选
共 0 条

    还没有人点评。第一个说出真实体验的人最有价值。

    README 原文

    未翻译

    What it is

    A player engine that gets the hard parts right (HDR, Dolby Vision, Dolby Atmos, container coverage, codec coverage) and exposes a single AetherPlayerView (UIKit / AppKit) or AetherPlayerSurface (SwiftUI) plus a handful of async methods. No AVPlayerViewController. No opinionated controls. No analytics. Bind the view, call play(), read the published properties for state.

    The view is polymorphic: under the hood the engine swaps the hosted CALayer (AVPlayerLayer for the native AVPlayer path, AVSampleBufferDisplayLayer for the SW dav1d fallback path) per session without the host having to know.

    You provide the transport bar. You provide the dropdowns. You provide the pretty.

    Used by

    • Sodalite: native Jellyfin client for Apple TV.
    • AetherPlayer: native macOS media player.
    • NowSeen: IPTV / Manifest app for tvOS.
    • KSKPix: KSKPix is a premium IPTV player for Live TV, Movies & Series.
    • Syravo: Xtream Codes, Jellyfin and radio client for iPhone, iPad and Apple TV.
    • KIPTV: Premium, cross-platform IPTV Player.
    • Silo: native iOS, tvOS and macOS client for the Silo self-hosted media server.
    • File Box: File Box is a simple and practical local file manager on iPhone and iPad.
    • Moonfin: A multi-platform third party Jellyfin client.
    • Snappier IPTV: IPTV App for iOS/iPadOS and tvOS.
    • stashy: iOS/tvOS native player for stash.
    • Gflix: A flawless media app for your media.
    • Melon Video: Video Player for Vision Pro.
    • WUW IPTV Player: Watch U Want — or simply WUW — your IPTV player for iPhone, iPad, Mac, Apple TV and Apple Vision Pro.
    • GiVo Stream: media player for iPhone, iPad and Apple TV with bring-your-own playlists, live timeshift, DVR and VOD.
    • LillyPlayer: The all-in-one IPTV and media player for Apple TV, iPhone and iPad (live TV, EPG, replay, Jellyfin, Plex, SMB...).
    • Plozz: Free forever open source native app for iPhone, iPad, and Apple TV. Supports Jellyfin, Plex, Emby, Silo, IPTV, SMB, WebDAV and more.
    • Crivid IPTV Player: IPTV player for M3U and Xtream on Apple TV.
    • HipTV: A cross-platform, synchronized IPTV player.
    • ZeoPlayer: Modern media player for iPhone, iPad, Mac, Apple TV and Apple Vision Pro with HDR, Dolby Vision, spatial audio and SMB support.
    • Theamo: IPTV player and TV guide for iPhone, iPad and Apple TV.
    • Lume: SwiftUI native open source IPTV and media player for iOS, tvOS and macOS.

    Shipping something on AetherEngine? Submit it to get listed here and on aetherengine.superuser404.de.

    What it handles

    A scannable summary; the depth for each row lives in docs/formats.md.

    AreaSummary
    ContainersMKV, MP4, WebM, MPEG-TS, AVI, ASF / WMV, OGG, FLV, RealMedia (.rm / .rmvb), 3GP / 3G2
    MPEG-DASHRemote on-demand manifests (static, one Period, SegmentBase, unencrypted): the video and every audio language are laid out as one fragmented MP4, so seeks, audio switching and both playback paths work unchanged. Live and SegmentTemplate manifests are refused with a reason
    DiscDVD-Video and Blu-ray ISO (decrypted): selectable titles and chapters, demuxed through the normal path
    Video (HW)H.264, HEVC, HEVC Main10 via VideoToolbox; AV1 Main profile where HW AV1 exists
    Video (SW)AV1 (dav1d) without HW, and AV1 High / Professional (4:4:4, 4:2:2, 12-bit) everywhere, VP9 (VideoToolbox hardware when supported, otherwise libavcodec) / VP8, MPEG-4 Part 2 / MPEG-2 / VC-1, QuickTime RLE, the Flash tail (Sorenson Spark, On2 VP6), RealVideo 1-4, H.263, Theora and anything else the FFmpeg build carries a decoder for (software is the default route; only HEVC, H.264 and HW-decodable AV1 go native), H.264 High 4:2:2 / 4:4:4 / 10 and HEVC Rext where VideoToolbox has no HW decoder (Intel Macs, older chips), interlaced H.264 (AVPlayer does not deinterlace; on VOD the declared field order is verified against decoded frames, so progressive-in-interlaced-carriage keeps hardware decode); GPU deinterlace (yadif_videotoolbox, Metal, field-rate by default) with a CPU bwdif fallback
    HDRHDR10, HDR10+ (per-frame ST 2094-40), Dolby Vision (P5, P7 on the native route as single-layer 8.1 (HDR10 base layer on a display without Dolby Vision; with the DolbyVisionFelCurves trait a FEL source keeps its mapping and has its enhancement layer folded into the luma curve per scene), or composed dual-layer on Metal with per-frame DV metadata for FEL when preferDualLayerDolbyVision is set, the DualLayerDolbyVision trait is on and VideoToolbox decodes HEVC in hardware, P8.1, P8.4, AV1 P10.x), HLG (declared, or named by an alternative transfer SEI as broadcast HLG is), HDR Vivid (played on its HLG / PQ base, CUVA metadata reported by probe)
    AudioAAC, AC3, EAC3, FLAC, ALAC stream-copy lossless; TrueHD / MLP / DTS / DTS-HD MA / MP3 / MP2 / Opus / Vorbis / LPCM (incl. Blu-ray, G.711) / WMA (Standard, Pro, Lossless, Voice) / Nellymoser / ADPCM-SWF / Speex / RealAudio (Cook, ATRAC3, Sipro, Lossless, 14.4 / 28.8) / AMR-NB / AMR-WB / DVD LPCM bridge to EAC3 5.1 (default) or lossless FLAC
    Dolby AtmosEAC3+JOC stream-copied on every route (HDMI MAT 2.0, AirPods spatial, BT downmix) except the opt-in Profile 7 FEL dual-layer route (preferDualLayerDolbyVision, DualLayerDolbyVision trait), which runs on the software host and plays an E-AC-3 JOC track as its 5.1 / 7.1 bed without the objects unless LoadOptions.objectAudioRendering is set (then the objects are kept: compressed JOC to the renderer, TrueHD and DTS:X object audio as a rendered bed, in a build with the object-audio traits). No container reliably declares JOC pre-decode, so an honest TrackInfo.isAtmos needs a bounded decode: AetherEngine.probeDetectingAtmos(url:/source:) answers for a details screen without starting playback, and LoadOptions.confirmAtmos has the running session confirm its own tracks in the background and republish audioTracks. Both are opt-in and neither sits on the playback-start path. TrueHD Atmos keeps its heights too, opt-in through LoadOptions.objectAudioRendering in a build with the TrueHDObjectAudio trait (Optional components): the engine decodes the Atmos objects, renders them into a speaker bed and delivers Apple Positional Audio, which tvOS sends to an Atmos receiver as Dolby MAT and iOS plays as Spatial Audio (details). DTS:X takes the same route with the DTSXObjectAudio trait, its four heights unfolded from the compatible 7.1 into 7.1.4 or 9.1.6 (details)
    Height channelsTrueHD Atmos and DTS:X built into a speaker bed from the stream's own lossless 7.1, steered by its objects or heights (TrueHD 5.1.2 up to 9.1.6, DTS:X 7.1.4 and 9.1.6), delivered as lossless 24-bit PCM, or APAC when the route plays over AirPlay (objectAudioRendering, optional components)
    Surround5.1 / 7.1 with correct AudioChannelLayout
    Audio-onlyLoadOptions.audioOnly: lean pipeline, no video machinery, system Now-Playing on tvOS / iOS
    Background audioAudio keeps playing when the app backgrounds on iOS: native AVPlayer stays alive, the software path drops video and keeps decoding audio (backgroundPlaybackEnabled / pictureInPictureActive); a paused session survives quick app switches for a grace window (backgroundTeardownGraceSeconds, default 15 s) before the wedge-safe teardown runs. tvOS tears down immediately (wedge-safe) unless a PiP window is active, which keeps the pipeline and loopback server alive; with PiP the software path keeps decoding video too (the window needs frames). Hosts gate corrective actions on the published isSessionReady
    Picture in PictureNative path: hosts build AVPictureInPictureController around currentAVPlayer, or around the session's own nativePlayerLayer when they render through bind(view:) rather than AVKit; while pictureInPictureActive a native->native load hands the AVPlayerItem over in place so the window survives next-episode transitions. Software path: published softwarePiPSource carries the AVSampleBufferDisplayLayer plus transport answers on the enqueued frames' PTS axis for sample-buffer PiP; makePictureInPictureController() builds the controller with a SoftwarePiPPlaybackDelegate over the engine's play, pause, skip and time range. While PiP is active the software path also composites active subtitle cues (text and PGS/DVB bitmaps) into the decoded frames, so the window shows subtitles the host overlay cannot reach. While PiP is active a bound AetherPlayerView shapes the software layer like the picture (aspect-fit, or aspect-fill clipped by the view) so the window mirrors it without offset or crop; a host mounting softwarePiPSource.layer itself sizes it the same way. On macOS the layer is laid out at the size of the system PiP window (found through public AppKit, followed on resize; the picture-shaped layout is kept when no window is found), and the system draws an opaque bar over the top of that window that no public API removes (Apple FB22411168). Ending the session closes the helper controller's window, and a load on the software path ends PiP (no hand-over as on the native path). On the native path, a selected bitmap track's compositions are OCR-recognized into its WebVTT rendition, so PGS subtitles render in the PiP window there as well. iOS renders sample-buffer PiP; tvOS AVKit does not evaluate sample-buffer content sources (Apple FB9751461, verified through tvOS 26)
    SubtitlesText (SRT / ASS / SSA / VTT / mov_text) inline, bitmap (PGS / DVB / DVD, plus local VobSub .idx + .sub sidecars) as CGImage, in-band CEA-608 closed captions (field-1, from an eia_608/c608 demuxable track or extracted from A53 cc_data embedded in the video bitstream: H.264/HEVC SEI on the native path, decoded-frame side data such as MPEG-2 on the software path, with the caption track surfacing lazily on first real caption data), DVB teletext decoded to text cues with broadcaster colour preserved and a selectable caption page (libzvbi, LoadOptions.teletextPage at load, setTeletextPage(_:) while the channel plays), external files as first-class tracks (registered, listed, selected like embedded streams), a live channel's own HLS SUBTITLES renditions surfaced as tracks and fetched only once one is selected, opt-in raw ASS markup + fonts; embedded-text cues harvested from the producer's own read (instant enable, no side-channel bandwidth); opt-in native WebVTT renditions (one per text track incl. load-declared external files, language-tagged) so subtitles survive PiP / AirPlay / external display (LoadOptions.prepareNativeSubtitles); bitmap tracks (PGS / DVB / DVD, embedded and external .sup) join as OCR-fed renditions: on-device Vision text recognition runs while the track is selected and fills a language-tagged text rendition, so bitmap subtitles survive PiP / AirPlay / external display on the native path too (lossy by design; fullscreen keeps the pixel-accurate overlay)
    FramesOff-playback FrameExtractor: thumbnail (scrub preview) + snapshot (frame-accurate)
    Audio tapOpt-in installAudioTap(): decoded playback audio as mono Float32 48 kHz PCM with source-PTS timestamps, off the render path (live transcription, ShazamKit); delivers on the loopback, remote-HLS (VOD + live), and software paths. The stream is bound to its session, so a session-preserving reload (audio / subtitle / disc-title switch, reloadAtCurrentPosition) finishes it and the host re-installs on stream end
    MetadataMediaMetadata (title / artist / album + cover) parsed on load; a container's album artist folds into artist as a fallback
    SeekVOD seeks into watched content are restart-free cache hits (byte-budgeted retention, 2 GiB cap); short forward scrubs ride the cached window; only never-produced targets restart the producer
    StreamingOne long-lived forward-streaming connection, reconnect-on-drop; CDN-stutter resilient; optional caller-bounded open-time probe budget (LoadOptions.probesize / maxAnalyzeDuration) to cut first-frame latency on sparse remote remuxes; configurable forward-buffer window (LoadOptions.forwardBufferSegments), from the 40 s default up to an opt-in whole-source pre-buffer that is bounded in bytes by the session's disk budget rather than in segments
    PrewarmAetherEngine.prewarm(url:httpHeaders:byteBudget:) fetches a source's opening bytes before anything asks to play it, for a host whose UI knows what is next. The following load() of that URL serves its parse reads out of RAM, takes the size with the bytes instead of probing for it, and opens its data connection at the warm frontier rather than at byte zero, so nothing waits on a first byte. Static and off the main actor: no engine instance, no audio session, no layer. It never queues for the origin, the bytes live in memory only until they are adopted and are dropped under memory pressure, and the headers are part of the key
    Live / DVRUnbounded live + optional timeshift; direct HLS ingest with AES-128 clear-key and SSAI ad-pod handling
    Custom inputPlay any byte source via the IOReader protocol (load(source:))
    NetworkSMB2/3 shares via the optional AetherEngineSMB product (NTLMv2 / guest, read-only)
    Certificate trustA media server behind a self-signed or private-CA certificate plays once the host answers for it: EngineTLS.serverTrustEvaluator is asked per challenge about the origin the challenge came from, so a LAN address behind a private certificate and a WAN address with a real one are decided separately. Covers every session the engine owns, and the native remote-HLS route too, where AVPlayer asks no delegate of its own: there the engine stands a loopback relay in front of the origin so the handshake runs where the evaluator is asked. The relay is mounted only for an origin the system actually refuses, and media is relayed as it arrives rather than read whole

    How it compares

    On Apple platforms the real choice is between AVPlayer, with deep OS integration but only the formats Apple ships, and a VLC- or mpv-derived engine, which plays almost anything but renders its own frames and bypasses the system's Dolby Vision, Atmos, and HDR handling. AetherEngine is built to give you both: FFmpeg's format breadth layered on top of VideoToolbox and AVPlayer, so Dolby Vision, Atmos, and Match Content keep working. KSPlayer is the closest analog, it reaches the same outcome through the same AVPlayer route, but it ships as a full player with its own UI, and its free build is GPL while a paid LGPL tier unlocks AV1 hardware decoding, the full demuxer and decoder set, and a list of playback features its own feature matrix enumerates; AetherEngine is an embeddable engine you drive from your own SwiftUI, with that codec and HDR breadth in the open-source core.

    AetherEngineKSPlayerAVPlayerVLCKitlibmpv
    ApproachEmbeddable engine, Apple-onlyFull player with bundled UI, FFmpeg + AVPlayer, Apple-onlyApple's built-in playerlibVLC wrapped for Applelibmpv, cross-platform
    Container & codec breadthWide, FFmpeg demuxWide, FFmpeg demuxNarrow, Apple's setWideWide
    Hardware decodeVideoToolbox, dav1d SW fallbackVideoToolbox, FFmpeg SW fallbackVideoToolboxVideoToolbox plus softwareVideoToolbox plus software
    Dolby VisionP5, P7 as 8.1 (or opt-in FEL dual-layer), P8.1, P8.4, AV1 P10.x, real display switchP5, P8 via AVPlayer; their matrix lists P5 HDR display without overheating as paid LGPLP5 and P8.1 onlyTone-maps, no DV displayTone-maps, no DV display
    Dolby AtmosEAC3+JOC stream-copied (HDMI MAT, spatial)EAC3+JOC via AVPlayerEAC3+JOC passthroughDecodes to PCM, no object passthroughNo Atmos passthrough on Apple
    TrueHD / DTS:X heightsSpeaker bed up to 9.1.6 built from the stream's lossless 7.1, steered by its objects or heights, delivered as lossless 24-bit PCM, APAC on AirPlay (optional components)7.1 core (FFmpeg decode)Not played7.1 core7.1 core
    HDR on tvOSNative Match Content switchNative Match Content on AVPlayer path, else Metal tone-mapNative Match ContentSoftware tone-mappingSoftware tone-mapping
    Rendering & UIOS-native, you ship SwiftUIOwn Metal renderer, bundled controlsOS-native, you ship UIOwn renderer, bundled controlsOwn renderer, bundled OSC
    Apple TV / App StoreYes, LGPL plus store exceptionFree build GPL, paid LGPL tier for AV1 hardware decode and the full decoder setYesYes, LGPLNot practical, GPL, no tvOS

    The engine leans on the platform where the platform is best (hardware decode, Dolby Vision display, Atmos passthrough) and only falls back to its own software path (dav1d, libavcodec) for the formats VideoToolbox cannot handle.

    Measured

    The table above is qualitative. The numbers below are measured on 4K HDR HEVC in Matroska, the container media servers actually serve, at 38 Mbit/s and at 90 Mbit/s, the rate of a UHD remux. Every engine plays each file twice: from local disk, and over HTTP from a local range origin with a fixed latency and a shared link rate, because media servers deliver over HTTP and an engine's network reader is not its file reader. They are produced by aetherengine-bench, which is run by this project's author, so its method, its raw data and the cases where AetherEngine does not win are all in that repository. AVPlayer does not open Matroska at all; on MP4, the one container it plays, it uses less than every engine here, see that repository's README.

    Measured on Apple-M1 (MacBookAir10,1), macOS 27.0, hevc-4k-hdr10.mkv (38 Mbit/s), hevc-4k-hdr10-90m.mkv (90 Mbit/s), each from local disk and over HTTP from a local range origin at 1000 Mbit/s shared, 20 ms latency per request, windowed, 3840x2160 px rendered, 60 s, median of 2. MacBookAir10,1 is fanless: sustained decode can reach thermal pressure, which is why the protocol has cooldowns between runs and discards throttled windows.

    hevc-4k-hdr10.mkv (38 Mbit/s)

    SourceGPU powerCPU loadRSSPeak footprintPlays
    AetherEnginedisk41 mW4.4% of a core342 MB323 MBYes
    HTTP40 mW5.4% of a core336 MB350 MBYes
    KSPlayerdisk43 mW5.8% of a core333 MB772 MBYes
    HTTP41 mW6.4% of a core357 MB496 MBYes
    AVPlayerdiskn/a (refuses hevc-4k-hdr10.mkv: This media format is not supported.)
    HTTPn/a (refuses hevc-4k-hdr10.mkv: This media format is not supported.)
    VLCKitdisk266 mW15.0% of a core111 MB281 MBYes
    HTTP244 mW17.0% of a core122 MB300 MBYes
    libmpvdisk887 mW20.7% of a core475 MB933 MBYes
    HTTP892 mW21.5% of a core629 MB1191 MBYes

    Launch failures on hevc-4k-hdr10.mkv (crashes that were retried, not refusals, see the README): KSPlayer 1 (HTTP).

    hevc-4k-hdr10-90m.mkv (90 Mbit/s)

    SourceGPU powerCPU loadRSSPeak footprintPlays
    AetherEnginedisk40 mW6.6% of a core577 MB681 MBYes
    HTTP40 mW9.3% of a core557 MB700 MBYes
    KSPlayerdisk41 mW6.7% of a core589 MB1040 MBYes
    HTTP41 mW7.6% of a core578 MB703 MBYes
    AVPlayerdiskn/a (refuses hevc-4k-hdr10-90m.mkv: This media format is not supported.)
    HTTPn/a (refuses hevc-4k-hdr10-90m.mkv: This media format is not supported.)
    VLCKitdisk235 mW16.2% of a core117 MB314 MBYes
    HTTP235 mW18.4% of a core135 MB325 MBYes
    libmpvdisk953 mW21.5% of a core501 MB980 MBYes
    HTTP962 mW22.3% of a core684 MB1188 MBYes

    Launch failures on hevc-4k-hdr10-90m.mkv (crashes that were retried, not refusals, see the README): KSPlayer 4 (disk).

    Frame delivery and output (median; resolution, bit depth and HDR transfer are informational per engine only, shown as 'varies across repeats' when they disagree, see the README): hevc-4k-hdr10.mkv

    • AetherEngine, disk: 1438/1438 delivered/expected, dropped 0, 3840x1714, 10-bit, hdr10, rendered into 3840x2160.
    • AetherEngine, HTTP: 1441/1438 delivered/expected, dropped 0, 3840x1714, 10-bit, hdr10, rendered into 3840x2160.
    • KSPlayer, disk: 1440/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, SMPTE_ST_2084_PQ, rendered into 3840x2160. Served via KSMEPlayer.
    • KSPlayer, HTTP: 1440/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, SMPTE_ST_2084_PQ, rendered into 3840x2160. Served via KSMEPlayer.
    • VLCKit, disk: 1451/1440 delivered/expected, dropped 0, 3840x1714, bit depth and color transfer not reported by this engine, rendered into 3840x2160.
    • VLCKit, HTTP: 1454/1440 delivered/expected, dropped 0, 3840x1714, bit depth and color transfer not reported by this engine, rendered into 3840x2160.
    • libmpv, disk: 1443/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, pq, rendered into 3840x2160.
    • libmpv, HTTP: 1443/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, pq, rendered into 3840x2160. hevc-4k-hdr10-90m.mkv
    • AetherEngine, disk: 1436/1438 delivered/expected, dropped 0, 3840x1714, 10-bit, hdr10, rendered into 3840x2160.
    • AetherEngine, HTTP: 1439/1438 delivered/expected, dropped 0, 3840x1714, 10-bit, hdr10, rendered into 3840x2160.
    • KSPlayer, disk: 1440/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, SMPTE_ST_2084_PQ, rendered into 3840x2160. Served via KSMEPlayer.
    • KSPlayer, HTTP: 1440/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, SMPTE_ST_2084_PQ, rendered into 3840x2160. Served via KSMEPlayer.
    • VLCKit, disk: 1454/1440 delivered/expected, dropped 0, 3840x1714, bit depth and color transfer not reported by this engine, rendered into 3840x2160.
    • VLCKit, HTTP: 1452/1440 delivered/expected, dropped 0, 3840x1714, bit depth and color transfer not reported by this engine, rendered into 3840x2160.
    • libmpv, disk: 1442/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, pq, rendered into 3840x2160.
    • libmpv, HTTP: 1443/1440 delivered/expected, dropped 0, 3840x1714, 10-bit, pq, rendered into 3840x2160.

    Versions: AetherEngine 7.15.2, KSPlayer 2.3.4, AVPlayer macOS 27.0, VLCKit 4.0.0-alpha.21, libmpv mpv v0.41.0.

    Power figures are package power with an idle baseline subtracted, so they are attributable to the run and not to the machine. CPU package power was measured for every run but is not published above: the idle baseline drifts with load enough that one engine's own repeats can disagree more than the column would be used to show between engines. For example, on hevc-4k-hdr10.mkv KSPlayer measured 76 to 267 mW of CPU package power across its own repeats, a 3.5x range. Recorded for every run in Results/; see "Known gaps" in the README for the full reasoning. libmpv is measured with --hwdec=auto-safe (hardware decode), not mpv's own software-decode default, see "Fairness decisions" in the README. Peak footprint is the kernel's lifetime maximum of the player process's physical footprint (what the system's memory limit acts on), load phase included; RSS is the mean over the measured window. Decoding that happens in a system service (VideoToolbox) is in neither. The hevc-4k-hdr10-90m.mkv rows come from a second run of the same protocol, build and origin on the same day and machine. A tvOS Simulator had been left running in the background for the first half of the main run, which raised idle package power to 188 to 214 mW (19 to 26 mW once it was shut down). Both hevc-4k-hdr10-90m.mkv baselines failed validation under it, so that fixture was measured again. The hevc-4k-hdr10.mkv baselines were taken under it too and passed; the GPU idle baseline stayed under 0.05 mW throughout, and CPU load, RSS and footprint are not baseline-subtracted, so none of the published columns moved with it. Method and raw results: https://github.com/superuser404notfound/aetherengine-bench

    Quick start

    import AetherEngine
    import SwiftUI
    
    let player = try AetherEngine()
    
    // SwiftUI: drop AetherPlayerSurface anywhere in the view tree
    var body: some View { AetherPlayerSurface(engine: player) }
    
    // UIKit / AppKit: bind an AetherPlayerView directly
    let surface = AetherPlayerView()
    player.bind(view: surface)
    
    try await player.load(url: videoURL)                            // or with a resume position
    try await player.load(url: videoURL, startPosition: 347.5)
    try await player.load(url: videoURL, options: .init(
        httpHeaders: headers,              // attached to every demux + segment fetch
        matchContentEnabled: matchContent  // tvOS Match Content master toggle
    ))
    try await player.reloadAtCurrentPosition()                      // background reopen, preserves options + transport
    try await player.load(url: trackURL, options: .init(audioOnly: true))   // lean audio path
    
    // Transport
    player.play()
    player.pause()
    player.togglePlayPause()
    player.setRate(1.5)                    // clamped to player.maxSupportedRate (2x video, 3x audio-only)
    await player.seek(to: 120)
    player.stop()
    
    // State (Combine @Published)
    player.$state          // .idle, .loading, .playing, .paused, .seeking, .ended, .error(String)
                           // .ended = played to completion (any backend); .idle = pre-load / stopped.
                           // .ended is TERMINAL: seek is rejected and play() does not revive it, so a
                           // replay is another load(). A VOD merely parked at its final frame is the
                           // other case and play() rewinds it. How every other failure arrives, and
                           // why a CancellationError out of load() is not one, is in docs/api.md.
    player.$errorInfo      // PlaybackErrorInfo?, the machine-readable half of .error: a stable
                           // PlaybackErrorKind plus the underlying NSError domain and code. Classify on
                           // this. The message cannot: on the native paths it is AVPlayerItem.error
                           // .localizedDescription forwarded verbatim, so it is in the device's
                           // language, and a substring rule over it buckets every non-English device as
                           // unknown. Set before .error is published, cleared when the state leaves it.
    player.$duration
    player.$videoFormat    // .sdr, .hdr10, .hdr10Plus, .dolbyVision, .hlg
    player.$isSeeking      // true until a seek physically lands (programmatic + native scrubs, and
                           // seeks stashed before the session can take them)
    player.$seekTarget     // in-flight seek destination (source-PTS), nil otherwise
    player.seekEvents      // AnyPublisher<SeekEvent, Never>: .began / .landed(renderedTime:) /
                           // .stalled / .superseded / .rejected, each with the target it belongs to.
                           // Use this where the FALLING edge of $isSeeking matters: the level cannot
                           // say whether a seek landed, gave up, or was superseded, and a seek that
                           // gave up can still land minutes later on a stalled source.
    player.$playbackPhase  // unified: .idle/.loading/.playing/.paused/.seeking/.rebuffering/
                           // .stalled(reconnecting:)/.ended/.error. One source of truth for a status
                           // spinner; derived from state + isBuffering + isSeeking + source reconnect.
                           // Prefer this over stitching the raw signals or matching EngineLog text.
    player.$videoRoute     // pipeline actually serving the session: .remoteBypass (AVPlayer on the
                           // origin URL) / .loopback / .software / .audio / .none. LoadOptions
                           // .nativeRemoteHLS is only the request: the carriage watchdog, the
                           // remembered verdict and the HLS reroutes move a session between the
                           // bypass and the loopback, mid-session too. Branch on this where behaviour
                           // differs per pipeline, above all who draws subtitles: on .remoteBypass
                           // AVPlayer renders the origin's renditions, elsewhere the host renders.
    player.$audioDelivery  // how the audio reaches the renderer: .streamCopy / .bridged / .decoded /
                           // .noAudioInSource / .playerManaged (AVFoundation owns it) / .none, and
                           // .droppedNoPipeline: the source HAS audio and none of it could be
                           // delivered (no decoder in this build, or the bridge could not be built),
                           // so the session plays video-only and silently. That is the value a
                           // fallback ladder demotes on, the same way it demotes on the
                           // PlaybackErrorKind.audioBridgeProducedNoOutput at the other end of the
                           // cascade. Classify on this; $activeAudioDecoder is the label for a human
                           // and cannot separate a source without audio from one that lost it.
    player.$hasFirstFrameReadyForDisplay
                           // the running path has a first frame ready for display, for the media THIS
                           // load opened: the edge a black cover comes off on. readyToPlay is not that
                           // edge (AVFoundation reaches it before the layer holds a picture, and it
                           // stays true across a seek), so a cover lifted on isSessionReady lifts onto
                           // black. Latched for the load, false again at the next load() / stop().
                           // For "has this seek reached the screen" use seekEvents .landed instead.
    player.$startupProgress // StartupProgress?, for a determinate loading bar: .completed of .total
                           // checkpoints, .fraction, and .stage naming the work in flight. Every value
                           // is work some part of the load actually finished, never a timer and never an
                           // estimate, so a slow stretch holds and a skipped one jumps. It covers the
                           // two stretches a host cannot otherwise see: the source open (connection,
                           // container, stream analysis) and the display-criteria handshake. Scoped to
                           // .generation, which counts the startups a user waited through rather than
                           // teardowns, so an engine-initiated reroute continues the bar instead of
                           // resetting it. Monotonic and deduped; nil before the first load and after
                           // stop(); a load that fails simply never reaches the last checkpoint.
    player.$currentAVPlayer // active AVPlayer, re-emitted on every reload (MPNowPlayingSession).
                           // nil on the .software route: that pipeline renders into its own display
                           // layer, and the property is cleared so AVKit cannot hold a player nothing
                           // feeds any more. A host that only ever hands currentAVPlayer to an
                           // AVPlayerViewController therefore gets audio and AVKit's own spinner over an
                           // empty video plane on that route (#298). Bind a surface for it, and pick the
                           // presentation off $videoRoute rather than off the source's codec.
    
    // System Now-Playing on the native video path (tvOS / iOS). Off by default: an
    // AVPlayerViewController host already gets this from AVKit and must NOT opt in.
    // Custom-transport hosts set it before load(), then register commands on the
    // session and stage identity through setVideoNowPlayingInfo.
    player.ownsVideoNowPlayingSession = true
    player.videoNowPlayingSession                  // MPNowPlayingSession?, nil unless opted in
    player.setVideoNowPlayingInfo([ /* MPMediaItemProperty… */ ])
    
    // Time lives on player.clock, a SEPARATE ObservableObject, so the ~10 Hz
    // ticks never fire objectWillChange on the engine (track lists / state views
    // don't re-render per tick; native tvOS Menu dropdowns stay stable).
    player.clock.$currentTime      // ~10 Hz playback clock (transport / scrub / resume)
    player.clock.$sourceTime       // source PTS of the displayed frame (render subtitles against this)
    player.clock.$bufferedPosition // source-axis position buffered ahead; draw a buffer bar as bufferedPosition / duration
    
    // Tracks
    player.audioTracks                             // [TrackInfo]
    player.selectAudioTrack(index: trackID)
    player.subtitleTracks                          // [TrackInfo], text + bitmap + external, one list
    player.selectSubtitleTrack(index: streamID)
    player.clearSubtitle()
    player.$subtitleCues                           // [SubtitleCue]: .text(String), .richText([SubtitleTextRun]), or .image(SubtitleImage)
    
    // #233: styled text arrives as .richText. A run carries colour, bold, italic, underline,
    // strikeout, font face and an ASS-relative font size; the cue carries the placement it asks
    // for (numpad alignment plus an optional [0, 1] anchor). This covers SRT, WebVTT, teletext
    // and ASS alike, because libavcodec converts them all to ASS event lines before the engine
    // sees them. A cue with no styling still arrives as .text, so handling only that case keeps
    // working. WebVTT cue settings are the exception: libavcodec does not convert them, so VTT
    // positioning does not arrive (its inline bold/italic/underline does).
    for cue in player.subtitleCues {
        if case .richText(let runs) = cue.body { render(runs, at: cue.placement) }
    }
    
    // External subtitle files are first-class tracks (#88): they appear in subtitleTracks
    // (isExternal == true, synthetic id) and select through the same call as embedded streams.
    let track = player.addExternalSubtitleTrack(
        ExternalSubtitleTrack(url: srtURL, name: "English", language: "en"))
    player.selectSubtitleTrack(index: track.id)
    // Declared at load instead, external tracks also join the native WebVTT renditions (PiP):
    // LoadOptions(prepareNativeSubtitles: true, externalSubtitles: [ExternalSubtitleTrack(url: srtURL, language: "en")])
    // When the URL is a container holding several subtitle streams, register one track per stream
    // with its absolute AVStream index; tracks sharing a URL are decoded in one pass (#266).
    ExternalSubtitleTrack(url: mkvURL, name: "Spanish", language: "es", sourceStreamIndex: 3)
    
    // Native WebVTT subtitle renditions (subtitles in PiP / AirPlay / external display; opt-in
    // via LoadOptions.prepareNativeSubtitles, details in docs/formats.md)
    player.$nativeSubtitleTracks                   // [NativeSubtitleTrack]: ordinal, language, displayName
    player.setNativeSubtitleSelected(track: 2)     // engage a rendition (e.g. on PiP entry); nil deselects
    
    // The SYSTEM turned captions on by itself: iOS 26 Settings > Accessibility > Subtitles & Captioning
    // > Automatic Subtitles (show when muted, on skip back, on a language mismatch). Those toggles have
    // no read API, so the selection is the only way to see the ask. The engine deselects the option (a
    // rendition rendered in fullscreen draws a caption box over the host's own subtitles) and forwards
    // the request; a host that wants the behaviour picks its own matching track. Subscribe per session.
    player.systemCaptionRequest                    // PassthroughSubject<SystemCaptionRequest, Never>
    
    // Second simultaneous subtitle track (bilingual / language learning)
    player.selectSecondarySubtitleTrack(index: streamID)
    player.selectSecondarySidecarSubtitle(url: srt2URL)
    player.clearSecondarySubtitle()
    player.$secondarySubtitleCues                  // [SubtitleCue] for the secondary track
    player.$isSecondarySubtitleActive             // Bool
    
    // Disc titles + chapters (DVD-Video / Blu-ray ISO; empty for non-disc sources)
    player.$discTitles                             // [TitleInfo]: id, name, durationSeconds, chapterCount (longest first, id 0 is the main feature)
    player.$selectedDiscTitle                      // TitleInfo?
    player.selectTitle(id: titleID)                // switch title (rebuilds from the new title's head)
    player.$discChapters                           // [ChapterInfo] for the selected title
    player.selectChapter(id: chapterID)            // seek to a chapter (disc chapters only)
    
    // Container chapters (Matroska / MP4; empty for disc sources, which publish discChapters instead)
    player.$mediaChapters                          // [ChapterInfo]; startSeconds are seek(to:) values, 0-based like currentTime
    
    // Info panel / Now Playing (iOS / tvOS)
    player.setExternalMetadata([ AVMetadataItem(/* title, artwork, etc. */) ])
    
    // Frame-accurate host rendering on the native path (#260). Cue times, chapters and sourceTime live on
    // the SOURCE axis; AVPlayerItem.currentTime() and its timebase read the ITEM axis. They differ by the
    // producer shift, which steps at every producer epoch (a seek restart, a live program boundary).
    player.presentationAxisMap                        // conversion both ways, readable off the main actor
    player.presentationAxisMap.itemSeconds(forSourceSeconds: cue.startTime)   // stamp an overlay sample
    player.presentationAxisMap.sourceSeconds(forItemSeconds: item.currentTime().seconds)
    player.$currentAVPlayerItem                       // items swap in place; this is the signal for it
    
    // Per muxed video frame, on both axes at once. Called on the producer's pump thread in DECODE order,
    // so `source` is not monotonic under B-frames; sort before using it as a frame-boundary list.
    // `epoch` rises strictly, process-wide: a restart and a load() both continue the sequence, so
    // "a higher epoch retires my older entries" separates one item's frames from the next's.
    player.setNativeVideoFrameTimeObserver { frame in
        frame.source; frame.item; frame.segmentIndex; frame.isKeyframe; frame.epoch
    }
    
    // The same question on the software path (#311), where the engine decodes and enqueues the source
    // timestamp unchanged: one axis, no segments, and the reports arrive past the reorder buffer in
    // ASCENDING presentation order. `generation` moves on every renderer flush, i.e. on a seek, and
    // rises across a load() the same way `epoch` does.
    player.softwarePresentationTimebase               // the master clock, on the source axis
    player.setSoftwareVideoFrameTimeObserver { frame in
        frame.presentation; frame.generation
    }
    
    // The rectangle those frames land in (#353): coded dimensions under the pixel aspect ratio the
    // decoder attached, read off the format description the renderer enqueues. `sourceVideoWidth` and
    // `sourceVideoHeight` are the CODED size, so anamorphic content laid out against them is off by the
    // pixel aspect (720x576 at 64:45 presents as 1024x576). nil off the software path and before the
    // first frame; it follows a mid-stream format change and is cleared with the session.
    player.softwareDisplaySize                        // CGSize?, @Published
    

    Subtitle cues land in raw source PTS; render the overlay against player.sourceTime (see docs/formats.md › Subtitles). A host compositing its own overlay onto the native path (libass and friends) needs the item axis too, since that is what the compositor pairs its samples against: presentationAxisMap converts arbitrary positions, setNativeVideoFrameTimeObserver reports the frames themselves. On the software path neither is needed: softwarePresentationTimebase hands out the clock the frames are presented against and setSoftwareVideoFrameTimeObserver reports them, both on the same axis as the cues, and softwareDisplaySize gives the rectangle to lay the overlay out in (the native path measures its own on AVPlayerLayer.videoRect). Both return nothing rather than a guess when no axis is established, because a defaulted shift is indistinguishable from a measured one at the call site. The 1 Hz diagnostics snapshot lives on player.diagnostics.liveTelemetry, off-the-engine for the same render-stability reason. Frame extraction and authored-ASS styling are documented in docs/formats.md; the full published surface, including the contracts that require the host to act rather than to read, is docs/api.md.

    Install via Swift Package Manager:

    .package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.61.1")
    

    Three samples ship in Examples/:

    • MinimalPlayer/: a single-file SwiftUI drop-in, transport bar included. Copy it into a new tvOS / iOS / macOS app, point at a URL, run.
    • LiveHost/: the live-TV half, the four contracts a channel needs that no compiler asks about (the liveSourceReset retune and its guard, CancellationError out of a superseded load, the audio tap that ends with its session).
    • DemoPlayerMac/: a standalone macOS app for testers. Drop a file on the window, it plays. A notarized universal .dmg is attached to every GitHub Release.

    Custom input source

    final class MyArchiveReader: IOReader {
        func read(_ buffer: UnsafeMutablePointer<UInt8>?, size: Int32) -> Int32 { /* ... */ }
        func seek(offset: Int64, whence: Int32) -> Int64 { /* ... */ }  // AVSEEK_SIZE (65536) returns total size
        func close() { /* ... */ }
    
        // Optional requirements have defaults. Override to unlock extra features:
        func cancel() { /* unblock a blocked read at teardown, do NOT invalidate the reader */ }
        func makeIndependentReader() -> IOReader? { /* a fresh cursor over the same source, or nil */ }
    
        // Optional, defaults to true. Return false when this is known to be an
        // ordinary media file rather than a raw ISO/UDF disc image.
        var discImageProbeEnabled: Bool { false }
    }
    
    let probe = try await engine.load(source: .custom(MyArchiveReader(), formatHint: "mp4"))
    // load() returns the probe metadata it gathered (discardable). A one-shot
    // AetherEngine.probe(source:) without starting playback works too.
    

    Seekable readers support audio-track switching and background reload; embedded subtitles and scrub-preview thumbnails additionally need makeIndependentReader() (a second cursor). Forward-only readers support plain playback + seeking (VOD on the software path; live sessions stay native). On the native path a custom reader's bytes are re-muxed to cleartext fMP4 on the loopback cache, fine for encrypted-at-rest archives, a cleartext exposure for content-protected sources. Full contract in docs/formats.md.

    SMB shares (optional AetherEngineSMB product)

    Playing media off an SMB2/3 share is a ready-made IOReader, shipped as a separate product so the SMB dependency (SMBClient, MIT, pure Swift, NWConnection-based) only enters consumers that opt in. Add the AetherEngineSMB product alongside AetherEngine; hosts that do not need SMB link only the core. The transport is pure Swift over Network.framework rather than libsmb2, which fails with EPERM on tvOS/iOS.

    import AetherEngineSMB
    
    let smb = try await SMBConnection.connect(
        server: URL(string: "smb://nas.local")!, share: "media",
        path: "Movies/film.mkv", user: "alice", password: "s3cret"
    )
    try await engine.load(source: .custom(
        SMBIOReader(source: smb),
        formatHint: "matroska"
    ))
    

    When the SMB path is known to be an ordinary media file, construct the reader with discImageProbeEnabled: false to skip ISO/UDF signature reads. Keep the default for raw disc images so DVD/Blu-ray recognition remains available.

    Read-only, NTLMv2 / guest auth (no Kerberos). On tvOS the host must declare NSLocalNetworkUsageDescription + the local-network entitlement to reach a LAN share. See aetherctl smbtest to validate a share from macOS.

    Known limitation: SMBClient negotiates only SMB 2.0.2 and 2.1, so there is no SMB3 transport encryption or AES-CMAC signing. Servers configured SMB3-only or with smb encrypt = required won't connect (libsmb2 spoke 3.1.1 here, but was itself unusable on tvOS/iOS, see above).

    Live TV / DVR

    // Live-only (seek() is a no-op), or live + timeshift:
    try await player.load(url: streamURL, options: LoadOptions(isLive: true))
    try await player.load(url: streamURL, options: LoadOptions(isLive: true, dvrWindowSeconds: 1800))
    
    // Lean-back live that may wait for the full 3 x TARGETDURATION holdback before the first picture
    // (the default .fastZap join tracks the source keyframe cadence and bounds that wait):
    try await player.load(url: streamURL, options: LoadOptions(isLive: true, liveJoinProfile: .standard))
    
    // Drive a scrubber from the live-edge fields (they tick, so they live on player.clock):
    player.clock.$seekableLiveRange   // ClosedRange<Double>?, session-relative; nil when DVR off
    player.clock.$behindLiveSeconds   // seconds behind the edge; 0 at the edge
    player.clock.$liveEdgeTime
    await player.seekToLiveEdge()
    await player.seek(to: player.liveEdgeTime - 300)   // 5 minutes back
    
    // The retune contract: the engine parked the session and only a fresh URL revives it.
    player.liveSourceReset            // PassthroughSubject<Void, Never>; subscribe per session
    
    // Ingest a live HLS upstream directly, no media server in the data path:
    try await player.load(
        source: .custom(HLSLiveIngestReader(playlistURL: upstreamM3U8), formatHint: "mpegts"),
        options: LoadOptions(isLive: true, dvrWindowSeconds: 600)
    )
    
    // Record the channel WITHOUT opening a second connection to the origin:
    try await player.startRecording(to: fileURL)
    await player.stopRecording()
    player.$recordingState            // .idle / .recording / .ended / .failed
    

    startRecording(to:) (AetherEngine#560) writes the live source to an MPEG-TS file fed from the connection the session already holds. That is the point of it rather than a detail: an IPTV plan commonly caps an account at 1 to 3 simultaneous connections, so a host that opens its own connection to record either fails outright or knocks the viewer off the channel. It is a stream copy of the source packets taken before any audio bridging, so a TrueHD or DTS channel records its original audio while playback listens to the bridged rendition, and a file cut short by a crash is still playable up to the cut. Recording follows the source rather than the playhead, so pausing or scrubbing back inside the DVR window does not interrupt it, and a liveSourceReset ends it cleanly rather than writing past a seam the codecs may not survive. The one route that cannot record is nativeRemoteHLS (.remoteBypass), where AVFoundation holds the source connection and the engine never sees a byte; startRecording throws .unsupportedRoute there instead of producing an empty file. Full contract in docs/api.md.

    liveJoinProfile defaults to .fastZap (AetherEngine#195/#208), which cuts live segments at every keyframe past 0.5 s instead of the standard ~4 s, so the served TARGETDURATION collapses to 1.5 x the source GOP length (1 s GOPs serve 2, and so do segments up to 1.666 s; the headroom covers a broadcast's irregular GOPs, AetherEngine#670) and its live-edge holdback (HOLD-BACK = 3 x TARGETDURATION, the RFC 8216bis floor; AetherEngine#189) shrinks with it. The first manifest still prefers the full holdback. After two finalized segments, a strict-realtime source gets one observed-segment grace clamped to 0.5...2.0 s, then a shallow first window may be served so startup stays bounded. This can produce one early -16832 or a short rebuffer. .standard retains the full-holdback guarantee. The smaller TARGETDURATION also tightens AVPlayer's unchanged-playlist patience and live-edge buffer, so origins that stall or burst mid-stream rebuffer more readily; choose .standard for lean-back viewing that can afford the full wait.

    liveSourceReset is live's counterpart to a terminal state = .error, and a host that plays live has to subscribe to it. It fires where the session cannot be revived from inside the engine and only a new URL can: a source that restarted from byte 0 (a Jellyfin transcode respawn), a playlist still frozen after the stall ladder's last reload rung (#65), or an in-engine reopen transport whose budget is spent (#199). Each of those halts production first, so a dead provider stops advertising blocking reloads behind the host's back. Answer it by negotiating a fresh URL and calling load again, and guard that answer (one retune in flight, a minimum spacing, a bounded count per session) or a permanently dead upstream turns into a retune loop. Spend that bound out loud: a retune ladder that ends on a silent return leaves the same dead channel behind a counter, so surface the exhaustion the way a terminal .error would be surfaced.

    A host with no negotiation to do, an IPTV channel whose URL is fixed, answers by loading the same URL again, and that is the cheap path rather than a no-op. The #168 carriage verdict (a master advertising HEVC while delivering MPEG-TS) is remembered per exact absolute URL for six hours, 32 entries, so the retune routes straight onto the live ingest instead of re-paying the doomed native mount and its watchdog grace. A URL carrying a rotated per-session token misses that memory and re-pays the one-time discovery per retune, which is worth knowing where a first-frame budget is measured against the retune as well.

    Left unsubscribed it costs a channel that stops while the engine still reports a session, which from the outside is indistinguishable from a slow one.

    Direct ingest covers MPEG-TS with demuxed-audio and packed-audio renditions, in-line AES-128 clear-key decryption, and SSAI ad-pod direct play (versioned init segments, audio re-anchoring, no-cut watchdog). Unsupported encryption / fMP4 playlists surface a typed HLSIngestError so the host can fall back. Details in docs/formats.md › Live ingest.

    A channel whose master declares an EXT-X-MEDIA:TYPE=SUBTITLES group gets those renditions as ordinary entries in subtitleTracks (#359). The ingest demuxes the picked video variant only, so the group never reaches the demuxer; the engine reads it off the master instead and, once selectSubtitleTrack(index:) picks one, fetches that rendition's WebVTT segments and publishes the cues on the host-overlay surface, placed against the picture by the shared EXT-X-PROGRAM-DATE-TIME geometry rather than by X-TIMESTAMP-MAP. Nothing is fetched until a track is selected. Details in docs/formats.md › Live HLS subtitle renditions.

    For an upstream AVPlayer can play natively (a standard remote master.m3u8, e.g. a Jellyfin live channel), LoadOptions.nativeRemoteHLS skips the demuxer probe and the loopback server entirely and hands the URL straight to AVPlayer, which manages the live edge and reconnect itself. Pair it with isLive: true. LoadOptions.httpHeaders rides into the AVURLAsset on this path, so origins that enforce per-stream Referer / User-Agent / Authorization headers (common for IPTV channels) work too.

    A live channel whose master advertises HEVC (or Dolby Vision / AV1) while delivering MPEG-TS segments is carriage AVFoundation builds no video track for, so the bypass would play it as audio over black. The engine recognizes that signature and reroutes the session onto the live ingest above (#168). The recognition runs alongside the mount: the playlist and the head of one segment are read while AVPlayer starts, so the reroute does not wait out a grace window, and a media playlist URL with no master to judge is covered too (#293). Only a codec the HLS Authoring Spec sanctions in fMP4 alone reaches that read, so an H.264 channel spends no extra request on it. LoadOptions.nativeRemoteHLSIngestFallback = false turns the whole recovery off.

    A LIVE remote m3u8 handed to the default (loopback) path routes the other way, onto the live ingest above (#363). The raw live path reads bytes, not playlists, so it used to reject an .m3u8 with a typed hlsPlaylistOnRawLivePath error naming the reader the host should have built. It builds that reader itself now, with LoadOptions.httpHeaders on the playlist, on every segment and on every AES key, which is what a tokenized IPTV origin enforces per request. Credential headers stay with the playlist's own origin: a segment or key on another host, or on http:// behind an https:// playlist, gets the rest of the headers but not the token. A custom IOReader carrying the same misroute still gets the typed error: it has no playlist URL to ingest from.

    If a live nativeRemoteHLS bypass is refused by the origin outright (HTTP 401 or 403, which reach the item as NSURLError -1013 / -1102), the engine hands that session to the same ingest instead of failing the load (#363). The ingest fetcher is a different client at that origin: it carries the configured headers on every request, caps itself at four concurrent fetches, and sends no AVFoundation user agent, so a UA filter or a per-token connection cap that turned AVPlayer away can still serve it. The refusal is not remembered for the next load the way a carriage verdict is; a token expires, a cap frees up. LoadOptions.nativeRemoteHLSIngestFallback = false turns this off along with the carriage recovery.

    A non-live remote m3u8 handed to the default (loopback) path reroutes onto the AVPlayer bypass instead: the bundled FFmpeg is built without network support, so the playlist can never be demuxed locally, and remote HLS is AVPlayer's native domain anyway (#154). On the bypass the engine surfaces the stream's external WebVTT subtitle renditions (the legible AVMediaSelectionGroup) as subtitleTracks; selectSubtitleTrack(index:) and clearSubtitle() drive AVPlayer's media selection, and AVPlayer renders the cues itself.

    Sidecar subtitles declared in LoadOptions.externalSubtitles become renditions on this bypass too (#316). Media selection on an HLS asset comes from the playlist and nowhere else, so for a VOD source the engine fetches the origin master, rewrites every variant, audio and key URI to an absolute origin URL, adds one EXT-X-MEDIA:TYPE=SUBTITLES entry per sidecar, and serves that master from the loopback origin. AVPlayer still fetches all A/V bytes straight from the origin, so E-AC-3 / Atmos passthrough is untouched; only the master and the WebVTT renditions are local. The tracks keep the external ids they were registered under, and selecting one drives media selection instead of the host overlay, so the subtitle survives PiP, AirPlay and a wired external display. Live sources (no EXT-X-ENDLIST), bitmap sidecars, a playlist that will not rewrite and a slow origin all fall back to playing the origin URL with host-overlay subtitles, which is the behaviour before #316; the load is never failed over this.

    IPTV timeshift / catch-up archives

    // An origin that answers every Range with a plausible 206 whose body is not at that offset:
    try await player.load(url: archiveURL, options: LoadOptions(
        sequentialOrigin: true,             // only byte 0 is addressable
        declaredDurationSeconds: 8100       // required on VOD; the tail-read estimate is gone
    ))
    

    Timeshift and catch-up archives commonly fabricate range answers: Range: bytes=X- returns 206 with a body that actually sits on a coarse internal chunk boundary. No header exposes that, so the caller declares it (#346). The reader then runs one long-lived unranged GET with no ranged probes, no byte-offset reconnects and no tail read, the demuxer's pb is non-seekable, and a dropped connection surfaces as a read error rather than end-of-media so the host can re-request. Such a source keeps the native path instead of being forced to software, and the session serves an append-only EVENT playlist carrying the durations actually muxed, completed with ENDLIST at true source EOF. Every producer reposition is refused by construction, so a seek lands only inside what the session already holds (the resident span that reaches the producer's frontier, on the native path); for any other target, and for an audio track switch, re-request the archive with a shifted start timestamp, passing audioSourceStreamIndex for the chosen track (AE#958). aetherctl play gains --sequential-origin / --declared-duration for reproducing one from macOS.

    Host setup on tvOS

    For HDR / Dolby Vision sources to play reliably on