2.4.0 - Custom IOReader input sources
Custom input sources
A new public IOReader protocol lets hosts play media from any byte source (memory buffers, encrypted-at-rest archives, proprietary containers) instead of only URLs. No breaking API change, existing load(url:) callers are unaffected. Resolves #26.
public protocol IOReader: AnyObject, Sendable {
func read(_ buffer: UnsafeMutablePointer<UInt8>?, size: Int32) -> Int32
func seek(offset: Int64, whence: Int32) -> Int64
func close()
func cancel() // default no-op: unblock a blocked read at teardown
func makeIndependentReader() -> IOReader? // default nil: a second independent cursor
}
public enum MediaSource: Sendable {
case url(URL)
case custom(IOReader, formatHint: String? = nil)
}
try await engine.load(source: .custom(MyArchiveReader(), formatHint: "mp4"))load(url:) is retained and forwards to load(source:). Internally the engine attaches the reader to the demuxer's AVFormatContext.pb, the same seam the built-in AVIOReader uses, so no FFmpeg types leak into the public surface.
What works for custom sources
- Both playback paths, video and audio. Seekable readers play on the native (AVPlayer / HLS-remux) and software decode paths. Audio-only custom sources route through the software (FFmpeg) audio path, since AVPlayer is URL-only.
- Forward-only readers (seek returns negative for SEEK_SET/CUR/END) play too, auto-routed to the software decode path.
- Full mid-playback feature set on capable readers. Audio-track switching and background-return reload work for seekable readers (the pipeline rebuilds on the retained reader). Embedded-subtitle selection and scrub-preview thumbnails work for readers that implement the optional
makeIndependentReader(); readers that cannot provide a second cursor simply skip those two features.
Two contracts to implement correctly
cancel()must only unblock the in-flightread, never invalidate the reader (the engine reuses the reader across an internal reload). It is now a protocol requirement with a default no-op, so a host override dispatches correctly through theany IOReaderexistential.makeIndependentReader()returns a fresh reader with its own cursor over the same source (a second file handle, a fresh decrypt context, etc.), or nil if the source cannot (one-shot streams). The engine owns and closes it.
Security note
On the native path the demuxed bytes are re-muxed to cleartext fMP4 and served over a loopback HLS connection to AVPlayer. Fine for encrypted-at-rest archives, worth knowing if the source is encrypted for content protection.
Acknowledgements
Thanks to @strangeliu for the proposal and to @DrHurt for the discussion on #26.
Full Changelog: 2.3.0...2.4.0