Set up swift-format

This commit is contained in:
Brendan Szymanski 2026-06-13 18:22:05 -04:00
parent 2411a220fe
commit 0b478113ac
26 changed files with 461 additions and 203 deletions

79
.swift-format Normal file
View file

@ -0,0 +1,79 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentBlankLines" : false,
"indentConditionalCompilationBlocks" : true,
"indentSwitchCaseLabels" : false,
"indentation" : {
"spaces" : 4
},
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineBreakBetweenDeclarationAttributes" : false,
"lineLength" : 100,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"orderedImports" : {
"includeConditionalImports" : false
},
"prioritizeKeepingFunctionOutputTogether" : false,
"reflowMultilineStringLiterals" : "never",
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLiteralForEmptyCollectionInit" : false,
"AlwaysUseLowerCamelCase" : true,
"AmbiguousTrailingClosureOverload" : true,
"AvoidRetroactiveConformances" : false,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : true,
"FileScopedDeclarationPrivacy" : true,
"FullyIndirectEnum" : true,
"GroupNumericLiterals" : true,
"IdentifiersMustBeASCII" : true,
"NeverForceUnwrap" : false,
"NeverUseForceTry" : false,
"NeverUseImplicitlyUnwrappedOptionals" : false,
"NoAccessLevelOnExtensionDeclaration" : true,
"NoAssignmentInExpressions" : true,
"NoBlockComments" : true,
"NoCasesWithOnlyFallthrough" : true,
"NoEmptyLinesOpeningClosingBraces" : false,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : true,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : true,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : false,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : true,
"ReturnVoidInsteadOfEmptyTuple" : true,
"TypeNamesShouldBeCapitalized" : true,
"UseEarlyExits" : false,
"UseExplicitNilCheckInConditions" : true,
"UseLetInEveryBoundCaseVariable" : true,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : true,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : false
},
"spacesAroundRangeFormationOperators" : false,
"spacesBeforeEndOfLineComments" : 2,
"tabWidth" : 4,
"version" : 1
}

View file

@ -43,12 +43,12 @@ let package = Package(
"LuminateLibrary", "LuminateLibrary",
"LuminatePlayer", "LuminatePlayer",
.product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"),
.product(name: "Localized", package: "localized") .product(name: "Localized", package: "localized"),
], ],
path: "Sources/Luminate", path: "Sources/Luminate",
resources: [.process("Localized.yml")], resources: [.process("Localized.yml")],
plugins: [.plugin(name: "GenerateLocalized", package: "localized")] plugins: [.plugin(name: "GenerateLocalized", package: "localized")]
) ),
], ],
swiftLanguageModes: [.v5] swiftLanguageModes: [.v5]
) )

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
import LuminateHome import LuminateHome
import LuminateLibrary import LuminateLibrary
@ -62,7 +62,8 @@ struct Luminate: App {
let auth = try await store.loadAuth() let auth = try await store.loadAuth()
guard let serverURL = URL(string: auth.serverURL) else { return } guard let serverURL = URL(string: auth.serverURL) else { return }
let client = JellyfinClient(serverURL: serverURL, token: auth.token, userId: auth.userId) let client = JellyfinClient(
serverURL: serverURL, token: auth.token, userId: auth.userId)
let isValid = await client.validateToken() let isValid = await client.validateToken()
guard isValid else { return } guard isValid else { return }
@ -83,24 +84,26 @@ struct ContentView: View {
var client: JellyfinClient var client: JellyfinClient
var userId: String var userId: String
@State private var activePlayerItem: Components.Schemas.BaseItemDto? @State var stack: NavigationStack<Page> = .init()
var view: Body { var view: Body {
if let item = activePlayerItem { NavigationView($stack, "Luminate") { page in
PlayerView( switch page {
item: item, case .folder(let title, let items):
client: client, LibraryPage(title: title, items: items, navigation: $stack)
userId: userId, .topToolbar {
mediaSourceId: item.id ?? "", ToolbarView()
playSessionId: "", }
streamURL: URL(string: "https://example.com/stream")!, .navigationTitle(title)
onClose: { activePlayerItem = nil } case .library(let item):
) Text("REPLACE ME")
} else {
HomeView()
.topToolbar {
ToolbarView()
} }
} initialView: {
HomeView(navigation: $stack)
.topToolbar {
ToolbarView()
}
.navigationTitle("Luminate")
} }
} }
} }

View file

@ -26,9 +26,9 @@ struct ToolbarView: View {
.shortcutsItem(Loc.keyboardShortcuts, accelerator: "question".ctrl()) .shortcutsItem(Loc.keyboardShortcuts, accelerator: "question".ctrl())
} }
} }
.titleWidget { // .titleWidget {
WindowTitle(subtitle: "", title: "Luminate") // WindowTitle(subtitle: "", title: "Luminate")
} // }
} }
var content: AnyView { var content: AnyView {

View file

@ -1,11 +1,11 @@
import Foundation import Foundation
extension Components.Schemas.BaseItemPerson: Identifiable { } extension Components.Schemas.BaseItemPerson: Identifiable {}
extension Components.Schemas.BaseItemDto: Identifiable { } extension Components.Schemas.BaseItemDto: Identifiable {}
public extension Components.Schemas.SearchHint { extension Components.Schemas.SearchHint {
var runtimeString: String { public 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
@ -15,8 +15,8 @@ public extension Components.Schemas.SearchHint {
} }
} }
public extension Components.Schemas.BaseItemDto { extension Components.Schemas.BaseItemDto {
var runtimeString: String { public 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
@ -25,16 +25,16 @@ public extension Components.Schemas.BaseItemDto {
return "\(minutes)m" return "\(minutes)m"
} }
var yearString: String { public var yearString: String {
guard let year = productionYear else { return "" } guard let year = productionYear else { return "" }
return "\(year)" return "\(year)"
} }
var primaryImageTag: String? { public var primaryImageTag: String? {
imageTags?.additionalProperties["Primary"] imageTags?.additionalProperties["Primary"]
} }
var backdropImageTag: String? { public var backdropImageTag: String? {
backdropImageTags?.first backdropImageTags?.first
} }
} }

View file

@ -20,7 +20,7 @@ public final class DIContainer {
guard let value = values[keyPath: keyPath] else { guard let value = values[keyPath: keyPath] else {
fatalError( fatalError(
"DIContainer: No value registered for \(keyPath). " "DIContainer: No value registered for \(keyPath). "
+ "Call DIContainer.shared.register(\\.key, value:) during app startup." + "Call DIContainer.shared.register(\\.key, value:) during app startup."
) )
} }
return value return value

View file

@ -1,6 +1,7 @@
import Foundation import Foundation
#if canImport(FoundationNetworking) #if canImport(FoundationNetworking)
import FoundationNetworking import FoundationNetworking
#endif #endif
public actor ImageService { public actor ImageService {
@ -12,7 +13,8 @@ public actor ImageService {
for: .cachesDirectory, in: .userDomainMask for: .cachesDirectory, in: .userDomainMask
).first!.appendingPathComponent("luminate/images") ).first!.appendingPathComponent("luminate/images")
self.cacheDir = cacheDir ?? defaultCache self.cacheDir = cacheDir ?? defaultCache
try? FileManager.default.createDirectory(at: self.cacheDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(
at: self.cacheDir, withIntermediateDirectories: true)
} }
public func loadImage(url: URL) async throws -> Data { public func loadImage(url: URL) async throws -> Data {

View file

@ -5,7 +5,6 @@ public struct Injected<T> {
private let keyPath: KeyPath<InjectionValues, T?> private let keyPath: KeyPath<InjectionValues, T?>
@MainActor
public var wrappedValue: T { public var wrappedValue: T {
DIContainer.shared.resolve(keyPath) DIContainer.shared.resolve(keyPath)
} }

View file

@ -1,7 +1,7 @@
import Foundation import Foundation
import HTTPTypes
import OpenAPIRuntime import OpenAPIRuntime
import OpenAPIURLSession import OpenAPIURLSession
import HTTPTypes
struct JellyfinDateTranscoder: DateTranscoder { struct JellyfinDateTranscoder: DateTranscoder {
func encode(_ date: Date) throws -> String { func encode(_ date: Date) throws -> String {
@ -24,7 +24,9 @@ struct JellyfinDateTranscoder: DateTranscoder {
return date return date
} }
throw DecodingError.dataCorrupted( throw DecodingError.dataCorrupted(
.init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted: \(dateString)") .init(
codingPath: [],
debugDescription: "Expected date string to be ISO8601-formatted: \(dateString)")
) )
} }
} }
@ -50,7 +52,9 @@ struct AuthMiddleware: ClientMiddleware {
body: OpenAPIRuntime.HTTPBody?, body: OpenAPIRuntime.HTTPBody?,
baseURL: URL, baseURL: URL,
operationID: String, operationID: String,
next: (HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?) next: (HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (
HTTPResponse, OpenAPIRuntime.HTTPBody?
)
) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?) { ) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?) {
var request = request var request = request
request.headerFields[.authorization] = mediaBrowserHeader(token: token) request.headerFields[.authorization] = mediaBrowserHeader(token: token)
@ -117,27 +121,36 @@ public actor JellyfinClient {
) )
} }
public func authenticate(username: String, password: String) async throws -> Components.Schemas.AuthenticationResult { public func authenticate(username: String, password: String) async throws
let response = try await client.authenticateUserByName(Operations.AuthenticateUserByName.Input( -> Components.Schemas.AuthenticationResult
body: .json(.init(value1: .init(username: username, pw: password))) {
)) let response = try await client.authenticateUserByName(
Operations.AuthenticateUserByName.Input(
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 .applicationJsonProfile_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 .applicationJsonProfile_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
@ -199,12 +212,15 @@ 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(
path: .init(itemId: itemId), Operations.GetItem.Input(
query: .init(userId: userId) path: .init(itemId: itemId),
)) query: .init(userId: userId)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -226,11 +242,14 @@ 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(
query: .init(userId: userId) Operations.GetUserViews.Input(
)) query: .init(userId: userId)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -311,10 +330,11 @@ 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(
path: .init(seriesId: seriesId), Operations.GetSeasons.Input(
query: query path: .init(seriesId: seriesId),
)) query: query
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -362,10 +382,11 @@ 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(
path: .init(seriesId: seriesId), Operations.GetEpisodes.Input(
query: query path: .init(seriesId: seriesId),
)) query: query
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -404,7 +425,8 @@ 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 {
@ -426,12 +448,15 @@ 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(
path: .init(itemId: itemId), Operations.MarkPlayedItem.Input(
query: .init(userId: userId, datePlayed: datePlayed) path: .init(itemId: itemId),
)) query: .init(userId: userId, datePlayed: datePlayed)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -455,12 +480,15 @@ 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(
path: .init(itemId: itemId), Operations.MarkUnplayedItem.Input(
query: .init(userId: userId) path: .init(itemId: itemId),
)) query: .init(userId: userId)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -484,12 +512,15 @@ 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(
path: .init(itemId: itemId), Operations.MarkFavoriteItem.Input(
query: .init(userId: userId) path: .init(itemId: itemId),
)) query: .init(userId: userId)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -511,12 +542,15 @@ 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(
path: .init(itemId: itemId), Operations.UnmarkFavoriteItem.Input(
query: .init(userId: userId) path: .init(itemId: itemId),
)) query: .init(userId: userId)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -538,12 +572,15 @@ 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(
path: .init(itemId: itemId), Operations.GetPlaybackInfo.Input(
query: .init(userId: userId) path: .init(itemId: itemId),
)) query: .init(userId: userId)
))
switch response { switch response {
case .ok(let ok): case .ok(let ok):
switch ok.body { switch ok.body {
@ -569,9 +606,10 @@ 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(
body: .json(.init(value1: info)) Operations.ReportPlaybackStart.Input(
)) body: .json(.init(value1: info))
))
switch response { switch response {
case .noContent: case .noContent:
return return
@ -588,9 +626,10 @@ 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(
body: .json(.init(value1: info)) Operations.ReportPlaybackProgress.Input(
)) body: .json(.init(value1: info))
))
switch response { switch response {
case .noContent: case .noContent:
return return
@ -607,9 +646,10 @@ 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(
body: .json(.init(value1: info)) Operations.ReportPlaybackStopped.Input(
)) body: .json(.init(value1: info))
))
switch response { switch response {
case .noContent: case .noContent:
return return
@ -647,7 +687,8 @@ 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 {
@ -669,8 +710,13 @@ public actor JellyfinClient {
} }
} }
public func imageURL(itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil, maxWidth: Int32? = nil, quality: Int32? = 90) -> URL? { public func imageURL(
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil } itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil,
maxWidth: Int32? = nil, quality: Int32? = 90
) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
return nil
}
components.path = "/Items/\(itemId)/Images/\(imageType)" components.path = "/Items/\(itemId)/Images/\(imageType)"
var queryItems: [URLQueryItem] = [] var queryItems: [URLQueryItem] = []
if let tag { queryItems.append(.init(name: "tag", value: tag)) } if let tag { queryItems.append(.init(name: "tag", value: tag)) }
@ -681,7 +727,9 @@ public actor JellyfinClient {
} }
public func userImageURL(userId: String, tag: String? = nil) -> URL? { public func userImageURL(userId: String, tag: String? = nil) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil } guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
return nil
}
components.path = "/Users/\(userId)/Images/Primary" components.path = "/Users/\(userId)/Images/Primary"
if let tag { components.queryItems = [.init(name: "tag", value: tag)] } if let tag { components.queryItems = [.init(name: "tag", value: tag)] }
return components.url return components.url

View file

@ -0,0 +1,20 @@
//
// Page.swift
// Luminate
//
// Created by Brendan Szymanski on 6/11/26.
//
public enum Page: CustomStringConvertible {
case library(item: Components.Schemas.BaseItemDto)
case folder(title: String, items: [Components.Schemas.BaseItemDto])
public var description: String {
switch self {
case .library(let item):
return item.name ?? "Library"
case .folder(let title, _):
return title
}
}
}

View file

@ -52,35 +52,39 @@ public actor SQLiteStore: PersistenceService {
for: .applicationSupportDirectory, for: .applicationSupportDirectory,
in: .userDomainMask in: .userDomainMask
).first! ).first!
return appSupport return
appSupport
.appendingPathComponent("dev.bscubed.Luminate") .appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite") .appendingPathComponent("db.sqlite")
} else { } else {
#if os(Linux) #if os(Linux)
let xdgData = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] let xdgData =
?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share" ProcessInfo.processInfo.environment["XDG_DATA_HOME"]
return URL(fileURLWithPath: xdgData) ?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share"
.appendingPathComponent("dev.bscubed.Luminate") return URL(fileURLWithPath: xdgData)
.appendingPathComponent("db.sqlite") .appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
#else #else
let appSupport = FileManager.default.urls( let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory, for: .applicationSupportDirectory,
in: .userDomainMask in: .userDomainMask
).first! ).first!
return appSupport return
.appendingPathComponent("dev.bscubed.Luminate") appSupport
.appendingPathComponent("db.sqlite") .appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
#endif #endif
} }
} }
public func loadAuth() async throws -> AuthData { public func loadAuth() async throws -> AuthData {
let stmt = try db.prepare("SELECT server_url, token, user_id, username FROM auth WHERE id = 1") let stmt = try db.prepare(
"SELECT server_url, token, user_id, username FROM auth WHERE id = 1")
for row in stmt { for row in stmt {
guard let serverURL = row[0] as? String, guard let serverURL = row[0] as? String,
let token = row[1] as? String, let token = row[1] as? String,
let userId = row[2] as? String, let userId = row[2] as? String,
let username = row[3] as? String let username = row[3] as? String
else { else {
throw PersistenceError.decodingFailed throw PersistenceError.decodingFailed
} }

View file

@ -1,6 +1,7 @@
import Foundation import Foundation
#if canImport(FoundationNetworking) #if canImport(FoundationNetworking)
import FoundationNetworking import FoundationNetworking
#endif #endif
public actor WebSocketClient { public actor WebSocketClient {
@ -51,7 +52,8 @@ public actor WebSocketClient {
switch message { switch message {
case .string(let text): case .string(let text):
guard let data = text.data(using: .utf8), guard let data = text.data(using: .utf8),
let event = try? JSONDecoder().decode(WebSocketEvent.self, from: data) else { return } let event = try? JSONDecoder().decode(WebSocketEvent.self, from: data)
else { return }
Task { @MainActor in Task { @MainActor in
NotificationCenter.default.post( NotificationCenter.default.post(
name: .init(event.messageType), name: .init(event.messageType),
@ -82,12 +84,21 @@ public struct AnyDecodable: Decodable {
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { value = intVal } if let intVal = try? container.decode(Int.self) {
else if let doubleVal = try? container.decode(Double.self) { value = doubleVal } value = intVal
else if let boolVal = try? container.decode(Bool.self) { value = boolVal } } else if let doubleVal = try? container.decode(Double.self) {
else if let stringVal = try? container.decode(String.self) { value = stringVal } value = doubleVal
else if let arrayVal = try? container.decode([AnyDecodable].self) { value = arrayVal.map(\.value) } } else if let boolVal = try? container.decode(Bool.self) {
else if let dictVal = try? container.decode([String: AnyDecodable].self) { value = dictVal.mapValues(\.value) } value = boolVal
else { throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "AnyDecodable error")) } } else if let stringVal = try? container.decode(String.self) {
value = stringVal
} else if let arrayVal = try? container.decode([AnyDecodable].self) {
value = arrayVal.map(\.value)
} else if let dictVal = try? container.decode([String: AnyDecodable].self) {
value = dictVal.mapValues(\.value)
} else {
throw DecodingError.dataCorrupted(
.init(codingPath: container.codingPath, debugDescription: "AnyDecodable error"))
}
} }
} }

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct HomePosterCell: View { struct HomePosterCell: View {
@ -54,16 +54,21 @@ struct HomePosterCell: View {
private func loadImage() { private func loadImage() {
guard let tag = item.primaryImageTag, guard let tag = item.primaryImageTag,
let itemId = item.seriesId ?? item.id else { return } let itemId = item.seriesId ?? item.id
else { return }
Task { Task {
guard let url = await client.imageURL( guard
itemId: itemId, let url = await client.imageURL(
imageType: .primary, itemId: itemId,
tag: tag, imageType: .primary,
maxWidth: 400 tag: tag,
) else { return } maxWidth: 400
)
else { return }
let service = ImageService() let service = ImageService()
imageData = try? await service.loadImage(url: url) await MainActor.run {
imageData = try? await service.loadImage(url: url)
}
} }
} }
} }

View file

@ -3,10 +3,13 @@ import LuminateCore
public struct HomeView: View { public struct HomeView: View {
nonisolated public init() {} nonisolated public init(navigation: Binding<NavigationStack<Page>>) {
_navigation = navigation
}
@Injected(\.client) var client @Injected(\.client) var client
@Injected(\.userId) var userId @Injected(\.userId) var userId
@Binding var navigation: NavigationStack<Page>
@State private var resumeItems: [Components.Schemas.BaseItemDto] = [] @State private var resumeItems: [Components.Schemas.BaseItemDto] = []
@State private var nextUpItems: [Components.Schemas.BaseItemDto] = [] @State private var nextUpItems: [Components.Schemas.BaseItemDto] = []
@State private var latestItems: [Components.Schemas.BaseItemDto] = [] @State private var latestItems: [Components.Schemas.BaseItemDto] = []
@ -30,29 +33,44 @@ public struct HomeView: View {
.frame(maxWidth: 64) .frame(maxWidth: 64)
} else { } else {
if !resumeItems.isEmpty { if !resumeItems.isEmpty {
let title = "Continue Watching"
MediaRow( MediaRow(
title: "Continue Watching", title: title,
items: resumeItems items: resumeItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.folder(title: title, items: resumeItems))
}
) )
.padding(32, .bottom) .padding(32, .bottom)
} }
if !nextUpItems.isEmpty { if !nextUpItems.isEmpty {
let title = "Next Up"
MediaRow( MediaRow(
title: "Next Up", title: title,
items: nextUpItems items: nextUpItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.folder(title: title, items: nextUpItems))
}
) )
.padding(32, .bottom) .padding(32, .bottom)
} }
if !latestItems.isEmpty { if !latestItems.isEmpty {
let title = "Recently Added"
MediaRow( MediaRow(
title: "Recently Added", title: title,
items: latestItems, items: latestItems,
onSeeAll: {} navigation: $navigation,
onSeeAll: {
navigation.push(.folder(title: title, items: latestItems))
}
) )
.padding(32, .bottom) .padding(32, .bottom)
} }
LibraryGrid( LibraryGrid(
libraries: libraries libraries: libraries,
navigation: $navigation
) )
.padding(32, .bottom) .padding(32, .bottom)
} }
@ -63,7 +81,6 @@ public struct HomeView: View {
} }
.hscrollbarPolicy(.never) .hscrollbarPolicy(.never)
.propagateNaturalHeight() .propagateNaturalHeight()
.navigationTitle("Luminate")
.onAppear { .onAppear {
loadHomeData() loadHomeData()
} }
@ -83,11 +100,11 @@ public struct HomeView: View {
async let latest = client.getLatestMedia(userId: userId, limit: 20) async let latest = client.getLatestMedia(userId: userId, limit: 20)
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 (resume, nextUp, latest, views) = try await (resume, nextUp, latest, views)
resumeItems = r.items ?? [] resumeItems = resume.items ?? []
nextUpItems = n.items ?? [] nextUpItems = nextUp.items ?? []
latestItems = l latestItems = latest
libraries = v.items ?? [] libraries = views.items ?? []
isLoading = false isLoading = false
} catch { } catch {
isLoading = false isLoading = false

View file

@ -1,20 +1,24 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct LibraryGrid: View { struct LibraryGrid: View {
var libraries: [Components.Schemas.BaseItemDto] var libraries: [Components.Schemas.BaseItemDto]
@Binding var navigation: NavigationStack<Page>
var title: String?
var view: Body { var view: Body {
VStack { VStack(spacing: 16) {
Text("Libraries") Text(title ?? "Libraries")
.style("title-3") .style("title-3")
.halign(.start) .halign(.start)
.padding(10, .horizontal) .padding(10, .horizontal)
FlowBox(libraries) { library in FlowBox(libraries) { library in
HomePosterCell(item: library) HomePosterCell(item: library)
} }
.columnSpacing(16)
.rowSpacing(16)
} }
} }
} }

View file

@ -0,0 +1,43 @@
//
// LibraryPage.swift
// Luminate
//
// Created by Brendan Szymanski on 6/11/26.
//
import Adwaita
import LuminateCore
public struct LibraryPage: View {
nonisolated public init(
title: String, items: [Components.Schemas.BaseItemDto],
navigation: Binding<NavigationStack<Page>>
) {
self.title = title
self.items = items
_navigation = navigation
}
private var items: [Components.Schemas.BaseItemDto]
private var title: String
@Binding var navigation: NavigationStack<Page>
public var view: Body {
ScrollView {
Clamp()
.maximumSize(1550)
.tighteningThreshold(550)
.child {
LibraryGrid(
libraries: items,
navigation: $navigation,
title: title
)
.padding(32, .bottom)
}
}
.hscrollbarPolicy(.never)
.propagateNaturalHeight()
}
}

View file

@ -1,33 +1,39 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct MediaRow: View { struct MediaRow: View {
var title: String var title: String
var items: [Components.Schemas.BaseItemDto] var items: [Components.Schemas.BaseItemDto]
@Binding var navigation: NavigationStack<Page>
var onSeeAll: (() -> Void)? var onSeeAll: (() -> Void)?
var view: Body { var view: Body {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack(spacing: 16) { HStack {
Text(title) Text(title)
.style("title-3") .style("title-3")
if onSeeAll != nil {
/// Poor man's `Spacer()`
Bin()
.hexpand()
if let onSeeAll {
Button("See All") { Button("See All") {
onSeeAll?() onSeeAll()
} }
} }
} }
ScrollView { ScrollView {
ForEach(items, horizontal: true) { item in ForEach(items, horizontal: true) { item in
HomePosterCell(item: item) HomePosterCell(item: item)
.padding(16, .trailing) .padding(16, .trailing)
} }
} }
.vscrollbarPolicy(.never) .vscrollbarPolicy(.never)
.hscrollbarPolicy(.external) .hscrollbarPolicy(.external)
} }
} }
} }

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct EpisodeList: View { struct EpisodeList: View {
@ -48,14 +48,14 @@ struct EpisodeRow: View {
Picture() Picture()
.data(data) .data(data)
.frame(minWidth: 100, minHeight: 56) .frame(minWidth: 100, minHeight: 56)
.frame(maxWidth: 100) .frame(maxWidth: 100)
.frame(maxHeight: 56) .frame(maxHeight: 56)
.style("card") .style("card")
} else { } else {
Box(spacing: 0) {} Box(spacing: 0) {}
.frame(minWidth: 100, minHeight: 56) .frame(minWidth: 100, minHeight: 56)
.frame(maxWidth: 100) .frame(maxWidth: 100)
.frame(maxHeight: 56) .frame(maxHeight: 56)
.style("card") .style("card")
} }
VStack { VStack {
@ -79,11 +79,18 @@ 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
itemId: itemId, imageType: .primary, tag: tag, maxWidth: 200 let url = await client.imageURL(
) else { return } itemId: itemId,
imageType: .primary,
tag: tag,
maxWidth: 200
)
else { return }
let service = ImageService() let service = ImageService()
imageData = try? await service.loadImage(url: url) imageData = try? await service.loadImage(url: url)
} }

View file

@ -1,3 +1,4 @@
import Adwaita import Adwaita
import LuminateCore import LuminateCore
public struct LuminateLibrary {} public struct LuminateLibrary {}

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct MovieDetailView: View { struct MovieDetailView: View {
@ -27,13 +27,13 @@ struct MovieDetailView: View {
Picture() Picture()
.data(data) .data(data)
.frame(minHeight: 300) .frame(minHeight: 300)
.frame(maxHeight: 300) .frame(maxHeight: 300)
.hexpand(true) .hexpand(true)
} }
HStack { HStack {
PosterCell(item: item, client: client) PosterCell(item: item, client: client)
.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")
@ -109,9 +109,11 @@ 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
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 let url = await client.imageURL(
) else { return } itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
)
else { return }
let service = ImageService() let service = ImageService()
backdropData = try? await service.loadImage(url: url) backdropData = try? await service.loadImage(url: url)
} }

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct PosterCell: View { struct PosterCell: View {
@ -33,7 +33,8 @@ 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,

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct SearchView: View { struct SearchView: View {
@ -67,14 +67,14 @@ struct SearchResultRow: View {
Picture() Picture()
.data(data) .data(data)
.frame(minWidth: 80, minHeight: 120) .frame(minWidth: 80, minHeight: 120)
.frame(maxWidth: 80) .frame(maxWidth: 80)
.frame(maxHeight: 120) .frame(maxHeight: 120)
.style("card") .style("card")
} else { } else {
Box(spacing: 0) {} Box(spacing: 0) {}
.frame(minWidth: 80, minHeight: 120) .frame(minWidth: 80, minHeight: 120)
.frame(maxWidth: 80) .frame(maxWidth: 80)
.frame(maxHeight: 120) .frame(maxHeight: 120)
.style("card") .style("card")
} }
VStack { VStack {
@ -105,11 +105,14 @@ 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
itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160 let url = await client.imageURL(
) else { return } itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160
)
else { return }
let service = ImageService() let service = ImageService()
imageData = try? await service.loadImage(url: url) imageData = try? await service.loadImage(url: url)
} }

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
struct TVShowView: View { struct TVShowView: View {
@ -18,13 +18,13 @@ struct TVShowView: View {
Picture() Picture()
.data(data) .data(data)
.frame(minHeight: 300) .frame(minHeight: 300)
.frame(maxHeight: 300) .frame(maxHeight: 300)
.hexpand(true) .hexpand(true)
} }
HStack { HStack {
PosterCell(item: item, client: client) PosterCell(item: item, client: client)
.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")
@ -79,9 +79,11 @@ 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
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 let url = await client.imageURL(
) else { return } itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
)
else { return }
let service = ImageService() let service = ImageService()
backdropData = try? await service.loadImage(url: url) backdropData = try? await service.loadImage(url: url)
} }

View file

@ -1,3 +1,4 @@
import Adwaita import Adwaita
import LuminateCore import LuminateCore
public struct LuminatePlayer {} public struct LuminatePlayer {}

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
import LuminateCore import LuminateCore
public struct PlayerView: View { public struct PlayerView: View {
@ -52,7 +52,7 @@ public struct PlayerView: View {
onTogglePlay: { isPlaying.toggle() }, onTogglePlay: { isPlaying.toggle() },
onSeekBack: { position = max(0, position - 10) }, onSeekBack: { position = max(0, position - 10) },
onSeekForward: { position = min(duration, position + 10) }, onSeekForward: { position = min(duration, position + 10) },
onFullscreen: { }, onFullscreen: {},
onClose: { onClose: {
stopPlayback() stopPlayback()
onClose() onClose()

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita import Adwaita
import Foundation
struct VideoPlayerWidget: View { struct VideoPlayerWidget: View {