luminate-old/Sources/LuminatePlayer/PlayerView.swift

340 lines
11 KiB
Swift

//
// PlayerView.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// 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<Void, Never>?
var controls: Task<Void, Never>?
}
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<Bool>,
position: Binding<Double>,
duration: Binding<Double>,
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>(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<SignalContext>.fromOpaque(ptr!).takeUnretainedValue()
if let scale = ctx.playbackState?.seekScale {
let range = unsafeBitCast(scale, to: UnsafeMutablePointer<GtkRange>?.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<SignalContext>.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<SignalContext>.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<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 context
class SignalContext {
var duration: Binding<Double>
var isPlaying: Binding<Bool>
weak var playbackState: PlayerPlaybackState?
init(
duration: Binding<Double>,
isPlaying: Binding<Bool>,
playbackState: PlayerPlaybackState?
) {
self.duration = duration
self.isPlaying = isPlaying
self.playbackState = playbackState
}
}