diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..d45ea95 --- /dev/null +++ b/.swift-format @@ -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 +} diff --git a/Package.swift b/Package.swift index 83c8f30..fe6589d 100644 --- a/Package.swift +++ b/Package.swift @@ -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] ) diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 168656a..b6699c3 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -1,5 +1,5 @@ -import Foundation import Adwaita +import Foundation import LuminateCore import LuminateHome import LuminateLibrary @@ -38,7 +38,7 @@ struct Luminate: App { DIContainer.shared.register(\.client, value: client) DIContainer.shared.register(\.userId, value: id) DIContainer.shared.register(\.imageService, value: ImageService()) - + self.client = client self.userId = id } @@ -57,19 +57,20 @@ struct Luminate: App { Task { do { defer { isLaunchLoading = false } - + let store = try SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) 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 } - + DIContainer.shared.register(\.client, value: client) DIContainer.shared.register(\.userId, value: auth.userId) DIContainer.shared.register(\.imageService, value: ImageService()) - + self.client = client self.userId = auth.userId } catch { @@ -83,24 +84,26 @@ struct ContentView: View { var client: JellyfinClient var userId: String - @State private var activePlayerItem: Components.Schemas.BaseItemDto? + @State var stack: NavigationStack = .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() - .topToolbar { - ToolbarView() + 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") } } } diff --git a/Sources/Luminate/ToolbarView.swift b/Sources/Luminate/ToolbarView.swift index b5aaf79..f77ef06 100644 --- a/Sources/Luminate/ToolbarView.swift +++ b/Sources/Luminate/ToolbarView.swift @@ -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 { diff --git a/Sources/LuminateCore/BaseItemDto+Display.swift b/Sources/LuminateCore/BaseItemDto+Display.swift index bd27831..629289a 100644 --- a/Sources/LuminateCore/BaseItemDto+Display.swift +++ b/Sources/LuminateCore/BaseItemDto+Display.swift @@ -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 } } diff --git a/Sources/LuminateCore/DIContainer.swift b/Sources/LuminateCore/DIContainer.swift index ddacc24..4eb2b0d 100644 --- a/Sources/LuminateCore/DIContainer.swift +++ b/Sources/LuminateCore/DIContainer.swift @@ -20,7 +20,7 @@ public final class DIContainer { guard let value = values[keyPath: keyPath] else { fatalError( "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 diff --git a/Sources/LuminateCore/ImageService.swift b/Sources/LuminateCore/ImageService.swift index 827b191..7a66626 100644 --- a/Sources/LuminateCore/ImageService.swift +++ b/Sources/LuminateCore/ImageService.swift @@ -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 { diff --git a/Sources/LuminateCore/Injected.swift b/Sources/LuminateCore/Injected.swift index 9b53dfd..a1a2b37 100644 --- a/Sources/LuminateCore/Injected.swift +++ b/Sources/LuminateCore/Injected.swift @@ -5,7 +5,6 @@ public struct Injected { private let keyPath: KeyPath - @MainActor public var wrappedValue: T { DIContainer.shared.resolve(keyPath) } diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index 24b7ec6..20fc7bb 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -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( - body: .json(.init(value1: .init(username: username, pw: password))) - )) + 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,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 } - let response = try await client.getItem(Operations.GetItem.Input( - path: .init(itemId: itemId), - query: .init(userId: userId) - )) + let response = try await client.getItem( + Operations.GetItem.Input( + path: .init(itemId: itemId), + query: .init(userId: userId) + )) switch response { case .ok(let ok): 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 } - let response = try await client.getUserViews(Operations.GetUserViews.Input( - query: .init(userId: userId) - )) + let response = try await client.getUserViews( + Operations.GetUserViews.Input( + query: .init(userId: userId) + )) switch response { case .ok(let ok): switch ok.body { @@ -311,10 +330,11 @@ public actor JellyfinClient { query.imageTypeLimit = imageTypeLimit query.enableImageTypes = enableImageTypes query.enableUserData = enableUserData - let response = try await client.getSeasons(Operations.GetSeasons.Input( - path: .init(seriesId: seriesId), - query: query - )) + let response = try await client.getSeasons( + Operations.GetSeasons.Input( + path: .init(seriesId: seriesId), + query: query + )) switch response { case .ok(let ok): switch ok.body { @@ -362,10 +382,11 @@ public actor JellyfinClient { query.imageTypeLimit = imageTypeLimit query.enableImageTypes = enableImageTypes query.enableUserData = enableUserData - let response = try await client.getEpisodes(Operations.GetEpisodes.Input( - path: .init(seriesId: seriesId), - query: query - )) + let response = try await client.getEpisodes( + Operations.GetEpisodes.Input( + path: .init(seriesId: seriesId), + query: query + )) switch response { case .ok(let ok): switch ok.body { @@ -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,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 } - let response = try await client.markPlayedItem(Operations.MarkPlayedItem.Input( - path: .init(itemId: itemId), - query: .init(userId: userId, datePlayed: datePlayed) - )) + let response = try await client.markPlayedItem( + Operations.MarkPlayedItem.Input( + path: .init(itemId: itemId), + query: .init(userId: userId, datePlayed: datePlayed) + )) switch response { case .ok(let ok): 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 } - let response = try await client.markUnplayedItem(Operations.MarkUnplayedItem.Input( - path: .init(itemId: itemId), - query: .init(userId: userId) - )) + let response = try await client.markUnplayedItem( + Operations.MarkUnplayedItem.Input( + path: .init(itemId: itemId), + query: .init(userId: userId) + )) switch response { case .ok(let ok): 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 } - let response = try await client.markFavoriteItem(Operations.MarkFavoriteItem.Input( - path: .init(itemId: itemId), - query: .init(userId: userId) - )) + let response = try await client.markFavoriteItem( + Operations.MarkFavoriteItem.Input( + path: .init(itemId: itemId), + query: .init(userId: userId) + )) switch response { case .ok(let ok): 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 } - let response = try await client.unmarkFavoriteItem(Operations.UnmarkFavoriteItem.Input( - path: .init(itemId: itemId), - query: .init(userId: userId) - )) + let response = try await client.unmarkFavoriteItem( + Operations.UnmarkFavoriteItem.Input( + path: .init(itemId: itemId), + query: .init(userId: userId) + )) switch response { case .ok(let ok): 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 } - let response = try await client.getPlaybackInfo(Operations.GetPlaybackInfo.Input( - path: .init(itemId: itemId), - query: .init(userId: userId) - )) + let response = try await client.getPlaybackInfo( + Operations.GetPlaybackInfo.Input( + path: .init(itemId: itemId), + query: .init(userId: userId) + )) switch response { case .ok(let ok): switch ok.body { @@ -569,9 +606,10 @@ 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( - body: .json(.init(value1: info)) - )) + let response = try await client.reportPlaybackStart( + Operations.ReportPlaybackStart.Input( + body: .json(.init(value1: info)) + )) switch response { case .noContent: return @@ -588,9 +626,10 @@ 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( - body: .json(.init(value1: info)) - )) + let response = try await client.reportPlaybackProgress( + Operations.ReportPlaybackProgress.Input( + body: .json(.init(value1: info)) + )) switch response { case .noContent: return @@ -607,9 +646,10 @@ 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( - body: .json(.init(value1: info)) - )) + let response = try await client.reportPlaybackStopped( + Operations.ReportPlaybackStopped.Input( + body: .json(.init(value1: info)) + )) switch response { case .noContent: return @@ -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 diff --git a/Sources/LuminateCore/Page.swift b/Sources/LuminateCore/Page.swift new file mode 100644 index 0000000..83501d3 --- /dev/null +++ b/Sources/LuminateCore/Page.swift @@ -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 + } + } +} diff --git a/Sources/LuminateCore/SQLiteStore.swift b/Sources/LuminateCore/SQLiteStore.swift index 3851081..b15f8f9 100644 --- a/Sources/LuminateCore/SQLiteStore.swift +++ b/Sources/LuminateCore/SQLiteStore.swift @@ -52,35 +52,39 @@ 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"] - ?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share" - return URL(fileURLWithPath: xdgData) - .appendingPathComponent("dev.bscubed.Luminate") - .appendingPathComponent("db.sqlite") + let xdgData = + ProcessInfo.processInfo.environment["XDG_DATA_HOME"] + ?? "\(FileManager.default.homeDirectoryForCurrentUser.path).local/share" + return URL(fileURLWithPath: xdgData) + .appendingPathComponent("dev.bscubed.Luminate") + .appendingPathComponent("db.sqlite") #else - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - return appSupport - .appendingPathComponent("dev.bscubed.Luminate") - .appendingPathComponent("db.sqlite") + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + return + appSupport + .appendingPathComponent("dev.bscubed.Luminate") + .appendingPathComponent("db.sqlite") #endif } } 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, - let userId = row[2] as? String, - let username = row[3] as? String + let token = row[1] as? String, + let userId = row[2] as? String, + let username = row[3] as? String else { throw PersistenceError.decodingFailed } diff --git a/Sources/LuminateCore/WebSocketClient.swift b/Sources/LuminateCore/WebSocketClient.swift index ec34c89..3d2fefd 100644 --- a/Sources/LuminateCore/WebSocketClient.swift +++ b/Sources/LuminateCore/WebSocketClient.swift @@ -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")) + } } } diff --git a/Sources/LuminateHome/HomePosterCell.swift b/Sources/LuminateHome/HomePosterCell.swift index 6cc5a35..7874d26 100644 --- a/Sources/LuminateHome/HomePosterCell.swift +++ b/Sources/LuminateHome/HomePosterCell.swift @@ -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( - itemId: itemId, - imageType: .primary, - tag: tag, - maxWidth: 400 - ) else { return } + guard + let url = await client.imageURL( + itemId: itemId, + imageType: .primary, + tag: tag, + maxWidth: 400 + ) + else { return } let service = ImageService() - imageData = try? await service.loadImage(url: url) + await MainActor.run { + imageData = try? await service.loadImage(url: url) + } } } } diff --git a/Sources/LuminateHome/HomeView.swift b/Sources/LuminateHome/HomeView.swift index 2ec1345..2adb91b 100644 --- a/Sources/LuminateHome/HomeView.swift +++ b/Sources/LuminateHome/HomeView.swift @@ -3,10 +3,13 @@ import LuminateCore public struct HomeView: View { - nonisolated public init() {} + nonisolated public init(navigation: Binding>) { + _navigation = navigation + } @Injected(\.client) var client @Injected(\.userId) var userId + @Binding var navigation: NavigationStack @State private var resumeItems: [Components.Schemas.BaseItemDto] = [] @State private var nextUpItems: [Components.Schemas.BaseItemDto] = [] @State private var latestItems: [Components.Schemas.BaseItemDto] = [] @@ -22,7 +25,7 @@ public struct HomeView: View { .child { VStack { if isLoading { - + Spinner() .halign(.center) .valign(.center) @@ -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 diff --git a/Sources/LuminateHome/LibraryGrid.swift b/Sources/LuminateHome/LibraryGrid.swift index 8f2def9..639df41 100644 --- a/Sources/LuminateHome/LibraryGrid.swift +++ b/Sources/LuminateHome/LibraryGrid.swift @@ -1,20 +1,24 @@ -import Foundation import Adwaita +import Foundation import LuminateCore struct LibraryGrid: View { var libraries: [Components.Schemas.BaseItemDto] + @Binding var navigation: NavigationStack + 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) } } } diff --git a/Sources/LuminateHome/LibraryPage.swift b/Sources/LuminateHome/LibraryPage.swift new file mode 100644 index 0000000..629fc10 --- /dev/null +++ b/Sources/LuminateHome/LibraryPage.swift @@ -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> + ) { + self.title = title + self.items = items + _navigation = navigation + } + + private var items: [Components.Schemas.BaseItemDto] + private var title: String + @Binding var navigation: NavigationStack + + public var view: Body { + ScrollView { + Clamp() + .maximumSize(1550) + .tighteningThreshold(550) + .child { + LibraryGrid( + libraries: items, + navigation: $navigation, + title: title + ) + .padding(32, .bottom) + } + } + .hscrollbarPolicy(.never) + .propagateNaturalHeight() + } +} diff --git a/Sources/LuminateHome/MediaRow.swift b/Sources/LuminateHome/MediaRow.swift index 9efba0c..5e2cb59 100644 --- a/Sources/LuminateHome/MediaRow.swift +++ b/Sources/LuminateHome/MediaRow.swift @@ -1,33 +1,39 @@ -import Foundation import Adwaita +import Foundation import LuminateCore struct MediaRow: View { var title: String var items: [Components.Schemas.BaseItemDto] + @Binding var navigation: NavigationStack 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) + ForEach(items, horizontal: true) { item in + HomePosterCell(item: item) .padding(16, .trailing) - } - } + } + } .vscrollbarPolicy(.never) .hscrollbarPolicy(.external) } } } - diff --git a/Sources/LuminateLibrary/EpisodeList.swift b/Sources/LuminateLibrary/EpisodeList.swift index d930d4a..370f6e3 100644 --- a/Sources/LuminateLibrary/EpisodeList.swift +++ b/Sources/LuminateLibrary/EpisodeList.swift @@ -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) } diff --git a/Sources/LuminateLibrary/LuminateLibrary.swift b/Sources/LuminateLibrary/LuminateLibrary.swift index 10b0bd3..8835247 100644 --- a/Sources/LuminateLibrary/LuminateLibrary.swift +++ b/Sources/LuminateLibrary/LuminateLibrary.swift @@ -1,3 +1,4 @@ import Adwaita import LuminateCore + public struct LuminateLibrary {} diff --git a/Sources/LuminateLibrary/MovieDetailView.swift b/Sources/LuminateLibrary/MovieDetailView.swift index 43a679a..d5b8052 100644 --- a/Sources/LuminateLibrary/MovieDetailView.swift +++ b/Sources/LuminateLibrary/MovieDetailView.swift @@ -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( - itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 - ) else { return } + guard + let url = await client.imageURL( + itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 + ) + else { return } let service = ImageService() backdropData = try? await service.loadImage(url: url) } diff --git a/Sources/LuminateLibrary/PosterCell.swift b/Sources/LuminateLibrary/PosterCell.swift index 9aaa888..cad7f5a 100644 --- a/Sources/LuminateLibrary/PosterCell.swift +++ b/Sources/LuminateLibrary/PosterCell.swift @@ -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, diff --git a/Sources/LuminateLibrary/SearchView.swift b/Sources/LuminateLibrary/SearchView.swift index 281e071..9d7d734 100644 --- a/Sources/LuminateLibrary/SearchView.swift +++ b/Sources/LuminateLibrary/SearchView.swift @@ -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( - itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160 - ) else { return } + guard + let url = await client.imageURL( + itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160 + ) + else { return } let service = ImageService() imageData = try? await service.loadImage(url: url) } diff --git a/Sources/LuminateLibrary/TVShowView.swift b/Sources/LuminateLibrary/TVShowView.swift index a4bd5c0..f8083c0 100644 --- a/Sources/LuminateLibrary/TVShowView.swift +++ b/Sources/LuminateLibrary/TVShowView.swift @@ -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( - itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 - ) else { return } + guard + let url = await client.imageURL( + itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 + ) + else { return } let service = ImageService() backdropData = try? await service.loadImage(url: url) } diff --git a/Sources/LuminatePlayer/LuminatePlayer.swift b/Sources/LuminatePlayer/LuminatePlayer.swift index 78f9a5b..5837e0d 100644 --- a/Sources/LuminatePlayer/LuminatePlayer.swift +++ b/Sources/LuminatePlayer/LuminatePlayer.swift @@ -1,3 +1,4 @@ import Adwaita import LuminateCore + public struct LuminatePlayer {} diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift index 5d3ef0b..6d632c1 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -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() diff --git a/Sources/LuminatePlayer/VideoPlayerWidget.swift b/Sources/LuminatePlayer/VideoPlayerWidget.swift index 6bada9c..5907cbf 100644 --- a/Sources/LuminatePlayer/VideoPlayerWidget.swift +++ b/Sources/LuminatePlayer/VideoPlayerWidget.swift @@ -1,5 +1,5 @@ -import Foundation import Adwaita +import Foundation struct VideoPlayerWidget: View {