Compare commits
16 commits
main
...
feature/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 447d370c7f | |||
| 29acef1e0a | |||
| 7168135f4c | |||
| 2efe17e859 | |||
| ec0d9b5062 | |||
| 4ff210cdde | |||
| cf16bb3c19 | |||
| 93667ac65a | |||
| 6c246eadaa | |||
| 6a7185f239 | |||
| 0908acd324 | |||
| cd2fc99c8a | |||
| 463071ae89 | |||
| 580cd1fe2b | |||
| 5bf43cf375 | |||
| 50aafca96b |
24 changed files with 1766 additions and 128 deletions
438
AGENTS.md
Normal file
438
AGENTS.md
Normal file
|
|
@ -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<Page>` enum.
|
||||||
|
- **`.style("card")` / `.style("suggested-action")` / `.style("flat")`** — widget styling.
|
||||||
|
- **`.halign(.start)` / `.hexpand(true)`** — alignment and expand modifiers.
|
||||||
|
|
||||||
|
### Always Load the Skill
|
||||||
|
|
||||||
|
If an `adwaita-swift` skill is available in your toolset, **load it before working on this project**. It provides API reference, widget docs, and patterns without needing the full documentation in context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Target Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
Luminate (executable)
|
||||||
|
├── Pages/ HomeView, ItemPage (top-level navigation views)
|
||||||
|
├── LuminateUI ───────────┐
|
||||||
|
│ ├── Components/ │ poster cells, detail views, search, etc.
|
||||||
|
│ └── Utilities/ │ PageAnimationTracker, AnyView+Overflow
|
||||||
|
├── LuminatePlayer ───────┤
|
||||||
|
├── LuminateDI ───────────┤
|
||||||
|
├── LuminateCore ─────────┤
|
||||||
|
├── CGtkWidgets ──────────┤ Custom GObject widgets (AspectContainer, FlowGrid)
|
||||||
|
└── LuminateAPI │
|
||||||
|
└── All UI components are in LuminateUI
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule:** All UI components live in `LuminateUI`. `LuminatePlayer` is the only separate feature target with its own concerns.
|
||||||
|
|
||||||
|
### Module Roles
|
||||||
|
|
||||||
|
#### `LuminateAPI` — Generated API client
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `LuminateAPI.swift` | Module namespace |
|
||||||
|
| `openapi.yaml` | Jellyfin OpenAPI spec v10.11.10 |
|
||||||
|
| `openapi-generator-config.yaml` | Generator config (public access, types + client) |
|
||||||
|
|
||||||
|
Generated types are in `.build/`. Clean build required to regenerate.
|
||||||
|
|
||||||
|
#### `LuminateCore` — Core library
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `LuminateCore.swift` | Module namespace, re-exports `LuminateAPI`, `OpenAPIRuntime`, `OpenAPIURLSession` via `@_exported import` |
|
||||||
|
| `JellyfinClient.swift` | `actor` wrapping OpenAPI-generated `Client`. Exposes curated API surface: auth, items, search, playback reporting, favorites. Uses `AuthMiddleware` for `MediaBrowser` header injection. |
|
||||||
|
| `BaseItemDto+Display.swift` | Extensions on generated types: `Identifiable` conformance, `isMovie`/`isSeries`/`isEpisode` helpers, `runtimeString`, `yearString`, image tag accessors |
|
||||||
|
| `ImageService.swift` | `actor` with 2-tier image cache (`NSCache` memory + disk at `cachesDirectory/luminate/images/`) |
|
||||||
|
| `WebSocketClient.swift` | `actor` connecting to `ws(s)://server/socket`, parses JSON events, publishes via `NotificationCenter` |
|
||||||
|
| `Observation.swift` | `ObservationRegistrar`, `ObservableProtocol`, `@Observable` macro — reactive property-mutation tracking for classes. Wire via `ObservationRegistrar.onChange = { StateManager.updateViews() }` |
|
||||||
|
| `PageAnimationTracking.swift` | Protocol for page transition animation state: `isAnimating: Bool`, `markPush()`. Implemented by `PageAnimationTracker` in `LuminateUI` |
|
||||||
|
| `Page.swift` | Navigation `Page` enum (`.library`, `.folder`) used with `NavigationStack` |
|
||||||
|
| `PersistenceService.swift` | `PersistenceService` protocol + `AuthData` struct for auth/preference persistence |
|
||||||
|
| `SQLiteStore.swift` | `actor` implementing `PersistenceService` via SQLite (SQLite.swift) |
|
||||||
|
| `PersistenceService+Mock.swift` | `MockPersistenceService` for testing |
|
||||||
|
|
||||||
|
#### `LuminateDI` — Dependency Injection
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `DIContainer.swift` | `@unchecked Sendable` singleton with thread-safe `register`/`resolve`/`addObserver`; triggers `StateManager.updateViews()` on change |
|
||||||
|
| `Injected.swift` | `@propertyWrapper` that resolves from `DIContainer` and observes changes; auto-cleans up observer on deinit |
|
||||||
|
| `InjectionValues.swift` | Holds optional injectable values: `client`, `userId`, `imageService`, `webSocketClient`, `persistence`, `pageAnimationTracker` |
|
||||||
|
|
||||||
|
#### `CGtkWidgets` — Custom C GObject Widgets
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `aspect_container.h` / `aspect_container.c` | GObject subclass of `GtkWidget` — single-child container that reports `height = width × ratio` during `measure()`. Properties: `aspect-ratio` (float), `max-width` (int). Used via `AspectContainer` Swift wrapper. |
|
||||||
|
| `flow_grid.h` / `flow_grid.c` | GObject subclass of `GtkWidget` — reflowing grid that re-measures children during allocation (avoids stale size caches). Properties: `minimum-size`, `column-spacing`, `row-spacing`, `justify`. Used via `FlowGrid` Swift wrapper. |
|
||||||
|
|
||||||
|
#### `LuminateUI` — Shared UI Components
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `PageAnimationTracker.swift` | `@Observable`-free class implementing `PageAnimationTracking`. Tracks `isAnimating` state for 250ms page transition windows. Uses `Idle(delay: 250)` timer and manually calls `StateManager.updateViews()` when animation ends to flush deferred image data. Push-generation counter prevents stale timers. |
|
||||||
|
|
||||||
|
All UI components live in `LuminateUI`, organized into `Components/` and `Utilities/` subdirectories. Files from the former `LuminateHome` and `LuminateLibrary` targets now reside here:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `HomePosterCell.swift` | Poster cell (200×300) with deferred image loading, uses `@Injected(\.client)` and `@Injected(\.pageAnimationTracker)` |
|
||||||
|
| `MediaRow.swift` | Horizontal scrolling row of poster cells with title + "See All" button; takes `navigation: Binding<NavigationStack<Page>>` |
|
||||||
|
| `ItemGrid.swift` | Grid of items with header; takes `items: [BaseItemDto]`, `navigation: Binding<NavigationStack<Page>>`, optional `title`. Renamed from `LibraryGrid`. |
|
||||||
|
| `PosterCell.swift` | Reusable poster cell (150×225). Uses `@Injected(\.pageAnimationTracker)` for deferred loading; `client` still via parameter. |
|
||||||
|
| `MovieDetailView.swift` | Full movie detail: backdrop hero, poster, metadata, action buttons (play, favorite, mark played), overview, cast grid, similar items |
|
||||||
|
| `TVShowView.swift` | TV show detail: backdrop, poster, metadata, season picker (horizontal buttons), episode list |
|
||||||
|
| `EpisodeList.swift` | Vertical list of episode rows with thumbnail, number, title, runtime, play button |
|
||||||
|
| `SearchView.swift` | Debounced search with `SearchEntry`, results via `getSearchHints()`. **`performSearch()` never called** — no `.onChange` or `onSubmit` trigger. `SearchResultRow` uses `@Injected(\.pageAnimationTracker)` for deferred image loading. |
|
||||||
|
| `Components/AspectContainer.swift` | Swift `Widget` wrapper for the custom aspect-ratio C widget. `init(aspectRatio:)`, modifiers: `.child {}`, `.maxWidth(Int?)`. |
|
||||||
|
| `Components/FlowGrid.swift` | Swift `Widget` wrapper for the custom flow grid C widget. Generic over `Element`/`Identifier`. Modifiers: `.minimumSize(Int)`, `.columnSpacing(Int)`, `.rowSpacing(Int)`, `.justify(FlowGridJustifySetting)`. |
|
||||||
|
| `Components/RatingBadge.swift` | Star rating display (`★ 7.5`) |
|
||||||
|
| `Components/PersonCell.swift` | Cast/crew display (avatar, name, role) |
|
||||||
|
| `Utilities/AnyView+Overflow.swift` | Extension on `AnyView` for GTK overflow behavior (`visible`/`hidden`), uses `CAdw` directly |
|
||||||
|
|
||||||
|
#### `LuminatePlayer` — Video Playback
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `LuminatePlayer.swift` | Module placeholder |
|
||||||
|
| `PlayerView.swift` | Player screen: binds `VideoPlayerWidget` + `PlayerControls`, reports playback start/stop to Jellyfin |
|
||||||
|
| `PlayerControls.swift` | Control bar: close, seek back, play/pause, seek forward, time/progress, fullscreen |
|
||||||
|
| `VideoPlayerWidget.swift` | **Placeholder** — shows "Now Playing" + URL. Planned to become mpv+GLArea custom `Widget` |
|
||||||
|
|
||||||
|
#### `Luminate` — App Assembly
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Luminate.swift` | `@main` entry. `App` struct with `AdwaitaApp`, `Window` scene. On startup, creates `SQLiteStore` and tries to load saved session from SQLite. `ContentView` uses `NavigationView` with `NavigationStack<Page>` to switch between login, home, and library folder views. |
|
||||||
|
| `ServerSetupView.swift` | Login form: server URL, username, password. Calls `JellyfinClient.authenticate()`. Saves `AuthData` via `DIContainer.shared.values.persistence` on success. |
|
||||||
|
| `ToolbarView.swift` | Hamburger menu with About dialog and keyboard shortcuts dialog |
|
||||||
|
| `Localized.yml` | Localization strings (en/de) via the `localized` package |
|
||||||
|
| `Pages/HomeView.swift` | Main scrollable home: Continue Watching, Next Up, Recently Added rows + Library grid. Uses `@Injected` for client/userId. Takes `navigation: Binding<NavigationStack<Page>>`. Parallel async data loading via `async let`. |
|
||||||
|
| `Pages/ItemPage.swift` | Scrollable wrapper for browsing a folder/library, uses `Clamp` + `ItemGrid` with `NavigationStack<Page>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Patterns
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- **`@State`** for local view state (loading flags, data arrays, UI state)
|
||||||
|
- **`@State.rawValue`** — reads/writes `@State` storage **without** triggering `StateManager.updateViews()`. Used to store loaded image data silently during page animations; the re-render is deferred until the animation completes.
|
||||||
|
- **`@Injected`** for reactive dependency injection (client, userId, etc.)
|
||||||
|
- **`@Observable`** — custom macro (not SwiftUI) for class property mutation tracking. Adds `_$observationRegistrar` to the class and wraps stored `var` properties with get/set that call `ObservationRegistrar.didChange()`. Wire globally: `ObservationRegistrar.onChange = { StateManager.updateViews() }`. Use sparingly — triggers global re-render on every mutation. Do NOT use for properties mutated inside GTK signal handlers (`.pushed`/`.popped`) as the synchronous `updateViews()` may reference freed widgets.
|
||||||
|
- **Dependencies** passed via initializer properties in `LuminateUI` views
|
||||||
|
- **Child → parent communication** via closure callbacks (`onClose`, `onSeeAll`, etc.) or `@Binding`
|
||||||
|
- **No ObservableObject, no @StateObject, no @EnvironmentObject**
|
||||||
|
- **DI observation**: `@Injected` sets up an observer in `DIContainer` that calls `StateManager.updateViews()` when the injected value changes
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
- **Services are `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<Page>`** — stack-based navigation
|
||||||
|
- `Page` enum: `.library(item:)`, `.folder(title:items:)`
|
||||||
|
- `HomeView` pushes `.folder` when "See All" tapped
|
||||||
|
- `ContentView` switches between initial view (home) and pushed pages
|
||||||
|
- Detail views (`MovieDetailView`, `TVShowView`) are not yet connected to navigation
|
||||||
|
- **Note:** `NavigationView` is an Adwaita-for-Swift widget, not SwiftUI's
|
||||||
|
- **Page transition animation control:** `.pushed { tracker.markPush() }` and `.popped { tracker.markPush() }` start a 250ms window during which cells defer image data updates (see Image Loading Pattern)
|
||||||
|
|
||||||
|
### Image Loading Pattern (repeated across views)
|
||||||
|
```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<Page>`** for navigation — do not add a separate navigation stack framework
|
||||||
|
13. **Clean `.build/` when `openapi.yaml` changes** — the OpenAPI generator plugin caches generated code
|
||||||
|
14. **Defer image loading during page transitions** — check `pageAnimationTracker.isAnimating` and use `_imageData.rawValue` to store loaded data silently; the `NavigationView`'s `.pushed`/`.popped` signals will trigger a re-render at 250ms to flush pending data
|
||||||
|
15. **Do NOT call `StateManager.updateViews()` inside GTK signal handlers** (`.pushed`, `.popped`, etc.) — use `Idle` to defer or rely on timer-based triggers instead
|
||||||
|
16. **Do NOT use `@Observable` for properties mutated in GTK signal handlers** — the synchronous `didChange()` → `StateManager.updateViews()` call will reference freed widgets during navigation transitions
|
||||||
|
17. **Every `.swift` file must start with a header comment** in this exact format:
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// <FileName>.swift
|
||||||
|
//
|
||||||
|
// Copyright <year> Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
```
|
||||||
|
Use the git creation year of the file if available; otherwise use the current year.
|
||||||
|
18. **Test coverage is required for new functionality** — use the `swift-testing` skill and Swift Testing framework (`@Test`, `#expect`). Place tests in `Tests/LuminateTests/`. Test files must follow XCTest migration boundaries; existing tests provide a pattern.
|
||||||
|
19. **Run `swift test` after large changes** to verify no regressions. Run it before committing to catch breakage early.
|
||||||
|
20. **Always ask before committing** — never commit without explicit user permission.
|
||||||
|
|
@ -4,7 +4,7 @@ import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Luminate",
|
name: "Luminate",
|
||||||
platforms: [.macOS(.v13)],
|
platforms: [.macOS(.v15)],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://git.aparoksha.dev/aparoksha/adwaita-swift", branch: "main"),
|
.package(url: "https://git.aparoksha.dev/aparoksha/adwaita-swift", branch: "main"),
|
||||||
.package(url: "https://git.aparoksha.dev/aparoksha/localized", branch: "main"),
|
.package(url: "https://git.aparoksha.dev/aparoksha/localized", branch: "main"),
|
||||||
|
|
@ -33,6 +33,7 @@ let package = Package(
|
||||||
"LuminateObservationMacros",
|
"LuminateObservationMacros",
|
||||||
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
|
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
|
||||||
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
|
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
.product(name: "SQLite", package: "SQLite.swift"),
|
.product(name: "SQLite", package: "SQLite.swift"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
@ -53,16 +54,25 @@ let package = Package(
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "CGtkWidgets",
|
name: "CModules",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "CAdw", package: "adwaita-swift")
|
.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(
|
.target(
|
||||||
name: "LuminateUI",
|
name: "LuminateUI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CGtkWidgets",
|
"CModules",
|
||||||
"LuminateCore",
|
"LuminateCore",
|
||||||
"LuminateDI",
|
"LuminateDI",
|
||||||
"LuminateObservationMacros",
|
"LuminateObservationMacros",
|
||||||
|
|
@ -73,6 +83,7 @@ let package = Package(
|
||||||
.target(
|
.target(
|
||||||
name: "LuminatePlayer",
|
name: "LuminatePlayer",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
"CModules",
|
||||||
"LuminateCore",
|
"LuminateCore",
|
||||||
"LuminateDI",
|
"LuminateDI",
|
||||||
"LuminateUI",
|
"LuminateUI",
|
||||||
|
|
@ -99,10 +110,12 @@ let package = Package(
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "LuminateTests",
|
name: "LuminateTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CGtkWidgets",
|
"CModules",
|
||||||
|
"LuminateCore",
|
||||||
.product(name: "CAdw", package: "adwaita-swift"),
|
.product(name: "CAdw", package: "adwaita-swift"),
|
||||||
.product(name: "Adwaita", package: "adwaita-swift"),
|
.product(name: "Adwaita", package: "adwaita-swift"),
|
||||||
]
|
],
|
||||||
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
swiftLanguageModes: [.v5]
|
swiftLanguageModes: [.v5]
|
||||||
|
|
|
||||||
36
Sources/CModules/include/mpv_widget.h
Normal file
36
Sources/CModules/include/mpv_widget.h
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#ifndef MPV_WIDGET_H
|
||||||
|
#define MPV_WIDGET_H
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
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 */
|
||||||
507
Sources/CModules/mpv_widget.c
Normal file
507
Sources/CModules/mpv_widget.c
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
#include "mpv_widget.h"
|
||||||
|
#include <mpv/client.h>
|
||||||
|
#include <mpv/render_gl.h>
|
||||||
|
#include <epoxy/gl.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <locale.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -137,11 +137,13 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.navigationTitle(page.description)
|
.navigationTitle(page.description)
|
||||||
case .movieDetail(let item):
|
case .movieDetail(let item):
|
||||||
MovieDetailView(for: item)
|
MovieDetailView(for: item, navigation: $stack)
|
||||||
.topToolbar {
|
.topToolbar {
|
||||||
ToolbarView()
|
ToolbarView()
|
||||||
}
|
}
|
||||||
.navigationTitle(page.description)
|
.navigationTitle(page.description)
|
||||||
|
case .player(let playerState):
|
||||||
|
PlayerView(playerState: playerState, onClose: { stack.pop() })
|
||||||
}
|
}
|
||||||
} initialView: {
|
} initialView: {
|
||||||
HomeView(navigation: $stack)
|
HomeView(navigation: $stack)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
public func getLatestMedia(
|
||||||
userId: String,
|
userId: String,
|
||||||
parentId: String? = nil,
|
parentId: String? = nil,
|
||||||
|
|
@ -755,4 +775,22 @@ public actor JellyfinClient {
|
||||||
if let tag { components.queryItems = [.init(name: "tag", value: tag)] }
|
if let tag { components.queryItems = [.init(name: "tag", value: tag)] }
|
||||||
return components.url
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,14 @@ public enum Page: CustomStringConvertible {
|
||||||
case item(item: BaseItemDto, type: DisplayType = .mixed)
|
case item(item: BaseItemDto, type: DisplayType = .mixed)
|
||||||
case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed)
|
case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed)
|
||||||
case movieDetail(item: BaseItemDto)
|
case movieDetail(item: BaseItemDto)
|
||||||
|
case player(PlayerState)
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
return switch self {
|
return switch self {
|
||||||
case .item(let item, _): item.name ?? "Library"
|
case .item(let item, _): item.name ?? "Library"
|
||||||
case .items(let title, _, _): title
|
case .items(let title, _, _): title
|
||||||
case .movieDetail(let item): item.name ?? "Luminate"
|
case .movieDetail(let item): item.name ?? "Luminate"
|
||||||
|
case .player: "Now Playing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// LuminatePlayer.swift
|
// PlayerPlaybackState.swift
|
||||||
//
|
//
|
||||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
//
|
//
|
||||||
|
|
@ -19,7 +19,12 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
//
|
//
|
||||||
|
|
||||||
import Adwaita
|
import Foundation
|
||||||
import LuminateCore
|
|
||||||
|
|
||||||
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() {}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// VideoPlayerWidget.swift
|
// PlayerState.swift
|
||||||
//
|
//
|
||||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
//
|
//
|
||||||
|
|
@ -19,32 +19,28 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
//
|
//
|
||||||
|
|
||||||
import Adwaita
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct VideoPlayerWidget: View {
|
public struct PlayerState: Hashable, Sendable {
|
||||||
|
|
||||||
var url: String
|
public var itemId: String
|
||||||
@Binding var isPlaying: Bool
|
public var mediaSourceId: String
|
||||||
@Binding var position: Double
|
public var playSessionId: String
|
||||||
@Binding var duration: Double
|
public var streamURL: URL
|
||||||
|
public var serverBase: String
|
||||||
|
|
||||||
var view: Body {
|
public init(
|
||||||
VStack {
|
itemId: String,
|
||||||
Text("Now Playing")
|
mediaSourceId: String,
|
||||||
.title1()
|
playSessionId: String,
|
||||||
Text(url)
|
streamURL: URL,
|
||||||
.caption()
|
serverBase: String
|
||||||
.dimLabel()
|
) {
|
||||||
HStack {
|
self.itemId = itemId
|
||||||
Button(icon: .default(icon: .mediaPlaybackStart)) {
|
self.mediaSourceId = mediaSourceId
|
||||||
isPlaying = true
|
self.playSessionId = playSessionId
|
||||||
}
|
self.streamURL = streamURL
|
||||||
.suggested()
|
self.serverBase = serverBase
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(50)
|
|
||||||
.frame(minWidth: 400, minHeight: 300)
|
|
||||||
.card()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -20,57 +20,116 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
|
import LuminateCore
|
||||||
|
import LuminateUI
|
||||||
|
|
||||||
public struct PlayerControls: View {
|
public struct PlayerControls: View {
|
||||||
|
|
||||||
@Binding var isPlaying: Bool
|
@Binding var isPlaying: Bool
|
||||||
@Binding var position: Double
|
@Binding var position: Double
|
||||||
@Binding var duration: Double
|
@Binding var duration: Double
|
||||||
public var onTogglePlay: () -> Void
|
var playbackState: PlayerPlaybackState?
|
||||||
|
public var onClose: () -> Void
|
||||||
public var onSeekBack: () -> Void
|
public var onSeekBack: () -> Void
|
||||||
public var onSeekForward: () -> Void
|
public var onSeekForward: () -> Void
|
||||||
|
public var onSeekAbsolute: (Double) -> Void
|
||||||
public var onFullscreen: () -> Void
|
public var onFullscreen: () -> Void
|
||||||
public var onClose: () -> Void
|
public var onSubtitleAudio: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
isPlaying: Binding<Bool>,
|
||||||
|
position: Binding<Double>,
|
||||||
|
duration: Binding<Double>,
|
||||||
|
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 {
|
public var view: Body {
|
||||||
HStack {
|
Box {
|
||||||
Button(icon: .default(icon: .windowClose)) {
|
HStack(spacing: 6) {
|
||||||
onClose()
|
Button(icon: .custom(name: "skip-backwards-10-symbolic")) {
|
||||||
}
|
onSeekBack()
|
||||||
.flat()
|
}
|
||||||
Button(icon: .default(icon: .goPrevious)) {
|
.style("circular")
|
||||||
onSeekBack()
|
|
||||||
}
|
Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) {
|
||||||
.flat()
|
isPlaying.toggle()
|
||||||
Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) {
|
}
|
||||||
onTogglePlay()
|
.style("circular")
|
||||||
}
|
|
||||||
.flat()
|
Button(icon: .custom(name: "skip-forward-10-symbolic")) {
|
||||||
Button(icon: .default(icon: .goNext)) {
|
onSeekForward()
|
||||||
onSeekForward()
|
}
|
||||||
}
|
.style("circular")
|
||||||
.flat()
|
|
||||||
HStack {
|
Separator()
|
||||||
|
.style("spacer")
|
||||||
|
|
||||||
Text(formatTime(position))
|
Text(formatTime(position))
|
||||||
.caption()
|
.caption()
|
||||||
LevelBar()
|
|
||||||
.value(duration > 0 ? position / duration : 0)
|
SeekBar(
|
||||||
.hexpand(true)
|
value: $position,
|
||||||
Text(formatTime(duration))
|
range: 0...(duration > 0 ? duration : 1),
|
||||||
|
playbackState: playbackState,
|
||||||
|
onSeek: { newPos in
|
||||||
|
onSeekAbsolute(newPos)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.hexpand(true)
|
||||||
|
|
||||||
|
Text("-\(formatTime(max(0, duration - position)))")
|
||||||
.caption()
|
.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)
|
.padding()
|
||||||
Button(icon: .default(icon: .viewFullscreen)) {
|
|
||||||
onFullscreen()
|
|
||||||
}
|
|
||||||
.flat()
|
|
||||||
}
|
}
|
||||||
.padding(10)
|
.style("card")
|
||||||
|
.style("view")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTime(_ seconds: Double) -> String {
|
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
|
let s = Int(seconds) % 60
|
||||||
|
if h > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
return String(format: "%d:%02d", m, s)
|
return String(format: "%d:%02d", m, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,89 +22,319 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
import LuminateDI
|
||||||
|
import CModules
|
||||||
|
import CAdw
|
||||||
|
|
||||||
|
final class PlayerTasks {
|
||||||
|
var progress: Task<Void, Never>?
|
||||||
|
var controls: Task<Void, Never>?
|
||||||
|
}
|
||||||
|
|
||||||
public struct PlayerView: View {
|
public struct PlayerView: View {
|
||||||
|
|
||||||
public var item: BaseItemDto
|
public var playerState: PlayerState
|
||||||
public var client: JellyfinClient
|
public var onClose: () -> Void
|
||||||
public var userId: String
|
|
||||||
public var mediaSourceId: String
|
@Injected(\.client) var client
|
||||||
public var playSessionId: String
|
@Injected(\.userId) var userId
|
||||||
public var streamURL: URL
|
|
||||||
@State private var isPlaying = true
|
@State private var isPlaying = true
|
||||||
@State private var position: Double = 0
|
@State private var position: Double = 0
|
||||||
@State private var duration: Double = 0
|
@State private var duration: Double = 0
|
||||||
@State private var showControls = true
|
@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(
|
private let playbackState = PlayerPlaybackState()
|
||||||
item: BaseItemDto,
|
|
||||||
client: JellyfinClient,
|
public init(playerState: PlayerState, onClose: @escaping () -> Void) {
|
||||||
userId: String,
|
self.playerState = playerState
|
||||||
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
|
self.onClose = onClose
|
||||||
}
|
}
|
||||||
|
|
||||||
public var view: Body {
|
public var view: Body {
|
||||||
VStack {
|
VideoPlayerWidget(
|
||||||
VideoPlayerWidget(
|
url: playerState.streamURL.absoluteString,
|
||||||
url: streamURL.absoluteString,
|
isPlaying: $isPlaying,
|
||||||
isPlaying: $isPlaying,
|
position: $position,
|
||||||
position: $position,
|
duration: $duration,
|
||||||
duration: $duration
|
playbackState: playbackState,
|
||||||
)
|
onWidgetCreated: { ptr in _mpvWidget.rawValue = ptr }
|
||||||
.hexpand(true)
|
)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.overlay {
|
||||||
if showControls {
|
if showControls {
|
||||||
PlayerControls(
|
VStack {
|
||||||
isPlaying: $isPlaying,
|
HStack {
|
||||||
position: $position,
|
Button(icon: .default(icon: .goPrevious)) {
|
||||||
duration: $duration,
|
stopPlayback()
|
||||||
onTogglePlay: { isPlaying.toggle() },
|
onClose()
|
||||||
onSeekBack: { position = max(0, position - 10) },
|
}
|
||||||
onSeekForward: { position = min(duration, position + 10) },
|
.flat()
|
||||||
onFullscreen: {},
|
.padding()
|
||||||
onClose: {
|
Box { }.hexpand(true)
|
||||||
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() {
|
private func startPlayback() {
|
||||||
Task {
|
Task {
|
||||||
try? await client.reportPlaybackStart(
|
try? await client.reportPlaybackStart(
|
||||||
info: .init(
|
info: .init(
|
||||||
itemId: item.id,
|
itemId: playerState.itemId,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: playerState.mediaSourceId,
|
||||||
playSessionId: playSessionId
|
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() {
|
private func startProgressTimer() {
|
||||||
Task {
|
tasks.progress = Task {
|
||||||
try? await client.reportPlaybackStopped(
|
while !Task.isCancelled {
|
||||||
info: .init(
|
try? await Task.sleep(for: .seconds(10))
|
||||||
itemId: item.id,
|
try? await client.reportPlaybackProgress(
|
||||||
mediaSourceId: mediaSourceId,
|
info: .init(
|
||||||
positionTicks: Int64(position * 10_000_000),
|
itemId: playerState.itemId,
|
||||||
playSessionId: playSessionId
|
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<Bool>,
|
||||||
|
position: Binding<Double>,
|
||||||
|
duration: Binding<Double>,
|
||||||
|
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>(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<SignalContext>.fromOpaque(ptr!).takeUnretainedValue()
|
||||||
|
if let scale = ctx.playbackState?.seekScale {
|
||||||
|
let range = unsafeBitCast(scale, to: UnsafeMutablePointer<GtkRange>?.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<SignalContext>.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<SignalContext>.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<Data>(
|
||||||
|
_ 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<Double>
|
||||||
|
var isPlaying: Binding<Bool>
|
||||||
|
weak var playbackState: PlayerPlaybackState?
|
||||||
|
|
||||||
|
init(
|
||||||
|
duration: Binding<Double>,
|
||||||
|
isPlaying: Binding<Bool>,
|
||||||
|
playbackState: PlayerPlaybackState?
|
||||||
|
) {
|
||||||
|
self.duration = duration
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
self.playbackState = playbackState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import CGtkWidgets
|
import CModules
|
||||||
|
|
||||||
/// A single‑child container that preserves a fixed aspect ratio.
|
/// A single‑child container that preserves a fixed aspect ratio.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ struct EpisodeList: View {
|
||||||
var seasonId: String
|
var seasonId: String
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
var userId: String
|
var userId: String
|
||||||
|
@Binding var navigation: NavigationStack<Page>
|
||||||
@State private var episodes: [BaseItemDto] = []
|
@State private var episodes: [BaseItemDto] = []
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
|
|
@ -37,7 +38,8 @@ struct EpisodeList: View {
|
||||||
ForEach(episodes) { episode in
|
ForEach(episodes) { episode in
|
||||||
EpisodeRow(
|
EpisodeRow(
|
||||||
episode: episode,
|
episode: episode,
|
||||||
client: client
|
client: client,
|
||||||
|
navigation: $navigation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,10 +64,18 @@ struct EpisodeRow: View {
|
||||||
|
|
||||||
var episode: BaseItemDto
|
var episode: BaseItemDto
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
|
@Binding var navigation: NavigationStack<Page>
|
||||||
@Injected(\.imageService) var imageService
|
@Injected(\.imageService) var imageService
|
||||||
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
|
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
|
||||||
|
@Injected(\.userId) var userId
|
||||||
@State private var imageData: Data?
|
@State private var imageData: Data?
|
||||||
|
|
||||||
|
init(episode: BaseItemDto, client: JellyfinClient, navigation: Binding<NavigationStack<Page>>) {
|
||||||
|
self.episode = episode
|
||||||
|
self.client = client
|
||||||
|
self._navigation = navigation
|
||||||
|
}
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
HStack {
|
HStack {
|
||||||
if let data = imageData {
|
if let data = imageData {
|
||||||
|
|
@ -92,7 +102,27 @@ struct EpisodeRow: View {
|
||||||
}
|
}
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
Button(icon: .default(icon: .mediaPlaybackStart)) {
|
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()
|
.flat()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import CGtkWidgets
|
import CModules
|
||||||
|
|
||||||
/// Controls how the last row is aligned when it doesn't fill the width.
|
/// Controls how the last row is aligned when it doesn't fill the width.
|
||||||
public enum FlowGridJustifySetting: Int {
|
public enum FlowGridJustifySetting: Int {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import LuminateDI
|
||||||
public struct MovieDetailView: View {
|
public struct MovieDetailView: View {
|
||||||
|
|
||||||
var item: BaseItemDto
|
var item: BaseItemDto
|
||||||
|
@Binding var navigation: NavigationStack<Page>
|
||||||
@Injected(\.client) private var client
|
@Injected(\.client) private var client
|
||||||
@Injected(\.userId) private var userId
|
@Injected(\.userId) private var userId
|
||||||
@Injected(\.imageService) private var imageService
|
@Injected(\.imageService) private var imageService
|
||||||
|
|
@ -35,8 +36,9 @@ public struct MovieDetailView: View {
|
||||||
@State private var similarItems: [BaseItemDto] = []
|
@State private var similarItems: [BaseItemDto] = []
|
||||||
@State private var backdropData: Data?
|
@State private var backdropData: Data?
|
||||||
|
|
||||||
public init(for item: BaseItemDto) {
|
public init(for item: BaseItemDto, navigation: Binding<NavigationStack<Page>>) {
|
||||||
self.item = item
|
self.item = item
|
||||||
|
self._navigation = navigation
|
||||||
_isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
|
_isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
|
||||||
_isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
|
_isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +73,27 @@ public struct MovieDetailView: View {
|
||||||
.halign(.start)
|
.halign(.start)
|
||||||
HStack {
|
HStack {
|
||||||
Button("Play", icon: .default(icon: .mediaPlaybackStart)) {
|
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()
|
.suggested()
|
||||||
Button(icon: .default(icon: .bookmarkNew)) {
|
Button(icon: .default(icon: .bookmarkNew)) {
|
||||||
|
|
|
||||||
145
Sources/LuminateUI/Components/SeekBar.swift
Normal file
145
Sources/LuminateUI/Components/SeekBar.swift
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
//
|
||||||
|
// SeekBar.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
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<Double>
|
||||||
|
var playbackState: PlayerPlaybackState?
|
||||||
|
var onSeek: ((Double) -> Void)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
value: Binding<Double>,
|
||||||
|
range: ClosedRange<Double>,
|
||||||
|
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>(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<SeekBarContext>.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<Data>(
|
||||||
|
_ 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)?
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ struct TVShowView: View {
|
||||||
var item: BaseItemDto
|
var item: BaseItemDto
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
var userId: String
|
var userId: String
|
||||||
|
@Binding var navigation: NavigationStack<Page>
|
||||||
@Injected(\.imageService) var imageService
|
@Injected(\.imageService) var imageService
|
||||||
@State private var seasons: [BaseItemDto] = []
|
@State private var seasons: [BaseItemDto] = []
|
||||||
@State private var selectedSeasonId: String?
|
@State private var selectedSeasonId: String?
|
||||||
|
|
@ -89,7 +90,8 @@ struct TVShowView: View {
|
||||||
seriesId: item.id ?? "",
|
seriesId: item.id ?? "",
|
||||||
seasonId: seasonId,
|
seasonId: seasonId,
|
||||||
client: client,
|
client: client,
|
||||||
userId: userId
|
userId: userId,
|
||||||
|
navigation: $navigation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
Tests/LuminateTests/PlayerTests.swift
Normal file
92
Tests/LuminateTests/PlayerTests.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
//
|
||||||
|
// PlayerTests.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import CAdw
|
import CAdw
|
||||||
import CGtkWidgets
|
import CModules
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,27 @@
|
||||||
"*.a"
|
"*.a"
|
||||||
],
|
],
|
||||||
"modules": [
|
"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",
|
"name": "Luminate",
|
||||||
"builddir": true,
|
"builddir": true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue