From 93667ac65a8dab4e536ac01556ae4f03554de875 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Wed, 24 Jun 2026 00:26:19 -0400 Subject: [PATCH] Rewrite PlayerView with real mpv widget and playback lifecycle --- Sources/Luminate/Luminate.swift | 2 + Sources/LuminatePlayer/PlayerControls.swift | 3 +- Sources/LuminatePlayer/PlayerView.swift | 309 ++++++++++++++++---- 3 files changed, 257 insertions(+), 57 deletions(-) diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 2b5af0f..74626c4 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -142,6 +142,8 @@ struct ContentView: View { ToolbarView() } .navigationTitle(page.description) + case .player(let playerState): + PlayerView(playerState: playerState, onClose: { stack.pop() }) } } initialView: { HomeView(navigation: $stack) diff --git a/Sources/LuminatePlayer/PlayerControls.swift b/Sources/LuminatePlayer/PlayerControls.swift index 03f1e30..1a59809 100644 --- a/Sources/LuminatePlayer/PlayerControls.swift +++ b/Sources/LuminatePlayer/PlayerControls.swift @@ -60,7 +60,7 @@ public struct PlayerControls: View { public var view: Body { Box { - HStack { + HStack(spacing: 6) { Button(icon: .custom(name: "skip-backwards-10-symbolic")) { onSeekBack() } @@ -112,7 +112,6 @@ public struct PlayerControls: View { } .style("flat") } - .spacing(6) .padding() } .style("card") diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift index 5213fa1..e9bf79c 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -22,89 +22,288 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI +import CModules +import CAdw public struct PlayerView: View { - public var item: BaseItemDto - public var client: JellyfinClient - public var userId: String - public var mediaSourceId: String - public var playSessionId: String - public var streamURL: URL + public var playerState: PlayerState + public var onClose: () -> Void + + @Injected(\.client) var client + @Injected(\.userId) var userId + @State private var isPlaying = true @State private var position: Double = 0 @State private var duration: Double = 0 @State private var showControls = true - public var onClose: () -> Void - public init( - item: BaseItemDto, - client: JellyfinClient, - userId: String, - mediaSourceId: String, - playSessionId: String, - streamURL: URL, - onClose: @escaping () -> Void - ) { - self.item = item - self.client = client - self.userId = userId - self.mediaSourceId = mediaSourceId - self.playSessionId = playSessionId - self.streamURL = streamURL + public init(playerState: PlayerState, onClose: @escaping () -> Void) { + self.playerState = playerState self.onClose = onClose } public var view: Body { - VStack { - VideoPlayerWidget( - url: streamURL.absoluteString, - isPlaying: $isPlaying, - position: $position, - duration: $duration - ) - .hexpand(true) - .vexpand(true) + VideoPlayerWidget( + url: playerState.streamURL.absoluteString, + isPlaying: $isPlaying, + position: $position, + duration: $duration + ) + .vexpand(true) + .hexpand(true) + .overlay { if showControls { - PlayerControls( - isPlaying: $isPlaying, - position: $position, - duration: $duration, - onTogglePlay: { isPlaying.toggle() }, - onSeekBack: { position = max(0, position - 10) }, - onSeekForward: { position = min(duration, position + 10) }, - onFullscreen: {}, - onClose: { - stopPlayback() - onClose() + VStack { + HStack { + Button(icon: .default(icon: .goPrevious)) { + stopPlayback() + onClose() + } + .flat() + .padding() + Box { }.hexpand(true) } - ) + Box { }.vexpand(true) + PlayerControls( + isPlaying: $isPlaying, + position: $position, + duration: $duration, + onClose: { + stopPlayback() + onClose() + }, + onSeekBack: { seekBy(-10) }, + onSeekForward: { seekBy(10) }, + onSeekAbsolute: { seekTo($0) }, + onFullscreen: { toggleFullscreen() }, + onSubtitleAudio: { } + ) + } } } + .onAppear { + startPlayback() + startControlsTimer() + } } + // MARK: - Playback + private func startPlayback() { Task { try? await client.reportPlaybackStart( info: .init( - itemId: item.id, - mediaSourceId: mediaSourceId, - playSessionId: playSessionId + itemId: playerState.itemId, + mediaSourceId: playerState.mediaSourceId, + playSessionId: playerState.playSessionId + ) + ) + startProgressTimer() + } + } + + private func stopPlayback() { + progressTask?.cancel() + controlsTask?.cancel() + Task { + try? await client.reportPlaybackStopped( + info: .init( + itemId: playerState.itemId, + mediaSourceId: playerState.mediaSourceId, + positionTicks: Int64(position * 10_000_000), + playSessionId: playerState.playSessionId ) ) } } - private func stopPlayback() { - Task { - try? await client.reportPlaybackStopped( - info: .init( - itemId: item.id, - mediaSourceId: mediaSourceId, - positionTicks: Int64(position * 10_000_000), - playSessionId: playSessionId + private func startProgressTimer() { + progressTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + try? await client.reportPlaybackProgress( + info: .init( + itemId: playerState.itemId, + mediaSourceId: playerState.mediaSourceId, + isPaused: !isPlaying, + positionTicks: Int64(position * 10_000_000), + playSessionId: playerState.playSessionId + ) ) - ) + if Int(position).isMultiple(of: 30) { + try? await client.pingPlaybackSession( + playSessionId: playerState.playSessionId + ) + } + } } } + + @State private var progressTask: Task? + @State private var controlsTask: Task? + + // MARK: - Seeking + + private func seekBy(_ seconds: Double) { + } + + private func seekTo(_ position: Double) { + } + + // MARK: - Controls visibility + + private func startControlsTimer() { + controlsTask?.cancel() + controlsTask = Task { + try? await Task.sleep(for: .seconds(3)) + showControls = false + } + } + + // MARK: - Fullscreen + + private func toggleFullscreen() { + } + +} + +// MARK: - VideoPlayerWidget (internal Widget wrapper) + +struct VideoPlayerWidget: Widget { + + #if exposeGeneratedAppearUpdateFunctions + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + var url: String? + @Binding var isPlaying: Bool + @Binding var position: Double + @Binding var duration: Double + + init(url: String?, isPlaying: Binding, position: Binding, duration: Binding) { + self.url = url + self._isPlaying = isPlaying + self._position = position + self._duration = duration + } + + func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let storage = ViewStorage(mpv_widget_new()?.opaque()) + + if let widgetPtr = storage.opaquePointer.map(UnsafeMutableRawPointer.init) { + let context = SeekBarBindingContext( + position: _position, + duration: _duration, + isPlaying: _isPlaying + ) + storage.fields["context"] = context + let ctxPtr = Unmanaged.passUnretained(context).toOpaque() + + let posHandler: @convention(c) ( + OpaquePointer?, + Double, + UnsafeMutableRawPointer? + ) -> Void = { _, pos, ptr in + let binding = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + binding.position.wrappedValue = pos + } + g_signal_connect_data( + widgetPtr, + "position-changed", + unsafeBitCast(posHandler, to: GCallback.self), + ctxPtr, + nil, + GConnectFlags(rawValue: 1)) + + let durHandler: @convention(c) ( + OpaquePointer?, + Double, + UnsafeMutableRawPointer? + ) -> Void = { _, dur, ptr in + let binding = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + binding.duration.wrappedValue = dur + } + g_signal_connect_data( + widgetPtr, + "duration-changed", + unsafeBitCast(durHandler, to: GCallback.self), + ctxPtr, + nil, + GConnectFlags(rawValue: 1)) + + let stateHandler: @convention(c) ( + OpaquePointer?, + Int32, + UnsafeMutableRawPointer? + ) -> Void = { _, paused, ptr in + let binding = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + binding.isPlaying.wrappedValue = paused == 0 + } + g_signal_connect_data( + widgetPtr, + "playback-state-changed", + unsafeBitCast(stateHandler, to: GCallback.self), + ctxPtr, + nil, + GConnectFlags(rawValue: 1)) + } + + for function in appearFunctions { + function(storage, data) + } + return storage + } + + func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + storage.modify { widget in + if updateProperties, let url, !(storage.previousState is Self) { + mpv_widget_load_url(widget, url) + mpv_widget_pause(widget) + if isPlaying { mpv_widget_play(widget) } + } + if updateProperties, + let prev = storage.previousState as? Self, + prev.isPlaying != isPlaying + { + if isPlaying { + mpv_widget_play(widget) + } else { + mpv_widget_pause(widget) + } + } + } + for function in updateFunctions { + function(storage, data, updateProperties) + } + if updateProperties { + storage.previousState = self + } + } + +} + +// MARK: - Signal binding context + +class SeekBarBindingContext { + var position: Binding + var duration: Binding + var isPlaying: Binding + + init(position: Binding, duration: Binding, isPlaying: Binding) { + self.position = position + self.duration = duration + self.isPlaying = isPlaying + } }