Add login screen, poster cell, and item grid
This commit is contained in:
parent
20096beb4f
commit
0c9938acc2
8 changed files with 278 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
Sources/Luminate/ServerSetupView.swift
Normal file
66
Sources/Luminate/ServerSetupView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
Sources/LuminateLibrary/ItemGrid.swift
Normal file
76
Sources/LuminateLibrary/ItemGrid.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Sources/LuminateLibrary/PosterCell.swift
Normal file
49
Sources/LuminateLibrary/PosterCell.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue