Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Foundation types shared across all modules.
| File | Type | Purpose |
|---|---|---|
| `VLCInstance.swift` | `final class VLCInstance: Sendable` | Manages `libvlc_instance_t*` lifecycle. Singleton `shared` or custom with arguments. Owns the per-instance `dialogRegistration` Mutex. |
| `VLCError.swift` | `enum VLCError: Error, Sendable, Equatable, Hashable, LocalizedError, CustomStringConvertible` | Typed errors with hand-rolled per-case accessors (`error.parseTimeout`, `error.mediaCreationFailed`, …). Auto-synthesized `Equatable`/`Hashable` over `String` payloads. |
| `VLCError.swift` | `enum VLCError: Error, Sendable, Equatable, Hashable, LocalizedError, CustomStringConvertible` | Typed errors with hand-rolled per-case accessors (`error.parseTimeout`, `error.mediaCreationFailed`, …) and synthesized `Equatable`/`Hashable`. |
| `Broadcaster.swift` | `final class Broadcaster<Element: Sendable>` | Internal multi-consumer fan-out used by the dialog, renderer, log, player-event, and playback-intent streams. Exposes `subscribe`, `broadcast`, `finishAll` (allows resubscribe) and `terminate` (permanent — future `subscribe` calls return immediately-finished streams) inside the module. Lifecycle reconciliation runs on a private serial queue. |
| `DialogHandler.swift` | `final class DialogHandler: Sendable` | Bridges libVLC's dialog callbacks (`login`, `question`, `progress`, `error`) onto a `Broadcaster<DialogEvent>`. `DialogID` carries the dialog handle through `DialogIDStorage` for safe `dismiss()` + post calls. |
| `Logging.swift` | `AsyncStream<LogEntry>` via `LogBroadcaster` | Filterable log stream backed by `Broadcaster<LogEntry>`. C shim formats `va_list` before Swift callback. `LogNoiseFilter` demotes known-noisy libVLC errors to warnings. |
Expand Down Expand Up @@ -687,6 +687,7 @@ failure and propagate an error thrown by the caller's closure unchanged.
| `parseFailed` | Media parsing reports failure status |
| `parseTimeout` | Parsing exceeds specified timeout |
| `trackNotFound` | Track selection fails (invalid track ID) |
| `rendererFailed` | libVLC synchronously rejects applying a renderer |
| `invalidState` | Operation attempted in wrong state |
| `invalidInput` | Public API argument is outside its documented range |
| `operationFailed` | Generic libVLC operation failure |
Expand Down
16 changes: 16 additions & 0 deletions Sources/SwiftVLC/Core/VLCError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public enum VLCError: Error, Sendable, Equatable, Hashable, LocalizedError, Cust
/// The requested track identifier does not match any track on the
/// current media.
case trackNotFound(id: String)
/// libVLC rejected applying the selected renderer to a native player.
///
/// This is a synchronous renderer-selection failure. A renderer session
/// that starts and fails later is reported through playback events instead.
case rendererFailed
/// The operation is valid in principle but not in the player's
/// current state (e.g. setting an A-B loop before any media is
/// loaded). The associated string names the constraint that failed.
Expand Down Expand Up @@ -57,6 +62,8 @@ public enum VLCError: Error, Sendable, Equatable, Hashable, LocalizedError, Cust
"Media parsing timed out"
case .trackNotFound(let id):
"Track not found: \(id)"
case .rendererFailed:
"Failed to apply renderer"
case .invalidState(let message):
"Invalid state: \(message)"
case .invalidInput(let message):
Expand Down Expand Up @@ -128,6 +135,15 @@ extension VLCError {
}
}

/// `Void` if this error is `.rendererFailed`, otherwise `nil`.
public var rendererFailed: Void? {
if case .rendererFailed = self {
()
} else {
nil
}
}

/// Constraint message if this error is `.invalidState`, otherwise `nil`.
public var invalidState: String? {
if case .invalidState(let value) = self {
Expand Down
40 changes: 40 additions & 0 deletions Sources/SwiftVLC/Media/Media.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,29 @@ public final class Media: Sendable {
pointer = media
}

/// Creates media from a URL with per-media HTTP identity headers.
///
/// - Parameters:
/// - url: The media source URL.
/// - httpUserAgent: Per-media HTTP `User-Agent`, or `nil` to use the
/// ``VLCInstance`` default.
/// - httpReferrer: Per-media HTTP `Referer`, or `nil` to omit it.
/// - Throws: `VLCError.mediaCreationFailed` if the URL is invalid.
public convenience init(
url: URL,
httpUserAgent: String?,
httpReferrer: String?
)
throws(VLCError) {
try self.init(url: url)
if let httpUserAgent {
setHTTPUserAgent(httpUserAgent)
}
if let httpReferrer {
setHTTPReferrer(httpReferrer)
}
}

/// Creates media from a file path.
/// - Parameter path: Absolute file path to the media file.
/// - Throws: `VLCError.mediaCreationFailed` if the path is invalid.
Expand Down Expand Up @@ -356,6 +379,23 @@ public final class Media: Sendable {
libvlc_media_add_option(pointer, option)
}

/// Sets the `User-Agent` header for HTTP requests made for this media.
///
/// Call before playback or parsing begins. Whether the option is honored
/// depends on the HTTP access module in the bundled libVLC build.
public func setHTTPUserAgent(_ value: String) {
addOption(":http-user-agent=\(value)")
}

/// Sets the `Referer` header for HTTP requests made for this media.
///
/// The libVLC option is spelled `http-referrer` even though the wire header
/// uses the historical HTTP spelling `Referer`. Call before playback or
/// parsing begins.
public func setHTTPReferrer(_ value: String) {
addOption(":http-referrer=\(value)")
}

// MARK: - Metadata Editing

/// Sets a metadata value on this media.
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftVLC/Player/Player+Drawable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,9 @@ extension Player {
if let incoming = media ?? currentMedia {
libvlc_media_player_set_media(newPointer, incoming.pointer)
}
guard libvlc_media_player_set_renderer(newPointer, selectedRenderer?.pointer) == 0 else {
guard setNativeRenderer(selectedRenderer, on: newPointer) == 0 else {
libvlc_media_player_release(newPointer)
throw .operationFailed("Set renderer")
throw .rendererFailed
}
let newLifetime = NativePlayerHandleLifetime(pointer: newPointer)
_ = libvlc_audio_set_volume(newPointer, Int32(_volume * 100))
Expand Down
10 changes: 8 additions & 2 deletions Sources/SwiftVLC/Player/Player+Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ extension Player {
// MARK: - Native state probes

/// libVLC's view of the player state — read directly from the
/// underlying handle, not the cached `state` property.
var nativePlaybackState: PlayerState {
/// underlying handle, not the asynchronously updated ``state`` mirror.
///
/// Use this for transport decisions that must account for a native stop,
/// pause, or resume before its event reaches the main actor. Prefer
/// ``state`` for observation-driven UI because this synchronous snapshot is
/// not itself observable.
public var nativePlaybackState: PlayerState {
#if DEBUG
if let _nativePlaybackStateOverrideForTesting {
return _nativePlaybackStateOverrideForTesting
Expand Down Expand Up @@ -303,6 +308,7 @@ extension Player {

case .voutChanged(let count):
activeVideoOutputs = count
refreshTracks()
withMutation(keyPath: \.videoSize) {}
withMutation(keyPath: \.hasVideoOutput) {}

Expand Down
18 changes: 14 additions & 4 deletions Sources/SwiftVLC/Player/Player+Programs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ extension Player {
/// > reach — applying a renderer there does not produce remote output.
///
/// - Parameter renderer: A ``RendererItem`` discovered by ``RendererDiscoverer``, or `nil`.
/// - Throws: `VLCError.operationFailed` if the renderer cannot be set,
/// - Throws: ``VLCError/rendererFailed`` if the renderer cannot be set,
/// or ``VLCError/invalidState(_:)`` if the player has already started
/// playback or isn't in an idle-like state.
public func setRenderer(_ renderer: RendererItem?) throws(VLCError) {
Expand All @@ -65,11 +65,21 @@ extension Player {
guard !nativePlayerHasStartedPlayback else {
throw .invalidState("setRenderer must be called before the first play() on this Player")
}
let result = libvlc_media_player_set_renderer(pointer, renderer?.pointer)
guard result == 0 else { throw .operationFailed("Set renderer") }
guard setNativeRenderer(renderer, on: pointer) == 0 else { throw .rendererFailed }
selectedRenderer = renderer
}

/// Applies a renderer through one testable boundary so both initial
/// selection and replacement-player selection surface the same typed error.
func setNativeRenderer(_ renderer: RendererItem?, on player: OpaquePointer) -> Int32 {
#if DEBUG
if let _nativeSetRendererOverrideForTesting {
return _nativeSetRendererOverrideForTesting(renderer)
}
#endif
return libvlc_media_player_set_renderer(player, renderer?.pointer)
}

/// Switches the active renderer mid-playback on this same `Player` —
/// drawable attachment, observation, and app-side Now-Playing wiring
/// all survive. Pass `nil` to return to local playback.
Expand Down Expand Up @@ -98,7 +108,7 @@ extension Player {
/// > Note: On tvOS the bundled libVLC ships no renderer output
/// > backends — see ``setRenderer(_:)``.
///
/// - Throws: ``VLCError/operationFailed(_:)`` if the renderer is
/// - Throws: ``VLCError/rendererFailed`` if the renderer is
/// rejected (prior renderer and local playback left intact),
/// ``VLCError/playbackFailed(reason:)`` if the replacement session
/// cannot be started (the renderer is applied at that point — the
Expand Down
15 changes: 11 additions & 4 deletions Sources/SwiftVLC/Player/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import Synchronization
public final class Player {
// MARK: - Observable State

/// Current playback state.
/// The latest playback state delivered by libVLC's asynchronous event
/// stream.
///
/// This observable mirror can briefly lag the underlying player after a
/// transport command. Read ``nativePlaybackState`` when transport logic
/// requires a synchronous native-state snapshot instead of UI observation.
public internal(set) var state: PlayerState = .idle

/// Whether playback controls should currently present the media as
Expand Down Expand Up @@ -466,6 +471,8 @@ public final class Player {
@ObservationIgnored
var _nativePauseSafetyOverrideForTesting: Bool?
@ObservationIgnored
var _nativeSetRendererOverrideForTesting: ((RendererItem?) -> Int32)?
@ObservationIgnored
var _seekOverridesForTesting = PlayerSeekTestOverrides()
#endif

Expand Down Expand Up @@ -721,7 +728,7 @@ public final class Player {

/// Loads media and starts playback in one step.
/// - Throws: ``VLCError/playbackFailed(reason:)`` if playback cannot
/// start, or ``VLCError/operationFailed(_:)`` if a selected renderer
/// start, or ``VLCError/rendererFailed`` if a selected renderer
/// cannot be applied to a replacement native player.
public func play(_ media: sending Media) throws(VLCError) {
// Guarded here as well as in `play()`: the replacement branch below
Expand Down Expand Up @@ -772,15 +779,15 @@ public final class Player {
/// they are streaming manifests.
/// - Throws: ``VLCError/mediaCreationFailed(source:)``,
/// ``VLCError/playbackFailed(reason:)``, or
/// ``VLCError/operationFailed(_:)`` if a selected renderer cannot be
/// ``VLCError/rendererFailed`` if a selected renderer cannot be
/// applied to a replacement native player.
public func play(url: URL) throws(VLCError) {
try play(Media(url: url))
}

/// Starts playback.
/// - Throws: ``VLCError/playbackFailed(reason:)`` if playback cannot
/// start, or ``VLCError/operationFailed(_:)`` if a selected renderer
/// start, or ``VLCError/rendererFailed`` if a selected renderer
/// cannot be applied to a replacement native player.
public func play() throws(VLCError) {
guard !isShutdown else {
Expand Down
3 changes: 3 additions & 0 deletions Sources/SwiftVLC/SwiftVLC.docc/HandlingErrors.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ do {
print("Parsing timed out")
} catch .trackNotFound(let id) {
print("No track matched: \(id)")
} catch .rendererFailed {
print("The selected renderer could not be applied")
} catch .invalidState(let message) {
print("Player wasn't ready: \(message)")
} catch .invalidInput(let message) {
Expand Down Expand Up @@ -60,6 +62,7 @@ closure, including application-defined error types.
| ``VLCError/parseFailed(reason:)`` | ``Media/parse(timeout:instance:)`` ended with a non-success status |
| ``VLCError/parseTimeout-enum.case`` | ``Media/parse(timeout:instance:)`` hit the requested timeout |
| ``VLCError/trackNotFound(id:)`` | No track matches the requested identifier |
| ``VLCError/rendererFailed`` | libVLC synchronously rejected applying a selected renderer |
| ``VLCError/invalidState(_:)`` | Operation is valid but the player isn't in the right state |
| ``VLCError/invalidInput(_:)`` | A public API argument is outside its documented range |
| ``VLCError/operationFailed(_:)`` | A libVLC call returned non-zero; the string names the attempted op |
Expand Down
3 changes: 3 additions & 0 deletions Sources/SwiftVLC/SwiftVLC.docc/PlaybackEssentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ binds to them directly, without a publisher or Combine adapter.
| Property | Type | Meaning |
|---|---|---|
| ``Player/state`` | ``PlayerState`` | `.idle`, `.opening`, `.buffering`, `.playing`, `.paused`, `.stopped`, `.stopping`, `.error` |
| ``Player/nativePlaybackState`` | ``PlayerState`` | Synchronous native snapshot for transport decisions; not observable |
| ``Player/isPlaying`` | `Bool` | User-facing playback signal for Play/Pause controls while libVLC state transitions settle |
| ``Player/isPlaybackRequestedActive`` | `Bool` | Lower-level playback intent mirrored by PiP and external transport controls |
| ``Player/bufferFill`` | `Float` | Continuously-updated cache level (`0.0…1.0`), independent of `state` |
Expand All @@ -51,6 +52,8 @@ binds to them directly, without a publisher or Combine adapter.
or playing.
- ``Player/state`` is the strict libVLC lifecycle state. It can lag
transport intent briefly during PiP and other asynchronous transitions.
- ``Player/nativePlaybackState`` reads the native handle synchronously when
code must distinguish that lag from libVLC's current lifecycle state.

## Observable state and checked mutations

Expand Down
18 changes: 14 additions & 4 deletions Sources/SwiftVLC/SwiftVLC.docc/WorkingWithMedia.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,21 @@ media.addOption(":start-time=30")

Options only affect media that has not yet started playing.

For HTTP and HTTPS streams, libVLC's supported request options can be
passed the same way:
For HTTP and HTTPS streams, use the typed initializer when the request needs
a per-media identity:

```swift
media.addOption(":http-user-agent=CustomApp/1.0")
media.addOption(":http-referrer=https://example.com")
let media = try Media(
url: streamURL,
httpUserAgent: "CustomApp/1.0",
httpReferrer: "https://example.com/catalog"
)
```

The ``Media/setHTTPUserAgent(_:)`` and ``Media/setHTTPReferrer(_:)`` helpers
provide the same options for an existing media created through another
initializer. Apply them before parsing or playback begins.

Cookie forwarding is handled by libVLC's internal cookie jar and is
enabled by default. The bundled libVLC build does not expose a string
media option for injecting an initial `Cookie` header or arbitrary
Expand All @@ -126,6 +133,7 @@ timer to display rates over time.

### Creating media
- ``Media/init(url:)``
- ``Media/init(url:httpUserAgent:httpReferrer:)``
- ``Media/init(path:)``
- ``Media/init(fileDescriptor:)``

Expand All @@ -149,5 +157,7 @@ timer to display rates over time.

### Options and statistics
- ``Media/addOption(_:)``
- ``Media/setHTTPUserAgent(_:)``
- ``Media/setHTTPReferrer(_:)``
- ``MediaStatistics``
- ``Player/statistics``
2 changes: 2 additions & 0 deletions Tests/SwiftVLCTests/Core/VLCErrorAccessorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension Logic {
expectNoDifference(VLCError.parseFailed(reason: "bad input").parseFailed, "bad input")
#expect(VLCError.parseTimeout.parseTimeout != nil)
expectNoDifference(VLCError.trackNotFound(id: "audio-1").trackNotFound, "audio-1")
#expect(VLCError.rendererFailed.rendererFailed != nil)
expectNoDifference(VLCError.invalidState("not loaded").invalidState, "not loaded")
expectNoDifference(VLCError.invalidInput("width").invalidInput, "width")
expectNoDifference(VLCError.operationFailed("Snapshot").operationFailed, "Snapshot")
Expand All @@ -26,6 +27,7 @@ extension Logic {
VLCError.parseTimeout.parseFailed == nil,
VLCError.instanceCreationFailed.parseTimeout == nil,
VLCError.parseTimeout.trackNotFound == nil,
VLCError.parseTimeout.rendererFailed == nil,
VLCError.parseTimeout.invalidState == nil,
VLCError.parseTimeout.invalidInput == nil,
VLCError.parseTimeout.operationFailed == nil
Expand Down
2 changes: 2 additions & 0 deletions Tests/SwiftVLCTests/Core/VLCErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension Logic {
(.parseFailed(reason: "timeout"), "Media parsing failed: timeout"),
(.parseTimeout, "Media parsing timed out"),
(.trackNotFound(id: "audio-0"), "Track not found: audio-0"),
(.rendererFailed, "Failed to apply renderer"),
(.invalidState("not playing"), "Invalid state: not playing"),
(.invalidInput("width must be non-negative"), "Invalid input: width must be non-negative"),
(.operationFailed("Snapshot"), "Snapshot failed")
Expand All @@ -29,6 +30,7 @@ extension Logic {
.parseFailed(reason: "z"),
.parseTimeout,
.trackNotFound(id: "t"),
.rendererFailed,
.invalidState("s"),
.invalidInput("i"),
.operationFailed("o"),
Expand Down
Loading
Loading