diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 90fcfd8..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM swift:noble - -RUN touch /var/mail/ubuntu && \ - chown ubuntu /var/mail/ubuntu && \ - userdel -r ubuntu && \ - apt-get update && \ - apt-get install -y libadwaita-1.0 libadwaita-1-dev diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 42f32e1..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "Swift", - "build": { - "dockerfile": "Dockerfile" - }, - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": "false", - "username": "vscode", - "userUid": "1000", - "userGid": "1000", - "upgradePackages": "false" - }, - "ghcr.io/devcontainers/features/git:1": { - "version": "os-provided", - "ppa": "false" - }, - "ghcr.io/devcontainers-contrib/features/curl-apt-get:1": {} - }, - "runArgs": [ - "--cap-add=SYS_PTRACE", - "--security-opt", - "seccomp=unconfined" - ], - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Set *default* container specific settings.json values on container create. - "settings": { - "lldb.library": "/usr/lib/liblldb.so" - }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "sswg.swift-lang" - ] - } - }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode" -} diff --git a/Package.swift b/Package.swift index cf7b7ed..600f172 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"), .package(url: "https://github.com/apple/swift-syntax", from: "603.0.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], targets: [ .target( @@ -48,6 +49,7 @@ let package = Package( dependencies: [ "LuminateCore", .product(name: "Adwaita", package: "adwaita-swift"), + .product(name: "Logging", package: "swift-log"), ] ), .target( @@ -79,6 +81,7 @@ let package = Package( "LuminateObservationMacros", .product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Localized", package: "localized"), + .product(name: "Logging", package: "swift-log"), ], path: "Sources/Luminate", resources: [.process("Localized.yml")], diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 60e530a..1e94167 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -21,6 +21,7 @@ import Adwaita import Foundation +import Logging import LuminateCore import LuminateDI import LuminatePlayer @@ -35,12 +36,22 @@ struct Luminate: App { @State private var isLaunchLoading = true init() { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + #if DEBUG + handler.logLevel = .debug + #else + handler.logLevel = .info + #endif + return handler + } ObservationRegistrar.onChange = { StateManager.updateViews() } if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) { DIContainer.shared.register(\.persistence, value: store) } DIContainer.shared.register(\.imageService, value: ImageService()) - DIContainer.shared.register(\.pageAnimationTracker, value: PageAnimationTracker()) + DIContainer.shared.register(\.viewUpdateScheduler, value: ViewUpdateScheduler()) + DIContainer.shared.register(\.logger, value: Logger(label: "dev.bscubed.Luminate")) } var scene: Scene { @@ -74,6 +85,9 @@ struct Luminate: App { } .keyboardShortcut("r".ctrl()) { _ in } + #if DEBUG + .devel() + #endif } private func loadSavedSession() { @@ -107,19 +121,22 @@ struct ContentView: View { var client: JellyfinClient var userId: String @State var stack: NavigationStack = .init() - @Injected(\.pageAnimationTracker) var pageAnimationTracker - var view: Body { NavigationView($stack, "Luminate") { page in switch page { - case .folder(let title, let items): - LibraryPage(title: title, items: items, navigation: $stack) + case .itemPage(let title, let items): + ItemPage(title: title, items: items, navigation: $stack) + .topToolbar { + ToolbarView() + } + .navigationTitle(title) + case .collectionPage(let item): + let title = item.name ?? "Library" + CollectionView(item: item, navigation: $stack) .topToolbar { ToolbarView() } .navigationTitle(title) - case .library(let item): - Text("REPLACE ME") } } initialView: { HomeView(navigation: $stack) @@ -128,11 +145,5 @@ struct ContentView: View { } .navigationTitle("Luminate") } - .pushed { - pageAnimationTracker.markPush() - } - .popped { - pageAnimationTracker.markPush() - } } } diff --git a/Sources/Luminate/Pages/CollectionView.swift b/Sources/Luminate/Pages/CollectionView.swift new file mode 100644 index 0000000..8063865 --- /dev/null +++ b/Sources/Luminate/Pages/CollectionView.swift @@ -0,0 +1,56 @@ +// +// CollectionView.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adwaita +import LuminateCore +import LuminateUI + +public struct CollectionView: View { + + nonisolated public init( + item: Components.Schemas.BaseItemDto, + navigation: Binding> + ) { + self.item = item + _navigation = navigation + } + + private var item: Components.Schemas.BaseItemDto + @Binding var navigation: NavigationStack + + public var view: Body { + ScrollView { + Clamp() + .maximumSize(1550) + .tighteningThreshold(550) + .child { + ItemGrid( + parentId: item.id, + title: item.name, + navigation: $navigation + ) + .padding(32, .vertical) + } + } + .hscrollbarPolicy(.never) + .propagateNaturalHeight() + } +} diff --git a/Sources/Luminate/Pages/HomeView.swift b/Sources/Luminate/Pages/HomeView.swift index dbd6645..28a9590 100644 --- a/Sources/Luminate/Pages/HomeView.swift +++ b/Sources/Luminate/Pages/HomeView.swift @@ -62,7 +62,7 @@ public struct HomeView: View { items: resumeItems, navigation: $navigation, onSeeAll: { - navigation.push(.folder(title: title, items: resumeItems)) + navigation.push(.itemPage(title: title, items: resumeItems)) } ) .padding(32, .bottom) @@ -74,7 +74,7 @@ public struct HomeView: View { items: nextUpItems, navigation: $navigation, onSeeAll: { - navigation.push(.folder(title: title, items: nextUpItems)) + navigation.push(.itemPage(title: title, items: nextUpItems)) } ) .padding(32, .bottom) @@ -86,7 +86,7 @@ public struct HomeView: View { items: latestItems, navigation: $navigation, onSeeAll: { - navigation.push(.folder(title: title, items: latestItems)) + navigation.push(.itemPage(title: title, items: latestItems)) } ) .padding(32, .bottom) @@ -99,7 +99,7 @@ public struct HomeView: View { } } .padding(8, .horizontal) - .padding(32, .bottom) + .padding(32, .vertical) } } .hscrollbarPolicy(.never) diff --git a/Sources/Luminate/Pages/LibraryPage.swift b/Sources/Luminate/Pages/ItemPage.swift similarity index 91% rename from Sources/Luminate/Pages/LibraryPage.swift rename to Sources/Luminate/Pages/ItemPage.swift index 4dbc03d..c884db9 100644 --- a/Sources/Luminate/Pages/LibraryPage.swift +++ b/Sources/Luminate/Pages/ItemPage.swift @@ -1,5 +1,8 @@ // -// LibraryPage.swift +// ItemPage.swift +// Luminate +// +// Created by Brendan Szymanski on 6/16/25. // // Copyright 2026 Brendan Szymanski // @@ -23,7 +26,7 @@ import Adwaita import LuminateCore import LuminateUI -public struct LibraryPage: View { +public struct ItemPage: View { nonisolated public init( title: String, items: [Components.Schemas.BaseItemDto], @@ -49,7 +52,7 @@ public struct LibraryPage: View { navigation: $navigation, title: title ) - .padding(32, .bottom) + .padding(32, .vertical) } } .hscrollbarPolicy(.never) diff --git a/Sources/LuminateCore/ImageService.swift b/Sources/LuminateCore/ImageService.swift index 4b1ab39..c8ca15f 100644 --- a/Sources/LuminateCore/ImageService.swift +++ b/Sources/LuminateCore/ImageService.swift @@ -20,6 +20,7 @@ // import Foundation +import Logging #if canImport(FoundationNetworking) import FoundationNetworking @@ -28,36 +29,65 @@ import Foundation public actor ImageService { private let cacheDir: URL private let memoryCache = NSCache() + private var activeDownloads = 0 + private var imagesDownloaded = 0 + private let maxConcurrent: Int + private var pendingContinuations: [CheckedContinuation] = [] - public init(cacheDir: URL? = nil) { + private var logger: Logger = .init(label: "dev.bscubed.Luminate") + + public init(cacheDir: URL? = nil, maxConcurrent: Int = 6) { let defaultCache = FileManager.default.urls( for: .cachesDirectory, in: .userDomainMask ).first!.appendingPathComponent("luminate/images") self.cacheDir = cacheDir ?? defaultCache + self.maxConcurrent = maxConcurrent try? FileManager.default.createDirectory( at: self.cacheDir, withIntermediateDirectories: true) + + logger.debug("New ImageService created") } public func loadImage(url: URL) async throws -> Data { let key = url.absoluteString as NSString if let cached = memoryCache.object(forKey: key) { + logger.debug("Hit memory cache for \(key)") return cached as Data } - let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString() - .replacingOccurrences(of: "/", with: "_") - let diskURL = cacheDir.appendingPathComponent(diskKey) + let diskURL = diskCacheURL(for: url) if let data = try? Data(contentsOf: diskURL) { memoryCache.setObject(data as NSData, forKey: key) + logger.debug("Hit disk cache for \(key)") return data } + + if activeDownloads >= maxConcurrent { + await withCheckedContinuation { (continuation: CheckedContinuation) in + logger.debug("Idk what this is doing tbh") + pendingContinuations.append(continuation) + } + } + + activeDownloads += 1 + defer { + activeDownloads -= 1 + if !pendingContinuations.isEmpty { + let next = pendingContinuations.removeFirst() + next.resume() + } + } + + logger.debug("Started downloading \(url)") let (data, _) = try await URLSession.shared.data(from: url) let nsData = data as NSData memoryCache.setObject(nsData, forKey: key) try? data.write(to: diskURL) + logger.debug("Finished downloading \(url)") return data } public func prefetch(urls: [URL]) async { + logger.debug("Prefetching \(urls)") await withTaskGroup(of: Void.self) { group in for url in urls { group.addTask { _ = try? await self.loadImage(url: url) } @@ -66,8 +96,15 @@ public actor ImageService { } public func clearCache() { + logger.debug("Clearing all cache...") memoryCache.removeAllObjects() try? FileManager.default.removeItem(at: cacheDir) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) } + + private func diskCacheURL(for url: URL) -> URL { + let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + return cacheDir.appendingPathComponent(diskKey) + } } diff --git a/Sources/LuminateCore/Page.swift b/Sources/LuminateCore/Page.swift index 70e470c..0bc1155 100644 --- a/Sources/LuminateCore/Page.swift +++ b/Sources/LuminateCore/Page.swift @@ -20,14 +20,14 @@ // public enum Page: CustomStringConvertible { - case library(item: Components.Schemas.BaseItemDto) - case folder(title: String, items: [Components.Schemas.BaseItemDto]) + case collectionPage(item: Components.Schemas.BaseItemDto) + case itemPage(title: String, items: [Components.Schemas.BaseItemDto]) public var description: String { switch self { - case .library(let item): + case .collectionPage(let item): return item.name ?? "Library" - case .folder(let title, _): + case .itemPage(let title, _): return title } } diff --git a/Sources/LuminateCore/PageAnimationTracking.swift b/Sources/LuminateCore/ViewUpdateScheduling.swift similarity index 85% rename from Sources/LuminateCore/PageAnimationTracking.swift rename to Sources/LuminateCore/ViewUpdateScheduling.swift index 864f048..181030e 100644 --- a/Sources/LuminateCore/PageAnimationTracking.swift +++ b/Sources/LuminateCore/ViewUpdateScheduling.swift @@ -1,5 +1,5 @@ // -// PageAnimationTracking.swift +// ViewUpdateScheduling.swift // // Copyright 2026 Brendan Szymanski // @@ -21,7 +21,6 @@ import Foundation -public protocol PageAnimationTracking: AnyObject { - var isAnimating: Bool { get } - func markPush() +public protocol ViewUpdateScheduling: AnyObject { + func scheduleFlush() } diff --git a/Sources/LuminateDI/DIContainer.swift b/Sources/LuminateDI/DIContainer.swift index c67b6b5..7770649 100644 --- a/Sources/LuminateDI/DIContainer.swift +++ b/Sources/LuminateDI/DIContainer.swift @@ -21,13 +21,21 @@ import Adwaita import Foundation +import Synchronization public final class DIContainer: @unchecked Sendable { public static let shared = DIContainer() - public private(set) var values = InjectionValues() - private var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:] - private let lock = NSLock() + + private struct State { + var values = InjectionValues() + var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:] + } + private let state = Mutex(.init()) + + public var values: InjectionValues { + state.withLock { $0.values } + } private init() {} @@ -35,8 +43,8 @@ public final class DIContainer: @unchecked Sendable { _ keyPath: WritableKeyPath, value: T ) { - lock.withLock { - values[keyPath: keyPath] = value + state.withLock { + $0.values[keyPath: keyPath] = value } notifyObservers(for: keyPath) } @@ -44,8 +52,8 @@ public final class DIContainer: @unchecked Sendable { public func resolve( _ keyPath: KeyPath ) -> T { - lock.withLock { - guard let value = values[keyPath: keyPath] else { + state.withLock { + guard let value = $0.values[keyPath: keyPath] else { fatalError( "DIContainer: No value registered for \(keyPath). " + "Call DIContainer.shared.register(\\.key, value:) during app startup." @@ -61,31 +69,30 @@ public final class DIContainer: @unchecked Sendable { handler: @escaping @Sendable () -> Void ) -> UUID { let id = UUID() - lock.withLock { - observers[keyPath, default: [:]][id] = handler + state.withLock { + $0.observers[keyPath, default: [:]][id] = handler } return id } func removeObserver(_ id: UUID) { - lock.withLock { - for keyPath in observers.keys { - observers[keyPath]?.removeValue(forKey: id) + state.withLock { + for keyPath in $0.observers.keys { + $0.observers[keyPath]?.removeValue(forKey: id) } } } private func notifyObservers(for keyPath: AnyKeyPath) { - let handlers: [@Sendable () -> Void] = lock.withLock { - Array((observers[keyPath] ?? [:]).values) + let handlers: [@Sendable () -> Void] = state.withLock { + Array(($0.observers[keyPath] ?? [:]).values) } handlers.forEach { $0() } } public func reset() { - lock.withLock { - values = InjectionValues() - observers.removeAll() + state.withLock { + $0 = State() } } } diff --git a/Sources/LuminateDI/InjectionValues.swift b/Sources/LuminateDI/InjectionValues.swift index 7adf183..3dfbb4a 100644 --- a/Sources/LuminateDI/InjectionValues.swift +++ b/Sources/LuminateDI/InjectionValues.swift @@ -20,6 +20,7 @@ // import Foundation +import Logging import LuminateCore public struct InjectionValues { @@ -29,7 +30,8 @@ public struct InjectionValues { public var imageService: ImageService? public var webSocketClient: WebSocketClient? public var persistence: PersistenceService? - public var pageAnimationTracker: (any PageAnimationTracking)? + public var viewUpdateScheduler: (any ViewUpdateScheduling)? + public var logger: Logger? public init() {} } diff --git a/Sources/LuminateUI/Components/EpisodeList.swift b/Sources/LuminateUI/Components/EpisodeList.swift index bcd74e0..0aa3a19 100644 --- a/Sources/LuminateUI/Components/EpisodeList.swift +++ b/Sources/LuminateUI/Components/EpisodeList.swift @@ -22,6 +22,7 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI struct EpisodeList: View { @@ -61,6 +62,8 @@ struct EpisodeRow: View { var episode: Components.Schemas.BaseItemDto var client: JellyfinClient + @Injected(\.imageService) var imageService + @Injected(\.viewUpdateScheduler) var viewUpdateScheduler @State private var imageData: Data? var view: Body { @@ -112,8 +115,9 @@ struct EpisodeRow: View { maxWidth: 200 ) else { return } - let service = ImageService() - imageData = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/HomePosterCell.swift b/Sources/LuminateUI/Components/HomePosterCell.swift index 5c50675..78d043e 100644 --- a/Sources/LuminateUI/Components/HomePosterCell.swift +++ b/Sources/LuminateUI/Components/HomePosterCell.swift @@ -26,57 +26,120 @@ import LuminateDI struct HomePosterCell: View { - var item: Components.Schemas.BaseItemDto + let item: Components.Schemas.BaseItemDto + let width: Int = 200 + @Binding var navigation: NavigationStack @Injected(\.client) var client - @Injected(\.pageAnimationTracker) var pageAnimationTracker + @Injected(\.imageService) var imageService + @Injected(\.viewUpdateScheduler) var viewUpdateScheduler @State private var imageData: Data? + private struct Constants { + static let padding = 8 + } + var view: Body { - VStack { - if let data = imageData { - Picture() - .contentFit(.cover) - .data(data) - .frame(minWidth: 200, minHeight: 300) - .frame(maxWidth: 200) - .frame(maxHeight: 300) - } else { - Box(spacing: 0) {} - .frame(minWidth: 200, minHeight: 300) - .frame(maxWidth: 200) - .frame(maxHeight: 300) - .card() + Bin { + VStack(spacing: 6) { + imageSection + textSection } - VStack(spacing: 0) { - if let title = item.name, !title.isEmpty { - Text(item.name ?? "") - .ellipsize() - .heading() - .halign(.center) - .frame(maxWidth: 200) - } - let subtitle = item.yearString - if !subtitle.isEmpty { - Text(subtitle) - .ellipsize() - .caption() - .dimLabel() - .halign(.center) - .frame(maxWidth: 200) - } + .padding(Constants.padding) + }.onClick { + if item.isFolder ?? false { + navigation.push(.collectionPage(item: item)) } - .padding(6, .vertical) - .padding(12, .horizontal) } .onAppear { Idle { loadImage() } } - .overflow(.hidden) + .frame(minWidth: width + (Constants.padding * 2)) + .halign(.fill) + .valign(.start) + .hexpand(false) + .style("activatable") .card() } + @ViewBuilder + private var imageSection: Body { + AspectFrame(ratio: 1.5) + .child { + image + .halign(.fill) + .hexpand() + } + .obeyChild(false) + .xalign(0.5) + .yalign(0.5) + .halign(.fill) + .hexpand() + .overflow(.hidden) + .card() + // image + // .halign(.fill) + // .hexpand() + // .frame(minHeight: Int(Double(width) * 1.5)) + // .overflow(.hidden) + // .card() + } + + @ViewBuilder + private var image: Body { + if let imageData { + // Picture() + // .contentFit(.cover) + // .data(imageData) + // .valign(.fill) + // .halign(.fill) + // .vexpand() + // .hexpand() + // .transition(.crossfade) + Overlay() + .overlay { + Picture() + .contentFit(.cover) + .data(imageData) + .valign(.fill) + .halign(.fill) + .vexpand() + .hexpand() + } + .transition(.crossfade) + } else { + Spinner() + .frame(minWidth: 64, minHeight: 64) + .transition(.crossfade) + } + } + + @ViewBuilder + private var textSection: Body { + Bin { + VStack(spacing: 2) { + if let title = item.name, !title.isEmpty { + Text(item.name ?? "") + .maxWidthChars(0) + .ellipsize() + .heading() + } + let subtitle = item.yearString + if !subtitle.isEmpty { + Text(subtitle) + .maxWidthChars(0) + .ellipsize() + .caption() + .dimLabel() + } + } + .valign(.center) + } + .frame(minHeight: 40) + .hexpand() + } + private func loadImage() { guard let tag = item.primaryImageTag, let itemId = item.seriesId ?? item.id @@ -87,17 +150,13 @@ struct HomePosterCell: View { itemId: itemId, imageType: .primary, tag: tag, - maxWidth: 400 + maxWidth: 200 ) else { return } - let service = ImageService() - let data = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) - if pageAnimationTracker.isAnimating { - _imageData.rawValue = data - } else { - imageData = data - } + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/ItemGrid.swift b/Sources/LuminateUI/Components/ItemGrid.swift index 3aed5b6..6ed389f 100644 --- a/Sources/LuminateUI/Components/ItemGrid.swift +++ b/Sources/LuminateUI/Components/ItemGrid.swift @@ -21,30 +21,30 @@ import Adwaita import LuminateCore +import LuminateDI public struct ItemGrid: View { - var client: JellyfinClient - var userId: String var parentId: String? var includeItemTypes: [Components.Schemas.BaseItemKind]? var title: String? + @Binding var navigation: NavigationStack + @Injected(\.client) var client + @Injected(\.userId) var userId @State private var items: [Components.Schemas.BaseItemDto] = [] - @State private var isLoading = false + @State private var isLoading = true private let pageSize: Int32 = 50 public init( - client: JellyfinClient, - userId: String, parentId: String? = nil, includeItemTypes: [Components.Schemas.BaseItemKind]? = nil, - title: String? = nil + title: String? = nil, + navigation: Binding> ) { - self.client = client - self.userId = userId self.parentId = parentId self.includeItemTypes = includeItemTypes self.title = title + _navigation = navigation } public var view: Body { @@ -58,11 +58,14 @@ public struct ItemGrid: View { if isLoading { Spinner() } else { - ScrollView { - FlowBox(items) { item in - PosterCell(item: item, client: client) - } + WrapBox(items, id: \.id) { item in + HomePosterCell(item: item, navigation: $navigation) } + .lineSpacing(16) + .childSpacing(16) + .justify(JustifyMode.none) + .justifyLastLine(false) + .halign(.start) } } .onAppear { @@ -71,26 +74,18 @@ public struct ItemGrid: View { } 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 + sortOrder: [.ascending] ) - await MainActor.run { - items = result.items ?? [] - isLoading = false - } + items = result.items ?? [] + isLoading = false } catch { - await MainActor.run { isLoading = false } + isLoading = false } } } diff --git a/Sources/LuminateUI/Components/LibraryGrid.swift b/Sources/LuminateUI/Components/LibraryGrid.swift index af42d9f..e04af70 100644 --- a/Sources/LuminateUI/Components/LibraryGrid.swift +++ b/Sources/LuminateUI/Components/LibraryGrid.swift @@ -45,11 +45,14 @@ public struct LibraryGrid: View { .title3() .halign(.start) .padding(10, .horizontal) - FlowBox(libraries) { library in - HomePosterCell(item: library) + WrapBox(libraries, id: \.id) { item in + HomePosterCell(item: item, navigation: $navigation) } - .columnSpacing(16) - .rowSpacing(16) + .lineSpacing(16) + .childSpacing(16) + .justify(.fill) + .justifyLastLine(false) + .halign(.start) } } } diff --git a/Sources/LuminateUI/Components/MediaRow.swift b/Sources/LuminateUI/Components/MediaRow.swift index 9025003..53d94c4 100644 --- a/Sources/LuminateUI/Components/MediaRow.swift +++ b/Sources/LuminateUI/Components/MediaRow.swift @@ -61,12 +61,14 @@ public struct MediaRow: View { ScrollView { ForEach(items, horizontal: true) { item in - HomePosterCell(item: item) + HomePosterCell(item: item, navigation: $navigation) .padding(16, .trailing) } } .vscrollbarPolicy(.never) .hscrollbarPolicy(.external) + .style("undershoot-start") + .style("undershoot-end") } } } diff --git a/Sources/LuminateUI/Components/MovieDetailView.swift b/Sources/LuminateUI/Components/MovieDetailView.swift index 8ec79ea..4b2c028 100644 --- a/Sources/LuminateUI/Components/MovieDetailView.swift +++ b/Sources/LuminateUI/Components/MovieDetailView.swift @@ -22,12 +22,14 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI struct MovieDetailView: View { var item: Components.Schemas.BaseItemDto var client: JellyfinClient var userId: String + @Injected(\.imageService) var imageService @State private var isFavorite: Bool @State private var isPlayed: Bool @State private var similarItems: [Components.Schemas.BaseItemDto] = [] @@ -52,7 +54,7 @@ struct MovieDetailView: View { .hexpand(true) } HStack { - PosterCell(item: item, client: client) + PosterCell(item: item) .frame(minWidth: 200) .frame(maxWidth: 200) VStack { @@ -111,7 +113,7 @@ struct MovieDetailView: View { ScrollView { HStack { ForEach(similarItems) { sim in - PosterCell(item: sim, client: client) + PosterCell(item: sim) } } } @@ -135,8 +137,7 @@ struct MovieDetailView: View { itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 ) else { return } - let service = ImageService() - backdropData = try? await service.loadImage(url: url) + backdropData = try? await imageService.loadImage(url: url) } } diff --git a/Sources/LuminateUI/Components/PosterCell.swift b/Sources/LuminateUI/Components/PosterCell.swift index df25294..8f65c35 100644 --- a/Sources/LuminateUI/Components/PosterCell.swift +++ b/Sources/LuminateUI/Components/PosterCell.swift @@ -27,8 +27,9 @@ import LuminateDI struct PosterCell: View { var item: Components.Schemas.BaseItemDto - var client: JellyfinClient - @Injected(\.pageAnimationTracker) var pageAnimationTracker + @Injected(\.client) private var client: JellyfinClient + @Injected(\.imageService) private var imageService + @Injected(\.viewUpdateScheduler) private var viewUpdateScheduler @State private var imageData: Data? var view: Body { @@ -68,14 +69,10 @@ struct PosterCell: View { maxWidth: 300 ) guard let url else { return } - let service = ImageService() - let data = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) - if pageAnimationTracker.isAnimating { - _imageData.rawValue = data - } else { - imageData = data - } + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/SearchView.swift b/Sources/LuminateUI/Components/SearchView.swift index 039a420..93daf5c 100644 --- a/Sources/LuminateUI/Components/SearchView.swift +++ b/Sources/LuminateUI/Components/SearchView.swift @@ -81,7 +81,8 @@ struct SearchResultRow: View { var hint: Components.Schemas.SearchHint var client: JellyfinClient - @Injected(\.pageAnimationTracker) var pageAnimationTracker + @Injected(\.viewUpdateScheduler) var viewUpdateScheduler + @Injected(\.imageService) var imageService @State private var imageData: Data? var view: Body { @@ -138,14 +139,10 @@ struct SearchResultRow: View { itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160 ) else { return } - let service = ImageService() - let data = try? await service.loadImage(url: url) + let data = try? await imageService.loadImage(url: url) - if pageAnimationTracker.isAnimating { - _imageData.rawValue = data - } else { - imageData = data - } + _imageData.rawValue = data + viewUpdateScheduler.scheduleFlush() } } } diff --git a/Sources/LuminateUI/Components/TVShowView.swift b/Sources/LuminateUI/Components/TVShowView.swift index 68b7284..3a16302 100644 --- a/Sources/LuminateUI/Components/TVShowView.swift +++ b/Sources/LuminateUI/Components/TVShowView.swift @@ -22,12 +22,14 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI struct TVShowView: View { var item: Components.Schemas.BaseItemDto var client: JellyfinClient var userId: String + @Injected(\.imageService) var imageService @State private var seasons: [Components.Schemas.BaseItemDto] = [] @State private var selectedSeasonId: String? @State private var backdropData: Data? @@ -43,7 +45,7 @@ struct TVShowView: View { .hexpand(true) } HStack { - PosterCell(item: item, client: client) + PosterCell(item: item) .frame(minWidth: 200) .frame(maxWidth: 200) VStack { @@ -106,8 +108,7 @@ struct TVShowView: View { itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920 ) else { return } - let service = ImageService() - backdropData = try? await service.loadImage(url: url) + backdropData = try? await imageService.loadImage(url: url) } } diff --git a/Sources/LuminateUI/Components/WrapBox.swift b/Sources/LuminateUI/Components/WrapBox.swift new file mode 100644 index 0000000..fffca03 --- /dev/null +++ b/Sources/LuminateUI/Components/WrapBox.swift @@ -0,0 +1,430 @@ +// +// WrapBox.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adwaita +import CAdw + +// MARK: - Swift Enum Wrappers for C Enums + +/// Controls how children are justified within each line. +public enum JustifyMode { + + /// No justification. + case none + /// Children are stretched to fill the line. + case fill + /// Extra space is distributed evenly between children. + case spread + + var cValue: AdwJustifyMode { + switch self { + case .none: ADW_JUSTIFY_NONE + case .fill: ADW_JUSTIFY_FILL + case .spread: ADW_JUSTIFY_SPREAD + } + } + +} + +/// Controls the packing direction. +public enum PackDirection { + + /// Children are packed from start to end. + case startToEnd + /// Children are packed from end to start. + case endToStart + + var cValue: AdwPackDirection { + switch self { + case .startToEnd: ADW_PACK_START_TO_END + case .endToStart: ADW_PACK_END_TO_START + } + } + +} + +/// Controls the wrapping policy. +public enum WrapPolicy { + + /// Wrapping occurs at the minimum size. + case minimum + /// Wrapping occurs at the natural size. + case natural + + var cValue: AdwWrapPolicy { + switch self { + case .minimum: ADW_WRAP_MINIMUM + case .natural: ADW_WRAP_NATURAL + } + } + +} + +/// Units for spacing and length properties. +public enum LengthUnit { + + /// Pixels. + case px + /// Points. + case pt + /// Scale-independent pixels. + case sp + + var cValue: AdwLengthUnit { + switch self { + case .px: ADW_LENGTH_UNIT_PX + case .pt: ADW_LENGTH_UNIT_PT + case .sp: ADW_LENGTH_UNIT_SP + } + } + +} + +// MARK: - WrapBox Widget + +/// A responsive wrapping container that arranges children in a reflowing grid. +/// +/// `WrapBox` places its children in a horizontal flow, wrapping to the next +/// line when the available width is exhausted. It is backed by `AdwWrapBox` from libadwaita. +/// +/// Use inside a `ScrollView` to allow shrinking in both axes. +/// +/// ```swift +/// ScrollView { +/// WrapBox(items) { item in +/// ItemCell(item: item) +/// } +/// .childSpacing(12) +/// .lineSpacing(12) +/// .lineHomogeneous(true) +/// } +/// ``` +public struct WrapBox: AdwaitaWidget where Identifier: Hashable { + + #if exposeGeneratedAppearUpdateFunctions + /// Additional update functions for type extensions. + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + /// Additional appear functions for type extensions. + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + /// Additional update functions for type extensions. + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + /// Additional appear functions for type extensions. + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + /// The amount of space between children. + var childSpacing: Int? + /// The unit for `childSpacing`. + var childSpacingUnit: LengthUnit? + /// The packing direction. + var packDirection: PackDirection? + /// The alignment of children within each line (0.0 to 1.0). + var align: Float? + /// The justification mode. + var justify: JustifyMode? + /// Whether to justify the last line. + var justifyLastLine: Bool? + /// The amount of space between lines. + var lineSpacing: Int? + /// The unit for `lineSpacing`. + var lineSpacingUnit: LengthUnit? + /// Whether all lines should be the same size. + var lineHomogeneous: Bool? + /// The natural length of each line. + var naturalLineLength: Int? + /// The unit for `naturalLineLength`. + var naturalLineLengthUnit: LengthUnit? + /// Whether to reverse the wrapping direction. + var wrapReverse: Bool? + /// The wrapping policy. + var wrapPolicy: WrapPolicy? + + /// The dynamic widget elements. + var elements: [Element] + /// The dynamic widget content. + var content: (Element) -> Body + /// The dynamic widget identifier key path. + var id: KeyPath + + /// Initialize `WrapBox`. + /// - Parameters: + /// - elements: The elements to display. + /// - id: The key path to the element's identifier. + /// - content: A view builder for rendering each element. + public init( + _ elements: [Element], + id: KeyPath, + @ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.id = id + } + + // MARK: - AdwaitaWidget + + public func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let storage = ViewStorage(adw_wrap_box_new()?.opaque()) + for function in appearFunctions { + function(storage, data) + } + return storage + } + + public func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + storage.modify { widget in + + // --- Apply property changes --- + if let childSpacing, updateProperties, + (storage.previousState as? Self)?.childSpacing != childSpacing + { + adw_wrap_box_set_child_spacing(widget, childSpacing.cInt) + } + if let childSpacingUnit, updateProperties, + (storage.previousState as? Self)?.childSpacingUnit != childSpacingUnit + { + adw_wrap_box_set_child_spacing_unit(widget, childSpacingUnit.cValue) + } + if let packDirection, updateProperties, + (storage.previousState as? Self)?.packDirection != packDirection + { + adw_wrap_box_set_pack_direction(widget, packDirection.cValue) + } + if let align, updateProperties, + (storage.previousState as? Self)?.align != align + { + adw_wrap_box_set_align(widget, align) + } + if let justify, updateProperties, + (storage.previousState as? Self)?.justify != justify + { + adw_wrap_box_set_justify(widget, justify.cValue) + } + if let justifyLastLine, updateProperties, + (storage.previousState as? Self)?.justifyLastLine != justifyLastLine + { + adw_wrap_box_set_justify_last_line(widget, justifyLastLine.cBool) + } + if let lineSpacing, updateProperties, + (storage.previousState as? Self)?.lineSpacing != lineSpacing + { + adw_wrap_box_set_line_spacing(widget, lineSpacing.cInt) + } + if let lineSpacingUnit, updateProperties, + (storage.previousState as? Self)?.lineSpacingUnit != lineSpacingUnit + { + adw_wrap_box_set_line_spacing_unit(widget, lineSpacingUnit.cValue) + } + if let lineHomogeneous, updateProperties, + (storage.previousState as? Self)?.lineHomogeneous != lineHomogeneous + { + adw_wrap_box_set_line_homogeneous(widget, lineHomogeneous.cBool) + } + if let naturalLineLength, updateProperties, + (storage.previousState as? Self)?.naturalLineLength != naturalLineLength + { + adw_wrap_box_set_natural_line_length(widget, naturalLineLength.cInt) + } + if let naturalLineLengthUnit, updateProperties, + (storage.previousState as? Self)?.naturalLineLengthUnit != naturalLineLengthUnit + { + adw_wrap_box_set_natural_line_length_unit(widget, naturalLineLengthUnit.cValue) + } + if let wrapReverse, updateProperties, + (storage.previousState as? Self)?.wrapReverse != wrapReverse + { + adw_wrap_box_set_wrap_reverse(widget, wrapReverse.cBool) + } + if let wrapPolicy, updateProperties, + (storage.previousState as? Self)?.wrapPolicy != wrapPolicy + { + adw_wrap_box_set_wrap_policy(widget, wrapPolicy.cValue) + } + + // --- Child management --- + var contentStorage: [ViewStorage] = storage.content[.mainContent] ?? [] + let oldElements = storage.fields["element"] as? [Element] ?? [] + + var oldByID: [Identifier: ViewStorage] = [:] + for (i, oldElement) in oldElements.enumerated() where i < contentStorage.count { + oldByID[oldElement[keyPath: id]] = contentStorage[i] + } + + var newContentStorage: [ViewStorage] = [] + var lastChild: OpaquePointer? + + for element in elements { + let elementID = element[keyPath: id] + if let existingStorage = oldByID.removeValue(forKey: elementID) { + newContentStorage.append(existingStorage) + if let lastPtr = lastChild { + adw_wrap_box_reorder_child_after( + widget, + existingStorage.opaquePointer?.cast(), + lastPtr.cast() + ) + } else { + adw_wrap_box_reorder_child_after( + widget, + existingStorage.opaquePointer?.cast(), + nil + ) + } + lastChild = existingStorage.opaquePointer + } else { + let child = content(element).storage(data: data, type: type) + if let lastPtr = lastChild { + adw_wrap_box_insert_child_after( + widget, + child.opaquePointer?.cast(), + lastPtr.cast() + ) + } else { + adw_wrap_box_prepend(widget, child.opaquePointer?.cast()) + } + newContentStorage.append(child) + lastChild = child.opaquePointer + } + } + + for (_, staleStorage) in oldByID { + adw_wrap_box_remove(widget, staleStorage.opaquePointer?.cast()) + } + + storage.fields["element"] = elements + storage.content[.mainContent] = newContentStorage + + for (index, element) in elements.enumerated() { + content(element).updateStorage( + newContentStorage[index], + data: data, + updateProperties: updateProperties, + type: type + ) + } + } + + for function in updateFunctions { + function(storage, data, updateProperties) + } + if updateProperties { + storage.previousState = self + } + } + +} + +// MARK: - Modifier Methods + +extension WrapBox { + + /// The amount of space between children. + public func childSpacing(_ childSpacing: Int?) -> Self { + modify { $0.childSpacing = childSpacing } + } + + /// The unit for `childSpacing`. + public func childSpacingUnit(_ childSpacingUnit: LengthUnit?) -> Self { + modify { $0.childSpacingUnit = childSpacingUnit } + } + + /// The packing direction. + public func packDirection(_ packDirection: PackDirection?) -> Self { + modify { $0.packDirection = packDirection } + } + + /// The alignment of children within each line (0.0 to 1.0). + public func align(_ align: Float?) -> Self { + modify { $0.align = align } + } + + /// The justification mode. + public func justify(_ justify: JustifyMode?) -> Self { + modify { $0.justify = justify } + } + + /// Whether to justify the last line. + public func justifyLastLine(_ justifyLastLine: Bool? = true) -> Self { + modify { $0.justifyLastLine = justifyLastLine } + } + + /// The amount of space between lines. + public func lineSpacing(_ lineSpacing: Int?) -> Self { + modify { $0.lineSpacing = lineSpacing } + } + + /// The unit for `lineSpacing`. + public func lineSpacingUnit(_ lineSpacingUnit: LengthUnit?) -> Self { + modify { $0.lineSpacingUnit = lineSpacingUnit } + } + + /// Whether all lines should be the same size. + public func lineHomogeneous(_ lineHomogeneous: Bool? = true) -> Self { + modify { $0.lineHomogeneous = lineHomogeneous } + } + + /// The natural length of each line. + public func naturalLineLength(_ naturalLineLength: Int?) -> Self { + modify { $0.naturalLineLength = naturalLineLength } + } + + /// The unit for `naturalLineLength`. + public func naturalLineLengthUnit(_ naturalLineLengthUnit: LengthUnit?) -> Self { + modify { $0.naturalLineLengthUnit = naturalLineLengthUnit } + } + + /// Whether to reverse the wrapping direction. + public func wrapReverse(_ wrapReverse: Bool? = true) -> Self { + modify { $0.wrapReverse = wrapReverse } + } + + /// The wrapping policy. + public func wrapPolicy(_ wrapPolicy: WrapPolicy?) -> Self { + modify { $0.wrapPolicy = wrapPolicy } + } + +} + +// MARK: - Convenience Initializer for Identifiable Elements + +extension WrapBox where Element: Identifiable, Identifier == Element.ID { + + /// Initialize `WrapBox` with identifiable elements. + /// - Parameters: + /// - elements: The identifiable elements to display. + /// - content: A view builder for rendering each element. + public init( + _ elements: [Element], + @ViewBuilder content: @escaping (Element) -> Body + ) { + self.elements = elements + self.content = content + self.id = \.id + } + +} diff --git a/Sources/LuminateUI/Utilities/Bin+Extensions.swift b/Sources/LuminateUI/Utilities/Bin+Extensions.swift new file mode 100644 index 0000000..46c517f --- /dev/null +++ b/Sources/LuminateUI/Utilities/Bin+Extensions.swift @@ -0,0 +1,29 @@ +// +// AnyView+Overflow.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adwaita + +extension Bin { + public init(@ViewBuilder content: @escaping () -> Body) { + self.init() + self = self.child(content) + } +} diff --git a/Sources/LuminateUI/Utilities/Button+Content.swift b/Sources/LuminateUI/Utilities/Button+Content.swift new file mode 100644 index 0000000..1432b18 --- /dev/null +++ b/Sources/LuminateUI/Utilities/Button+Content.swift @@ -0,0 +1,32 @@ +// +// HomePosterCell.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adwaita + +extension Button { + + public init(@ViewBuilder child: @escaping () -> Body, handler: @escaping () -> Void) { + self.init("", handler: handler) + self = self.label(nil) + .child(child) + .hasFrame(false) + } +} diff --git a/Sources/LuminateUI/Utilities/PageAnimationTracker.swift b/Sources/LuminateUI/Utilities/ViewUpdateScheduler.swift similarity index 66% rename from Sources/LuminateUI/Utilities/PageAnimationTracker.swift rename to Sources/LuminateUI/Utilities/ViewUpdateScheduler.swift index 805c6e1..aa544e3 100644 --- a/Sources/LuminateUI/Utilities/PageAnimationTracker.swift +++ b/Sources/LuminateUI/Utilities/ViewUpdateScheduler.swift @@ -1,5 +1,5 @@ // -// PageAnimationTracker.swift +// ViewUpdateScheduler.swift // // Copyright 2026 Brendan Szymanski // @@ -22,22 +22,23 @@ import Adwaita import Foundation import LuminateCore +import Synchronization -public class PageAnimationTracker: PageAnimationTracking { - public var isAnimating = false - private var pushGeneration = 0 +public class ViewUpdateScheduler: ViewUpdateScheduling { + private let isPending = Mutex(false) public init() {} - public func markPush() { - isAnimating = true - pushGeneration += 1 - let captured = pushGeneration - Idle(delay: 250) { [weak self] in - guard let self, self.pushGeneration == captured else { return false } - self.isAnimating = false + public func scheduleFlush() { + let shouldSchedule = isPending.withLock { + if $0 { return false } + $0 = true + return true + } + guard shouldSchedule else { return } + Idle { [weak self] in StateManager.updateViews() - return false + self?.isPending.withLock { $0 = false } } } }