Add InjectionValues property container

This commit is contained in:
Brendan Szymanski 2026-06-10 17:02:19 -04:00
parent e6d44ac1ea
commit cc5c836f04
20 changed files with 266 additions and 198 deletions

View file

@ -52,7 +52,7 @@ struct ContentView: View {
item: item, item: item,
client: client, client: client,
userId: userId, userId: userId,
mediaSourceId: item.Id ?? "", mediaSourceId: item.id ?? "",
playSessionId: "", playSessionId: "",
streamURL: URL(string: "https://example.com/stream")!, streamURL: URL(string: "https://example.com/stream")!,
onClose: { activePlayerItem = nil } onClose: { activePlayerItem = nil }

View file

@ -61,7 +61,7 @@ public struct ServerSetupView: View {
do { do {
let client = JellyfinClient(serverURL: url) let client = JellyfinClient(serverURL: url)
let result = try await client.authenticate(username: username, password: password) let result = try await client.authenticate(username: username, password: password)
let userId = result.User?.value1.Id ?? "" let userId = result.user?.value1.id ?? ""
isLoading = false isLoading = false
onLogin(client, userId) onLogin(client, userId)

View file

@ -1,16 +1,12 @@
import Foundation import Foundation
extension Components.Schemas.BaseItemPerson: Identifiable { extension Components.Schemas.BaseItemPerson: Identifiable { }
public var id: String { Name ?? UUID().uuidString }
}
extension Components.Schemas.BaseItemDto: Identifiable { extension Components.Schemas.BaseItemDto: Identifiable { }
public var id: String { Id ?? "" }
}
public extension Components.Schemas.SearchHint { public extension Components.Schemas.SearchHint {
var runtimeString: String { var runtimeString: String {
guard let ticks = RunTimeTicks else { return "" } guard let ticks = runTimeTicks else { return "" }
let totalSeconds = Int(ticks / 10_000_000) let totalSeconds = Int(ticks / 10_000_000)
let hours = totalSeconds / 3600 let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60 let minutes = (totalSeconds % 3600) / 60
@ -20,14 +16,8 @@ public extension Components.Schemas.SearchHint {
} }
public extension Components.Schemas.BaseItemDto { public extension Components.Schemas.BaseItemDto {
var isMovie: Bool { _Type?.value1 == .Movie }
var isSeries: Bool { _Type?.value1 == .Series }
var isEpisode: Bool { _Type?.value1 == .Episode }
var isSeason: Bool { _Type?.value1 == .Season }
var isFolder: Bool { IsFolder ?? false }
var runtimeString: String { var runtimeString: String {
guard let ticks = RunTimeTicks else { return "" } guard let ticks = runTimeTicks else { return "" }
let totalSeconds = Int(ticks / 10_000_000) let totalSeconds = Int(ticks / 10_000_000)
let hours = totalSeconds / 3600 let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60 let minutes = (totalSeconds % 3600) / 60
@ -36,15 +26,15 @@ public extension Components.Schemas.BaseItemDto {
} }
var yearString: String { var yearString: String {
guard let year = ProductionYear else { return "" } guard let year = productionYear else { return "" }
return "\(year)" return "\(year)"
} }
var primaryImageTag: String? { var primaryImageTag: String? {
ImageTags?.additionalProperties["Primary"] imageTags?.additionalProperties["Primary"]
} }
var backdropImageTag: String? { var backdropImageTag: String? {
BackdropImageTags?.first backdropImageTags?.first
} }
} }

View file

@ -0,0 +1,12 @@
import Foundation
public struct InjectionValues {
public var client: JellyfinClient?
public var userId: String?
public var imageService: ImageService?
public var webSocketClient: WebSocketClient?
public init() {}
}

View file

@ -95,29 +95,29 @@ public actor JellyfinClient {
} }
public func authenticate(username: String, password: String) async throws -> Components.Schemas.AuthenticationResult { public func authenticate(username: String, password: String) async throws -> Components.Schemas.AuthenticationResult {
let response = try await client.AuthenticateUserByName(Operations.AuthenticateUserByName.Input( let response = try await client.authenticateUserByName(Operations.AuthenticateUserByName.Input(
body: .json(.init(value1: .init(Username: username, Pw: password))) body: .json(.init(value1: .init(username: username, pw: password)))
)) ))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse } guard let accessToken = result.accessToken else { throw JellyfinError.invalidResponse }
token = accessToken token = accessToken
client = makeClient(token: accessToken) client = makeClient(token: accessToken)
userId = result.User?.value1.Id userId = result.user?.value1.id
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse } guard let accessToken = result.accessToken else { throw JellyfinError.invalidResponse }
token = accessToken token = accessToken
client = makeClient(token: accessToken) client = makeClient(token: accessToken)
userId = result.User?.value1.Id userId = result.user?.value1.id
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse } guard let accessToken = result.accessToken else { throw JellyfinError.invalidResponse }
token = accessToken token = accessToken
client = makeClient(token: accessToken) client = makeClient(token: accessToken)
userId = result.User?.value1.Id userId = result.user?.value1.id
return result return result
} }
case .serviceUnavailable: case .serviceUnavailable:
@ -154,15 +154,15 @@ public actor JellyfinClient {
query.limit = limit query.limit = limit
query.recursive = recursive query.recursive = recursive
query.isFavorite = isFavorite query.isFavorite = isFavorite
let response = try await client.GetItems(Operations.GetItems.Input(query: query)) let response = try await client.getItems(Operations.GetItems.Input(query: query))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -178,7 +178,7 @@ public actor JellyfinClient {
public func getItem(itemId: String, userId: String? = nil) async throws -> Components.Schemas.BaseItemDto { public func getItem(itemId: String, userId: String? = nil) async throws -> Components.Schemas.BaseItemDto {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.GetItem(Operations.GetItem.Input( let response = try await client.getItem(Operations.GetItem.Input(
path: .init(itemId: itemId), path: .init(itemId: itemId),
query: .init(userId: userId) query: .init(userId: userId)
)) ))
@ -187,9 +187,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -205,7 +205,7 @@ public actor JellyfinClient {
public func getUserViews(userId: String) async throws -> Components.Schemas.BaseItemDtoQueryResult { public func getUserViews(userId: String) async throws -> Components.Schemas.BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.GetUserViews(Operations.GetUserViews.Input( let response = try await client.getUserViews(Operations.GetUserViews.Input(
query: .init(userId: userId) query: .init(userId: userId)
)) ))
switch response { switch response {
@ -213,9 +213,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -248,15 +248,15 @@ public actor JellyfinClient {
query.parentId = parentId query.parentId = parentId
query.enableResumable = enableResumable query.enableResumable = enableResumable
query.enableRewatching = enableRewatching query.enableRewatching = enableRewatching
let response = try await client.GetNextUp(Operations.GetNextUp.Input(query: query)) let response = try await client.getNextUp(Operations.GetNextUp.Input(query: query))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -288,7 +288,7 @@ public actor JellyfinClient {
query.imageTypeLimit = imageTypeLimit query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData query.enableUserData = enableUserData
let response = try await client.GetSeasons(Operations.GetSeasons.Input( let response = try await client.getSeasons(Operations.GetSeasons.Input(
path: .init(seriesId: seriesId), path: .init(seriesId: seriesId),
query: query query: query
)) ))
@ -297,9 +297,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .notFound: case .notFound:
@ -339,7 +339,7 @@ public actor JellyfinClient {
query.imageTypeLimit = imageTypeLimit query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData query.enableUserData = enableUserData
let response = try await client.GetEpisodes(Operations.GetEpisodes.Input( let response = try await client.getEpisodes(Operations.GetEpisodes.Input(
path: .init(seriesId: seriesId), path: .init(seriesId: seriesId),
query: query query: query
)) ))
@ -348,9 +348,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .notFound: case .notFound:
@ -381,15 +381,15 @@ public actor JellyfinClient {
query.limit = limit query.limit = limit
query.includeItemTypes = includeItemTypes query.includeItemTypes = includeItemTypes
query.parentId = parentId query.parentId = parentId
let response = try await client.GetSearchHints(Operations.GetSearchHints.Input(query: query)) let response = try await client.getSearchHints(Operations.GetSearchHints.Input(query: query))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -405,7 +405,7 @@ public actor JellyfinClient {
public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws -> Components.Schemas.UserItemDataDto { public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.MarkPlayedItem(Operations.MarkPlayedItem.Input( let response = try await client.markPlayedItem(Operations.MarkPlayedItem.Input(
path: .init(itemId: itemId), path: .init(itemId: itemId),
query: .init(userId: userId, datePlayed: datePlayed) query: .init(userId: userId, datePlayed: datePlayed)
)) ))
@ -414,9 +414,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .notFound: case .notFound:
@ -434,7 +434,7 @@ public actor JellyfinClient {
public func markUnplayedItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto { public func markUnplayedItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.MarkUnplayedItem(Operations.MarkUnplayedItem.Input( let response = try await client.markUnplayedItem(Operations.MarkUnplayedItem.Input(
path: .init(itemId: itemId), path: .init(itemId: itemId),
query: .init(userId: userId) query: .init(userId: userId)
)) ))
@ -443,9 +443,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .notFound: case .notFound:
@ -463,7 +463,7 @@ public actor JellyfinClient {
public func markFavoriteItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto { public func markFavoriteItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.MarkFavoriteItem(Operations.MarkFavoriteItem.Input( let response = try await client.markFavoriteItem(Operations.MarkFavoriteItem.Input(
path: .init(itemId: itemId), path: .init(itemId: itemId),
query: .init(userId: userId) query: .init(userId: userId)
)) ))
@ -472,9 +472,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -490,7 +490,7 @@ public actor JellyfinClient {
public func unmarkFavoriteItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto { public func unmarkFavoriteItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.UnmarkFavoriteItem(Operations.UnmarkFavoriteItem.Input( let response = try await client.unmarkFavoriteItem(Operations.UnmarkFavoriteItem.Input(
path: .init(itemId: itemId), path: .init(itemId: itemId),
query: .init(userId: userId) query: .init(userId: userId)
)) ))
@ -499,9 +499,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:
@ -517,7 +517,7 @@ public actor JellyfinClient {
public func getPlaybackInfo(itemId: String, userId: String) async throws -> Components.Schemas.PlaybackInfoResponse { public func getPlaybackInfo(itemId: String, userId: String) async throws -> Components.Schemas.PlaybackInfoResponse {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.GetPlaybackInfo(Operations.GetPlaybackInfo.Input( let response = try await client.getPlaybackInfo(Operations.GetPlaybackInfo.Input(
path: .init(itemId: itemId), path: .init(itemId: itemId),
query: .init(userId: userId) query: .init(userId: userId)
)) ))
@ -526,9 +526,9 @@ public actor JellyfinClient {
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .notFound: case .notFound:
@ -546,7 +546,7 @@ public actor JellyfinClient {
public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws { public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.ReportPlaybackStart(Operations.ReportPlaybackStart.Input( let response = try await client.reportPlaybackStart(Operations.ReportPlaybackStart.Input(
body: .json(.init(value1: info)) body: .json(.init(value1: info))
)) ))
switch response { switch response {
@ -565,7 +565,7 @@ public actor JellyfinClient {
public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws { public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.ReportPlaybackProgress(Operations.ReportPlaybackProgress.Input( let response = try await client.reportPlaybackProgress(Operations.ReportPlaybackProgress.Input(
body: .json(.init(value1: info)) body: .json(.init(value1: info))
)) ))
switch response { switch response {
@ -584,7 +584,7 @@ public actor JellyfinClient {
public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws { public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated } guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.ReportPlaybackStopped(Operations.ReportPlaybackStopped.Input( let response = try await client.reportPlaybackStopped(Operations.ReportPlaybackStopped.Input(
body: .json(.init(value1: info)) body: .json(.init(value1: info))
)) ))
switch response { switch response {
@ -624,15 +624,15 @@ public actor JellyfinClient {
query.enableImageTypes = enableImageTypes query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData query.enableUserData = enableUserData
query.groupItems = groupItems query.groupItems = groupItems
let response = try await client.GetLatestMedia(Operations.GetLatestMedia.Input(query: query)) let response = try await client.getLatestMedia(Operations.GetLatestMedia.Input(query: query))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
case .json(let result): case .json(let result):
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .applicationJsonProfile_Quot_camelcase_quot_(let result):
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .applicationJsonProfile_Quot_pascalcase_quot_(let result):
return result return result
} }
case .unauthorized: case .unauthorized:

View file

@ -2,3 +2,4 @@ generate:
- types - types
- client - client
accessModifier: public accessModifier: public
namingStrategy: idiomatic

View file

@ -0,0 +1,40 @@
//
// AnyView+Overflow.swift
// Luminate
//
// Created by Brendan Szymanski on 6/8/26.
//
import Adwaita
import CAdw
/// The overflow behavior for a widget.
public enum Overflow: Int {
/// Content is not clipped.
case visible
/// Content is clipped to the widget's bounds.
case hidden
/// Get the GtkOverflow value.
public var cValue: GtkOverflow {
switch self {
case .visible:
CAdw.GTK_OVERFLOW_VISIBLE
case .hidden:
CAdw.GTK_OVERFLOW_HIDDEN
}
}
}
extension AnyView {
/// Set the overflow behavior of the widget.
public func overflow(_ overflow: Overflow) -> AnyView {
wrapModifier(properties: [overflow]) { storage in
gtk_widget_set_overflow(storage.opaquePointer?.cast(), overflow.cValue)
}
}
}

View file

@ -12,34 +12,47 @@ struct HomePosterCell: View {
VStack { VStack {
if let data = imageData { if let data = imageData {
Picture() Picture()
.contentFit(.cover)
.data(data) .data(data)
.frame(minWidth: 150, minHeight: 225) .frame(minWidth: 200, minHeight: 300)
.frame(maxWidth: 150) .frame(maxWidth: 200)
.frame(maxHeight: 225) .frame(maxHeight: 300)
} else { } else {
Box(spacing: 0) {} Box(spacing: 0) {}
.frame(minWidth: 150, minHeight: 225) .frame(minWidth: 200, minHeight: 300)
.frame(maxWidth: 150) .frame(maxWidth: 200)
.frame(maxHeight: 225) .frame(maxHeight: 300)
.style("card") .card()
} }
Text(item.Name ?? "") VStack(spacing: 0) {
.style("body") Text(item.name ?? "")
.halign(.center) .ellipsize()
.frame(maxWidth: 150) .heading()
.halign(.center)
.frame(maxWidth: 200)
Text(item.yearString)
.ellipsize()
.caption()
.dimLabel()
.halign(.center)
.frame(maxWidth: 200)
}
.padding(4)
} }
.onAppear { .onAppear {
loadImage() loadImage()
} }
.overflow(.hidden)
.card()
} }
private func loadImage() { private func loadImage() {
guard let tag = item.primaryImageTag, guard let tag = item.primaryImageTag,
let itemId = item.Id else { return } let itemId = item.id else { return }
Task { Task {
guard let url = await client.imageURL( guard let url = await client.imageURL(
itemId: itemId, itemId: itemId,
imageType: .Primary, imageType: .primary,
tag: tag, tag: tag,
maxWidth: 300 maxWidth: 300
) else { return } ) else { return }

View file

@ -12,6 +12,9 @@ public struct HomeView: View {
@State private var latestItems: [Components.Schemas.BaseItemDto] = [] @State private var latestItems: [Components.Schemas.BaseItemDto] = []
@State private var libraries: [Components.Schemas.BaseItemDto] = [] @State private var libraries: [Components.Schemas.BaseItemDto] = []
@State private var isLoading = true @State private var isLoading = true
@State private var isLoadingData = false
@Environment("client") var apiClient: JellyfinClient?
public init( public init(
app: AdwaitaApp, app: AdwaitaApp,
@ -27,56 +30,67 @@ public struct HomeView: View {
public var view: Body { public var view: Body {
ScrollView { ScrollView {
VStack { Clamp()
if isLoading { .maximumSize(1550)
Spinner() .tighteningThreshold(550)
.padding(50) .child {
} else { VStack {
if !resumeItems.isEmpty { if isLoading {
MediaRow(
title: "Continue Watching", Spinner()
items: resumeItems, .halign(.center)
client: client .valign(.center)
) .frame(minWidth: 64)
.padding(10, .bottom) .frame(maxWidth: 64)
} else {
if !resumeItems.isEmpty {
MediaRow(
title: "Continue Watching",
items: resumeItems,
client: client
)
.padding(16, .bottom)
}
if !nextUpItems.isEmpty {
MediaRow(
title: "Next Up",
items: nextUpItems,
client: client
)
.padding(16, .bottom)
}
if !latestItems.isEmpty {
MediaRow(
title: "Recently Added",
items: latestItems,
client: client,
onSeeAll: {}
)
.padding(16, .bottom)
}
LibraryGrid(
libraries: libraries,
client: client
)
}
} }
if !nextUpItems.isEmpty { .padding(8, .horizontal)
MediaRow(
title: "Next Up",
items: nextUpItems,
client: client
)
.padding(10, .bottom)
}
if !latestItems.isEmpty {
MediaRow(
title: "Recently Added",
items: latestItems,
client: client,
onSeeAll: {}
)
.padding(10, .bottom)
}
LibraryGrid(
libraries: libraries,
client: client
)
} }
}
} }
.hscrollbarPolicy(.never)
.propagateNaturalHeight()
.onAppear { .onAppear {
loadHomeData() loadHomeData()
} }
} }
private func loadHomeData() { private func loadHomeData() {
isLoading = true
Task { Task {
async let resume = client.getItems( async let resume = client.getItems(
userId: userId, userId: userId,
filters: [.IsResumable], filters: [.isResumable],
sortBy: [.DatePlayed], sortBy: [.datePlayed],
sortOrder: [.Descending], sortOrder: [.descending],
limit: 20, limit: 20,
recursive: true recursive: true
) )
@ -85,16 +99,15 @@ public struct HomeView: View {
async let views = client.getUserViews(userId: userId) async let views = client.getUserViews(userId: userId)
do { do {
let (r, n, l, v) = try await (resume, nextUp, latest, views) let (r, n, l, v) = try await (resume, nextUp, latest, views)
await MainActor.run { resumeItems = r.items ?? []
resumeItems = r.Items ?? [] nextUpItems = n.items ?? []
nextUpItems = n.Items ?? [] latestItems = l
latestItems = l libraries = v.items ?? []
libraries = v.Items ?? [] isLoading = false
isLoading = false
}
} catch { } catch {
await MainActor.run { isLoading = false } isLoading = false
} }
isLoadingData = false
} }
} }
} }

View file

@ -1,2 +0,0 @@
import Adwaita
import LuminateCore

View file

@ -10,7 +10,7 @@ struct MediaRow: View {
var onSeeAll: (() -> Void)? var onSeeAll: (() -> Void)?
var view: Body { var view: Body {
VStack { VStack(spacing: 8) {
HStack { HStack {
Text(title) Text(title)
.style("title-3") .style("title-3")
@ -21,14 +21,15 @@ struct MediaRow: View {
.style("flat") .style("flat")
} }
} }
.padding(10, .horizontal)
ScrollView { ScrollView {
HStack { ForEach(items, horizontal: true) { item in
ForEach(items) { item in HomePosterCell(item: item, client: client)
HomePosterCell(item: item, client: client) .padding(16, .trailing)
} }
} }
} .vscrollbarPolicy(.never)
.hscrollbarPolicy(.external)
} }
} }
} }

View file

@ -6,9 +6,9 @@ struct PersonCell: View {
var view: Body { var view: Body {
VStack { VStack {
Avatar(showInitials: false, size: 60) Avatar(showInitials: false, size: 60)
Text(person.Name ?? "") Text(person.name ?? "")
.style("caption") .style("caption")
if let role = person.Role { if let role = person.role {
Text(role) Text(role)
.style("caption") .style("caption")
.dimLabel() .dimLabel()

View file

@ -31,7 +31,7 @@ struct EpisodeList: View {
userId: userId, userId: userId,
seasonId: seasonId seasonId: seasonId
) )
await MainActor.run { episodes = result?.Items ?? [] } await MainActor.run { episodes = result?.items ?? [] }
} }
} }
} }
@ -59,7 +59,7 @@ struct EpisodeRow: View {
.style("card") .style("card")
} }
VStack { VStack {
Text("\(episode.IndexNumber ?? 0). \(episode.Name ?? "")") Text("\(episode.indexNumber ?? 0). \(episode.name ?? "")")
.style("body") .style("body")
.halign(.start) .halign(.start)
Text(episode.runtimeString) Text(episode.runtimeString)
@ -79,10 +79,10 @@ struct EpisodeRow: View {
} }
private func loadImage() { private func loadImage() {
guard let tag = episode.primaryImageTag, let itemId = episode.Id else { return } guard let tag = episode.primaryImageTag, let itemId = episode.id else { return }
Task { Task {
guard let url = await client.imageURL( guard let url = await client.imageURL(
itemId: itemId, imageType: .Primary, tag: tag, maxWidth: 200 itemId: itemId, imageType: .primary, tag: tag, maxWidth: 200
) else { return } ) else { return }
let service = ImageService() let service = ImageService()
imageData = try? await service.loadImage(url: url) imageData = try? await service.loadImage(url: url)

View file

@ -57,15 +57,15 @@ public struct ItemGrid: View {
userId: userId, userId: userId,
parentId: parentId, parentId: parentId,
includeItemTypes: includeItemTypes, includeItemTypes: includeItemTypes,
fields: [.Overview, .Genres, .People, .MediaSources], fields: [.overview, .genres, .people, .mediaSources],
sortBy: [.SortName], sortBy: [.sortName],
sortOrder: [.Ascending], sortOrder: [.ascending],
startIndex: 0, startIndex: 0,
limit: pageSize, limit: pageSize,
recursive: true recursive: true
) )
await MainActor.run { await MainActor.run {
items = result.Items ?? [] items = result.items ?? []
isLoading = false isLoading = false
} }
} catch { } catch {

View file

@ -16,8 +16,8 @@ struct MovieDetailView: View {
self.item = item self.item = item
self.client = client self.client = client
self.userId = userId self.userId = userId
_isFavorite = .init(wrappedValue: item.UserData?.value1.IsFavorite ?? false) _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
_isPlayed = .init(wrappedValue: item.UserData?.value1.Played ?? false) _isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
} }
var view: Body { var view: Body {
@ -35,15 +35,15 @@ struct MovieDetailView: View {
.frame(minWidth: 200) .frame(minWidth: 200)
.frame(maxWidth: 200) .frame(maxWidth: 200)
VStack { VStack {
Text(item.Name ?? "") Text(item.name ?? "")
.style("title-1") .style("title-1")
.halign(.start) .halign(.start)
HStack { HStack {
if let year = item.ProductionYear { if let year = item.productionYear {
Text("\(year)") Text("\(year)")
} }
Text(item.runtimeString) Text(item.runtimeString)
if let rating = item.CommunityRating { if let rating = item.communityRating {
RatingBadge(rating: Double(rating)) RatingBadge(rating: Double(rating))
} }
} }
@ -62,7 +62,7 @@ struct MovieDetailView: View {
.style("flat") .style("flat")
} }
.padding(10, .vertical) .padding(10, .vertical)
if let overview = item.Overview { if let overview = item.overview {
Text(overview) Text(overview)
.style("body") .style("body")
.halign(.start) .halign(.start)
@ -71,7 +71,7 @@ struct MovieDetailView: View {
.hexpand(true) .hexpand(true)
} }
.padding() .padding()
if let people = item.People, !people.isEmpty { if let people = item.people, !people.isEmpty {
VStack { VStack {
Text("Cast") Text("Cast")
.style("title-3") .style("title-3")
@ -107,10 +107,10 @@ struct MovieDetailView: View {
} }
private func loadBackdrop() { private func loadBackdrop() {
guard let tag = item.backdropImageTag, let itemId = item.Id else { return } guard let tag = item.backdropImageTag, let itemId = item.id else { return }
Task { Task {
guard let url = await client.imageURL( guard let url = await client.imageURL(
itemId: itemId, imageType: .Backdrop, tag: tag, maxWidth: 1920 itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
) else { return } ) else { return }
let service = ImageService() let service = ImageService()
backdropData = try? await service.loadImage(url: url) backdropData = try? await service.loadImage(url: url)
@ -121,22 +121,22 @@ struct MovieDetailView: View {
Task { Task {
let result = try? await client.getItems( let result = try? await client.getItems(
userId: userId, userId: userId,
includeItemTypes: [.Movie], includeItemTypes: [.movie],
fields: [.Overview, .Genres, .MediaSources], fields: [.overview, .genres, .mediaSources],
sortBy: [.SortName], sortBy: [.sortName],
limit: 10, limit: 10,
recursive: true recursive: true
) )
await MainActor.run { similarItems = result?.Items ?? [] } await MainActor.run { similarItems = result?.items ?? [] }
} }
} }
private func toggleFavorite() { private func toggleFavorite() {
Task { Task {
if isFavorite { if isFavorite {
try? await client.unmarkFavoriteItem(itemId: item.Id ?? "", userId: userId) try? await client.unmarkFavoriteItem(itemId: item.id ?? "", userId: userId)
} else { } else {
try? await client.markFavoriteItem(itemId: item.Id ?? "", userId: userId) try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId)
} }
await MainActor.run { isFavorite.toggle() } await MainActor.run { isFavorite.toggle() }
} }
@ -145,9 +145,9 @@ struct MovieDetailView: View {
private func togglePlayed() { private func togglePlayed() {
Task { Task {
if isPlayed { if isPlayed {
try? await client.markUnplayedItem(itemId: item.Id ?? "", userId: userId) try? await client.markUnplayedItem(itemId: item.id ?? "", userId: userId)
} else { } else {
try? await client.markPlayedItem(itemId: item.Id ?? "", userId: userId) try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId)
} }
await MainActor.run { isPlayed.toggle() } await MainActor.run { isPlayed.toggle() }
} }

View file

@ -21,7 +21,7 @@ struct PosterCell: View {
.frame(maxHeight: 225) .frame(maxHeight: 225)
.style("card") .style("card")
} }
Text(item.Name ?? "") Text(item.name ?? "")
.style("body") .style("body")
.halign(.center) .halign(.center)
.frame(maxWidth: 150) .frame(maxWidth: 150)
@ -33,11 +33,11 @@ struct PosterCell: View {
private func loadImage() { private func loadImage() {
guard let tag = item.primaryImageTag, guard let tag = item.primaryImageTag,
let itemId = item.Id else { return } let itemId = item.id else { return }
Task { Task {
let url = await client.imageURL( let url = await client.imageURL(
itemId: itemId, itemId: itemId,
imageType: .Primary, imageType: .primary,
tag: tag, tag: tag,
maxWidth: 300 maxWidth: 300
) )

View file

@ -44,7 +44,7 @@ struct SearchView: View {
limit: 50 limit: 50
) )
await MainActor.run { await MainActor.run {
results = result?.SearchHints ?? [] results = result?.searchHints ?? []
isSearching = false isSearching = false
} }
} }
@ -52,7 +52,7 @@ struct SearchView: View {
} }
extension Components.Schemas.SearchHint: Identifiable { extension Components.Schemas.SearchHint: Identifiable {
public var id: String { Id ?? ItemId ?? String(describing: self) } public var id: String { id ?? itemId ?? String(describing: self) }
} }
struct SearchResultRow: View { struct SearchResultRow: View {
@ -78,15 +78,15 @@ struct SearchResultRow: View {
.style("card") .style("card")
} }
VStack { VStack {
Text(hint.Name ?? "") Text(hint.name ?? "")
.style("body") .style("body")
.halign(.start) .halign(.start)
if let type = hint._Type?.value1 { if let type = hint._type?.value1 {
Text("\(type)") Text("\(type)")
.style("caption") .style("caption")
.halign(.start) .halign(.start)
} }
if let year = hint.ProductionYear { if let year = hint.productionYear {
Text("\(year)") Text("\(year)")
.style("caption") .style("caption")
.halign(.start) .halign(.start)
@ -104,11 +104,11 @@ struct SearchResultRow: View {
} }
private func loadImage() { private func loadImage() {
guard let tag = hint.PrimaryImageTag, guard let tag = hint.primaryImageTag,
let itemId = hint.Id ?? hint.ItemId else { return } let itemId = hint.id ?? hint.itemId else { return }
Task { Task {
guard let url = await client.imageURL( guard let url = await client.imageURL(
itemId: itemId, imageType: .Primary, tag: tag, maxWidth: 160 itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160
) else { return } ) else { return }
let service = ImageService() let service = ImageService()
imageData = try? await service.loadImage(url: url) imageData = try? await service.loadImage(url: url)

View file

@ -26,15 +26,15 @@ struct TVShowView: View {
.frame(minWidth: 200) .frame(minWidth: 200)
.frame(maxWidth: 200) .frame(maxWidth: 200)
VStack { VStack {
Text(item.Name ?? "") Text(item.name ?? "")
.style("title-1") .style("title-1")
.halign(.start) .halign(.start)
if let status = item.Status { if let status = item.status {
Text(status) Text(status)
.style("caption") .style("caption")
.halign(.start) .halign(.start)
} }
if let overview = item.Overview { if let overview = item.overview {
Text(overview) Text(overview)
.style("body") .style("body")
.halign(.start) .halign(.start)
@ -51,10 +51,10 @@ struct TVShowView: View {
.halign(.start) .halign(.start)
HStack { HStack {
ForEach(seasons) { season in ForEach(seasons) { season in
Button(season.Name ?? "?") { Button(season.name ?? "?") {
selectedSeasonId = season.Id selectedSeasonId = season.id
} }
.style(selectedSeasonId == season.Id ? "suggested-action" : "flat") .style(selectedSeasonId == season.id ? "suggested-action" : "flat")
} }
} }
} }
@ -62,7 +62,7 @@ struct TVShowView: View {
} }
if let seasonId = selectedSeasonId { if let seasonId = selectedSeasonId {
EpisodeList( EpisodeList(
seriesId: item.Id ?? "", seriesId: item.id ?? "",
seasonId: seasonId, seasonId: seasonId,
client: client, client: client,
userId: userId userId: userId
@ -77,10 +77,10 @@ struct TVShowView: View {
} }
private func loadBackdrop() { private func loadBackdrop() {
guard let tag = item.backdropImageTag, let itemId = item.Id else { return } guard let tag = item.backdropImageTag, let itemId = item.id else { return }
Task { Task {
guard let url = await client.imageURL( guard let url = await client.imageURL(
itemId: itemId, imageType: .Backdrop, tag: tag, maxWidth: 1920 itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
) else { return } ) else { return }
let service = ImageService() let service = ImageService()
backdropData = try? await service.loadImage(url: url) backdropData = try? await service.loadImage(url: url)
@ -89,10 +89,10 @@ struct TVShowView: View {
private func loadSeasons() { private func loadSeasons() {
Task { Task {
let result = try? await client.getSeasons(seriesId: item.Id ?? "", userId: userId) let result = try? await client.getSeasons(seriesId: item.id ?? "", userId: userId)
await MainActor.run { await MainActor.run {
seasons = result?.Items ?? [] seasons = result?.items ?? []
selectedSeasonId = seasons.first?.Id selectedSeasonId = seasons.first?.id
} }
} }
} }

View file

@ -66,9 +66,9 @@ public struct PlayerView: View {
Task { Task {
try? await client.reportPlaybackStart( try? await client.reportPlaybackStart(
info: .init( info: .init(
ItemId: item.Id, itemId: item.id,
MediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
PlaySessionId: playSessionId playSessionId: playSessionId
) )
) )
} }
@ -78,10 +78,10 @@ public struct PlayerView: View {
Task { Task {
try? await client.reportPlaybackStopped( try? await client.reportPlaybackStopped(
info: .init( info: .init(
ItemId: item.Id, itemId: item.id,
MediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
PositionTicks: Int64(position * 10_000_000), positionTicks: Int64(position * 10_000_000),
PlaySessionId: playSessionId playSessionId: playSessionId
) )
) )
} }

View file

@ -1,7 +1,7 @@
{ {
"app-id": "dev.bscubed.Luminate", "app-id": "dev.bscubed.Luminate",
"runtime": "org.gnome.Platform", "runtime": "org.gnome.Platform",
"runtime-version": "49", "runtime-version": "50",
"sdk": "org.gnome.Sdk", "sdk": "org.gnome.Sdk",
"sdk-extensions": [ "sdk-extensions": [
"org.freedesktop.Sdk.Extension.swift6" "org.freedesktop.Sdk.Extension.swift6"