Compare commits

...
Sign in to create a new pull request.

16 commits

Author SHA1 Message Date
447d370c7f Fix stutter by removing StateManager calls from playback path 2026-06-25 19:24:34 -04:00
29acef1e0a Align mpv_widget with working Python reference from Gelata 2026-06-24 15:05:47 -04:00
7168135f4c Add PlayerState, tick conversion, and time formatting tests 2026-06-24 15:05:47 -04:00
2efe17e859 Add mpv setup instructions to AGENTS.md 2026-06-24 15:05:47 -04:00
ec0d9b5062 Address code quality review issues across multiple files 2026-06-24 15:05:47 -04:00
4ff210cdde Wire Play button in MovieDetailView 2026-06-24 15:05:47 -04:00
cf16bb3c19 Add mpv module to Flatpak manifest 2026-06-24 15:05:47 -04:00
93667ac65a Rewrite PlayerView with real mpv widget and playback lifecycle 2026-06-24 15:05:47 -04:00
6c246eadaa Add .player case to Page enum 2026-06-24 15:05:47 -04:00
6a7185f239 Add PlayerState model for playback navigation 2026-06-24 15:05:47 -04:00
0908acd324 Add SeekBar Swift wrapper for GtkScale 2026-06-24 15:05:47 -04:00
cd2fc99c8a Correct event error handling and add compute_expand to mpv_widget 2026-06-24 15:04:03 -04:00
463071ae89 Remove invalid mpv options and fix event handling 2026-06-24 15:04:03 -04:00
580cd1fe2b Add mpv_widget C GObject with GtkGLArea rendering 2026-06-24 00:08:46 -04:00
5bf43cf375 Delete placeholder LuminatePlayer and VideoPlayerWidget files 2026-06-24 00:03:32 -04:00
50aafca96b Rename CGtkWidgets to CModules 2026-06-24 00:01:41 -04:00
24 changed files with 1766 additions and 128 deletions

438
AGENTS.md Normal file
View 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.

View file

@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "Luminate",
platforms: [.macOS(.v13)],
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://git.aparoksha.dev/aparoksha/adwaita-swift", branch: "main"),
.package(url: "https://git.aparoksha.dev/aparoksha/localized", branch: "main"),
@ -33,6 +33,7 @@ let package = Package(
"LuminateObservationMacros",
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SQLite", package: "SQLite.swift"),
]
),
@ -53,16 +54,25 @@ let package = Package(
]
),
.target(
name: "CGtkWidgets",
name: "CModules",
dependencies: [
.product(name: "CAdw", package: "adwaita-swift")
],
cSettings: [
.headerSearchPath("include"),
.unsafeFlags(["-I/opt/homebrew/include"], .when(platforms: [.macOS])),
],
linkerSettings: [
.unsafeFlags(["-L/opt/homebrew/lib"], .when(platforms: [.macOS])),
.linkedLibrary("mpv"),
.linkedLibrary("epoxy"),
]
),
.target(
name: "LuminateUI",
dependencies: [
"CGtkWidgets",
"CModules",
"LuminateCore",
"LuminateDI",
"LuminateObservationMacros",
@ -73,6 +83,7 @@ let package = Package(
.target(
name: "LuminatePlayer",
dependencies: [
"CModules",
"LuminateCore",
"LuminateDI",
"LuminateUI",
@ -99,10 +110,12 @@ let package = Package(
.testTarget(
name: "LuminateTests",
dependencies: [
"CGtkWidgets",
"CModules",
"LuminateCore",
.product(name: "CAdw", package: "adwaita-swift"),
.product(name: "Adwaita", package: "adwaita-swift"),
]
],
),
],
swiftLanguageModes: [.v5]

View 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 */

View 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;
}

View file

@ -137,11 +137,13 @@ struct ContentView: View {
}
.navigationTitle(page.description)
case .movieDetail(let item):
MovieDetailView(for: item)
MovieDetailView(for: item, navigation: $stack)
.topToolbar {
ToolbarView()
}
.navigationTitle(page.description)
case .player(let playerState):
PlayerView(playerState: playerState, onClose: { stack.pop() })
}
} initialView: {
HomeView(navigation: $stack)

View file

@ -685,6 +685,26 @@ public actor JellyfinClient {
}
}
public func pingPlaybackSession(playSessionId: String) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.pingPlaybackSession(
Operations.PingPlaybackSession.Input(
query: .init(playSessionId: playSessionId)
))
switch response {
case .noContent:
return
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getLatestMedia(
userId: String,
parentId: String? = nil,
@ -755,4 +775,22 @@ public actor JellyfinClient {
if let tag { components.queryItems = [.init(name: "tag", value: tag)] }
return components.url
}
public func streamURL(
itemId: String,
mediaSourceId: String,
playSessionId: String
) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
return nil
}
components.path = "/Videos/\(itemId)/stream"
components.queryItems = [
.init(name: "static", value: "true"),
.init(name: "mediaSourceId", value: mediaSourceId),
.init(name: "playSessionId", value: playSessionId),
.init(name: "api_key", value: token ?? ""),
]
return components.url
}
}

View file

@ -23,12 +23,14 @@ public enum Page: CustomStringConvertible {
case item(item: BaseItemDto, type: DisplayType = .mixed)
case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed)
case movieDetail(item: BaseItemDto)
case player(PlayerState)
public var description: String {
return switch self {
case .item(let item, _): item.name ?? "Library"
case .items(let title, _, _): title
case .movieDetail(let item): item.name ?? "Luminate"
case .player: "Now Playing"
}
}
}

View file

@ -1,5 +1,5 @@
//
// LuminatePlayer.swift
// PlayerPlaybackState.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
@ -19,7 +19,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
import LuminateCore
import Foundation
public struct LuminatePlayer {}
/// Shared mutable state for direct C widget manipulation during playback.
/// Accessed from signal handlers to update GTK widgets without triggering
/// Adwaita's `StateManager.updateViews()`.
public final class PlayerPlaybackState {
public var seekScale: OpaquePointer?
public init() {}
}

View file

@ -1,5 +1,5 @@
//
// VideoPlayerWidget.swift
// PlayerState.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
@ -19,32 +19,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
import Foundation
struct VideoPlayerWidget: View {
public struct PlayerState: Hashable, Sendable {
var url: String
@Binding var isPlaying: Bool
@Binding var position: Double
@Binding var duration: Double
public var itemId: String
public var mediaSourceId: String
public var playSessionId: String
public var streamURL: URL
public var serverBase: String
var view: Body {
VStack {
Text("Now Playing")
.title1()
Text(url)
.caption()
.dimLabel()
HStack {
Button(icon: .default(icon: .mediaPlaybackStart)) {
isPlaying = true
}
.suggested()
}
}
.padding(50)
.frame(minWidth: 400, minHeight: 300)
.card()
public init(
itemId: String,
mediaSourceId: String,
playSessionId: String,
streamURL: URL,
serverBase: String
) {
self.itemId = itemId
self.mediaSourceId = mediaSourceId
self.playSessionId = playSessionId
self.streamURL = streamURL
self.serverBase = serverBase
}
}

View file

@ -20,57 +20,116 @@
//
import Adwaita
import LuminateCore
import LuminateUI
public struct PlayerControls: View {
@Binding var isPlaying: Bool
@Binding var position: Double
@Binding var duration: Double
public var onTogglePlay: () -> Void
var playbackState: PlayerPlaybackState?
public var onClose: () -> Void
public var onSeekBack: () -> Void
public var onSeekForward: () -> Void
public var onSeekAbsolute: (Double) -> Void
public var onFullscreen: () -> Void
public var onClose: () -> Void
public var onSubtitleAudio: () -> Void
init(
isPlaying: Binding<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 {
HStack {
Button(icon: .default(icon: .windowClose)) {
onClose()
}
.flat()
Button(icon: .default(icon: .goPrevious)) {
onSeekBack()
}
.flat()
Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) {
onTogglePlay()
}
.flat()
Button(icon: .default(icon: .goNext)) {
onSeekForward()
}
.flat()
HStack {
Box {
HStack(spacing: 6) {
Button(icon: .custom(name: "skip-backwards-10-symbolic")) {
onSeekBack()
}
.style("circular")
Button(icon: .default(icon: isPlaying ? .mediaPlaybackPause : .mediaPlaybackStart)) {
isPlaying.toggle()
}
.style("circular")
Button(icon: .custom(name: "skip-forward-10-symbolic")) {
onSeekForward()
}
.style("circular")
Separator()
.style("spacer")
Text(formatTime(position))
.caption()
LevelBar()
.value(duration > 0 ? position / duration : 0)
.hexpand(true)
Text(formatTime(duration))
SeekBar(
value: $position,
range: 0...(duration > 0 ? duration : 1),
playbackState: playbackState,
onSeek: { newPos in
onSeekAbsolute(newPos)
}
)
.hexpand(true)
Text("-\(formatTime(max(0, duration - position)))")
.caption()
Separator()
.style("spacer")
Button(icon: .custom(name: "language-symbolic")) {
onSubtitleAudio()
}
.style("flat")
Button(icon: .default(icon: .audioVolumeHigh)) {
// Volume popover future
}
.style("flat")
.insensitive()
Button(icon: .default(icon: .viewFullscreen)) {
onFullscreen()
}
.style("flat")
}
.hexpand(true)
Button(icon: .default(icon: .viewFullscreen)) {
onFullscreen()
}
.flat()
.padding()
}
.padding(10)
.style("card")
.style("view")
}
private func formatTime(_ seconds: Double) -> String {
let m = Int(seconds) / 60
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let h = Int(seconds) / 3600
let m = (Int(seconds) / 60) % 60
let s = Int(seconds) % 60
if h > 0 {
return String(format: "%d:%02d:%02d", h, m, s)
}
return String(format: "%d:%02d", m, s)
}
}

View file

@ -22,89 +22,319 @@
import Adwaita
import Foundation
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 var item: BaseItemDto
public var client: JellyfinClient
public var userId: String
public var mediaSourceId: String
public var playSessionId: String
public var streamURL: URL
public var playerState: PlayerState
public var onClose: () -> Void
@Injected(\.client) var client
@Injected(\.userId) var userId
@State private var isPlaying = true
@State private var position: Double = 0
@State private var duration: Double = 0
@State private var showControls = true
public var onClose: () -> Void
@State private var isFullscreen = false
@State private var mpvWidget: OpaquePointer?
@State private var tasks = PlayerTasks()
public init(
item: BaseItemDto,
client: JellyfinClient,
userId: String,
mediaSourceId: String,
playSessionId: String,
streamURL: URL,
onClose: @escaping () -> Void
) {
self.item = item
self.client = client
self.userId = userId
self.mediaSourceId = mediaSourceId
self.playSessionId = playSessionId
self.streamURL = streamURL
private let playbackState = PlayerPlaybackState()
public init(playerState: PlayerState, onClose: @escaping () -> Void) {
self.playerState = playerState
self.onClose = onClose
}
public var view: Body {
VStack {
VideoPlayerWidget(
url: streamURL.absoluteString,
isPlaying: $isPlaying,
position: $position,
duration: $duration
)
.hexpand(true)
.vexpand(true)
VideoPlayerWidget(
url: playerState.streamURL.absoluteString,
isPlaying: $isPlaying,
position: $position,
duration: $duration,
playbackState: playbackState,
onWidgetCreated: { ptr in _mpvWidget.rawValue = ptr }
)
.vexpand(true)
.hexpand(true)
.overlay {
if showControls {
PlayerControls(
isPlaying: $isPlaying,
position: $position,
duration: $duration,
onTogglePlay: { isPlaying.toggle() },
onSeekBack: { position = max(0, position - 10) },
onSeekForward: { position = min(duration, position + 10) },
onFullscreen: {},
onClose: {
stopPlayback()
onClose()
VStack {
HStack {
Button(icon: .default(icon: .goPrevious)) {
stopPlayback()
onClose()
}
.flat()
.padding()
Box { }.hexpand(true)
}
)
Box { }.vexpand(true)
PlayerControls(
isPlaying: $isPlaying,
position: $position,
duration: $duration,
playbackState: playbackState,
onClose: {
stopPlayback()
onClose()
},
onSeekBack: { seekBy(-10) },
onSeekForward: { seekBy(10) },
onSeekAbsolute: { seekTo($0) },
onFullscreen: { toggleFullscreen() },
onSubtitleAudio: { }
)
}
}
}
.onAppear {
startPlayback()
startControlsTimer()
}
}
// MARK: - Playback
private func startPlayback() {
Task {
try? await client.reportPlaybackStart(
info: .init(
itemId: item.id,
mediaSourceId: mediaSourceId,
playSessionId: playSessionId
itemId: playerState.itemId,
mediaSourceId: playerState.mediaSourceId,
playSessionId: playerState.playSessionId
)
)
startProgressTimer()
}
}
private func stopPlayback() {
tasks.progress?.cancel()
tasks.controls?.cancel()
Task {
try? await client.reportPlaybackStopped(
info: .init(
itemId: playerState.itemId,
mediaSourceId: playerState.mediaSourceId,
positionTicks: Int64(position * 10_000_000),
playSessionId: playerState.playSessionId
)
)
}
}
private func stopPlayback() {
Task {
try? await client.reportPlaybackStopped(
info: .init(
itemId: item.id,
mediaSourceId: mediaSourceId,
positionTicks: Int64(position * 10_000_000),
playSessionId: playSessionId
private func startProgressTimer() {
tasks.progress = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(10))
try? await client.reportPlaybackProgress(
info: .init(
itemId: playerState.itemId,
mediaSourceId: playerState.mediaSourceId,
isPaused: !isPlaying,
positionTicks: Int64(position * 10_000_000),
playSessionId: playerState.playSessionId
)
)
)
if Int(position).isMultiple(of: 30) {
try? await client.pingPlaybackSession(
playSessionId: playerState.playSessionId
)
}
}
}
}
// MARK: - Seeking
private func seekBy(_ seconds: Double) {
guard let mpvWidget else { return }
mpv_widget_seek_relative(mpvWidget, seconds)
}
private func seekTo(_ position: Double) {
guard let mpvWidget else { return }
mpv_widget_seek_absolute(mpvWidget, position)
}
// MARK: - Controls visibility
private func startControlsTimer() {
tasks.controls?.cancel()
tasks.controls = Task {
try? await Task.sleep(for: .seconds(3))
showControls = false
}
}
// MARK: - Fullscreen
private func toggleFullscreen() {
guard let widget = mpvWidget else { return }
let root = gtk_widget_get_root(widget.cast())
guard let root else { return }
if isFullscreen {
gtk_window_unfullscreen(root.cast())
} else {
gtk_window_fullscreen(root.cast())
}
isFullscreen.toggle()
}
}
// MARK: - VideoPlayerWidget (internal Widget wrapper)
struct VideoPlayerWidget: Widget {
#if exposeGeneratedAppearUpdateFunctions
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
#else
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
#endif
var url: String?
@Binding var isPlaying: Bool
@Binding var position: Double
@Binding var duration: Double
var playbackState: PlayerPlaybackState?
var onWidgetCreated: ((OpaquePointer) -> Void)?
init(
url: String?,
isPlaying: Binding<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
}
}

View file

@ -20,7 +20,7 @@
//
import Adwaita
import CGtkWidgets
import CModules
/// A singlechild container that preserves a fixed aspect ratio.
///

View file

@ -30,6 +30,7 @@ struct EpisodeList: View {
var seasonId: String
var client: JellyfinClient
var userId: String
@Binding var navigation: NavigationStack<Page>
@State private var episodes: [BaseItemDto] = []
var view: Body {
@ -37,7 +38,8 @@ struct EpisodeList: View {
ForEach(episodes) { episode in
EpisodeRow(
episode: episode,
client: client
client: client,
navigation: $navigation
)
}
}
@ -62,10 +64,18 @@ struct EpisodeRow: View {
var episode: BaseItemDto
var client: JellyfinClient
@Binding var navigation: NavigationStack<Page>
@Injected(\.imageService) var imageService
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
@Injected(\.userId) var userId
@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 {
HStack {
if let data = imageData {
@ -92,7 +102,27 @@ struct EpisodeRow: View {
}
.hexpand(true)
Button(icon: .default(icon: .mediaPlaybackStart)) {
Task {
guard let itemId = episode.id else { return }
guard let info = try? await client.getPlaybackInfo(
itemId: itemId, userId: userId
) else { return }
guard let mediaSource = info.mediaSources?.first,
let playSessionId = info.playSessionId else { return }
guard let streamURL = await client.streamURL(
itemId: itemId,
mediaSourceId: mediaSource.id ?? "",
playSessionId: playSessionId
) else { return }
let state = PlayerState(
itemId: itemId,
mediaSourceId: mediaSource.id ?? "",
playSessionId: playSessionId,
streamURL: streamURL,
serverBase: await client.serverURL.absoluteString
)
navigation.push(.player(state))
}
}
.flat()
}

View file

@ -20,7 +20,7 @@
//
import Adwaita
import CGtkWidgets
import CModules
/// Controls how the last row is aligned when it doesn't fill the width.
public enum FlowGridJustifySetting: Int {

View file

@ -27,6 +27,7 @@ import LuminateDI
public struct MovieDetailView: View {
var item: BaseItemDto
@Binding var navigation: NavigationStack<Page>
@Injected(\.client) private var client
@Injected(\.userId) private var userId
@Injected(\.imageService) private var imageService
@ -35,8 +36,9 @@ public struct MovieDetailView: View {
@State private var similarItems: [BaseItemDto] = []
@State private var backdropData: Data?
public init(for item: BaseItemDto) {
public init(for item: BaseItemDto, navigation: Binding<NavigationStack<Page>>) {
self.item = item
self._navigation = navigation
_isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
_isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
}
@ -71,7 +73,27 @@ public struct MovieDetailView: View {
.halign(.start)
HStack {
Button("Play", icon: .default(icon: .mediaPlaybackStart)) {
Task {
guard let itemId = item.id else { return }
guard let info = try? await client.getPlaybackInfo(
itemId: itemId, userId: userId
) else { return }
guard let mediaSource = info.mediaSources?.first,
let playSessionId = info.playSessionId else { return }
guard let streamURL = await client.streamURL(
itemId: itemId,
mediaSourceId: mediaSource.id ?? "",
playSessionId: playSessionId
) else { return }
let state = PlayerState(
itemId: itemId,
mediaSourceId: mediaSource.id ?? "",
playSessionId: playSessionId,
streamURL: streamURL,
serverBase: await client.serverURL.absoluteString
)
navigation.push(.player(state))
}
}
.suggested()
Button(icon: .default(icon: .bookmarkNew)) {

View 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)?
}

View file

@ -29,6 +29,7 @@ struct TVShowView: View {
var item: BaseItemDto
var client: JellyfinClient
var userId: String
@Binding var navigation: NavigationStack<Page>
@Injected(\.imageService) var imageService
@State private var seasons: [BaseItemDto] = []
@State private var selectedSeasonId: String?
@ -89,7 +90,8 @@ struct TVShowView: View {
seriesId: item.id ?? "",
seasonId: seasonId,
client: client,
userId: userId
userId: userId,
navigation: $navigation
)
}
}

View 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")
}

View file

@ -1,6 +1,6 @@
import Adwaita
import CAdw
import CGtkWidgets
import CModules
import Testing
// MARK: - Helpers

View file

@ -33,6 +33,27 @@
"*.a"
],
"modules": [
{
"name": "mpv",
"buildsystem": "meson",
"config-opts": [
"-Dlibmpv=true",
"-Dcplayer=false",
"-Dlua=disabled",
"-Djavascript=disabled",
"-Duchardet=disabled",
"-Drubberband=disabled",
"-Dvapoursynth=disabled",
"-Dmanpage-build=disabled"
],
"sources": [
{
"type": "archive",
"url": "https://github.com/mpv-player/mpv/archive/v0.39.0.tar.gz",
"sha256": "replace-with-actual-sha256"
}
]
},
{
"name": "Luminate",
"builddir": true,