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(
|
||||
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]
|
||||
|
|
|
|||
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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
//
|
||||
|
||||
import Adwaita
|
||||
import CGtkWidgets
|
||||
import CModules
|
||||
|
||||
/// A single‑child container that preserves a fixed aspect ratio.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 CAdw
|
||||
import CGtkWidgets
|
||||
import CModules
|
||||
import Testing
|
||||
|
||||
// MARK: - Helpers
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue