luminate-old/AGENTS.md

26 KiB
Raw Permalink Blame History

Luminate — GNOME Jellyfin Client

Quick Facts

Attribute Value
App ID dev.bscubed.Luminate
Platform GNOME/Linux via Flatpak (GNOME Platform 50)
Dev Platform macOS 13+
Language Swift 6 (.v5 language mode)
UI Framework Adwaita for Swift (wraps GTK4/libadwaita)
API Jellyfin v10.11.10 via swift-openapi-generator
Media Movies + TV Shows (v1 scope)
License GPL-3.0

⚠️ CRITICAL: Adwaita for Swift ≠ SwiftUI

This project uses Adwaita for Swift, NOT SwiftUI. While the DSL syntax looks similar (declarative View structs, @State, VStack/HStack, modifiers), the frameworks are fundamentally different.

What Does NOT Transfer From SwiftUI

SwiftUI Assumption Adwaita Reality
List Use ForEach (vertical list) or FlowGrid (grid)
ObservableObject / @StateObject / @ObservedObject Does not exist. Use @State for local state, pass dependencies via initializer or @Injected
@EnvironmentObject Does not exist. Inject via constructor or @Injected
@Published Does not exist. Mutate @State directly
@AppStorage / @SceneStorage Does not exist. Use SQLiteStore + PersistenceService
onAppear / onDisappear Exists as .onAppear {} but different behavior
Button("Label") { } Button("Label") { } works, but icons use icon: .default(icon: .iconName)
Image(systemName:) Use Picture() with .data() or .icon()
ScrollView(.horizontal) ScrollView { ForEach(items, horizontal: true) { } }
.padding() / .frame() Similar but different API surface
.sheet() / .fullScreenCover() Does not exist. Use .aboutDialog(), .shortcutsDialog() etc.
.task { } modifier Does not exist. Use .onAppear { Task { } }
@MainActor on views Adwaita views are implicitly @MainActor

Adwaita for Swift Key Concepts

  • @State — local view state only. Triggers view update on mutation.
  • @Binding — shared state between parent/child views.
  • @Injected — DI property wrapper; resolves from DIContainer.shared (e.g. @Injected(\.client)).
  • View protocol — requires var view: Body { get } (not some View/@ViewBuilder).
  • Body — a concrete type, not an opaque return (some View).
  • VStack / HStack / Box — layout primitives.
  • ScrollView — scrollable container with .hscrollbarPolicy() / .vscrollbarPolicy() modifiers.
  • FlowGrid — wraps children into a responsive grid (custom C widget, ported from Gelata's flow_grid.rs). Prefer over GtkFlowBox which has stale size cache issues.
  • AspectContainer — single-child container that preserves a fixed aspect ratio (custom C widget). Reports height = width × ratio during measure pass; does not delegate width to children.
  • ForEach — list rendering; ForEach(items) for vertical lists, ForEach(items, horizontal: true) for horizontal scrolls.
  • Picture() — image display via .data() or file path.
  • StatusPage — centered hero page with icon, title, description (used for login screen).
  • PreferencesGroup / EntryRow / PasswordEntryRow — form input widgets.
  • SearchEntry — search input widget with .text($binding).
  • Spinner() — loading indicator.
  • HeaderBar — window header with .start/.end sections.
  • Menu / MenuButton / MenuSection — dropdown menus.
  • LevelBar — progress bar (used for seek position).
  • NavigationView — stack-based navigation with NavigationStack<Page> enum.
  • .style("card") / .style("suggested-action") / .style("flat") — widget styling.
  • .halign(.start) / .hexpand(true) — alignment and expand modifiers.

Always Load the Skill

If an adwaita-swift skill is available in your toolset, load it before working on this project. It provides API reference, widget docs, and patterns without needing the full documentation in context.


Architecture

Target Dependency Graph

Luminate (executable)
  ├── Pages/                HomeView, ItemPage (top-level navigation views)
  ├── LuminateUI ───────────┐
  │     ├── Components/     │  poster cells, detail views, search, etc.
  │     └── Utilities/      │  PageAnimationTracker, AnyView+Overflow
  ├── LuminatePlayer ───────┤
  ├── LuminateDI ───────────┤
  ├── LuminateCore ─────────┤
  ├── CGtkWidgets ──────────┤  Custom GObject widgets (AspectContainer, FlowGrid)
  └── LuminateAPI           │
                            └── All UI components are in LuminateUI

Rule: All UI components live in LuminateUI. LuminatePlayer is the only separate feature target with its own concerns.

Module Roles

LuminateAPI — Generated API client

File Purpose
LuminateAPI.swift Module namespace
openapi.yaml Jellyfin OpenAPI spec v10.11.10
openapi-generator-config.yaml Generator config (public access, types + client)

Generated types are in .build/. Clean build required to regenerate.

LuminateCore — Core library

File Purpose
LuminateCore.swift Module namespace, re-exports LuminateAPI, OpenAPIRuntime, OpenAPIURLSession via @_exported import
JellyfinClient.swift actor wrapping OpenAPI-generated Client. Exposes curated API surface: auth, items, search, playback reporting, favorites. Uses AuthMiddleware for MediaBrowser header injection.
BaseItemDto+Display.swift Extensions on generated types: Identifiable conformance, isMovie/isSeries/isEpisode helpers, runtimeString, yearString, image tag accessors
ImageService.swift actor with 2-tier image cache (NSCache memory + disk at cachesDirectory/luminate/images/)
WebSocketClient.swift actor connecting to ws(s)://server/socket, parses JSON events, publishes via NotificationCenter
Observation.swift ObservationRegistrar, ObservableProtocol, @Observable macro — reactive property-mutation tracking for classes. Wire via ObservationRegistrar.onChange = { StateManager.updateViews() }
PageAnimationTracking.swift Protocol for page transition animation state: isAnimating: Bool, markPush(). Implemented by PageAnimationTracker in LuminateUI
Page.swift Navigation Page enum (.library, .folder) used with NavigationStack
PersistenceService.swift PersistenceService protocol + AuthData struct for auth/preference persistence
SQLiteStore.swift actor implementing PersistenceService via SQLite (SQLite.swift)
PersistenceService+Mock.swift MockPersistenceService for testing

LuminateDI — Dependency Injection

File Purpose
DIContainer.swift @unchecked Sendable singleton with thread-safe register/resolve/addObserver; triggers StateManager.updateViews() on change
Injected.swift @propertyWrapper that resolves from DIContainer and observes changes; auto-cleans up observer on deinit
InjectionValues.swift Holds optional injectable values: client, userId, imageService, webSocketClient, persistence, pageAnimationTracker

CGtkWidgets — Custom C GObject Widgets

File Purpose
aspect_container.h / aspect_container.c GObject subclass of GtkWidget — single-child container that reports height = width × ratio during measure(). Properties: aspect-ratio (float), max-width (int). Used via AspectContainer Swift wrapper.
flow_grid.h / flow_grid.c GObject subclass of GtkWidget — reflowing grid that re-measures children during allocation (avoids stale size caches). Properties: minimum-size, column-spacing, row-spacing, justify. Used via FlowGrid Swift wrapper.

LuminateUI — Shared UI Components

File Purpose
PageAnimationTracker.swift @Observable-free class implementing PageAnimationTracking. Tracks isAnimating state for 250ms page transition windows. Uses Idle(delay: 250) timer and manually calls StateManager.updateViews() when animation ends to flush deferred image data. Push-generation counter prevents stale timers.

All UI components live in LuminateUI, organized into Components/ and Utilities/ subdirectories. Files from the former LuminateHome and LuminateLibrary targets now reside here:

File Purpose
HomePosterCell.swift Poster cell (200×300) with deferred image loading, uses @Injected(\.client) and @Injected(\.pageAnimationTracker)
MediaRow.swift Horizontal scrolling row of poster cells with title + "See All" button; takes navigation: Binding<NavigationStack<Page>>
ItemGrid.swift Grid of items with header; takes items: [BaseItemDto], navigation: Binding<NavigationStack<Page>>, optional title. Renamed from LibraryGrid.
PosterCell.swift Reusable poster cell (150×225). Uses @Injected(\.pageAnimationTracker) for deferred loading; client still via parameter.
MovieDetailView.swift Full movie detail: backdrop hero, poster, metadata, action buttons (play, favorite, mark played), overview, cast grid, similar items
TVShowView.swift TV show detail: backdrop, poster, metadata, season picker (horizontal buttons), episode list
EpisodeList.swift Vertical list of episode rows with thumbnail, number, title, runtime, play button
SearchView.swift Debounced search with SearchEntry, results via getSearchHints(). performSearch() never called — no .onChange or onSubmit trigger. SearchResultRow uses @Injected(\.pageAnimationTracker) for deferred image loading.
Components/AspectContainer.swift Swift Widget wrapper for the custom aspect-ratio C widget. init(aspectRatio:), modifiers: .child {}, .maxWidth(Int?).
Components/FlowGrid.swift Swift Widget wrapper for the custom flow grid C widget. Generic over Element/Identifier. Modifiers: .minimumSize(Int), .columnSpacing(Int), .rowSpacing(Int), .justify(FlowGridJustifySetting).
Components/RatingBadge.swift Star rating display (★ 7.5)
Components/PersonCell.swift Cast/crew display (avatar, name, role)
Utilities/AnyView+Overflow.swift Extension on AnyView for GTK overflow behavior (visible/hidden), uses CAdw directly

LuminatePlayer — Video Playback

File Purpose
LuminatePlayer.swift Module placeholder
PlayerView.swift Player screen: binds VideoPlayerWidget + PlayerControls, reports playback start/stop to Jellyfin
PlayerControls.swift Control bar: close, seek back, play/pause, seek forward, time/progress, fullscreen
VideoPlayerWidget.swift Placeholder — shows "Now Playing" + URL. Planned to become mpv+GLArea custom Widget

Luminate — App Assembly

File Purpose
Luminate.swift @main entry. App struct with AdwaitaApp, Window scene. On startup, creates SQLiteStore and tries to load saved session from SQLite. ContentView uses NavigationView with NavigationStack<Page> to switch between login, home, and library folder views.
ServerSetupView.swift Login form: server URL, username, password. Calls JellyfinClient.authenticate(). Saves AuthData via DIContainer.shared.values.persistence on success.
ToolbarView.swift Hamburger menu with About dialog and keyboard shortcuts dialog
Localized.yml Localization strings (en/de) via the localized package
Pages/HomeView.swift Main scrollable home: Continue Watching, Next Up, Recently Added rows + Library grid. Uses @Injected for client/userId. Takes navigation: Binding<NavigationStack<Page>>. Parallel async data loading via async let.
Pages/ItemPage.swift Scrollable wrapper for browsing a folder/library, uses Clamp + ItemGrid with NavigationStack<Page>

Design Patterns

State Management

  • @State for local view state (loading flags, data arrays, UI state)
  • @State.rawValue — reads/writes @State storage without triggering StateManager.updateViews(). Used to store loaded image data silently during page animations; the re-render is deferred until the animation completes.
  • @Injected for reactive dependency injection (client, userId, etc.)
  • @Observable — custom macro (not SwiftUI) for class property mutation tracking. Adds _$observationRegistrar to the class and wraps stored var properties with get/set that call ObservationRegistrar.didChange(). Wire globally: ObservationRegistrar.onChange = { StateManager.updateViews() }. Use sparingly — triggers global re-render on every mutation. Do NOT use for properties mutated inside GTK signal handlers (.pushed/.popped) as the synchronous updateViews() may reference freed widgets.
  • Dependencies passed via initializer properties in LuminateUI views
  • Child → parent communication via closure callbacks (onClose, onSeeAll, etc.) or @Binding
  • No ObservableObject, no @StateObject, no @EnvironmentObject
  • DI observation: @Injected sets up an observer in DIContainer that calls StateManager.updateViews() when the injected value changes

Concurrency

  • Services are actorsJellyfinClient, ImageService, WebSocketClient, SQLiteStore
  • Views use Task { } inside .onAppear or action handlers
  • await MainActor.run to update @State from background tasks
  • async let for parallel fetches (see HomeView.loadHomeData())

Data Flow

ServerSetupView → JellyfinClient (authenticate) → client + userId registered in DIContainer
                                                                       ↓
Luminate.loadSavedSession() → SQLiteStore.loadAuth() → if valid, register in DI → ContentView
                                                                       ↓
ContentView → NavigationView with:
  - HomeView (uses @Injected for client/userId)
  - ItemPage (for folder browsing)
  - (future: detail views)
  
HomeView → MediaRow → HomePosterCell (image from ImageService via @Injected(\.client))
ItemGrid → HomePosterCell
SearchView → SearchHint results → tap → detail (not connected)
ContentView → activePlayerItem != nil ? PlayerView : NavigationView

Navigation

  • NavigationView + NavigationStack<Page> — stack-based navigation
  • Page enum: .library(item:), .folder(title:items:)
  • HomeView pushes .folder when "See All" tapped
  • ContentView switches between initial view (home) and pushed pages
  • Detail views (MovieDetailView, TVShowView) are not yet connected to navigation
  • Note: NavigationView is an Adwaita-for-Swift widget, not SwiftUI's
  • Page transition animation control: .pushed { tracker.markPush() } and .popped { tracker.markPush() } start a 250ms window during which cells defer image data updates (see Image Loading Pattern)

Image Loading Pattern (repeated across views)

@State private var imageData: Data?
@Injected(\.pageAnimationTracker) var pageAnimationTracker

// In view body:
if let data = imageData {
    Picture().data(data)
} else {
    Box(spacing: 0) {}.style("card")
}

// In loadImage():
guard let tag = item.primaryImageTag, let itemId = item.id else { return }
Task {
    guard let url = await client.imageURL(itemId: itemId, imageType: .Primary, tag: tag, maxWidth: 300) else { return }
    let service = ImageService()
    let data = try? await service.loadImage(url: url)
    // Defer UI update during page transition animations (250ms)
    if pageAnimationTracker.isAnimating {
        _imageData.rawValue = data   // Silent write, no re-render
        // When animation ends, StateManager.updateViews() fires → cell re-renders → shows Picture
    } else {
        imageData = data             // Triggers re-render immediately
    }
}

Deferred loading rationale: When a Page is pushed onto NavigationStack, cells render during the 250ms transition animation. If imageData is set immediately, StateManager.updateViews() re-renders the widget tree mid-transition, causing stutter. By checking PageAnimationTracker.isAnimating and using rawValue, image data is loaded eagerly (populating ImageService's cache) but the UI update is deferred until the animation completes. The NavigationView's .pushed/.popped signals call tracker.markPush() to start the 250ms window.

Dependency Injection

  • @Injected property wrapper for reactive DI in Adwaita views
  • InjectionValues struct defines injectable keys: client, userId, imageService, webSocketClient, persistence, pageAnimationTracker
  • DIContainer.shared.register(\.key, value:) during startup/login
  • DIContainer.shared.resolve(\.key) for manual resolution (fatalError if unregistered)
  • Note: Luminate page views (HomeView) use @Injected; LuminateUI components (ItemGrid, MovieDetailView, TVShowView, EpisodeList) still use parameter-based injection for client/userId, but PosterCell and SearchResultRow now use @Injected(\.pageAnimationTracker)

Persistence

  • SQLiteStore actor (implements PersistenceService) for persistent auth/preferences
  • Database at $XDG_DATA_HOME/dev.bscubed.Luminate/db.sqlite (Linux) or ApplicationSupport/dev.bscubed.Luminate/db.sqlite (macOS)
  • WAL journal mode, schema migration via db.userVersion
  • Auth data saved after successful login, loaded on app launch for auto-login
  • MockPersistenceService available for testing

Development Setup

Build & Run

# Build
swift build

# Run (macOS development)
swift run

# Release build
swift build -c release

Prerequisites

  • macOS: brew install mpv
  • Linux (Flatpak): mpv is built as part of the Flatpak manifest; no manual install needed
  • Linux (direct): sudo dnf install mpv-libs-devel (Fedora) or sudo apt install libmpv-dev (Ubuntu/Debian)

VS Code Dev Container

The .devcontainer/ directory provides a Docker container with Swift 6 + libadwaita. Open the project in VS Code with the Dev Containers extension and Swift + CodeLLDB extensions.

GNOME Builder

Open the project folder in GNOME Builder. It auto-detects the Flatpak manifest and downloads dependencies.

Flatpak Build

flatpak-builder --force-clean build-dir dev.bscubed.Luminate.json
flatpak-builder --run build-dir dev.bscubed.Luminate.json Luminate

Coding Conventions

File Organization

  • One view per file — each View struct in its own .swift file
  • Grouped by feature targetSources/Luminate/ contains the app shell and Pages/ subdirectory
  • Component subdirectoriesLuminateUI/Components/ for reusable views, LuminateUI/Utilities/ for helpers

Import Order

import Foundation
import Adwaita
import LuminateCore
// LuminateDI if using @Injected

Visibility

  • public on types and initializers that need to be accessed across targets
  • internal (default) for types internal to a feature module
  • Module placeholders use public struct

@State Initialization

init(item: ..., client: ..., userId: ...) {
    self.item = item
    _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
}

@Injected Usage

@Injected(\.client) var client
@Injected(\.userId) var userId

View Structure

struct SomeView: View {

    @State private var data: [Type] = []
    var client: JellyfinClient

    var view: Body {
        // layout
    }

    private func loadData() {
        Task {
            // async work
        }
    }
}

Formatting

  • . at start of chained modifiers, indented 4 spaces from parent
  • @State before var (non-state), before closures
  • Components.Schemas.BaseItemDto fully qualified

Dependencies

Package Purpose Source
Adwaita for Swift Swift DSL over GTK4/libadwaita git.aparoksha.dev/aparoksha/adwaita-swift (branch main)
Localized YAML-based localization git.aparoksha.dev/aparoksha/localized (branch main)
swift-openapi-generator Generates API client from OpenAPI spec github.com/apple/swift-openapi-generator (>=1.0.0)
swift-openapi-runtime Runtime for generated OpenAPI client github.com/apple/swift-openapi-runtime (>=1.0.0)
swift-openapi-urlsession URLSession transport for OpenAPI github.com/apple/swift-openapi-urlsession (>=1.0.0)
SQLite.swift SQLite database for persistence github.com/stephencelis/SQLite.swift (>=0.16.0)

Known Gaps & Future Work

  • VideoPlayerWidget is a placeholder — needs mpv + GtkGLArea integration
  • LuminateUI views still use parameter injectionItemGrid, MovieDetailView, TVShowView, EpisodeList take client/userId as constructor params instead of @Injected. PosterCell and SearchResultRow now use @Injected(\.pageAnimationTracker) but client remains a parameter.
  • Detail views not connected to navigationMovieDetailView/TVShowView exist but cannot be reached from poster cells (no tap handlers on HomePosterCell/PosterCell)
  • ContentView .library case placeholderNavigationView branch for Page.library shows Text("REPLACE ME")
  • SearchView search method never calledperformSearch() exists but no .onChange or onSubmit triggers it
  • No CI/CD — no GitHub Actions or other pipeline
  • Keyboard shortcuts are stubsCtrl+F/Ctrl+R actions are empty
  • README.md is outdated — still references "Adwaita Template"
  • No error handling UI — most Task blocks use try? silently
  • DIContainer uses @unchecked Sendable — relies on manual NSLock synchronization
  • SQLiteStore.Connection uses @retroactive @unchecked Sendable — workaround for SQLite.swift's lack of Sendable conformance

Agent Instructions

  1. Always load the adwaita-swift skill before writing or modifying views
  2. Do NOT import or assume SwiftUI patterns — the DSL is adwaita-swift
  3. Run swift build after making changes to verify compilation
  4. Check existing patterns before adding new views or features — the codebase has established conventions
  5. No formatters or linters are configured — maintain the existing style
  6. Components.Schemas.BaseItemDto is the main model type used throughout (generated by OpenAPI)
  7. No snapshot or UI testing framework is available
  8. LuminateCore module uses @_exported import for LuminateAPI, OpenAPIRuntime, and OpenAPIURLSession — these types are available to all feature targets without explicit imports
  9. All views requiring Adwaita should import it explicitly in each file
  10. When adding new files, place them in the correct feature target directory and ensure they're included in the right SPM target in Package.swift
  11. Use @Injected for DI in new views (preferred over constructor parameter passing) — requires import LuminateDI
  12. Use NavigationView + NavigationStack<Page> for navigation — do not add a separate navigation stack framework
  13. Clean .build/ when openapi.yaml changes — the OpenAPI generator plugin caches generated code
  14. Defer image loading during page transitions — check pageAnimationTracker.isAnimating and use _imageData.rawValue to store loaded data silently; the NavigationView's .pushed/.popped signals will trigger a re-render at 250ms to flush pending data
  15. Do NOT call StateManager.updateViews() inside GTK signal handlers (.pushed, .popped, etc.) — use Idle to defer or rely on timer-based triggers instead
  16. Do NOT use @Observable for properties mutated in GTK signal handlers — the synchronous didChange()StateManager.updateViews() call will reference freed widgets during navigation transitions
  17. Every .swift file must start with a header comment in this exact format:
    //
    //  <FileName>.swift
    //
    //  Copyright <year> Brendan Szymanski <hello@bscubed.dev>
    //
    //  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 <https://www.gnu.org/licenses/>.
    //
    //  SPDX-License-Identifier: GPL-3.0-or-later
    //
    
    Use the git creation year of the file if available; otherwise use the current year.
  18. Test coverage is required for new functionality — use the swift-testing skill and Swift Testing framework (@Test, #expect). Place tests in Tests/LuminateTests/. Test files must follow XCTest migration boundaries; existing tests provide a pattern.
  19. Run swift test after large changes to verify no regressions. Run it before committing to catch breakage early.
  20. Always ask before committing — never commit without explicit user permission.