diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 35aa284..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,438 +0,0 @@ -# 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 5ab943b..957d766 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "Luminate", - platforms: [.macOS(.v15)], + platforms: [.macOS(.v13)], dependencies: [ .package(url: "https://git.aparoksha.dev/aparoksha/adwaita-swift", branch: "main"), .package(url: "https://git.aparoksha.dev/aparoksha/localized", branch: "main"), @@ -33,7 +33,6 @@ 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"), ] ), @@ -54,25 +53,16 @@ let package = Package( ] ), .target( - name: "CModules", + name: "CGtkWidgets", 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: [ - "CModules", + "CGtkWidgets", "LuminateCore", "LuminateDI", "LuminateObservationMacros", @@ -83,7 +73,6 @@ let package = Package( .target( name: "LuminatePlayer", dependencies: [ - "CModules", "LuminateCore", "LuminateDI", "LuminateUI", @@ -110,12 +99,10 @@ let package = Package( .testTarget( name: "LuminateTests", dependencies: [ - "CModules", - "LuminateCore", + "CGtkWidgets", .product(name: "CAdw", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"), - ], - + ] ), ], swiftLanguageModes: [.v5] diff --git a/Sources/CModules/aspect_container.c b/Sources/CGtkWidgets/aspect_container.c similarity index 100% rename from Sources/CModules/aspect_container.c rename to Sources/CGtkWidgets/aspect_container.c diff --git a/Sources/CModules/flow_grid.c b/Sources/CGtkWidgets/flow_grid.c similarity index 100% rename from Sources/CModules/flow_grid.c rename to Sources/CGtkWidgets/flow_grid.c diff --git a/Sources/CModules/include/aspect_container.h b/Sources/CGtkWidgets/include/aspect_container.h similarity index 100% rename from Sources/CModules/include/aspect_container.h rename to Sources/CGtkWidgets/include/aspect_container.h diff --git a/Sources/CModules/include/flow_grid.h b/Sources/CGtkWidgets/include/flow_grid.h similarity index 100% rename from Sources/CModules/include/flow_grid.h rename to Sources/CGtkWidgets/include/flow_grid.h diff --git a/Sources/CModules/include/mpv_widget.h b/Sources/CModules/include/mpv_widget.h deleted file mode 100644 index e84a344..0000000 --- a/Sources/CModules/include/mpv_widget.h +++ /dev/null @@ -1,36 +0,0 @@ -#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 deleted file mode 100644 index 4b83cf6..0000000 --- a/Sources/CModules/mpv_widget.c +++ /dev/null @@ -1,507 +0,0 @@ -#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 585d55c..2b5af0f 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -137,13 +137,11 @@ struct ContentView: View { } .navigationTitle(page.description) case .movieDetail(let item): - MovieDetailView(for: item, navigation: $stack) + MovieDetailView(for: item) .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 cf6b8e8..97e8f55 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -685,26 +685,6 @@ 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, @@ -775,22 +755,4 @@ 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 52ad2ea..c696422 100644 --- a/Sources/LuminateCore/Page.swift +++ b/Sources/LuminateCore/Page.swift @@ -23,14 +23,12 @@ 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/LuminateCore/PlayerPlaybackState.swift b/Sources/LuminatePlayer/LuminatePlayer.swift similarity index 69% rename from Sources/LuminateCore/PlayerPlaybackState.swift rename to Sources/LuminatePlayer/LuminatePlayer.swift index 9b0875c..5aa0d1a 100644 --- a/Sources/LuminateCore/PlayerPlaybackState.swift +++ b/Sources/LuminatePlayer/LuminatePlayer.swift @@ -1,5 +1,5 @@ // -// PlayerPlaybackState.swift +// LuminatePlayer.swift // // Copyright 2026 Brendan Szymanski // @@ -19,12 +19,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -import Foundation +import Adwaita +import LuminateCore -/// 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() {} -} +public struct LuminatePlayer {} diff --git a/Sources/LuminatePlayer/PlayerControls.swift b/Sources/LuminatePlayer/PlayerControls.swift index d9030fa..0bdb2e4 100644 --- a/Sources/LuminatePlayer/PlayerControls.swift +++ b/Sources/LuminatePlayer/PlayerControls.swift @@ -20,116 +20,57 @@ // import Adwaita -import LuminateCore -import LuminateUI public struct PlayerControls: View { @Binding var isPlaying: Bool @Binding var position: Double @Binding var duration: Double - var playbackState: PlayerPlaybackState? - public var onClose: () -> Void + public var onTogglePlay: () -> Void public var onSeekBack: () -> Void public var onSeekForward: () -> Void - public var onSeekAbsolute: (Double) -> Void public var onFullscreen: () -> 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 onClose: () -> Void public var view: Body { - 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") - + 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 { Text(formatTime(position)) .caption() - - SeekBar( - value: $position, - range: 0...(duration > 0 ? duration : 1), - playbackState: playbackState, - onSeek: { newPos in - onSeekAbsolute(newPos) - } - ) - .hexpand(true) - - Text("-\(formatTime(max(0, duration - position)))") + LevelBar() + .value(duration > 0 ? position / duration : 0) + .hexpand(true) + Text(formatTime(duration)) .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") } - .padding() + .hexpand(true) + Button(icon: .default(icon: .viewFullscreen)) { + onFullscreen() + } + .flat() } - .style("card") - .style("view") + .padding(10) } private func formatTime(_ seconds: Double) -> String { - guard seconds.isFinite, seconds >= 0 else { return "0:00" } - let h = Int(seconds) / 3600 - let m = (Int(seconds) / 60) % 60 + let m = Int(seconds) / 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 a02708c..5213fa1 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -22,319 +22,89 @@ 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 playerState: PlayerState - public var onClose: () -> Void - - @Injected(\.client) var client - @Injected(\.userId) var userId - + public var item: BaseItemDto + public var client: JellyfinClient + public var userId: String + public var mediaSourceId: String + public var playSessionId: String + public var streamURL: URL @State private var isPlaying = true @State private var position: Double = 0 @State private var duration: Double = 0 @State private var showControls = true - @State private var isFullscreen = false - @State private var mpvWidget: OpaquePointer? - @State private var tasks = PlayerTasks() + public var onClose: () -> Void - private let playbackState = PlayerPlaybackState() - - public init(playerState: PlayerState, onClose: @escaping () -> Void) { - self.playerState = playerState + 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 self.onClose = onClose } public var view: Body { - VideoPlayerWidget( - url: playerState.streamURL.absoluteString, - isPlaying: $isPlaying, - position: $position, - duration: $duration, - playbackState: playbackState, - onWidgetCreated: { ptr in _mpvWidget.rawValue = ptr } - ) - .vexpand(true) - .hexpand(true) - .overlay { + VStack { + VideoPlayerWidget( + url: streamURL.absoluteString, + isPlaying: $isPlaying, + position: $position, + duration: $duration + ) + .hexpand(true) + .vexpand(true) if showControls { - VStack { - HStack { - Button(icon: .default(icon: .goPrevious)) { - stopPlayback() - onClose() - } - .flat() - .padding() - Box { }.hexpand(true) + 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() } - 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: playerState.itemId, - mediaSourceId: playerState.mediaSourceId, - playSessionId: playerState.playSessionId + itemId: item.id, + mediaSourceId: mediaSourceId, + playSessionId: playSessionId ) ) - startProgressTimer() } } private func stopPlayback() { - tasks.progress?.cancel() - tasks.controls?.cancel() Task { try? await client.reportPlaybackStopped( info: .init( - itemId: playerState.itemId, - mediaSourceId: playerState.mediaSourceId, + itemId: item.id, + mediaSourceId: mediaSourceId, positionTicks: Int64(position * 10_000_000), - playSessionId: playerState.playSessionId + 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/LuminateCore/PlayerState.swift b/Sources/LuminatePlayer/VideoPlayerWidget.swift similarity index 55% rename from Sources/LuminateCore/PlayerState.swift rename to Sources/LuminatePlayer/VideoPlayerWidget.swift index 1be25c5..03c0bdc 100644 --- a/Sources/LuminateCore/PlayerState.swift +++ b/Sources/LuminatePlayer/VideoPlayerWidget.swift @@ -1,5 +1,5 @@ // -// PlayerState.swift +// VideoPlayerWidget.swift // // Copyright 2026 Brendan Szymanski // @@ -19,28 +19,32 @@ // SPDX-License-Identifier: GPL-3.0-or-later // +import Adwaita import Foundation -public struct PlayerState: Hashable, Sendable { +struct VideoPlayerWidget: View { - public var itemId: String - public var mediaSourceId: String - public var playSessionId: String - public var streamURL: URL - public var serverBase: String + var url: String + @Binding var isPlaying: Bool + @Binding var position: Double + @Binding var duration: Double - 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 + 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() } - } diff --git a/Sources/LuminateUI/Components/AspectContainer.swift b/Sources/LuminateUI/Components/AspectContainer.swift index 4267419..9567357 100644 --- a/Sources/LuminateUI/Components/AspectContainer.swift +++ b/Sources/LuminateUI/Components/AspectContainer.swift @@ -20,7 +20,7 @@ // import Adwaita -import CModules +import CGtkWidgets /// 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 d19080d..e52232a 100644 --- a/Sources/LuminateUI/Components/EpisodeList.swift +++ b/Sources/LuminateUI/Components/EpisodeList.swift @@ -30,7 +30,6 @@ struct EpisodeList: View { var seasonId: String var client: JellyfinClient var userId: String - @Binding var navigation: NavigationStack @State private var episodes: [BaseItemDto] = [] var view: Body { @@ -38,8 +37,7 @@ struct EpisodeList: View { ForEach(episodes) { episode in EpisodeRow( episode: episode, - client: client, - navigation: $navigation + client: client ) } } @@ -64,18 +62,10 @@ 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 { @@ -102,27 +92,7 @@ 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 304461f..8c336f0 100644 --- a/Sources/LuminateUI/Components/FlowGrid.swift +++ b/Sources/LuminateUI/Components/FlowGrid.swift @@ -20,7 +20,7 @@ // import Adwaita -import CModules +import CGtkWidgets /// 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 05f45e6..f86a5ad 100644 --- a/Sources/LuminateUI/Components/MovieDetailView.swift +++ b/Sources/LuminateUI/Components/MovieDetailView.swift @@ -27,7 +27,6 @@ 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 @@ -36,9 +35,8 @@ public struct MovieDetailView: View { @State private var similarItems: [BaseItemDto] = [] @State private var backdropData: Data? - public init(for item: BaseItemDto, navigation: Binding>) { + public init(for item: BaseItemDto) { self.item = item - self._navigation = navigation _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false) _isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false) } @@ -73,27 +71,7 @@ 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 deleted file mode 100644 index 0791940..0000000 --- a/Sources/LuminateUI/Components/SeekBar.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// 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 1b2e3e9..6b37a5b 100644 --- a/Sources/LuminateUI/Components/TVShowView.swift +++ b/Sources/LuminateUI/Components/TVShowView.swift @@ -29,7 +29,6 @@ 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? @@ -90,8 +89,7 @@ struct TVShowView: View { seriesId: item.id ?? "", seasonId: seasonId, client: client, - userId: userId, - navigation: $navigation + userId: userId ) } } diff --git a/Tests/LuminateTests/PlayerTests.swift b/Tests/LuminateTests/PlayerTests.swift deleted file mode 100644 index 2f57131..0000000 --- a/Tests/LuminateTests/PlayerTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// 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 3bb918e..71ffda3 100644 --- a/Tests/LuminateTests/WidgetTests.swift +++ b/Tests/LuminateTests/WidgetTests.swift @@ -1,6 +1,6 @@ import Adwaita import CAdw -import CModules +import CGtkWidgets import Testing // MARK: - Helpers diff --git a/dev.bscubed.Luminate.json b/dev.bscubed.Luminate.json index 643c9e5..c9d679f 100644 --- a/dev.bscubed.Luminate.json +++ b/dev.bscubed.Luminate.json @@ -33,27 +33,6 @@ "*.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,