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",
"LuminatePlayer",
.product(name: "Adwaita", package: "adwaita-swift"),
.product(name: "Localized", package: "localized")
.product(name: "Localized", package: "localized"),
],
path: "Sources/Luminate",
resources: [.process("Localized.yml")],
plugins: [.plugin(name: "GenerateLocalized", package: "localized")]
)
),
],
swiftLanguageModes: [.v5]
)

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
import FoundationNetworking
#endif
public actor ImageService {
@ -12,7 +13,8 @@ public actor ImageService {
for: .cachesDirectory, in: .userDomainMask
).first!.appendingPathComponent("luminate/images")
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 {

View file

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

View file

@ -1,7 +1,7 @@
import Foundation
import HTTPTypes
import OpenAPIRuntime
import OpenAPIURLSession
import HTTPTypes
struct JellyfinDateTranscoder: DateTranscoder {
func encode(_ date: Date) throws -> String {
@ -24,7 +24,9 @@ struct JellyfinDateTranscoder: DateTranscoder {
return date
}
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?,
baseURL: URL,
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?) {
var request = request
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 {
let response = try await client.authenticateUserByName(Operations.AuthenticateUserByName.Input(
public func authenticate(username: String, password: String) async throws
-> Components.Schemas.AuthenticationResult
{
let response = try await client.authenticateUserByName(
Operations.AuthenticateUserByName.Input(
body: .json(.init(value1: .init(username: username, pw: password)))
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
guard let accessToken = result.accessToken else { throw JellyfinError.invalidResponse }
guard let accessToken = result.accessToken else {
throw JellyfinError.invalidResponse
}
token = accessToken
client = makeClient(token: accessToken)
userId = result.user?.value1.id
return 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
client = makeClient(token: accessToken)
userId = result.user?.value1.id
return 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
client = makeClient(token: accessToken)
userId = result.user?.value1.id
@ -199,9 +212,12 @@ 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 }
let response = try await client.getItem(Operations.GetItem.Input(
let response = try await client.getItem(
Operations.GetItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
@ -226,9 +242,12 @@ 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 }
let response = try await client.getUserViews(Operations.GetUserViews.Input(
let response = try await client.getUserViews(
Operations.GetUserViews.Input(
query: .init(userId: userId)
))
switch response {
@ -311,7 +330,8 @@ public actor JellyfinClient {
query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes
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),
query: query
))
@ -362,7 +382,8 @@ public actor JellyfinClient {
query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes
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),
query: query
))
@ -404,7 +425,8 @@ public actor JellyfinClient {
query.limit = limit
query.includeItemTypes = includeItemTypes
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 {
case .ok(let ok):
switch ok.body {
@ -426,9 +448,12 @@ 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 }
let response = try await client.markPlayedItem(Operations.MarkPlayedItem.Input(
let response = try await client.markPlayedItem(
Operations.MarkPlayedItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId, datePlayed: datePlayed)
))
@ -455,9 +480,12 @@ 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 }
let response = try await client.markUnplayedItem(Operations.MarkUnplayedItem.Input(
let response = try await client.markUnplayedItem(
Operations.MarkUnplayedItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
@ -484,9 +512,12 @@ 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 }
let response = try await client.markFavoriteItem(Operations.MarkFavoriteItem.Input(
let response = try await client.markFavoriteItem(
Operations.MarkFavoriteItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
@ -511,9 +542,12 @@ 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 }
let response = try await client.unmarkFavoriteItem(Operations.UnmarkFavoriteItem.Input(
let response = try await client.unmarkFavoriteItem(
Operations.UnmarkFavoriteItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
@ -538,9 +572,12 @@ 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 }
let response = try await client.getPlaybackInfo(Operations.GetPlaybackInfo.Input(
let response = try await client.getPlaybackInfo(
Operations.GetPlaybackInfo.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
@ -569,7 +606,8 @@ public actor JellyfinClient {
public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws {
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))
))
switch response {
@ -588,7 +626,8 @@ public actor JellyfinClient {
public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws {
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))
))
switch response {
@ -607,7 +646,8 @@ public actor JellyfinClient {
public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws {
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))
))
switch response {
@ -647,7 +687,8 @@ public actor JellyfinClient {
query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData
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 {
case .ok(let ok):
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? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil }
public func imageURL(
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)"
var queryItems: [URLQueryItem] = []
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? {
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"
if let tag { components.queryItems = [.init(name: "tag", value: tag)] }
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,12 +52,14 @@ public actor SQLiteStore: PersistenceService {
for: .applicationSupportDirectory,
in: .userDomainMask
).first!
return appSupport
return
appSupport
.appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
} else {
#if os(Linux)
let xdgData = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]
let xdgData =
ProcessInfo.processInfo.environment["XDG_DATA_HOME"]
?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share"
return URL(fileURLWithPath: xdgData)
.appendingPathComponent("dev.bscubed.Luminate")
@ -67,7 +69,8 @@ public actor SQLiteStore: PersistenceService {
for: .applicationSupportDirectory,
in: .userDomainMask
).first!
return appSupport
return
appSupport
.appendingPathComponent("dev.bscubed.Luminate")
.appendingPathComponent("db.sqlite")
#endif
@ -75,7 +78,8 @@ public actor SQLiteStore: PersistenceService {
}
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 {
guard let serverURL = row[0] as? String,
let token = row[1] as? String,

View file

@ -1,6 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
import FoundationNetworking
#endif
public actor WebSocketClient {
@ -51,7 +52,8 @@ public actor WebSocketClient {
switch message {
case .string(let text):
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
NotificationCenter.default.post(
name: .init(event.messageType),
@ -82,12 +84,21 @@ public struct AnyDecodable: Decodable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { value = intVal }
else if let doubleVal = try? container.decode(Double.self) { value = doubleVal }
else if let boolVal = try? container.decode(Bool.self) { value = boolVal }
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")) }
if let intVal = try? container.decode(Int.self) {
value = intVal
} else if let doubleVal = try? container.decode(Double.self) {
value = doubleVal
} else if let boolVal = try? container.decode(Bool.self) {
value = boolVal
} 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 Foundation
import LuminateCore
struct HomePosterCell: View {
@ -54,16 +54,21 @@ struct HomePosterCell: View {
private func loadImage() {
guard let tag = item.primaryImageTag,
let itemId = item.seriesId ?? item.id else { return }
let itemId = item.seriesId ?? item.id
else { return }
Task {
guard let url = await client.imageURL(
guard
let url = await client.imageURL(
itemId: itemId,
imageType: .primary,
tag: tag,
maxWidth: 400
) else { return }
)
else { return }
let service = ImageService()
await MainActor.run {
imageData = try? await service.loadImage(url: url)
}
}
}
}

View file

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

View file

@ -1,20 +1,24 @@
import Foundation
import Adwaita
import Foundation
import LuminateCore
struct LibraryGrid: View {
var libraries: [Components.Schemas.BaseItemDto]
@Binding var navigation: NavigationStack<Page>
var title: String?
var view: Body {
VStack {
Text("Libraries")
VStack(spacing: 16) {
Text(title ?? "Libraries")
.style("title-3")
.halign(.start)
.padding(10, .horizontal)
FlowBox(libraries) { library in
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,24 +1,31 @@
import Foundation
import Adwaita
import Foundation
import LuminateCore
struct MediaRow: View {
var title: String
var items: [Components.Schemas.BaseItemDto]
@Binding var navigation: NavigationStack<Page>
var onSeeAll: (() -> Void)?
var view: Body {
VStack(spacing: 16) {
HStack(spacing: 16) {
HStack {
Text(title)
.style("title-3")
if onSeeAll != nil {
/// Poor man's `Spacer()`
Bin()
.hexpand()
if let onSeeAll {
Button("See All") {
onSeeAll?()
onSeeAll()
}
}
}
ScrollView {
ForEach(items, horizontal: true) { item in
HomePosterCell(item: item)
@ -30,4 +37,3 @@ struct MediaRow: View {
}
}
}

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita
import Foundation
import LuminateCore
struct MovieDetailView: View {
@ -27,13 +27,13 @@ struct MovieDetailView: View {
Picture()
.data(data)
.frame(minHeight: 300)
.frame(maxHeight: 300)
.frame(maxHeight: 300)
.hexpand(true)
}
HStack {
PosterCell(item: item, client: client)
.frame(minWidth: 200)
.frame(maxWidth: 200)
.frame(maxWidth: 200)
VStack {
Text(item.name ?? "")
.style("title-1")
@ -109,9 +109,11 @@ struct MovieDetailView: View {
private func loadBackdrop() {
guard let tag = item.backdropImageTag, let itemId = item.id else { return }
Task {
guard let url = await client.imageURL(
guard
let url = await client.imageURL(
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
) else { return }
)
else { return }
let service = ImageService()
backdropData = try? await service.loadImage(url: url)
}

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita
import Foundation
import LuminateCore
struct PosterCell: View {
@ -33,7 +33,8 @@ struct PosterCell: View {
private func loadImage() {
guard let tag = item.primaryImageTag,
let itemId = item.id else { return }
let itemId = item.id
else { return }
Task {
let url = await client.imageURL(
itemId: itemId,

View file

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

View file

@ -1,5 +1,5 @@
import Foundation
import Adwaita
import Foundation
import LuminateCore
struct TVShowView: View {
@ -18,13 +18,13 @@ struct TVShowView: View {
Picture()
.data(data)
.frame(minHeight: 300)
.frame(maxHeight: 300)
.frame(maxHeight: 300)
.hexpand(true)
}
HStack {
PosterCell(item: item, client: client)
.frame(minWidth: 200)
.frame(maxWidth: 200)
.frame(maxWidth: 200)
VStack {
Text(item.name ?? "")
.style("title-1")
@ -79,9 +79,11 @@ struct TVShowView: View {
private func loadBackdrop() {
guard let tag = item.backdropImageTag, let itemId = item.id else { return }
Task {
guard let url = await client.imageURL(
guard
let url = await client.imageURL(
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
) else { return }
)
else { return }
let service = ImageService()
backdropData = try? await service.loadImage(url: url)
}

View file

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

View file

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

View file

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