340 lines
11 KiB
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
|
|
}
|
|
}
|
|
|