Add login screen, poster cell, and item grid

This commit is contained in:
Brendan Szymanski 2026-06-05 01:26:05 -04:00
parent 20096beb4f
commit 0c9938acc2
8 changed files with 278 additions and 11 deletions

View file

@ -24,7 +24,7 @@ let package = Package(
), ),
.target( .target(
name: "LuminateHome", name: "LuminateHome",
dependencies: ["LuminateCore", .product(name: "Adwaita", package: "adwaita-swift")] dependencies: ["LuminateCore", "LuminateLibrary", .product(name: "Adwaita", package: "adwaita-swift")]
), ),
.target( .target(
name: "LuminateLibrary", name: "LuminateLibrary",

View file

@ -1,25 +1,70 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
import Adwaita import Adwaita
import LuminateCore
import LuminateHome
@main @main
struct Luminate: App { struct Luminate: App {
let app = AdwaitaApp(id: "dev.bscubed.Luminate") let app = AdwaitaApp(id: "dev.bscubed.Luminate")
@State private var client: JellyfinClient?
@State private var userId = ""
var scene: Scene { var scene: Scene {
Window(id: "main") { window in Window(id: "main") { window in
Text(Loc.helloWorld) if let client {
.padding() ContentView(
.topToolbar { app: app,
ToolbarView(app: app, window: window) 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() .quitShortcut()
.closeShortcut() .closeShortcut()
} }
} }
struct ContentView: View {
var app: AdwaitaApp
var window: AdwaitaWindow
var client: JellyfinClient
var userId: String
@State private var stack = NavigationStack<String>()
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")
}
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -1,5 +1,9 @@
import Foundation import Foundation
extension Components.Schemas.BaseItemDto: Identifiable {
public var id: String { Id ?? "" }
}
public extension Components.Schemas.BaseItemDto { public extension Components.Schemas.BaseItemDto {
var isMovie: Bool { _Type?.value1 == .Movie } var isMovie: Bool { _Type?.value1 == .Movie }
var isSeries: Bool { _Type?.value1 == .Series } var isSeries: Bool { _Type?.value1 == .Series }

View file

@ -28,6 +28,7 @@ public enum JellyfinError: Error {
public actor JellyfinClient { public actor JellyfinClient {
public let serverURL: URL public let serverURL: URL
public private(set) var userId: String?
private var token: String? private var token: String?
private var client: Client private var client: Client
@ -59,16 +60,19 @@ public actor JellyfinClient {
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
return result return result
case .application_json_profile__quot_camelcase_quot_(let result): case .application_json_profile__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
return result return result
case .application_json_profile__quot_pascalcase_quot_(let result): case .application_json_profile__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
return result return result
} }
case .serviceUnavailable: case .serviceUnavailable:

View file

@ -1,3 +1,26 @@
import Adwaita import Adwaita
import LuminateCore 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"
)
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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)
}
}
}