From 31a558773fea575820fdf3c4ef6759af1e88b66a Mon Sep 17 00:00:00 2001 From: Omar Albeik Date: Wed, 5 Aug 2026 14:43:34 +0200 Subject: [PATCH 1/2] Resolve remaining v1.1.0 API issues --- ARCHITECTURE.md | 3 +- Sources/SwiftVLC/Core/VLCError.swift | 16 ++++++ Sources/SwiftVLC/Media/Media.swift | 39 ++++++++++++++ Sources/SwiftVLC/Player/Player+Drawable.swift | 4 +- Sources/SwiftVLC/Player/Player+Events.swift | 10 +++- Sources/SwiftVLC/Player/Player+Programs.swift | 18 +++++-- Sources/SwiftVLC/Player/Player.swift | 15 ++++-- .../SwiftVLC/SwiftVLC.docc/HandlingErrors.md | 3 ++ .../SwiftVLC.docc/PlaybackEssentials.md | 3 ++ .../SwiftVLC.docc/WorkingWithMedia.md | 18 +++++-- .../Core/VLCErrorAccessorTests.swift | 2 + Tests/SwiftVLCTests/Core/VLCErrorTests.swift | 2 + .../Core/VLCInstanceIdentityTests.swift | 51 +++++++++++++++++-- .../Player/PlayerBranchCoverageTests.swift | 32 ++++++++++++ .../PlayerPublicTrackRefreshTests.swift | 8 +++ .../Player/PlayerVideoInfoTests.swift | 35 +++++++++++++ 16 files changed, 238 insertions(+), 21 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 87af4978..ea4c6407 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` | 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`. `DialogID` carries the dialog handle through `DialogIDStorage` for safe `dismiss()` + post calls. | | `Logging.swift` | `AsyncStream` via `LogBroadcaster` | Filterable log stream backed by `Broadcaster`. C shim formats `va_list` before Swift callback. `LogNoiseFilter` demotes known-noisy libVLC errors to warnings. | @@ -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 | diff --git a/Sources/SwiftVLC/Core/VLCError.swift b/Sources/SwiftVLC/Core/VLCError.swift index 1812f0e0..2c1770b1 100644 --- a/Sources/SwiftVLC/Core/VLCError.swift +++ b/Sources/SwiftVLC/Core/VLCError.swift @@ -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. @@ -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): @@ -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 { diff --git a/Sources/SwiftVLC/Media/Media.swift b/Sources/SwiftVLC/Media/Media.swift index 75dd2aca..2ee1c7fd 100644 --- a/Sources/SwiftVLC/Media/Media.swift +++ b/Sources/SwiftVLC/Media/Media.swift @@ -120,6 +120,28 @@ 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. @@ -356,6 +378,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. diff --git a/Sources/SwiftVLC/Player/Player+Drawable.swift b/Sources/SwiftVLC/Player/Player+Drawable.swift index a7262a8d..5e5cfd4b 100644 --- a/Sources/SwiftVLC/Player/Player+Drawable.swift +++ b/Sources/SwiftVLC/Player/Player+Drawable.swift @@ -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)) diff --git a/Sources/SwiftVLC/Player/Player+Events.swift b/Sources/SwiftVLC/Player/Player+Events.swift index 7060a85a..395e02d8 100644 --- a/Sources/SwiftVLC/Player/Player+Events.swift +++ b/Sources/SwiftVLC/Player/Player+Events.swift @@ -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 @@ -303,6 +308,7 @@ extension Player { case .voutChanged(let count): activeVideoOutputs = count + refreshTracks() withMutation(keyPath: \.videoSize) {} withMutation(keyPath: \.hasVideoOutput) {} diff --git a/Sources/SwiftVLC/Player/Player+Programs.swift b/Sources/SwiftVLC/Player/Player+Programs.swift index 3f7afb8a..e92ab6aa 100644 --- a/Sources/SwiftVLC/Player/Player+Programs.swift +++ b/Sources/SwiftVLC/Player/Player+Programs.swift @@ -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) { @@ -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. @@ -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 diff --git a/Sources/SwiftVLC/Player/Player.swift b/Sources/SwiftVLC/Player/Player.swift index 37988c70..6f1fc6b1 100644 --- a/Sources/SwiftVLC/Player/Player.swift +++ b/Sources/SwiftVLC/Player/Player.swift @@ -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 @@ -466,6 +471,8 @@ public final class Player { @ObservationIgnored var _nativePauseSafetyOverrideForTesting: Bool? @ObservationIgnored + var _nativeSetRendererOverrideForTesting: ((RendererItem?) -> Int32)? + @ObservationIgnored var _seekOverridesForTesting = PlayerSeekTestOverrides() #endif @@ -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 @@ -772,7 +779,7 @@ 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)) @@ -780,7 +787,7 @@ public final class Player { /// 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 { diff --git a/Sources/SwiftVLC/SwiftVLC.docc/HandlingErrors.md b/Sources/SwiftVLC/SwiftVLC.docc/HandlingErrors.md index 624d2ffb..81094528 100644 --- a/Sources/SwiftVLC/SwiftVLC.docc/HandlingErrors.md +++ b/Sources/SwiftVLC/SwiftVLC.docc/HandlingErrors.md @@ -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) { @@ -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 | diff --git a/Sources/SwiftVLC/SwiftVLC.docc/PlaybackEssentials.md b/Sources/SwiftVLC/SwiftVLC.docc/PlaybackEssentials.md index f281b040..18662d00 100644 --- a/Sources/SwiftVLC/SwiftVLC.docc/PlaybackEssentials.md +++ b/Sources/SwiftVLC/SwiftVLC.docc/PlaybackEssentials.md @@ -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` | @@ -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 diff --git a/Sources/SwiftVLC/SwiftVLC.docc/WorkingWithMedia.md b/Sources/SwiftVLC/SwiftVLC.docc/WorkingWithMedia.md index 50ee615e..210f485b 100644 --- a/Sources/SwiftVLC/SwiftVLC.docc/WorkingWithMedia.md +++ b/Sources/SwiftVLC/SwiftVLC.docc/WorkingWithMedia.md @@ -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 @@ -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:)`` @@ -149,5 +157,7 @@ timer to display rates over time. ### Options and statistics - ``Media/addOption(_:)`` +- ``Media/setHTTPUserAgent(_:)`` +- ``Media/setHTTPReferrer(_:)`` - ``MediaStatistics`` - ``Player/statistics`` diff --git a/Tests/SwiftVLCTests/Core/VLCErrorAccessorTests.swift b/Tests/SwiftVLCTests/Core/VLCErrorAccessorTests.swift index f649e8eb..8ae9b259 100644 --- a/Tests/SwiftVLCTests/Core/VLCErrorAccessorTests.swift +++ b/Tests/SwiftVLCTests/Core/VLCErrorAccessorTests.swift @@ -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") @@ -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 diff --git a/Tests/SwiftVLCTests/Core/VLCErrorTests.swift b/Tests/SwiftVLCTests/Core/VLCErrorTests.swift index d69289a2..d2442213 100644 --- a/Tests/SwiftVLCTests/Core/VLCErrorTests.swift +++ b/Tests/SwiftVLCTests/Core/VLCErrorTests.swift @@ -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") @@ -29,6 +30,7 @@ extension Logic { .parseFailed(reason: "z"), .parseTimeout, .trackNotFound(id: "t"), + .rendererFailed, .invalidState("s"), .invalidInput("i"), .operationFailed("o"), diff --git a/Tests/SwiftVLCTests/Core/VLCInstanceIdentityTests.swift b/Tests/SwiftVLCTests/Core/VLCInstanceIdentityTests.swift index 12bca7d8..aed9b41a 100644 --- a/Tests/SwiftVLCTests/Core/VLCInstanceIdentityTests.swift +++ b/Tests/SwiftVLCTests/Core/VLCInstanceIdentityTests.swift @@ -70,6 +70,37 @@ extension Integration { #expect(userAgent.contains("SwiftVLC")) } + @Test(.tags(.async, .media), .timeLimit(.minutes(1))) + @MainActor + func `Per-media HTTP identity reaches the wire`() async throws { + let server = try UserAgentProbeServer() + defer { server.stop() } + + let instance = try VLCInstance(arguments: Self.quietArguments) + let player = Player(instance: instance) + defer { player.stop() } + let media = try Media( + url: server.url, + httpUserAgent: "PerMedia/2.0", + httpReferrer: "https://example.com/catalog" + ) + + do { + try player.play(media) + } catch { + // The payload is intentionally not valid media; only the request + // headers produced while opening it are under test. + } + + try #require(await poll(every: .milliseconds(50), timeout: .seconds(10)) { + server.capturedUserAgent != nil && server.capturedReferrer != nil + }, "Waiting for: libVLC to send per-media HTTP identity headers") + + let userAgent = try #require(server.capturedUserAgent) + #expect(userAgent.hasPrefix("PerMedia/2.0")) + #expect(server.capturedReferrer == "https://example.com/catalog") + } + @Test func `Set user agent after init does not crash`() throws { let instance = try VLCInstance(arguments: Self.quietArguments) @@ -108,6 +139,10 @@ private final class UserAgentProbeServer: @unchecked Sendable { state.capturedUserAgent } + var capturedReferrer: String? { + state.capturedReferrer + } + init() throws { let fd = socket(AF_INET, SOCK_STREAM, 0) guard fd >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } @@ -186,8 +221,11 @@ private final class UserAgentProbeServer: @unchecked Sendable { private static func handle(client: Int32, state: StateBox) { let request = readRequest(from: client) - if let userAgent = userAgentHeader(in: request) { - state.mutex.withLock { $0.capturedUserAgent = userAgent } + let userAgent = header(named: "user-agent", in: request) + let referrer = header(named: "referer", in: request) + state.mutex.withLock { + $0.capturedUserAgent = userAgent + $0.capturedReferrer = referrer } let body = "not a transport stream" @@ -202,9 +240,9 @@ private final class UserAgentProbeServer: @unchecked Sendable { } } - private static func userAgentHeader(in request: String) -> String? { + private static func header(named name: String, in request: String) -> String? { for line in request.components(separatedBy: "\r\n") { - let prefix = "user-agent:" + let prefix = "\(name):" guard line.lowercased().hasPrefix(prefix) else { continue } return line.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) } @@ -230,6 +268,7 @@ private final class UserAgentProbeServer: @unchecked Sendable { private struct State: @unchecked Sendable { var isStopped = false var capturedUserAgent: String? + var capturedReferrer: String? } private final class StateBox: @unchecked Sendable { @@ -238,6 +277,10 @@ private final class UserAgentProbeServer: @unchecked Sendable { var capturedUserAgent: String? { mutex.withLock { $0.capturedUserAgent } } + + var capturedReferrer: String? { + mutex.withLock { $0.capturedReferrer } + } } } diff --git a/Tests/SwiftVLCTests/Player/PlayerBranchCoverageTests.swift b/Tests/SwiftVLCTests/Player/PlayerBranchCoverageTests.swift index 42a2b019..c58b5b74 100644 --- a/Tests/SwiftVLCTests/Player/PlayerBranchCoverageTests.swift +++ b/Tests/SwiftVLCTests/Player/PlayerBranchCoverageTests.swift @@ -54,6 +54,38 @@ extension Integration { #expect(player.isPlaying == false) } + @Test + func `native playback state bypasses the asynchronous mirror`() { + let player = Player(instance: TestInstance.shared) + player._setStateForTesting(state: .playing) + player._nativePlaybackStateOverrideForTesting = .stopped + + #expect(player.state == .playing) + #expect(player.nativePlaybackState == .stopped) + } + + @Test + func `renderer rejection has a dedicated typed error`() throws { + let player = Player(instance: TestInstance.shared) + player._nativeSetRendererOverrideForTesting = { _ in -1 } + + #expect(throws: VLCError.rendererFailed) { + try player.setRenderer(nil) + } + } + + @Test + func `replacement player renderer rejection has a dedicated typed error`() throws { + let player = Player(instance: TestInstance.shared) + let originalPointer = player.pointer + player._nativeSetRendererOverrideForTesting = { _ in -1 } + + #expect(throws: VLCError.rendererFailed) { + try player.replaceNativePlayerForDrawablePlayback(target: nil) + } + #expect(player.pointer == originalPointer) + } + @Test func `togglePlayPause can cancel a pending resume while native state is still paused`() { let player = Player(instance: TestInstance.shared) diff --git a/Tests/SwiftVLCTests/Player/PlayerPublicTrackRefreshTests.swift b/Tests/SwiftVLCTests/Player/PlayerPublicTrackRefreshTests.swift index 73794535..8de9d61a 100644 --- a/Tests/SwiftVLCTests/Player/PlayerPublicTrackRefreshTests.swift +++ b/Tests/SwiftVLCTests/Player/PlayerPublicTrackRefreshTests.swift @@ -13,4 +13,12 @@ import Testing #expect(player.videoTracks.isEmpty) #expect(player.subtitleTracks.isEmpty) } + + @Test + func `native playback state is publicly readable`() { + let player = Player() + let state: PlayerState = player.nativePlaybackState + + #expect(state == .idle || state == .stopped) + } } diff --git a/Tests/SwiftVLCTests/Player/PlayerVideoInfoTests.swift b/Tests/SwiftVLCTests/Player/PlayerVideoInfoTests.swift index 803bc1a7..94be6473 100644 --- a/Tests/SwiftVLCTests/Player/PlayerVideoInfoTests.swift +++ b/Tests/SwiftVLCTests/Player/PlayerVideoInfoTests.swift @@ -49,6 +49,41 @@ extension Integration { #expect(fired.withLock { $0 }) } + @Test + func `voutChanged refreshes observable track snapshots`() { + let player = Player(instance: TestInstance.shared) + player.videoTracks = [ + Track( + id: "stale-video", + type: .video, + name: "Stale video", + codec: 0, + language: nil, + trackDescription: nil, + isSelected: true, + bitrate: 0, + channels: nil, + sampleRate: nil, + width: 0, + height: 0, + frameRate: nil, + frameRateRatio: nil, + encoding: nil + ) + ] + let fired = Mutex(false) + withObservationTracking { + _ = player.videoTracks + } onChange: { + fired.withLock { $0 = true } + } + + player._handleEventForTesting(.voutChanged(1)) + + #expect(fired.withLock { $0 }) + #expect(player.videoTracks.isEmpty) + } + @Test func `tracksChanged invalidates videoSize observation`() { let player = Player(instance: TestInstance.shared) From b92985d3420155295ad215ccc52a5fe0aa3d3163 Mon Sep 17 00:00:00 2001 From: Omar Albeik Date: Wed, 5 Aug 2026 14:46:02 +0200 Subject: [PATCH 2/2] Apply SwiftFormat to media initializer --- Sources/SwiftVLC/Media/Media.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftVLC/Media/Media.swift b/Sources/SwiftVLC/Media/Media.swift index 2ee1c7fd..fa400886 100644 --- a/Sources/SwiftVLC/Media/Media.swift +++ b/Sources/SwiftVLC/Media/Media.swift @@ -132,7 +132,8 @@ public final class Media: Sendable { url: URL, httpUserAgent: String?, httpReferrer: String? - ) throws(VLCError) { + ) + throws(VLCError) { try self.init(url: url) if let httpUserAgent { setHTTPUserAgent(httpUserAgent)