Add video player widget, player view, controls, and app state wiring
This commit is contained in:
parent
c521d8290d
commit
05caf7b32d
8 changed files with 318 additions and 21 deletions
|
|
@ -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",
|
||||
|
|
|
|||
5
Sources/CMPV/module.modulemap
Normal file
5
Sources/CMPV/module.modulemap
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module CMPV [system] {
|
||||
header "shim.h"
|
||||
link "mpv"
|
||||
export *
|
||||
}
|
||||
3
Sources/CMPV/shim.h
Normal file
3
Sources/CMPV/shim.h
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#include <mpv/client.h>
|
||||
#include <mpv/render.h>
|
||||
#include <mpv/render_gl.h>
|
||||
20
Sources/Luminate/AppState.swift
Normal file
20
Sources/Luminate/AppState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
Sources/LuminatePlayer/PlayerControls.swift
Normal file
54
Sources/LuminatePlayer/PlayerControls.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
76
Sources/LuminatePlayer/PlayerView.swift
Normal file
76
Sources/LuminatePlayer/PlayerView.swift
Normal 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Sources/LuminatePlayer/VideoPlayerWidget.swift
Normal file
115
Sources/LuminatePlayer/VideoPlayerWidget.swift
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue