diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35aa284 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,438 @@ +# 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](https://git.aparoksha.dev/aparoksha/adwaita-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` 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>` | +| `ItemGrid.swift` | Grid of items with header; takes `items: [BaseItemDto]`, `navigation: Binding>`, 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` 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>`. Parallel async data loading via `async let`. | +| `Pages/ItemPage.swift` | Scrollable wrapper for browsing a folder/library, uses `Clamp` + `ItemGrid` with `NavigationStack` | + +--- + +## 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 `actor`s** — `JellyfinClient`, `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`** — 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) +```swift +@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 + +```bash +# 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 + +```bash +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 target** — `Sources/Luminate/` contains the app shell and `Pages/` subdirectory +- **Component subdirectories** — `LuminateUI/Components/` for reusable views, `LuminateUI/Utilities/` for helpers + +### Import Order +```swift +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 +```swift +init(item: ..., client: ..., userId: ...) { + self.item = item + _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false) +} +``` + +### @Injected Usage +```swift +@Injected(\.client) var client +@Injected(\.userId) var userId +``` + +### View Structure +```swift +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 injection** — `ItemGrid`, `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 navigation** — `MovieDetailView`/`TVShowView` exist but cannot be reached from poster cells (no tap handlers on `HomePosterCell`/`PosterCell`) +- **`ContentView` `.library` case placeholder** — `NavigationView` branch for `Page.library` shows `Text("REPLACE ME")` +- **`SearchView` search method never called** — `performSearch()` exists but no `.onChange` or `onSubmit` triggers it +- **No CI/CD** — no GitHub Actions or other pipeline +- **Keyboard shortcuts are stubs** — `Ctrl+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`** 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: + ```swift + // + // .swift + // + // Copyright 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 + // + ``` + 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. diff --git a/Package.swift b/Package.swift index 957d766..5ab943b 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "Luminate", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v15)], dependencies: [ .package(url: "https://git.aparoksha.dev/aparoksha/adwaita-swift", branch: "main"), .package(url: "https://git.aparoksha.dev/aparoksha/localized", branch: "main"), @@ -33,6 +33,7 @@ let package = Package( "LuminateObservationMacros", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), + .product(name: "Logging", package: "swift-log"), .product(name: "SQLite", package: "SQLite.swift"), ] ), @@ -53,16 +54,25 @@ let package = Package( ] ), .target( - name: "CGtkWidgets", + name: "CModules", dependencies: [ .product(name: "CAdw", package: "adwaita-swift") + ], + cSettings: [ + .headerSearchPath("include"), + .unsafeFlags(["-I/opt/homebrew/include"], .when(platforms: [.macOS])), + ], + linkerSettings: [ + .unsafeFlags(["-L/opt/homebrew/lib"], .when(platforms: [.macOS])), + .linkedLibrary("mpv"), + .linkedLibrary("epoxy"), ] ), .target( name: "LuminateUI", dependencies: [ - "CGtkWidgets", + "CModules", "LuminateCore", "LuminateDI", "LuminateObservationMacros", @@ -73,6 +83,7 @@ let package = Package( .target( name: "LuminatePlayer", dependencies: [ + "CModules", "LuminateCore", "LuminateDI", "LuminateUI", @@ -99,10 +110,12 @@ let package = Package( .testTarget( name: "LuminateTests", dependencies: [ - "CGtkWidgets", + "CModules", + "LuminateCore", .product(name: "CAdw", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"), - ] + ], + ), ], swiftLanguageModes: [.v5] diff --git a/Sources/CGtkWidgets/aspect_container.c b/Sources/CModules/aspect_container.c similarity index 100% rename from Sources/CGtkWidgets/aspect_container.c rename to Sources/CModules/aspect_container.c diff --git a/Sources/CGtkWidgets/flow_grid.c b/Sources/CModules/flow_grid.c similarity index 100% rename from Sources/CGtkWidgets/flow_grid.c rename to Sources/CModules/flow_grid.c diff --git a/Sources/CGtkWidgets/include/aspect_container.h b/Sources/CModules/include/aspect_container.h similarity index 100% rename from Sources/CGtkWidgets/include/aspect_container.h rename to Sources/CModules/include/aspect_container.h diff --git a/Sources/CGtkWidgets/include/flow_grid.h b/Sources/CModules/include/flow_grid.h similarity index 100% rename from Sources/CGtkWidgets/include/flow_grid.h rename to Sources/CModules/include/flow_grid.h diff --git a/Sources/CModules/include/mpv_widget.h b/Sources/CModules/include/mpv_widget.h new file mode 100644 index 0000000..e84a344 --- /dev/null +++ b/Sources/CModules/include/mpv_widget.h @@ -0,0 +1,36 @@ +#ifndef MPV_WIDGET_H +#define MPV_WIDGET_H + +#include + +G_BEGIN_DECLS + +#define MPV_TYPE_WIDGET (mpv_widget_get_type()) +#define MPV_WIDGET(obj) (G_TYPE_CHECK_INSTANCE_CAST((obj), MPV_TYPE_WIDGET, MpvWidget)) +#define MPV_IS_WIDGET(obj) (G_TYPE_CHECK_INSTANCE_TYPE((obj), MPV_TYPE_WIDGET)) + +typedef struct _MpvWidget MpvWidget; +typedef struct _MpvWidgetClass MpvWidgetClass; + +struct _MpvWidgetClass { + GtkWidgetClass parent_class; +}; + +GType mpv_widget_get_type (void); +GtkWidget *mpv_widget_new (void); + +void mpv_widget_load_url (MpvWidget *self, const char *url); +void mpv_widget_play (MpvWidget *self); +void mpv_widget_pause (MpvWidget *self); +void mpv_widget_seek_relative (MpvWidget *self, double offset_seconds); +void mpv_widget_seek_absolute (MpvWidget *self, double position_seconds); +void mpv_widget_set_volume (MpvWidget *self, double volume); + +double mpv_widget_get_position (MpvWidget *self); +double mpv_widget_get_duration (MpvWidget *self); +gboolean mpv_widget_get_paused (MpvWidget *self); +double mpv_widget_get_volume (MpvWidget *self); + +G_END_DECLS + +#endif /* MPV_WIDGET_H */ diff --git a/Sources/CModules/mpv_widget.c b/Sources/CModules/mpv_widget.c new file mode 100644 index 0000000..4b83cf6 --- /dev/null +++ b/Sources/CModules/mpv_widget.c @@ -0,0 +1,507 @@ +#include "mpv_widget.h" +#include +#include +#include +#include +#include +#include +#include + +struct _MpvWidget { + GtkWidget parent_instance; + GtkWidget *gl_area; + mpv_handle *mpv; + mpv_render_context *mpv_gl; + guint tick_source; + double position; + double duration; + gboolean paused; + char *pending_url; + gboolean initialized; + gboolean should_play; +}; + +enum { + SIGNAL_POSITION_CHANGED, + SIGNAL_DURATION_CHANGED, + SIGNAL_PLAYBACK_STATE_CHANGED, + SIGNAL_ERROR, + LAST_SIGNAL +}; + +static guint signals[LAST_SIGNAL] = {0}; + +G_DEFINE_TYPE(MpvWidget, mpv_widget, GTK_TYPE_WIDGET) + +static gboolean +mpv_widget_render_frame(gpointer user_data) +{ + MpvWidget *self = MPV_WIDGET(user_data); + if (!self->mpv_gl) + return G_SOURCE_REMOVE; + + int64_t flags = mpv_render_context_update(self->mpv_gl); + if (flags & MPV_RENDER_UPDATE_FRAME) + gtk_gl_area_queue_render(GTK_GL_AREA(self->gl_area)); + + return G_SOURCE_REMOVE; +} + +static void +mpv_render_update(void *cb_ctx) +{ + MpvWidget *self = MPV_WIDGET(cb_ctx); + if (self->mpv_gl) + g_idle_add(mpv_widget_render_frame, self); +} + +static void * +mpv_gl_get_proc_address(void *fn_ctx, const char *name) +{ + (void)fn_ctx; + if (!name) + return NULL; + return dlsym(RTLD_DEFAULT, name); +} + +static gboolean mpv_widget_tick(gpointer user_data); + +static void +mpv_widget_on_realize(GtkWidget *widget, gpointer user_data) +{ + MpvWidget *self = MPV_WIDGET(user_data); + + gtk_gl_area_make_current(GTK_GL_AREA(widget)); + + setlocale(LC_NUMERIC, "C"); + + self->mpv = mpv_create(); + if (!self->mpv) + return; + + mpv_set_option_string(self->mpv, "vo", "libmpv"); + mpv_set_option_string(self->mpv, "hwdec", "no"); + mpv_set_option_string(self->mpv, "keep-open", "yes"); + + if (mpv_initialize(self->mpv) < 0) { + mpv_terminate_destroy(self->mpv); + self->mpv = NULL; + return; + } + + GdkGLContext *gl_context = gtk_gl_area_get_context(GTK_GL_AREA(widget)); + mpv_opengl_init_params gl_init = { + .get_proc_address = mpv_gl_get_proc_address, + .get_proc_address_ctx = gl_context, + }; + + mpv_render_param params[] = { + {MPV_RENDER_PARAM_API_TYPE, MPV_RENDER_API_TYPE_OPENGL}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init}, + {0} + }; + + if (mpv_render_context_create(&self->mpv_gl, self->mpv, params) < 0) { + mpv_terminate_destroy(self->mpv); + self->mpv = NULL; + return; + } + + mpv_render_context_set_update_callback( + self->mpv_gl, mpv_render_update, self); + + self->tick_source = g_timeout_add(500, (GSourceFunc)mpv_widget_tick, self); + self->initialized = TRUE; + + if (self->pending_url) { + mpv_command(self->mpv, (const char *[]){"loadfile", self->pending_url, NULL}); + mpv_set_property_string(self->mpv, "pause", + self->should_play ? "no" : "yes"); + g_free(self->pending_url); + self->pending_url = NULL; + } +} + +static void +mpv_widget_on_unrealize(GtkWidget *widget, gpointer user_data) +{ + MpvWidget *self = MPV_WIDGET(user_data); + + if (self->tick_source) { + g_source_remove(self->tick_source); + self->tick_source = 0; + } + + if (self->mpv_gl) { + gtk_gl_area_make_current(GTK_GL_AREA(widget)); + mpv_render_context_free(self->mpv_gl); + self->mpv_gl = NULL; + } + + if (self->mpv) { + mpv_terminate_destroy(self->mpv); + self->mpv = NULL; + } +} + +static gboolean +mpv_widget_on_render(GtkGLArea *area, GdkGLContext *context, gpointer user_data) +{ + (void)context; + MpvWidget *self = MPV_WIDGET(user_data); + + if (!self->mpv_gl) + return TRUE; + + int scale = gtk_widget_get_scale_factor(GTK_WIDGET(area)); + int width = gtk_widget_get_width(GTK_WIDGET(area)) * scale; + int height = gtk_widget_get_height(GTK_WIDGET(area)) * scale; + + if (width <= 0 || height <= 0) + return TRUE; + + GLint fbo_id = 0; + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &fbo_id); + + mpv_opengl_fbo fbo = { + .fbo = (int)fbo_id, + .w = width, + .h = height, + }; + + int flip_y = 1; + int block = 0; + + mpv_render_param params[] = { + {MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flip_y}, + {MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block}, + {0} + }; + + mpv_render_context_render(self->mpv_gl, params); + + return TRUE; +} + +static gboolean +mpv_widget_tick(gpointer user_data) +{ + MpvWidget *self = MPV_WIDGET(user_data); + + if (!self->mpv) + return G_SOURCE_CONTINUE; + + while (self->mpv) { + mpv_event *event = mpv_wait_event(self->mpv, 0); + if (event->event_id == MPV_EVENT_NONE) + break; + if (event->event_id == MPV_EVENT_END_FILE) + g_signal_emit(self, signals[SIGNAL_PLAYBACK_STATE_CHANGED], 0, TRUE); + } + + double pos = 0; + if (mpv_get_property(self->mpv, "time-pos", + MPV_FORMAT_DOUBLE, &pos) == MPV_ERROR_SUCCESS) + { + double diff = fabs(pos - self->position); + if (diff > 1.0) { + self->position = pos; + g_signal_emit(self, signals[SIGNAL_POSITION_CHANGED], 0, pos); + } + } + + double dur = 0; + if (mpv_get_property(self->mpv, "duration", + MPV_FORMAT_DOUBLE, &dur) == MPV_ERROR_SUCCESS) + { + if (fabs(dur - self->duration) > 0.1) { + self->duration = dur; + g_signal_emit(self, signals[SIGNAL_DURATION_CHANGED], 0, dur); + } + } + + int paused = 0; + if (mpv_get_property(self->mpv, "pause", + MPV_FORMAT_FLAG, &paused) == MPV_ERROR_SUCCESS) + { + gboolean p = !!paused; + if (p != self->paused) { + self->paused = p; + g_signal_emit(self, signals[SIGNAL_PLAYBACK_STATE_CHANGED], 0, p); + } + } + + return G_SOURCE_CONTINUE; +} + +static void +mpv_widget_size_allocate(GtkWidget *widget, + int width, int height, int baseline) +{ + GtkWidget *child = gtk_widget_get_first_child(widget); + if (!child) + return; + + GtkAllocation alloc = { + .x = 0, + .y = 0, + .width = width, + .height = height, + }; + gtk_widget_size_allocate(child, &alloc, baseline); +} + +static void +mpv_widget_compute_expand(GtkWidget *widget, + gboolean *hexpand_p, + gboolean *vexpand_p) +{ + GtkWidget *child = gtk_widget_get_first_child(widget); + if (child) { + *hexpand_p = gtk_widget_compute_expand(child, GTK_ORIENTATION_HORIZONTAL); + *vexpand_p = gtk_widget_compute_expand(child, GTK_ORIENTATION_VERTICAL); + } else { + *hexpand_p = FALSE; + *vexpand_p = FALSE; + } +} + +static void +mpv_widget_measure(GtkWidget *widget, + GtkOrientation orientation, + int for_size, + int *minimum, + int *natural, + int *minimum_baseline, + int *natural_baseline) +{ + GtkWidget *child = gtk_widget_get_first_child(widget); + if (child && gtk_widget_get_visible(child)) { + gtk_widget_measure(child, orientation, for_size, + minimum, natural, + minimum_baseline, natural_baseline); + } else { + if (minimum) *minimum = 0; + if (natural) *natural = 0; + if (minimum_baseline) *minimum_baseline = -1; + if (natural_baseline) *natural_baseline = -1; + } +} + +static void +mpv_widget_dispose(GObject *object) +{ + MpvWidget *self = MPV_WIDGET(object); + + if (self->tick_source) { + g_source_remove(self->tick_source); + self->tick_source = 0; + } + + g_free(self->pending_url); + self->pending_url = NULL; + + if (self->gl_area) { + gtk_widget_unparent(self->gl_area); + self->gl_area = NULL; + } + + G_OBJECT_CLASS(mpv_widget_parent_class)->dispose(object); +} + +static void +mpv_widget_init(MpvWidget *self) +{ + self->gl_area = gtk_gl_area_new(); + gtk_gl_area_set_auto_render(GTK_GL_AREA(self->gl_area), FALSE); + gtk_widget_set_hexpand(self->gl_area, TRUE); + gtk_widget_set_vexpand(self->gl_area, TRUE); + + g_signal_connect(self->gl_area, "realize", + G_CALLBACK(mpv_widget_on_realize), self); + g_signal_connect(self->gl_area, "unrealize", + G_CALLBACK(mpv_widget_on_unrealize), self); + g_signal_connect(self->gl_area, "render", + G_CALLBACK(mpv_widget_on_render), self); + + gtk_widget_set_parent(self->gl_area, GTK_WIDGET(self)); + + self->position = 0.0; + self->duration = 0.0; + self->paused = TRUE; + self->tick_source = 0; + self->pending_url = NULL; + self->initialized = FALSE; + self->should_play = FALSE; +} + +static void +mpv_widget_class_init(MpvWidgetClass *klass) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass); + + gobject_class->dispose = mpv_widget_dispose; + widget_class->size_allocate = mpv_widget_size_allocate; + widget_class->measure = mpv_widget_measure; + widget_class->compute_expand = mpv_widget_compute_expand; + + signals[SIGNAL_POSITION_CHANGED] = g_signal_new( + "position-changed", + MPV_TYPE_WIDGET, + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, + g_cclosure_marshal_VOID__DOUBLE, + G_TYPE_NONE, 1, G_TYPE_DOUBLE); + + signals[SIGNAL_DURATION_CHANGED] = g_signal_new( + "duration-changed", + MPV_TYPE_WIDGET, + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, + g_cclosure_marshal_VOID__DOUBLE, + G_TYPE_NONE, 1, G_TYPE_DOUBLE); + + signals[SIGNAL_PLAYBACK_STATE_CHANGED] = g_signal_new( + "playback-state-changed", + MPV_TYPE_WIDGET, + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, + g_cclosure_marshal_VOID__BOOLEAN, + G_TYPE_NONE, 1, G_TYPE_BOOLEAN); + + signals[SIGNAL_ERROR] = g_signal_new( + "error", + MPV_TYPE_WIDGET, + G_SIGNAL_RUN_LAST, + 0, NULL, NULL, + g_cclosure_marshal_VOID__STRING, + G_TYPE_NONE, 1, G_TYPE_STRING); + + gtk_widget_class_set_css_name(widget_class, "mpvwidget"); + gtk_widget_class_set_accessible_role(widget_class, + GTK_ACCESSIBLE_ROLE_GENERIC); +} + +GtkWidget * +mpv_widget_new(void) +{ + return g_object_new(MPV_TYPE_WIDGET, NULL); +} + +void +mpv_widget_load_url(MpvWidget *self, const char *url) +{ + g_return_if_fail(MPV_IS_WIDGET(self)); + g_return_if_fail(url != NULL); + + if (!self->mpv) { + g_free(self->pending_url); + self->pending_url = g_strdup(url); + return; + } + + const char *cmd[] = {"loadfile", url, NULL}; + mpv_command(self->mpv, cmd); + mpv_set_property_string(self->mpv, "pause", "yes"); +} + +void +mpv_widget_play(MpvWidget *self) +{ + g_return_if_fail(MPV_IS_WIDGET(self)); + + if (!self->mpv) { + self->should_play = TRUE; + return; + } + mpv_set_property_string(self->mpv, "pause", "no"); +} + +void +mpv_widget_pause(MpvWidget *self) +{ + g_return_if_fail(MPV_IS_WIDGET(self)); + + if (!self->mpv) { + self->should_play = FALSE; + return; + } + mpv_set_property_string(self->mpv, "pause", "yes"); +} + +void +mpv_widget_seek_relative(MpvWidget *self, double offset_seconds) +{ + g_return_if_fail(MPV_IS_WIDGET(self)); + + if (!self->mpv) + return; + + char *offset_str = g_strdup_printf("%+.1f", offset_seconds); + const char *cmd[] = {"seek", offset_str, "relative", NULL}; + mpv_command(self->mpv, cmd); + g_free(offset_str); +} + +void +mpv_widget_seek_absolute(MpvWidget *self, double position_seconds) +{ + g_return_if_fail(MPV_IS_WIDGET(self)); + + if (!self->mpv) + return; + + char *pos_str = g_strdup_printf("%.1f", position_seconds); + const char *cmd[] = {"seek", pos_str, "absolute", NULL}; + mpv_command(self->mpv, cmd); + g_free(pos_str); +} + +void +mpv_widget_set_volume(MpvWidget *self, double volume) +{ + g_return_if_fail(MPV_IS_WIDGET(self)); + + if (!self->mpv) + return; + + char *vol_str = g_strdup_printf("%.0f", volume); + mpv_set_property_string(self->mpv, "volume", vol_str); + g_free(vol_str); +} + +double +mpv_widget_get_position(MpvWidget *self) +{ + g_return_val_if_fail(MPV_IS_WIDGET(self), 0.0); + return self->position; +} + +double +mpv_widget_get_duration(MpvWidget *self) +{ + g_return_val_if_fail(MPV_IS_WIDGET(self), 0.0); + return self->duration; +} + +gboolean +mpv_widget_get_paused(MpvWidget *self) +{ + g_return_val_if_fail(MPV_IS_WIDGET(self), FALSE); + return self->paused; +} + +double +mpv_widget_get_volume(MpvWidget *self) +{ + g_return_val_if_fail(MPV_IS_WIDGET(self), 0.0); + + if (!self->mpv) + return 100.0; + + double vol = 100.0; + mpv_get_property(self->mpv, "volume", MPV_FORMAT_DOUBLE, &vol); + return vol; +} diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 2b5af0f..585d55c 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -137,11 +137,13 @@ struct ContentView: View { } .navigationTitle(page.description) case .movieDetail(let item): - MovieDetailView(for: item) + MovieDetailView(for: item, navigation: $stack) .topToolbar { ToolbarView() } .navigationTitle(page.description) + case .player(let playerState): + PlayerView(playerState: playerState, onClose: { stack.pop() }) } } initialView: { HomeView(navigation: $stack) diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index 97e8f55..cf6b8e8 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -685,6 +685,26 @@ public actor JellyfinClient { } } + public func pingPlaybackSession(playSessionId: String) async throws { + guard token != nil else { throw JellyfinError.notAuthenticated } + let response = try await client.pingPlaybackSession( + Operations.PingPlaybackSession.Input( + query: .init(playSessionId: playSessionId) + )) + switch response { + case .noContent: + return + case .unauthorized: + throw JellyfinError.httpError(401) + case .forbidden: + throw JellyfinError.httpError(403) + case .serviceUnavailable: + throw JellyfinError.httpError(503) + case .undocumented(let code, _): + throw JellyfinError.httpError(code) + } + } + public func getLatestMedia( userId: String, parentId: String? = nil, @@ -755,4 +775,22 @@ public actor JellyfinClient { if let tag { components.queryItems = [.init(name: "tag", value: tag)] } return components.url } + + public func streamURL( + itemId: String, + mediaSourceId: String, + playSessionId: String + ) -> URL? { + guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { + return nil + } + components.path = "/Videos/\(itemId)/stream" + components.queryItems = [ + .init(name: "static", value: "true"), + .init(name: "mediaSourceId", value: mediaSourceId), + .init(name: "playSessionId", value: playSessionId), + .init(name: "api_key", value: token ?? ""), + ] + return components.url + } } diff --git a/Sources/LuminateCore/Page.swift b/Sources/LuminateCore/Page.swift index c696422..52ad2ea 100644 --- a/Sources/LuminateCore/Page.swift +++ b/Sources/LuminateCore/Page.swift @@ -23,12 +23,14 @@ public enum Page: CustomStringConvertible { case item(item: BaseItemDto, type: DisplayType = .mixed) case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed) case movieDetail(item: BaseItemDto) + case player(PlayerState) public var description: String { return switch self { case .item(let item, _): item.name ?? "Library" case .items(let title, _, _): title case .movieDetail(let item): item.name ?? "Luminate" + case .player: "Now Playing" } } } diff --git a/Sources/LuminatePlayer/LuminatePlayer.swift b/Sources/LuminateCore/PlayerPlaybackState.swift similarity index 69% rename from Sources/LuminatePlayer/LuminatePlayer.swift rename to Sources/LuminateCore/PlayerPlaybackState.swift index 5aa0d1a..9b0875c 100644 --- a/Sources/LuminatePlayer/LuminatePlayer.swift +++ b/Sources/LuminateCore/PlayerPlaybackState.swift @@ -1,5 +1,5 @@ // -// LuminatePlayer.swift +// PlayerPlaybackState.swift // // Copyright 2026 Brendan Szymanski // @@ -19,7 +19,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -import Adwaita -import LuminateCore +import Foundation -public struct LuminatePlayer {} +/// Shared mutable state for direct C widget manipulation during playback. +/// Accessed from signal handlers to update GTK widgets without triggering +/// Adwaita's `StateManager.updateViews()`. +public final class PlayerPlaybackState { + public var seekScale: OpaquePointer? + public init() {} +} diff --git a/Sources/LuminatePlayer/VideoPlayerWidget.swift b/Sources/LuminateCore/PlayerState.swift similarity index 55% rename from Sources/LuminatePlayer/VideoPlayerWidget.swift rename to Sources/LuminateCore/PlayerState.swift index 03c0bdc..1be25c5 100644 --- a/Sources/LuminatePlayer/VideoPlayerWidget.swift +++ b/Sources/LuminateCore/PlayerState.swift @@ -1,5 +1,5 @@ // -// VideoPlayerWidget.swift +// PlayerState.swift // // Copyright 2026 Brendan Szymanski // @@ -19,32 +19,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -import Adwaita import Foundation -struct VideoPlayerWidget: View { +public struct PlayerState: Hashable, Sendable { - var url: String - @Binding var isPlaying: Bool - @Binding var position: Double - @Binding var duration: Double + public var itemId: String + public var mediaSourceId: String + public var playSessionId: String + public var streamURL: URL + public var serverBase: String - var view: Body { - VStack { - Text("Now Playing") - .title1() - Text(url) - .caption() - .dimLabel() - HStack { - Button(icon: .default(icon: .mediaPlaybackStart)) { - isPlaying = true - } - .suggested() - } - } - .padding(50) - .frame(minWidth: 400, minHeight: 300) - .card() + public init( + itemId: String, + mediaSourceId: String, + playSessionId: String, + streamURL: URL, + serverBase: String + ) { + self.itemId = itemId + self.mediaSourceId = mediaSourceId + self.playSessionId = playSessionId + self.streamURL = streamURL + self.serverBase = serverBase } + } diff --git a/Sources/LuminatePlayer/PlayerControls.swift b/Sources/LuminatePlayer/PlayerControls.swift index 0bdb2e4..d9030fa 100644 --- a/Sources/LuminatePlayer/PlayerControls.swift +++ b/Sources/LuminatePlayer/PlayerControls.swift @@ -20,57 +20,116 @@ // import Adwaita +import LuminateCore +import LuminateUI public struct PlayerControls: View { @Binding var isPlaying: Bool @Binding var position: Double @Binding var duration: Double - public var onTogglePlay: () -> Void + var playbackState: PlayerPlaybackState? + public var onClose: () -> Void public var onSeekBack: () -> Void public var onSeekForward: () -> Void + public var onSeekAbsolute: (Double) -> Void public var onFullscreen: () -> Void - public var onClose: () -> Void + public var onSubtitleAudio: () -> Void + + init( + isPlaying: Binding, + position: Binding, + duration: Binding, + playbackState: PlayerPlaybackState? = nil, + onClose: @escaping () -> Void, + onSeekBack: @escaping () -> Void, + onSeekForward: @escaping () -> Void, + onSeekAbsolute: @escaping (Double) -> Void, + onFullscreen: @escaping () -> Void, + onSubtitleAudio: @escaping () -> Void + ) { + self._isPlaying = isPlaying + self._position = position + self._duration = duration + self.playbackState = playbackState + self.onClose = onClose + self.onSeekBack = onSeekBack + self.onSeekForward = onSeekForward + self.onSeekAbsolute = onSeekAbsolute + self.onFullscreen = onFullscreen + self.onSubtitleAudio = onSubtitleAudio + } public var view: Body { - HStack { - Button(icon: .default(icon: .windowClose)) { - onClose() - } - .flat() - Button(icon: .default(icon: .goPrevious)) { - onSeekBack() - } - .flat() - Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) { - onTogglePlay() - } - .flat() - Button(icon: .default(icon: .goNext)) { - onSeekForward() - } - .flat() - HStack { + Box { + HStack(spacing: 6) { + Button(icon: .custom(name: "skip-backwards-10-symbolic")) { + onSeekBack() + } + .style("circular") + + Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) { + isPlaying.toggle() + } + .style("circular") + + Button(icon: .custom(name: "skip-forward-10-symbolic")) { + onSeekForward() + } + .style("circular") + + Separator() + .style("spacer") + Text(formatTime(position)) .caption() - LevelBar() - .value(duration > 0 ? position / duration : 0) - .hexpand(true) - Text(formatTime(duration)) + + SeekBar( + value: $position, + range: 0...(duration > 0 ? duration : 1), + playbackState: playbackState, + onSeek: { newPos in + onSeekAbsolute(newPos) + } + ) + .hexpand(true) + + Text("-\(formatTime(max(0, duration - position)))") .caption() + + Separator() + .style("spacer") + + Button(icon: .custom(name: "language-symbolic")) { + onSubtitleAudio() + } + .style("flat") + + Button(icon: .default(icon: .audioVolumeHigh)) { + // Volume popover — future + } + .style("flat") + .insensitive() + + Button(icon: .default(icon: .viewFullscreen)) { + onFullscreen() + } + .style("flat") } - .hexpand(true) - Button(icon: .default(icon: .viewFullscreen)) { - onFullscreen() - } - .flat() + .padding() } - .padding(10) + .style("card") + .style("view") } private func formatTime(_ seconds: Double) -> String { - let m = Int(seconds) / 60 + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let h = Int(seconds) / 3600 + let m = (Int(seconds) / 60) % 60 let s = Int(seconds) % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } return String(format: "%d:%02d", m, s) } } diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift index 5213fa1..a02708c 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -22,89 +22,319 @@ import Adwaita import Foundation import LuminateCore +import LuminateDI +import CModules +import CAdw + +final class PlayerTasks { + var progress: Task? + var controls: Task? +} public struct PlayerView: View { - public var item: BaseItemDto - public var client: JellyfinClient - public var userId: String - public var mediaSourceId: String - public var playSessionId: String - public var streamURL: URL + public var playerState: PlayerState + public var onClose: () -> Void + + @Injected(\.client) var client + @Injected(\.userId) var userId + @State private var isPlaying = true @State private var position: Double = 0 @State private var duration: Double = 0 @State private var showControls = true - public var onClose: () -> Void + @State private var isFullscreen = false + @State private var mpvWidget: OpaquePointer? + @State private var tasks = PlayerTasks() - public init( - item: BaseItemDto, - client: JellyfinClient, - userId: String, - mediaSourceId: String, - playSessionId: String, - streamURL: URL, - onClose: @escaping () -> Void - ) { - self.item = item - self.client = client - self.userId = userId - self.mediaSourceId = mediaSourceId - self.playSessionId = playSessionId - self.streamURL = streamURL + private let playbackState = PlayerPlaybackState() + + public init(playerState: PlayerState, onClose: @escaping () -> Void) { + self.playerState = playerState self.onClose = onClose } public var view: Body { - VStack { - VideoPlayerWidget( - url: streamURL.absoluteString, - isPlaying: $isPlaying, - position: $position, - duration: $duration - ) - .hexpand(true) - .vexpand(true) + VideoPlayerWidget( + url: playerState.streamURL.absoluteString, + isPlaying: $isPlaying, + position: $position, + duration: $duration, + playbackState: playbackState, + onWidgetCreated: { ptr in _mpvWidget.rawValue = ptr } + ) + .vexpand(true) + .hexpand(true) + .overlay { if showControls { - PlayerControls( - isPlaying: $isPlaying, - position: $position, - duration: $duration, - onTogglePlay: { isPlaying.toggle() }, - onSeekBack: { position = max(0, position - 10) }, - onSeekForward: { position = min(duration, position + 10) }, - onFullscreen: {}, - onClose: { - stopPlayback() - onClose() + VStack { + HStack { + Button(icon: .default(icon: .goPrevious)) { + stopPlayback() + onClose() + } + .flat() + .padding() + Box { }.hexpand(true) } - ) + Box { }.vexpand(true) + PlayerControls( + isPlaying: $isPlaying, + position: $position, + duration: $duration, + playbackState: playbackState, + onClose: { + stopPlayback() + onClose() + }, + onSeekBack: { seekBy(-10) }, + onSeekForward: { seekBy(10) }, + onSeekAbsolute: { seekTo($0) }, + onFullscreen: { toggleFullscreen() }, + onSubtitleAudio: { } + ) + } } } + .onAppear { + startPlayback() + startControlsTimer() + } } + // MARK: - Playback + private func startPlayback() { Task { try? await client.reportPlaybackStart( info: .init( - itemId: item.id, - mediaSourceId: mediaSourceId, - playSessionId: playSessionId + itemId: playerState.itemId, + mediaSourceId: playerState.mediaSourceId, + playSessionId: playerState.playSessionId + ) + ) + startProgressTimer() + } + } + + private func stopPlayback() { + tasks.progress?.cancel() + tasks.controls?.cancel() + Task { + try? await client.reportPlaybackStopped( + info: .init( + itemId: playerState.itemId, + mediaSourceId: playerState.mediaSourceId, + positionTicks: Int64(position * 10_000_000), + playSessionId: playerState.playSessionId ) ) } } - private func stopPlayback() { - Task { - try? await client.reportPlaybackStopped( - info: .init( - itemId: item.id, - mediaSourceId: mediaSourceId, - positionTicks: Int64(position * 10_000_000), - playSessionId: playSessionId + private func startProgressTimer() { + tasks.progress = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + try? await client.reportPlaybackProgress( + info: .init( + itemId: playerState.itemId, + mediaSourceId: playerState.mediaSourceId, + isPaused: !isPlaying, + positionTicks: Int64(position * 10_000_000), + playSessionId: playerState.playSessionId + ) ) - ) + if Int(position).isMultiple(of: 30) { + try? await client.pingPlaybackSession( + playSessionId: playerState.playSessionId + ) + } + } } } + + // MARK: - Seeking + + private func seekBy(_ seconds: Double) { + guard let mpvWidget else { return } + mpv_widget_seek_relative(mpvWidget, seconds) + } + + private func seekTo(_ position: Double) { + guard let mpvWidget else { return } + mpv_widget_seek_absolute(mpvWidget, position) + } + + // MARK: - Controls visibility + + private func startControlsTimer() { + tasks.controls?.cancel() + tasks.controls = Task { + try? await Task.sleep(for: .seconds(3)) + showControls = false + } + } + + // MARK: - Fullscreen + + private func toggleFullscreen() { + guard let widget = mpvWidget else { return } + let root = gtk_widget_get_root(widget.cast()) + guard let root else { return } + if isFullscreen { + gtk_window_unfullscreen(root.cast()) + } else { + gtk_window_fullscreen(root.cast()) + } + isFullscreen.toggle() + } + } + +// MARK: - VideoPlayerWidget (internal Widget wrapper) + +struct VideoPlayerWidget: Widget { + + #if exposeGeneratedAppearUpdateFunctions + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + var url: String? + @Binding var isPlaying: Bool + @Binding var position: Double + @Binding var duration: Double + var playbackState: PlayerPlaybackState? + var onWidgetCreated: ((OpaquePointer) -> Void)? + + init( + url: String?, + isPlaying: Binding, + position: Binding, + duration: Binding, + playbackState: PlayerPlaybackState? = nil, + onWidgetCreated: ((OpaquePointer) -> Void)? = nil + ) { + self.url = url + self._isPlaying = isPlaying + self._position = position + self._duration = duration + self.playbackState = playbackState + self.onWidgetCreated = onWidgetCreated + } + + func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let storage = ViewStorage(mpv_widget_new()?.opaque()) + + if let widgetPtr = storage.opaquePointer.map(UnsafeMutableRawPointer.init) { + let ctx = SignalContext( + duration: _duration, + isPlaying: _isPlaying, + playbackState: playbackState + ) + storage.fields["ctx"] = ctx + let ctxPtr = Unmanaged.passUnretained(ctx).toOpaque() + + let posHandler: @convention(c) ( + OpaquePointer?, Double, UnsafeMutableRawPointer? + ) -> Void = { _, pos, ptr in + let ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + if let scale = ctx.playbackState?.seekScale { + let range = unsafeBitCast(scale, to: UnsafeMutablePointer?.self) + gtk_range_set_value(range, pos) + } + } + g_signal_connect_data( + widgetPtr, "position-changed", + unsafeBitCast(posHandler, to: GCallback.self), + ctxPtr, nil, GConnectFlags(rawValue: 1)) + + let durHandler: @convention(c) ( + OpaquePointer?, Double, UnsafeMutableRawPointer? + ) -> Void = { _, dur, ptr in + let ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + ctx.duration.wrappedValue = dur + } + g_signal_connect_data( + widgetPtr, "duration-changed", + unsafeBitCast(durHandler, to: GCallback.self), + ctxPtr, nil, GConnectFlags(rawValue: 1)) + + let stateHandler: @convention(c) ( + OpaquePointer?, Int32, UnsafeMutableRawPointer? + ) -> Void = { _, paused, ptr in + let ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + ctx.isPlaying.wrappedValue = paused == 0 + } + g_signal_connect_data( + widgetPtr, "playback-state-changed", + unsafeBitCast(stateHandler, to: GCallback.self), + ctxPtr, nil, GConnectFlags(rawValue: 1)) + } + + if let ptr = storage.opaquePointer, let onWidgetCreated { + onWidgetCreated(ptr) + } + + for function in appearFunctions { + function(storage, data) + } + return storage + } + + func update( + _ storage: ViewStorage, + data: WidgetData, + updateProperties: Bool, + type: Data.Type + ) where Data: ViewRenderData { + storage.modify { widget in + if updateProperties, let url, !(storage.previousState is Self) { + mpv_widget_load_url(widget, url) + mpv_widget_pause(widget) + if isPlaying { mpv_widget_play(widget) } + } + if updateProperties, + let prev = storage.previousState as? Self, + prev.isPlaying != isPlaying + { + if isPlaying { + mpv_widget_play(widget) + } else { + mpv_widget_pause(widget) + } + } + } + for function in updateFunctions { + function(storage, data, updateProperties) + } + if updateProperties { + storage.previousState = self + } + } + +} + +// MARK: - Signal context + +class SignalContext { + var duration: Binding + var isPlaying: Binding + weak var playbackState: PlayerPlaybackState? + + init( + duration: Binding, + isPlaying: Binding, + playbackState: PlayerPlaybackState? + ) { + self.duration = duration + self.isPlaying = isPlaying + self.playbackState = playbackState + } +} + diff --git a/Sources/LuminateUI/Components/AspectContainer.swift b/Sources/LuminateUI/Components/AspectContainer.swift index 9567357..4267419 100644 --- a/Sources/LuminateUI/Components/AspectContainer.swift +++ b/Sources/LuminateUI/Components/AspectContainer.swift @@ -20,7 +20,7 @@ // import Adwaita -import CGtkWidgets +import CModules /// A single‑child container that preserves a fixed aspect ratio. /// diff --git a/Sources/LuminateUI/Components/EpisodeList.swift b/Sources/LuminateUI/Components/EpisodeList.swift index e52232a..d19080d 100644 --- a/Sources/LuminateUI/Components/EpisodeList.swift +++ b/Sources/LuminateUI/Components/EpisodeList.swift @@ -30,6 +30,7 @@ struct EpisodeList: View { var seasonId: String var client: JellyfinClient var userId: String + @Binding var navigation: NavigationStack @State private var episodes: [BaseItemDto] = [] var view: Body { @@ -37,7 +38,8 @@ struct EpisodeList: View { ForEach(episodes) { episode in EpisodeRow( episode: episode, - client: client + client: client, + navigation: $navigation ) } } @@ -62,10 +64,18 @@ struct EpisodeRow: View { var episode: BaseItemDto var client: JellyfinClient + @Binding var navigation: NavigationStack @Injected(\.imageService) var imageService @Injected(\.viewUpdateScheduler) var viewUpdateScheduler + @Injected(\.userId) var userId @State private var imageData: Data? + init(episode: BaseItemDto, client: JellyfinClient, navigation: Binding>) { + self.episode = episode + self.client = client + self._navigation = navigation + } + var view: Body { HStack { if let data = imageData { @@ -92,7 +102,27 @@ struct EpisodeRow: View { } .hexpand(true) Button(icon: .default(icon: .mediaPlaybackStart)) { - + Task { + guard let itemId = episode.id else { return } + guard let info = try? await client.getPlaybackInfo( + itemId: itemId, userId: userId + ) else { return } + guard let mediaSource = info.mediaSources?.first, + let playSessionId = info.playSessionId else { return } + guard let streamURL = await client.streamURL( + itemId: itemId, + mediaSourceId: mediaSource.id ?? "", + playSessionId: playSessionId + ) else { return } + let state = PlayerState( + itemId: itemId, + mediaSourceId: mediaSource.id ?? "", + playSessionId: playSessionId, + streamURL: streamURL, + serverBase: await client.serverURL.absoluteString + ) + navigation.push(.player(state)) + } } .flat() } diff --git a/Sources/LuminateUI/Components/FlowGrid.swift b/Sources/LuminateUI/Components/FlowGrid.swift index 8c336f0..304461f 100644 --- a/Sources/LuminateUI/Components/FlowGrid.swift +++ b/Sources/LuminateUI/Components/FlowGrid.swift @@ -20,7 +20,7 @@ // import Adwaita -import CGtkWidgets +import CModules /// Controls how the last row is aligned when it doesn't fill the width. public enum FlowGridJustifySetting: Int { diff --git a/Sources/LuminateUI/Components/MovieDetailView.swift b/Sources/LuminateUI/Components/MovieDetailView.swift index f86a5ad..05f45e6 100644 --- a/Sources/LuminateUI/Components/MovieDetailView.swift +++ b/Sources/LuminateUI/Components/MovieDetailView.swift @@ -27,6 +27,7 @@ import LuminateDI public struct MovieDetailView: View { var item: BaseItemDto + @Binding var navigation: NavigationStack @Injected(\.client) private var client @Injected(\.userId) private var userId @Injected(\.imageService) private var imageService @@ -35,8 +36,9 @@ public struct MovieDetailView: View { @State private var similarItems: [BaseItemDto] = [] @State private var backdropData: Data? - public init(for item: BaseItemDto) { + public init(for item: BaseItemDto, navigation: Binding>) { self.item = item + self._navigation = navigation _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false) _isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false) } @@ -71,7 +73,27 @@ public struct MovieDetailView: View { .halign(.start) HStack { Button("Play", icon: .default(icon: .mediaPlaybackStart)) { - + Task { + guard let itemId = item.id else { return } + guard let info = try? await client.getPlaybackInfo( + itemId: itemId, userId: userId + ) else { return } + guard let mediaSource = info.mediaSources?.first, + let playSessionId = info.playSessionId else { return } + guard let streamURL = await client.streamURL( + itemId: itemId, + mediaSourceId: mediaSource.id ?? "", + playSessionId: playSessionId + ) else { return } + let state = PlayerState( + itemId: itemId, + mediaSourceId: mediaSource.id ?? "", + playSessionId: playSessionId, + streamURL: streamURL, + serverBase: await client.serverURL.absoluteString + ) + navigation.push(.player(state)) + } } .suggested() Button(icon: .default(icon: .bookmarkNew)) { diff --git a/Sources/LuminateUI/Components/SeekBar.swift b/Sources/LuminateUI/Components/SeekBar.swift new file mode 100644 index 0000000..0791940 --- /dev/null +++ b/Sources/LuminateUI/Components/SeekBar.swift @@ -0,0 +1,145 @@ +// +// SeekBar.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 +import LuminateCore + +/// A seekable progress slider wrapping `GtkScale`. +/// +/// Use for media playback position scrubbing. Emits the new value +/// via the `onSeek` callback when the user drags the slider. +/// +/// ```swift +/// SeekBar(value: $position, range: 0...duration, onSeek: { pos in +/// player.seek(to: pos) +/// }) +/// ``` +public struct SeekBar: Widget { + + #if exposeGeneratedAppearUpdateFunctions + public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #else + var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = [] + var appearFunctions: [(ViewStorage, WidgetData) -> Void] = [] + #endif + + @Binding var value: Double + var range: ClosedRange + var playbackState: PlayerPlaybackState? + var onSeek: ((Double) -> Void)? + + public init( + value: Binding, + range: ClosedRange, + playbackState: PlayerPlaybackState? = nil, + onSeek: ((Double) -> Void)? = nil + ) { + self._value = value + self.range = range + self.playbackState = playbackState + self.onSeek = onSeek + } + + // MARK: - Widget + + public func container(data: WidgetData, type: Data.Type) -> ViewStorage + where Data: ViewRenderData { + let widgetPtr = gtk_scale_new_with_range( + .GTK_ORIENTATION_HORIZONTAL, + range.lowerBound, + range.upperBound, + 1.0 + ) + let scale = widgetPtr.map(OpaquePointer.init) + gtk_scale_set_draw_value(scale?.cast(), 0) + gtk_widget_set_hexpand(scale?.cast(), 1) + gtk_range_set_show_fill_level(scale?.cast(), 1) + gtk_range_set_restrict_to_fill_level(scale?.cast(), 0) + + // Register scale for direct C-level updates during playback + if let scale { + playbackState?.seekScale = scale + } + + let storage = ViewStorage(scale) + + let context = SeekBarContext() + context.onSeek = onSeek + let binding = _value + context.setValue = { binding.wrappedValue = $0 } + storage.fields["context"] = context + + let callback: @convention(c) ( + OpaquePointer?, + GtkScrollType, + Double, + UnsafeMutableRawPointer? + ) -> Int32 = { _, _, val, userData in + let ctx = Unmanaged.fromOpaque(userData!).takeUnretainedValue() + ctx.setValue?(val) + ctx.onSeek?(val) + return 0 + } + g_signal_connect_data( + scale.map(UnsafeMutableRawPointer.init), + "change-value", + unsafeBitCast(callback, to: GCallback.self), + Unmanaged.passUnretained(context).toOpaque(), + nil, + GConnectFlags(rawValue: 1) + ) + + 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 + let currentValue = gtk_range_get_value(widget?.cast()) + if abs(currentValue - value) > 0.5 { + gtk_range_set_value(widget?.cast(), value) + } + } + for function in updateFunctions { + function(storage, data, updateProperties) + } + if updateProperties { + storage.previousState = self + } + } + +} + +// MARK: - Context + +private class SeekBarContext { + var setValue: ((Double) -> Void)? + var onSeek: ((Double) -> Void)? +} diff --git a/Sources/LuminateUI/Components/TVShowView.swift b/Sources/LuminateUI/Components/TVShowView.swift index 6b37a5b..1b2e3e9 100644 --- a/Sources/LuminateUI/Components/TVShowView.swift +++ b/Sources/LuminateUI/Components/TVShowView.swift @@ -29,6 +29,7 @@ struct TVShowView: View { var item: BaseItemDto var client: JellyfinClient var userId: String + @Binding var navigation: NavigationStack @Injected(\.imageService) var imageService @State private var seasons: [BaseItemDto] = [] @State private var selectedSeasonId: String? @@ -89,7 +90,8 @@ struct TVShowView: View { seriesId: item.id ?? "", seasonId: seasonId, client: client, - userId: userId + userId: userId, + navigation: $navigation ) } } diff --git a/Tests/LuminateTests/PlayerTests.swift b/Tests/LuminateTests/PlayerTests.swift new file mode 100644 index 0000000..2f57131 --- /dev/null +++ b/Tests/LuminateTests/PlayerTests.swift @@ -0,0 +1,92 @@ +// +// PlayerTests.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 +// +// +// NOTE: Requires Testing or XCTest framework which is not available in the +// current CLI-only Swift 6.4 beta toolchain. These tests compile and run +// inside Xcode or with a full macOS SDK that includes the Testing framework. +// See WidgetTests.swift for the same constraint. +// + +import Foundation +@testable import LuminateCore + +// MARK: - PlayerState Tests + +func testPlayerStateHashable() { + let url = URL(string: "https://example.com/stream")! + let a = PlayerState( + itemId: "item1", + mediaSourceId: "ms1", + playSessionId: "ps1", + streamURL: url, + serverBase: "https://example.com" + ) + let b = PlayerState( + itemId: "item1", + mediaSourceId: "ms1", + playSessionId: "ps1", + streamURL: url, + serverBase: "https://example.com" + ) + assert(a == b, "Equal instances should be equal") + assert(a.hashValue == b.hashValue, "Equal instances should have equal hashes") + + let c = PlayerState( + itemId: "item2", + mediaSourceId: "ms1", + playSessionId: "ps1", + streamURL: url, + serverBase: "https://example.com" + ) + assert(a != c, "Different instances should not be equal") +} + +// MARK: - Tick Conversion Tests + +func testTickConversion() { + let seconds: Double = 65.5 + let ticks = Int64(seconds * 10_000_000) + assert(ticks == 655_000_000, "65.5s should be 655,000,000 ticks") + + let back = Double(ticks) / 10_000_000 + assert(abs(back - seconds) < 0.001, "Round-trip should match within tolerance") +} + +// MARK: - Time Formatting Tests + +func testTimeFormatting() { + let formatTime: (Double) -> String = { seconds in + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let h = Int(seconds) / 3600 + let m = (Int(seconds) / 60) % 60 + let s = Int(seconds) % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%d:%02d", m, s) + } + + assert(formatTime(0) == "0:00") + assert(formatTime(65) == "1:05") + assert(formatTime(3661) == "1:01:01") + assert(formatTime(-1) == "0:00") + assert(formatTime(Double.infinity) == "0:00") +} diff --git a/Tests/LuminateTests/WidgetTests.swift b/Tests/LuminateTests/WidgetTests.swift index 71ffda3..3bb918e 100644 --- a/Tests/LuminateTests/WidgetTests.swift +++ b/Tests/LuminateTests/WidgetTests.swift @@ -1,6 +1,6 @@ import Adwaita import CAdw -import CGtkWidgets +import CModules import Testing // MARK: - Helpers diff --git a/dev.bscubed.Luminate.json b/dev.bscubed.Luminate.json index c9d679f..643c9e5 100644 --- a/dev.bscubed.Luminate.json +++ b/dev.bscubed.Luminate.json @@ -33,6 +33,27 @@ "*.a" ], "modules": [ + { + "name": "mpv", + "buildsystem": "meson", + "config-opts": [ + "-Dlibmpv=true", + "-Dcplayer=false", + "-Dlua=disabled", + "-Djavascript=disabled", + "-Duchardet=disabled", + "-Drubberband=disabled", + "-Dvapoursynth=disabled", + "-Dmanpage-build=disabled" + ], + "sources": [ + { + "type": "archive", + "url": "https://github.com/mpv-player/mpv/archive/v0.39.0.tar.gz", + "sha256": "replace-with-actual-sha256" + } + ] + }, { "name": "Luminate", "builddir": true,