Rewrite PlayerView with real mpv widget and playback lifecycle
This commit is contained in:
parent
6c246eadaa
commit
93667ac65a
3 changed files with 257 additions and 57 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue