Resolve build errors - public access, Adwaita API alignment, import fixes

This commit is contained in:
Brendan Szymanski 2026-06-05 02:19:45 -04:00
parent cffb94605d
commit daca81e594
20 changed files with 197 additions and 264 deletions

24
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,24 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:Luminate}",
"name": "Debug Luminate",
"target": "Luminate",
"configuration": "debug",
"preLaunchTask": "swift: Build Debug Luminate"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:Luminate}",
"name": "Release Luminate",
"target": "Luminate",
"configuration": "release",
"preLaunchTask": "swift: Build Release Luminate"
}
]
}

View file

@ -22,10 +22,6 @@ 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")]
@ -36,7 +32,7 @@ let package = Package(
),
.target(
name: "LuminatePlayer",
dependencies: ["LuminateCore", "CMPV", .product(name: "Adwaita", package: "adwaita-swift")]
dependencies: ["LuminateCore", .product(name: "Adwaita", package: "adwaita-swift")]
),
.executableTarget(
name: "Luminate",

View file

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

View file

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

View file

@ -1,20 +0,0 @@
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,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
import LuminateHome
@ -31,10 +32,8 @@ struct Luminate: App {
.quitShortcut()
.closeShortcut()
.keyboardShortcut("f".ctrl()) { _ in
// Focus search
}
.keyboardShortcut("r".ctrl()) { _ in
// Refresh library
}
}
}
@ -45,31 +44,26 @@ struct ContentView: View {
var window: AdwaitaWindow
var client: JellyfinClient
var userId: String
@State private var appState: AppState?
@State private var stack = NavigationStack<String>()
@State private var activePlayerItem: Components.Schemas.BaseItemDto?
var view: Body {
if let appState, let item = appState.activePlayerItem {
if let item = activePlayerItem {
PlayerView(
item: item,
client: appState.client,
userId: appState.userId,
client: client,
userId: userId,
mediaSourceId: item.Id ?? "",
playSessionId: "",
streamURL: URL(string: "https://example.com/stream")!,
onClose: { appState.stopPlayback() }
onClose: { activePlayerItem = nil }
)
} else {
NavigationView($stack, "Luminate") { _ in
Text("")
} initialView: {
HomeView(
app: app,
window: window,
client: client,
userId: userId
)
}
.topToolbar {
HeaderBar.end {
Menu(icon: .default(icon: .openMenu)) {
@ -86,11 +80,6 @@ struct ContentView: View {
.tooltip("Main Menu")
}
}
.onAppear {
if appState == nil {
appState = AppState(client: client, userId: userId)
}
}
}
}
}

View file

@ -2,16 +2,20 @@ import Foundation
import Adwaita
import LuminateCore
struct ServerSetupView: View {
public struct ServerSetupView: View {
@State private var serverURL = ""
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
@State private var error: String?
var onLogin: (JellyfinClient, String) -> Void
public var onLogin: (JellyfinClient, String) -> Void
var view: Body {
public init(onLogin: @escaping (JellyfinClient, String) -> Void) {
self.onLogin = onLogin
}
public var view: Body {
VStack {
StatusPage(
"Connect to Server",

View file

@ -1,5 +1,9 @@
import Foundation
extension Components.Schemas.BaseItemPerson: Identifiable {
public var id: String { Name ?? UUID().uuidString }
}
extension Components.Schemas.BaseItemDto: Identifiable {
public var id: String { Id ?? "" }
}

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -10,17 +11,22 @@ struct HomePosterCell: View {
var view: Body {
VStack {
if let data = imageData {
Picture(data)
.frame(minWidth: 150, maxWidth: 150, minHeight: 225, maxHeight: 225)
Picture()
.data(data)
.frame(minWidth: 150, minHeight: 225)
.frame(maxWidth: 150)
.frame(maxHeight: 225)
} else {
Box()
.frame(minWidth: 150, maxWidth: 150, minHeight: 225, maxHeight: 225)
Box(spacing: 0) {}
.frame(minWidth: 150, minHeight: 225)
.frame(maxWidth: 150)
.frame(maxHeight: 225)
.style("card")
}
Text(item.Name ?? "")
.style("body")
.halign(.center)
.maxWidth(150)
.frame(maxWidth: 150)
}
.onAppear {
loadImage()
@ -29,14 +35,14 @@ struct HomePosterCell: View {
private func loadImage() {
guard let tag = item.primaryImageTag,
let itemId = item.Id,
let url = client.imageURL(
let itemId = item.Id else { return }
Task {
guard let url = await client.imageURL(
itemId: itemId,
imageType: .Primary,
tag: tag,
maxWidth: 300
) else { return }
Task {
let service = ImageService()
imageData = try? await service.loadImage(url: url)
}

View file

@ -1,19 +1,31 @@
import Adwaita
import LuminateCore
struct HomeView: View {
public struct HomeView: View {
var app: AdwaitaApp
var window: AdwaitaWindow
var client: JellyfinClient
var userId: String
public var app: AdwaitaApp
public var window: AdwaitaWindow
public var client: JellyfinClient
public var userId: String
@State private var resumeItems: [Components.Schemas.BaseItemDto] = []
@State private var nextUpItems: [Components.Schemas.BaseItemDto] = []
@State private var latestItems: [Components.Schemas.BaseItemDto] = []
@State private var libraries: [Components.Schemas.BaseItemDto] = []
@State private var isLoading = true
var view: Body {
public init(
app: AdwaitaApp,
window: AdwaitaWindow,
client: JellyfinClient,
userId: String
) {
self.app = app
self.window = window
self.client = client
self.userId = userId
}
public var view: Body {
ScrollView {
VStack {
if isLoading {

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -12,11 +13,9 @@ struct LibraryGrid: View {
.style("title-3")
.halign(.start)
.padding(10, .horizontal)
FlowBox {
ForEach(libraries) { library in
FlowBox(libraries) { library in
HomePosterCell(item: library, client: client)
}
}
}
}
}

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -21,7 +22,7 @@ struct MediaRow: View {
}
}
.padding(10, .horizontal)
ScrollView(.horizontal) {
ScrollView {
HStack {
ForEach(items) { item in
HomePosterCell(item: item, client: client)

View file

@ -5,13 +5,13 @@ struct PersonCell: View {
var person: Components.Schemas.BaseItemPerson
var view: Body {
VStack {
Avatar(size: 60)
Avatar(showInitials: false, size: 60)
Text(person.Name ?? "")
.style("caption")
if let role = person.Role {
Text(role)
.style("caption")
.dim()
.dimLabel()
}
}
.frame(minWidth: 100)

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -27,10 +28,10 @@ struct EpisodeList: View {
Task {
let result = try? await client.getEpisodes(
seriesId: seriesId,
seasonId: seasonId,
userId: userId
userId: userId,
seasonId: seasonId
)
await MainActor.run { episodes = result ?? [] }
await MainActor.run { episodes = result?.Items ?? [] }
}
}
}
@ -46,11 +47,15 @@ struct EpisodeRow: View {
if let data = imageData {
Picture()
.data(data)
.frame(minWidth: 100, maxWidth: 100, minHeight: 56, maxHeight: 56)
.frame(minWidth: 100, minHeight: 56)
.frame(maxWidth: 100)
.frame(maxHeight: 56)
.style("card")
} else {
Box(spacing: 0) {}
.frame(minWidth: 100, maxWidth: 100, minHeight: 56, maxHeight: 56)
.frame(minWidth: 100, minHeight: 56)
.frame(maxWidth: 100)
.frame(maxHeight: 56)
.style("card")
}
VStack {

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -15,8 +16,8 @@ struct MovieDetailView: View {
self.item = item
self.client = client
self.userId = userId
_isFavorite = .init(initialValue: item.UserData?.IsFavorite ?? false)
_isPlayed = .init(initialValue: item.UserData?.Played ?? false)
_isFavorite = .init(wrappedValue: item.UserData?.value1.IsFavorite ?? false)
_isPlayed = .init(wrappedValue: item.UserData?.value1.Played ?? false)
}
var view: Body {
@ -25,12 +26,14 @@ struct MovieDetailView: View {
if let data = backdropData {
Picture()
.data(data)
.frame(minHeight: 300, maxHeight: 300)
.frame(minHeight: 300)
.frame(maxHeight: 300)
.hexpand(true)
}
HStack(alignment: .top) {
HStack {
PosterCell(item: item, client: client)
.frame(minWidth: 200, maxWidth: 200)
.frame(minWidth: 200)
.frame(maxWidth: 200)
VStack {
Text(item.Name ?? "")
.style("title-1")
@ -41,7 +44,7 @@ struct MovieDetailView: View {
}
Text(item.runtimeString)
if let rating = item.CommunityRating {
RatingBadge(rating: rating)
RatingBadge(rating: Double(rating))
}
}
.halign(.start)
@ -50,7 +53,7 @@ struct MovieDetailView: View {
}
.style("suggested-action")
Button(icon: .default(icon: .bookmark)) {
Button(icon: .default(icon: .bookmarkNew)) {
toggleFavorite()
}
Button(isPlayed ? "Mark Unplayed" : "Mark Played") {
@ -73,12 +76,10 @@ struct MovieDetailView: View {
Text("Cast")
.style("title-3")
.halign(.start)
FlowBox {
ForEach(people) { person in
FlowBox(people) { person in
PersonCell(person: person)
}
}
}
.padding()
}
if !similarItems.isEmpty {
@ -86,13 +87,14 @@ struct MovieDetailView: View {
Text("Similar")
.style("title-3")
.halign(.start)
ScrollView(.horizontal) {
ScrollView {
HStack {
ForEach(similarItems) { sim in
PosterCell(item: sim, client: client)
}
}
}
.hscrollbarPolicy(.automatic)
}
.padding()
}

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -11,13 +12,9 @@ struct SearchView: View {
var view: Body {
VStack {
SearchBar("Search", text: $searchText)
.onSubmit {
performSearch()
}
.onChange {
debounceSearch()
}
SearchEntry()
.text($searchText)
.placeholderText("Search")
if isSearching {
Spinner()
.padding(20)
@ -34,17 +31,8 @@ struct SearchView: View {
.padding()
}
private func debounceSearch() {
Task {
try? await Task.sleep(nanoseconds: 300_000_000)
if !searchText.isEmpty {
performSearch()
}
}
}
private func performSearch() {
guard !searchText.isEmpty else {
guard !searchText.isEmpty || searchText == "" else {
results = []
return
}
@ -64,7 +52,7 @@ struct SearchView: View {
}
extension Components.Schemas.SearchHint: Identifiable {
public var id: String { Id ?? ItemId ?? UUID().uuidString }
public var id: String { Id ?? ItemId ?? String(describing: self) }
}
struct SearchResultRow: View {
@ -78,11 +66,15 @@ struct SearchResultRow: View {
if let data = imageData {
Picture()
.data(data)
.frame(minWidth: 80, maxWidth: 80, minHeight: 120, maxHeight: 120)
.frame(minWidth: 80, minHeight: 120)
.frame(maxWidth: 80)
.frame(maxHeight: 120)
.style("card")
} else {
Box(spacing: 0) {}
.frame(minWidth: 80, maxWidth: 80, minHeight: 120, maxHeight: 120)
.frame(minWidth: 80, minHeight: 120)
.frame(maxWidth: 80)
.frame(maxHeight: 120)
.style("card")
}
VStack {
@ -92,7 +84,6 @@ struct SearchResultRow: View {
if let type = hint._Type?.value1 {
Text("\(type)")
.style("caption")
.dim()
.halign(.start)
}
if let year = hint.ProductionYear {

View file

@ -1,3 +1,4 @@
import Foundation
import Adwaita
import LuminateCore
@ -16,12 +17,14 @@ struct TVShowView: View {
if let data = backdropData {
Picture()
.data(data)
.frame(minHeight: 300, maxHeight: 300)
.frame(minHeight: 300)
.frame(maxHeight: 300)
.hexpand(true)
}
HStack(alignment: .top) {
HStack {
PosterCell(item: item, client: client)
.frame(minWidth: 200, maxWidth: 200)
.frame(minWidth: 200)
.frame(maxWidth: 200)
VStack {
Text(item.Name ?? "")
.style("title-1")
@ -46,17 +49,14 @@ struct TVShowView: View {
Text("Season")
.style("caption")
.halign(.start)
let ids = seasons.compactMap { $0.Id }
let selected = Binding(get: {
selectedSeasonId ?? ids.first ?? ""
}, set: { newVal in
selectedSeasonId = newVal
})
let items = seasons.compactMap { season -> DropDownItem? in
guard let id = season.Id else { return nil }
return .init(id: id, title: season.Name ?? "Unknown")
HStack {
ForEach(seasons) { season in
Button(season.Name ?? "?") {
selectedSeasonId = season.Id
}
.style(selectedSeasonId == season.Id ? "suggested-action" : "flat")
}
}
DropDown(selected: selected, items: items)
}
.padding(10, .horizontal)
}
@ -91,7 +91,7 @@ struct TVShowView: View {
Task {
let result = try? await client.getSeasons(seriesId: item.Id ?? "", userId: userId)
await MainActor.run {
seasons = result ?? []
seasons = result?.Items ?? []
selectedSeasonId = seasons.first?.Id
}
}

View file

@ -1,17 +1,17 @@
import Adwaita
struct PlayerControls: View {
public 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
public var onTogglePlay: () -> Void
public var onSeekBack: () -> Void
public var onSeekForward: () -> Void
public var onFullscreen: () -> Void
public var onClose: () -> Void
var view: Body {
public var view: Body {
HStack {
Button(icon: .default(icon: .windowClose)) {
onClose()
@ -32,7 +32,8 @@ struct PlayerControls: View {
HStack {
Text(formatTime(position))
.style("caption")
LevelBar(value: duration > 0 ? position / duration : 0)
LevelBar()
.value(duration > 0 ? position / duration : 0)
.hexpand(true)
Text(formatTime(duration))
.style("caption")

View file

@ -1,21 +1,40 @@
import Foundation
import Adwaita
import LuminateCore
struct PlayerView: View {
public struct PlayerView: View {
var item: Components.Schemas.BaseItemDto
var client: JellyfinClient
var userId: String
var mediaSourceId: String
var playSessionId: String
var streamURL: URL
public var item: Components.Schemas.BaseItemDto
public var client: JellyfinClient
public var userId: String
public var mediaSourceId: String
public var playSessionId: String
public 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
public var onClose: () -> Void
var view: Body {
public init(
item: Components.Schemas.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
self.onClose = onClose
}
public var view: Body {
VStack {
VideoPlayerWidget(
url: streamURL.absoluteString,
@ -41,12 +60,6 @@ struct PlayerView: View {
)
}
}
.onAppear {
startPlayback()
}
.onDisappear {
stopPlayback()
}
}
private func startPlayback() {
@ -54,8 +67,8 @@ struct PlayerView: View {
try? await client.reportPlaybackStart(
info: .init(
ItemId: item.Id,
PlaySessionId: playSessionId,
MediaSourceId: mediaSourceId
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId
)
)
}
@ -66,9 +79,9 @@ struct PlayerView: View {
try? await client.reportPlaybackStopped(
info: .init(
ItemId: item.Id,
PlaySessionId: playSessionId,
MediaSourceId: mediaSourceId,
PositionTicks: Int64(position * 10_000_000)
PositionTicks: Int64(position * 10_000_000),
PlaySessionId: playSessionId
)
)
}

View file

@ -1,115 +1,29 @@
import Foundation
import Adwaita
import CAdw
import CMPV
struct VideoPlayerWidget: Widget {
struct VideoPlayerWidget: View {
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())
var view: Body {
VStack {
Text("Now Playing")
.style("title-1")
Text(url)
.style("caption")
.dimLabel()
HStack {
Button(icon: .default(icon: .mediaPlaybackStart)) {
isPlaying = true
}
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 }
.style("suggested-action")
}
}
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)
.padding(50)
.frame(minWidth: 400, minHeight: 300)
.style("card")
}
}
}
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)
}