diff --git a/Package.swift b/Package.swift index 194f86d..0e832d3 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( ), .target( name: "LuminateHome", - dependencies: ["LuminateCore", .product(name: "Adwaita", package: "adwaita-swift")] + dependencies: ["LuminateCore", "LuminateLibrary", .product(name: "Adwaita", package: "adwaita-swift")] ), .target( name: "LuminateLibrary", diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 2c013e2..69234c3 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -1,25 +1,70 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - import Adwaita +import LuminateCore +import LuminateHome @main struct Luminate: App { let app = AdwaitaApp(id: "dev.bscubed.Luminate") + @State private var client: JellyfinClient? + @State private var userId = "" var scene: Scene { Window(id: "main") { window in - Text(Loc.helloWorld) - .padding() - .topToolbar { - ToolbarView(app: app, window: window) + if let client { + ContentView( + app: app, + window: window, + client: client, + userId: userId + ) + } else { + ServerSetupView { client, id in + self.client = client + self.userId = id } + } } - .defaultSize(width: 450, height: 300) + .defaultSize(width: 1100, height: 700) .quitShortcut() .closeShortcut() } - } +struct ContentView: View { + + var app: AdwaitaApp + var window: AdwaitaWindow + var client: JellyfinClient + var userId: String + @State private var stack = NavigationStack() + + var view: Body { + NavigationView($stack, "Luminate") { _ in + Text("") + } initialView: { + HomeView( + app: app, + window: window, + client: client, + userId: userId + ) + } + .topToolbar { + HeaderBar.end { + Menu(icon: .default(icon: .openMenu)) { + MenuButton("Search", window: false) { + // Navigate to search + } + MenuSection { + MenuButton("About", window: false) { + // About dialog + } + } + } + .primary() + .tooltip("Main Menu") + } + } + } +} diff --git a/Sources/Luminate/ServerSetupView.swift b/Sources/Luminate/ServerSetupView.swift new file mode 100644 index 0000000..9f1cdf7 --- /dev/null +++ b/Sources/Luminate/ServerSetupView.swift @@ -0,0 +1,66 @@ +import Foundation +import Adwaita +import LuminateCore + +struct ServerSetupView: View { + + @State private var serverURL = "" + @State private var username = "" + @State private var password = "" + @State private var isLoading = false + @State private var error: String? + var onLogin: (JellyfinClient, String) -> Void + + var view: Body { + VStack { + StatusPage( + "Connect to Server", + icon: .custom(name: "dev.bscubed.Luminate"), + description: "Enter your Jellyfin server details" + ) { + VStack { + EntryRow("Server URL", text: $serverURL) + EntryRow("Username", text: $username) + PasswordEntryRow("Password", text: $password) + Button("Connect") { + connect() + } + .style("suggested-action") + if isLoading { + Spinner() + } + if let error { + Text(error) + .style("error") + } + } + .padding() + } + } + } + + private func connect() { + guard let url = URL(string: serverURL), !username.isEmpty else { + error = "Please enter a valid server URL and username" + return + } + isLoading = true + error = nil + Task { + do { + let client = JellyfinClient(serverURL: url) + let result = try await client.authenticate(username: username, password: password) + let userId = result.User?.value1.Id ?? "" + await MainActor.run { + isLoading = false + onLogin(client, userId) + } + } catch { + await MainActor.run { + isLoading = false + self.error = error.localizedDescription + } + } + } + } +} diff --git a/Sources/LuminateCore/BaseItemDto+Display.swift b/Sources/LuminateCore/BaseItemDto+Display.swift index 80dd828..4abe0ac 100644 --- a/Sources/LuminateCore/BaseItemDto+Display.swift +++ b/Sources/LuminateCore/BaseItemDto+Display.swift @@ -1,5 +1,9 @@ import Foundation +extension Components.Schemas.BaseItemDto: Identifiable { + public var id: String { Id ?? "" } +} + public extension Components.Schemas.BaseItemDto { var isMovie: Bool { _Type?.value1 == .Movie } var isSeries: Bool { _Type?.value1 == .Series } diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index 39b4a7a..6aaf3a3 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -28,6 +28,7 @@ public enum JellyfinError: Error { public actor JellyfinClient { public let serverURL: URL + public private(set) var userId: String? private var token: String? private var client: Client @@ -59,16 +60,19 @@ public actor JellyfinClient { guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse } token = accessToken client = makeClient(token: accessToken) + userId = result.User?.value1.Id return result case .application_json_profile__quot_camelcase_quot_(let result): guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse } token = accessToken client = makeClient(token: accessToken) + userId = result.User?.value1.Id return result case .application_json_profile__quot_pascalcase_quot_(let result): guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse } token = accessToken client = makeClient(token: accessToken) + userId = result.User?.value1.Id return result } case .serviceUnavailable: diff --git a/Sources/LuminateHome/LuminateHome.swift b/Sources/LuminateHome/LuminateHome.swift index f92a3e4..77f9dda 100644 --- a/Sources/LuminateHome/LuminateHome.swift +++ b/Sources/LuminateHome/LuminateHome.swift @@ -1,3 +1,26 @@ import Adwaita import LuminateCore -public struct LuminateHome {} +import LuminateLibrary + +public struct HomeView: View { + + public var app: AdwaitaApp + public var window: AdwaitaWindow + public var client: JellyfinClient + public var userId: String + + public init(app: AdwaitaApp, window: AdwaitaWindow, client: JellyfinClient, userId: String) { + self.app = app + self.window = window + self.client = client + self.userId = userId + } + + public var view: Body { + ItemGrid( + client: client, + userId: userId, + title: "Library" + ) + } +} diff --git a/Sources/LuminateLibrary/ItemGrid.swift b/Sources/LuminateLibrary/ItemGrid.swift new file mode 100644 index 0000000..d5e3215 --- /dev/null +++ b/Sources/LuminateLibrary/ItemGrid.swift @@ -0,0 +1,76 @@ +import Adwaita +import LuminateCore + +public struct ItemGrid: View { + + var client: JellyfinClient + var userId: String + var parentId: String? + var includeItemTypes: [Components.Schemas.BaseItemKind]? + var title: String? + @State private var items: [Components.Schemas.BaseItemDto] = [] + @State private var isLoading = false + private let pageSize: Int32 = 50 + + public init( + client: JellyfinClient, + userId: String, + parentId: String? = nil, + includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, + title: String? = nil + ) { + self.client = client + self.userId = userId + self.parentId = parentId + self.includeItemTypes = includeItemTypes + self.title = title + } + + public var view: Body { + VStack { + if let title { + Text(title) + .style("title-2") + .halign(.start) + .padding() + } + if isLoading { + Spinner() + } else { + ScrollView { + FlowBox(items) { item in + PosterCell(item: item, client: client) + } + } + } + } + .onAppear { + loadItems() + } + } + + private func loadItems() { + isLoading = true + Task { + do { + let result = try await client.getItems( + userId: userId, + parentId: parentId, + includeItemTypes: includeItemTypes, + fields: [.Overview, .Genres, .People, .MediaSources], + sortBy: [.SortName], + sortOrder: [.Ascending], + startIndex: 0, + limit: pageSize, + recursive: true + ) + await MainActor.run { + items = result.Items ?? [] + isLoading = false + } + } catch { + await MainActor.run { isLoading = false } + } + } + } +} diff --git a/Sources/LuminateLibrary/PosterCell.swift b/Sources/LuminateLibrary/PosterCell.swift new file mode 100644 index 0000000..5262e1b --- /dev/null +++ b/Sources/LuminateLibrary/PosterCell.swift @@ -0,0 +1,49 @@ +import Foundation +import Adwaita +import LuminateCore + +struct PosterCell: View { + + var item: Components.Schemas.BaseItemDto + var client: JellyfinClient + @State private var imageData: Data? + + var view: Body { + VStack { + if let data = imageData { + Picture() + .data(data) + .frame(maxWidth: 150) + .frame(maxHeight: 225) + } else { + Box(spacing: 0) {} + .frame(maxWidth: 150) + .frame(maxHeight: 225) + .style("card") + } + Text(item.Name ?? "") + .style("body") + .halign(.center) + .frame(maxWidth: 150) + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard let tag = item.primaryImageTag, + let itemId = item.Id else { return } + Task { + let url = await client.imageURL( + itemId: itemId, + imageType: .Primary, + tag: tag, + maxWidth: 300 + ) + guard let url else { return } + let service = ImageService() + imageData = try? await service.loadImage(url: url) + } + } +}