// // PlayerView.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import Adwaita import Foundation import LuminateCore import LuminateDI import CModules import CAdw final class PlayerTasks { var progress: Task? var controls: Task? } public struct PlayerView: View { 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 @State private var isFullscreen = false @State private var mpvWidget: OpaquePointer? @State private var tasks = PlayerTasks() private let playbackState = PlayerPlaybackState() public init(playerState: PlayerState, onClose: @escaping () -> Void) { self.playerState = playerState self.onClose = onClose } public var view: Body { VideoPlayerWidget( url: playerState.streamURL.absoluteString, isPlaying: $isPlaying, position: $position, duration: $duration, playbackState: playbackState, onWidgetCreated: { ptr in _mpvWidget.rawValue = ptr } ) .vexpand(true) .hexpand(true) .overlay { if showControls { VStack { HStack { Button(icon: .default(icon: .goPrevious)) { stopPlayback() onClose() } .flat() .padding() Box { }.hexpand(true) } Box { }.vexpand(true) PlayerControls( isPlaying: $isPlaying, position: $position, duration: $duration, playbackState: playbackState, 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: playerState.itemId, mediaSourceId: playerState.mediaSourceId, playSessionId: playerState.playSessionId ) ) startProgressTimer() } } private func stopPlayback() { tasks.progress?.cancel() tasks.controls?.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 startProgressTimer() { tasks.progress = 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 ) } } } } // MARK: - Seeking private func seekBy(_ seconds: Double) { guard let mpvWidget else { return } mpv_widget_seek_relative(mpvWidget, seconds) } private func seekTo(_ position: Double) { guard let mpvWidget else { return } mpv_widget_seek_absolute(mpvWidget, position) } // MARK: - Controls visibility private func startControlsTimer() { tasks.controls?.cancel() tasks.controls = Task { try? await Task.sleep(for: .seconds(3)) showControls = false } } // MARK: - Fullscreen private func toggleFullscreen() { guard let widget = mpvWidget else { return } let root = gtk_widget_get_root(widget.cast()) guard let root else { return } if isFullscreen { gtk_window_unfullscreen(root.cast()) } else { gtk_window_fullscreen(root.cast()) } isFullscreen.toggle() } } // 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 var playbackState: PlayerPlaybackState? var onWidgetCreated: ((OpaquePointer) -> Void)? init( url: String?, isPlaying: Binding, position: Binding, duration: Binding, playbackState: PlayerPlaybackState? = nil, onWidgetCreated: ((OpaquePointer) -> Void)? = nil ) { self.url = url self._isPlaying = isPlaying self._position = position self._duration = duration self.playbackState = playbackState self.onWidgetCreated = onWidgetCreated } 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 ctx = SignalContext( duration: _duration, isPlaying: _isPlaying, playbackState: playbackState ) storage.fields["ctx"] = ctx let ctxPtr = Unmanaged.passUnretained(ctx).toOpaque() let posHandler: @convention(c) ( OpaquePointer?, Double, UnsafeMutableRawPointer? ) -> Void = { _, pos, ptr in let ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() if let scale = ctx.playbackState?.seekScale { let range = unsafeBitCast(scale, to: UnsafeMutablePointer?.self) gtk_range_set_value(range, 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 ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() ctx.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 ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() ctx.isPlaying.wrappedValue = paused == 0 } g_signal_connect_data( widgetPtr, "playback-state-changed", unsafeBitCast(stateHandler, to: GCallback.self), ctxPtr, nil, GConnectFlags(rawValue: 1)) } if let ptr = storage.opaquePointer, let onWidgetCreated { onWidgetCreated(ptr) } 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 context class SignalContext { var duration: Binding var isPlaying: Binding weak var playbackState: PlayerPlaybackState? init( duration: Binding, isPlaying: Binding, playbackState: PlayerPlaybackState? ) { self.duration = duration self.isPlaying = isPlaying self.playbackState = playbackState } }