Rewrite PlayerView with real mpv widget and playback lifecycle

This commit is contained in:
Brendan Szymanski 2026-06-24 00:26:19 -04:00
parent 6c246eadaa
commit 93667ac65a
3 changed files with 257 additions and 57 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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<Void, Never>?
@State private var controlsTask: Task<Void, Never>?
// 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<Bool>, position: Binding<Double>, duration: Binding<Double>) {
self.url = url
self._isPlaying = isPlaying
self._position = position
self._duration = duration
}
func container<Data>(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<SeekBarBindingContext>.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<SeekBarBindingContext>.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<SeekBarBindingContext>.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<Data>(
_ 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<Double>
var duration: Binding<Double>
var isPlaying: Binding<Bool>
init(position: Binding<Double>, duration: Binding<Double>, isPlaying: Binding<Bool>) {
self.position = position
self.duration = duration
self.isPlaying = isPlaying
}
}