Add video player widget, player view, controls, and app state wiring

This commit is contained in:
Brendan Szymanski 2026-06-05 01:51:35 -04:00
parent c521d8290d
commit 05caf7b32d
8 changed files with 318 additions and 21 deletions

View file

@ -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",

View file

@ -0,0 +1,5 @@
module CMPV [system] {
header "shim.h"
link "mpv"
export *
}

3
Sources/CMPV/shim.h Normal file
View file

@ -0,0 +1,3 @@
#include <mpv/client.h>
#include <mpv/render.h>
#include <mpv/render_gl.h>

View file

@ -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
}
}

View file

@ -1,6 +1,8 @@
import Adwaita
import LuminateCore
import LuminateHome
import LuminateLibrary
import LuminatePlayer
@main
struct Luminate: App {
@ -37,9 +39,21 @@ struct ContentView: View {
var window: AdwaitaWindow
var client: JellyfinClient
var userId: String
@State private var appState: AppState?
@State private var stack = NavigationStack<String>()
var view: Body {
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() }
)
} else {
NavigationView($stack, "Luminate") { _ in
Text("")
} initialView: {
@ -54,11 +68,11 @@ struct ContentView: View {
HeaderBar.end {
Menu(icon: .default(icon: .openMenu)) {
MenuButton("Search", window: false) {
// Navigate to search
}
MenuSection {
MenuButton("About", window: false) {
// About dialog
}
}
}
@ -66,5 +80,11 @@ struct ContentView: View {
.tooltip("Main Menu")
}
}
.onAppear {
if appState == nil {
appState = AppState(client: client, userId: userId)
}
}
}
}
}

View file

@ -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)
}
}

View file

@ -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)
)
)
}
}
}

View file

@ -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>(
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<Data>(
_ 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<AnyObject>.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<AnyObject>.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<AnyObject>.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<AnyObject>.fromOpaque(mpvPtr).takeUnretainedValue()
let mpvHandle = mpv.opaque(OpaquePointer.self)
mpv_terminate_destroy(mpvHandle)
}
}
private func mpv_get_proc_address(
_ ctx: UnsafeMutableRawPointer?,
_ name: UnsafePointer<CChar>?
) -> UnsafeMutableRawPointer? {
guard let name else { return nil }
return glXGetProcAddress(name)
}