Set up swift-format
This commit is contained in:
parent
2411a220fe
commit
0b478113ac
26 changed files with 461 additions and 203 deletions
79
.swift-format
Normal file
79
.swift-format
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Foundation
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
import LuminateHome
|
import LuminateHome
|
||||||
import LuminateLibrary
|
import LuminateLibrary
|
||||||
|
|
@ -38,7 +38,7 @@ struct Luminate: App {
|
||||||
DIContainer.shared.register(\.client, value: client)
|
DIContainer.shared.register(\.client, value: client)
|
||||||
DIContainer.shared.register(\.userId, value: id)
|
DIContainer.shared.register(\.userId, value: id)
|
||||||
DIContainer.shared.register(\.imageService, value: ImageService())
|
DIContainer.shared.register(\.imageService, value: ImageService())
|
||||||
|
|
||||||
self.client = client
|
self.client = client
|
||||||
self.userId = id
|
self.userId = id
|
||||||
}
|
}
|
||||||
|
|
@ -57,19 +57,20 @@ struct Luminate: App {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
defer { isLaunchLoading = false }
|
defer { isLaunchLoading = false }
|
||||||
|
|
||||||
let store = try SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL())
|
let store = try SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL())
|
||||||
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 }
|
||||||
|
|
||||||
DIContainer.shared.register(\.client, value: client)
|
DIContainer.shared.register(\.client, value: client)
|
||||||
DIContainer.shared.register(\.userId, value: auth.userId)
|
DIContainer.shared.register(\.userId, value: auth.userId)
|
||||||
DIContainer.shared.register(\.imageService, value: ImageService())
|
DIContainer.shared.register(\.imageService, value: ImageService())
|
||||||
|
|
||||||
self.client = client
|
self.client = client
|
||||||
self.userId = auth.userId
|
self.userId = auth.userId
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
Sources/LuminateCore/Page.swift
Normal file
20
Sources/LuminateCore/Page.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] = []
|
||||||
|
|
@ -22,7 +25,7 @@ public struct HomeView: View {
|
||||||
.child {
|
.child {
|
||||||
VStack {
|
VStack {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
|
|
||||||
Spinner()
|
Spinner()
|
||||||
.halign(.center)
|
.halign(.center)
|
||||||
.valign(.center)
|
.valign(.center)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
Sources/LuminateHome/LibraryPage.swift
Normal file
43
Sources/LuminateHome/LibraryPage.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
|
||||||
public struct LuminateLibrary {}
|
public struct LuminateLibrary {}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
|
||||||
public struct LuminatePlayer {}
|
public struct LuminatePlayer {}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Foundation
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct VideoPlayerWidget: View {
|
struct VideoPlayerWidget: View {
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue