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(
|
.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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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