From 05caf7b32dce820a21474f0a8dc0b7e90d8843dc Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 5 Jun 2026 01:51:35 -0400 Subject: [PATCH] Add video player widget, player view, controls, and app state wiring --- Package.swift | 6 +- Sources/CMPV/module.modulemap | 5 + Sources/CMPV/shim.h | 3 + Sources/Luminate/AppState.swift | 20 +++ Sources/Luminate/Luminate.swift | 60 ++++++--- Sources/LuminatePlayer/PlayerControls.swift | 54 ++++++++ Sources/LuminatePlayer/PlayerView.swift | 76 ++++++++++++ .../LuminatePlayer/VideoPlayerWidget.swift | 115 ++++++++++++++++++ 8 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 Sources/CMPV/module.modulemap create mode 100644 Sources/CMPV/shim.h create mode 100644 Sources/Luminate/AppState.swift create mode 100644 Sources/LuminatePlayer/PlayerControls.swift create mode 100644 Sources/LuminatePlayer/PlayerView.swift create mode 100644 Sources/LuminatePlayer/VideoPlayerWidget.swift diff --git a/Package.swift b/Package.swift index 194f86d..4349158 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,10 @@ let package = Package( .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator") ] ), + .target( + name: "CMPV", + dependencies: [] + ), .target( name: "LuminateHome", dependencies: ["LuminateCore", .product(name: "Adwaita", package: "adwaita-swift")] @@ -32,7 +36,7 @@ let package = Package( ), .target( name: "LuminatePlayer", - dependencies: ["LuminateCore", .product(name: "Adwaita", package: "adwaita-swift")] + dependencies: ["LuminateCore", "CMPV", .product(name: "Adwaita", package: "adwaita-swift")] ), .executableTarget( name: "Luminate", diff --git a/Sources/CMPV/module.modulemap b/Sources/CMPV/module.modulemap new file mode 100644 index 0000000..5376dd9 --- /dev/null +++ b/Sources/CMPV/module.modulemap @@ -0,0 +1,5 @@ +module CMPV [system] { + header "shim.h" + link "mpv" + export * +} diff --git a/Sources/CMPV/shim.h b/Sources/CMPV/shim.h new file mode 100644 index 0000000..28a0822 --- /dev/null +++ b/Sources/CMPV/shim.h @@ -0,0 +1,3 @@ +#include +#include +#include diff --git a/Sources/Luminate/AppState.swift b/Sources/Luminate/AppState.swift new file mode 100644 index 0000000..bee3fb0 --- /dev/null +++ b/Sources/Luminate/AppState.swift @@ -0,0 +1,20 @@ +import LuminateCore + +class AppState: ObservableObject { + @Published var activePlayerItem: Components.Schemas.BaseItemDto? + @Published var client: JellyfinClient + @Published var userId: String + + init(client: JellyfinClient, userId: String) { + self.client = client + self.userId = userId + } + + func startPlayback(item: Components.Schemas.BaseItemDto) { + activePlayerItem = item + } + + func stopPlayback() { + activePlayerItem = nil + } +} diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 69234c3..b6cc799 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -1,6 +1,8 @@ import Adwaita import LuminateCore import LuminateHome +import LuminateLibrary +import LuminatePlayer @main struct Luminate: App { @@ -37,33 +39,51 @@ struct ContentView: View { var window: AdwaitaWindow var client: JellyfinClient var userId: String + @State private var appState: AppState? @State private var stack = NavigationStack() var view: Body { - NavigationView($stack, "Luminate") { _ in - Text("") - } initialView: { - HomeView( - app: app, - window: window, - client: client, - userId: userId + if let appState, let item = appState.activePlayerItem { + PlayerView( + item: item, + client: appState.client, + userId: appState.userId, + mediaSourceId: item.Id ?? "", + playSessionId: "", + streamURL: URL(string: "https://example.com/stream")!, + onClose: { appState.stopPlayback() } ) - } - .topToolbar { - HeaderBar.end { - Menu(icon: .default(icon: .openMenu)) { - MenuButton("Search", window: false) { - // Navigate to search - } - MenuSection { - MenuButton("About", window: false) { - // About dialog + } else { + NavigationView($stack, "Luminate") { _ in + Text("") + } initialView: { + HomeView( + app: app, + window: window, + client: client, + userId: userId + ) + } + .topToolbar { + HeaderBar.end { + Menu(icon: .default(icon: .openMenu)) { + MenuButton("Search", window: false) { + + } + MenuSection { + MenuButton("About", window: false) { + + } } } + .primary() + .tooltip("Main Menu") + } + } + .onAppear { + if appState == nil { + appState = AppState(client: client, userId: userId) } - .primary() - .tooltip("Main Menu") } } } diff --git a/Sources/LuminatePlayer/PlayerControls.swift b/Sources/LuminatePlayer/PlayerControls.swift new file mode 100644 index 0000000..0908805 --- /dev/null +++ b/Sources/LuminatePlayer/PlayerControls.swift @@ -0,0 +1,54 @@ +import Adwaita + +struct PlayerControls: View { + + @Binding var isPlaying: Bool + @Binding var position: Double + @Binding var duration: Double + var onTogglePlay: () -> Void + var onSeekBack: () -> Void + var onSeekForward: () -> Void + var onFullscreen: () -> Void + var onClose: () -> Void + + var view: Body { + HStack { + Button(icon: .default(icon: .windowClose)) { + onClose() + } + .style("flat") + Button(icon: .default(icon: .goPrevious)) { + onSeekBack() + } + .style("flat") + Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) { + onTogglePlay() + } + .style("flat") + Button(icon: .default(icon: .goNext)) { + onSeekForward() + } + .style("flat") + HStack { + Text(formatTime(position)) + .style("caption") + LevelBar(value: duration > 0 ? position / duration : 0) + .hexpand(true) + Text(formatTime(duration)) + .style("caption") + } + .hexpand(true) + Button(icon: .default(icon: .viewFullscreen)) { + onFullscreen() + } + .style("flat") + } + .padding(10) + } + + private func formatTime(_ seconds: Double) -> String { + let m = Int(seconds) / 60 + let s = Int(seconds) % 60 + return String(format: "%d:%02d", m, s) + } +} diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift new file mode 100644 index 0000000..78a9966 --- /dev/null +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -0,0 +1,76 @@ +import Adwaita +import LuminateCore + +struct PlayerView: View { + + var item: Components.Schemas.BaseItemDto + var client: JellyfinClient + var userId: String + var mediaSourceId: String + var playSessionId: String + var streamURL: URL + @State private var isPlaying = true + @State private var position: Double = 0 + @State private var duration: Double = 0 + @State private var showControls = true + var onClose: () -> Void + + var view: Body { + VStack { + VideoPlayerWidget( + url: streamURL.absoluteString, + isPlaying: $isPlaying, + position: $position, + duration: $duration + ) + .hexpand(true) + .vexpand(true) + 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() + } + ) + } + } + .onAppear { + startPlayback() + } + .onDisappear { + stopPlayback() + } + } + + private func startPlayback() { + Task { + try? await client.reportPlaybackStart( + info: .init( + ItemId: item.Id, + PlaySessionId: playSessionId, + MediaSourceId: mediaSourceId + ) + ) + } + } + + private func stopPlayback() { + Task { + try? await client.reportPlaybackStopped( + info: .init( + ItemId: item.Id, + PlaySessionId: playSessionId, + MediaSourceId: mediaSourceId, + PositionTicks: Int64(position * 10_000_000) + ) + ) + } + } +} diff --git a/Sources/LuminatePlayer/VideoPlayerWidget.swift b/Sources/LuminatePlayer/VideoPlayerWidget.swift new file mode 100644 index 0000000..ed7b1e0 --- /dev/null +++ b/Sources/LuminatePlayer/VideoPlayerWidget.swift @@ -0,0 +1,115 @@ +import Adwaita +import CAdw +import CMPV + +struct VideoPlayerWidget: Widget { + + var url: String + @Binding var isPlaying: Bool + @Binding var position: Double + @Binding var duration: Double + + func container( + data: WidgetData, + type: Data.Type + ) -> ViewStorage where Data: ViewRenderData { + let glArea = gtk_gl_area_new()! + gtk_gl_area_set_required_version(glArea, 3, 2) + gtk_gl_area_set_auto_render(glArea, true) + + let mpv = mpv_create()! + mpv_set_option_string(mpv, "vo", "gpu") + mpv_set_option_string(mpv, "hwdec", "auto") + mpv_initialize(mpv) + + let mpvPtr = Unmanaged.passRetained(mpv as AnyObject).toOpaque() + g_object_set_data(glArea, "mpv", mpvPtr) + + g_signal_connect_data(glArea, "realize", GCallback(c_realize), mpvPtr, nil, G_CONNECT_AFTER) + g_signal_connect_data(glArea, "render", GCallback(c_render), mpvPtr, nil, G_CONNECT_AFTER) + g_signal_connect_data(glArea, "unrealize", GCallback(c_unrealize), mpvPtr, nil, G_CONNECT_AFTER) + + return ViewStorage(glArea.pointee.widget.pointee.opaque()) + } + + func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + let ptr = storage.opaquePointer?.opaque(WidgetData.self) + guard let ptr else { return } + } +} + +private func c_realize(widget: UnsafeMutableRawPointer?, data: UnsafeMutableRawPointer?) { + guard let widget, let data else { return } + let glArea = widget.opaque(OpaquePointer.self) + gtk_gl_area_make_current(glArea) + let mpv = Unmanaged.fromOpaque(data).takeUnretainedValue() + let mpvHandle = mpv.opaque(OpaquePointer.self) + + var initParams = mpv_opengl_init_params( + get_proc_address: mpv_get_proc_address, + get_proc_address_ctx: nil + ) + withUnsafeMutablePointer(to: &initParams) { params in + var renderParams: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_INITIALIZATION_PARAMS, data: params), + mpv_render_param() + ] + var renderContext: OpaquePointer? + mpv_render_context_create(&renderContext, mpvHandle, &renderParams) + if let renderContext { + let ctxPtr = Unmanaged.passRetained(renderContext as AnyObject).toOpaque() + g_object_set_data(glArea, "mpv-render-context", ctxPtr) + } + } +} + +private func c_render(widget: UnsafeMutableRawPointer?, data: UnsafeMutableRawPointer?) -> Bool { + guard let widget else { return false } + let glArea = widget.opaque(OpaquePointer.self) + guard let ctxPtr = g_object_get_data(glArea, "mpv-render-context") else { return false } + let renderContext = Unmanaged.fromOpaque(ctxPtr).takeUnretainedValue() + let renderCtx = renderContext.opaque(OpaquePointer.self) + + var fbo: Int32 = 0 + glGetIntegerv(GLenum(GL_FRAMEBUFFER_BINDING), &fbo) + var dims: [Int32] = [0, 0] + dims[0] = gtk_widget_get_width(gtk_widget_get_parent(glArea)) + dims[1] = gtk_widget_get_height(gtk_widget_get_parent(glArea)) + + var renderParams: [mpv_render_param] = [ + mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: &fbo), + mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: [Int32(1)]), + mpv_render_param(type: MPV_RENDER_PARAM_PRESENT_FENCE, data: [Int32(0)]), + mpv_render_param() + ] + mpv_render_context_render(renderCtx, &renderParams) + return true +} + +private func c_unrealize(widget: UnsafeMutableRawPointer?, data: UnsafeMutableRawPointer?) { + guard let widget else { return } + let glArea = widget.opaque(OpaquePointer.self) + if let ctxPtr = g_object_get_data(glArea, "mpv-render-context") { + let renderContext = Unmanaged.fromOpaque(ctxPtr).takeUnretainedValue() + let renderCtx = renderContext.opaque(OpaquePointer.self) + mpv_render_context_free(renderCtx) + } + if let mpvPtr = g_object_get_data(glArea, "mpv") { + let mpv = Unmanaged.fromOpaque(mpvPtr).takeUnretainedValue() + let mpvHandle = mpv.opaque(OpaquePointer.self) + mpv_terminate_destroy(mpvHandle) + } +} + +private func mpv_get_proc_address( + _ ctx: UnsafeMutableRawPointer?, + _ name: UnsafePointer? +) -> UnsafeMutableRawPointer? { + guard let name else { return nil } + return glXGetProcAddress(name) +}