feat(settings): rework settings page; remove unused SkeletonLoader/StorageManagement
Settings page refactor plus supporting docs (requirements, ux-flows, traceability) and the frontend-domain-model spec with implementation-status banner. Removes SkeletonLoader and StorageManagement components (no remaining references).
This commit is contained in:
@@ -67,6 +67,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||||
|
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -235,6 +236,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||||
|
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -300,6 +302,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
|
| UR-057 | - | DR-086 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# Spec: Remove Jellyfin-specific models from the frontend
|
||||||
|
|
||||||
|
> **Implementation status (branch `frontend-domain-model`, worktree
|
||||||
|
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
||||||
|
> classification* and *time units* no longer speak Jellyfin:
|
||||||
|
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
||||||
|
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
||||||
|
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
||||||
|
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
||||||
|
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
||||||
|
> `formatDuration` takes ms; progress bars are unit-consistent.
|
||||||
|
> - User-facing type badge → `kindLabel()`.
|
||||||
|
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
||||||
|
>
|
||||||
|
> **Deferred (tracked, not done):**
|
||||||
|
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
||||||
|
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
||||||
|
> round-trip). Catalog `MediaItem` already has `imageId`.
|
||||||
|
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
||||||
|
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
||||||
|
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
||||||
|
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
||||||
|
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
||||||
|
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
||||||
|
|
||||||
|
**Status:** Partially implemented (catalog surface); see banner.
|
||||||
|
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
||||||
|
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
||||||
|
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
||||||
|
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
||||||
|
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
||||||
|
architecture/boundary migration.
|
||||||
|
**Supersedes / revises:** none. Extends the boundary work started in
|
||||||
|
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
||||||
|
*whole media model*.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
||||||
|
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
||||||
|
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
||||||
|
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
||||||
|
comparisons across 23 files** and two frontend utility modules
|
||||||
|
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
||||||
|
field conversion in the presentation layer.
|
||||||
|
|
||||||
|
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
||||||
|
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
||||||
|
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
||||||
|
names — remains in `src/`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Two concrete problems, one strategic:
|
||||||
|
|
||||||
|
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
||||||
|
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
||||||
|
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
||||||
|
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
||||||
|
is domain knowledge in the wrong layer, 36 files deep.
|
||||||
|
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
||||||
|
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
||||||
|
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
||||||
|
should have already resolved.
|
||||||
|
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
||||||
|
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
||||||
|
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
||||||
|
shut.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
||||||
|
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
||||||
|
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
||||||
|
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
||||||
|
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
||||||
|
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
||||||
|
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
||||||
|
|
||||||
|
**Borderline calls, resolved:**
|
||||||
|
|
||||||
|
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
||||||
|
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
||||||
|
subset of the new domain model, not a special case.
|
||||||
|
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
||||||
|
playback context, sessions) are **already domain-neutral** — they are *our*
|
||||||
|
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
||||||
|
`item_type`. Only the Jellyfin item-type strings move.
|
||||||
|
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
||||||
|
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
||||||
|
self-contained — deferred to a late phase, not phase 1.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Single canonical model, one location, isolated mappings
|
||||||
|
|
||||||
|
The domain model is defined **once**, in a dedicated top-level Rust module
|
||||||
|
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
||||||
|
whole app:
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/domain/
|
||||||
|
media.rs canonical MediaItem, MediaKind, and the other media types
|
||||||
|
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
||||||
|
mod.rs re-exports
|
||||||
|
| tauri-specta (export_typescript_bindings test)
|
||||||
|
v
|
||||||
|
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
||||||
|
```
|
||||||
|
|
||||||
|
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
||||||
|
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
||||||
|
projection of it. There is no second hand-written copy in either language, so it
|
||||||
|
cannot drift — "shared between frontend and backend" is realized by generation,
|
||||||
|
not duplication.
|
||||||
|
- **Mappings live beside the model, never in consumers.** All provider translation
|
||||||
|
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
||||||
|
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
||||||
|
touches the domain type. Adding a second provider later means a new
|
||||||
|
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
||||||
|
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
||||||
|
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
||||||
|
repository-specific.
|
||||||
|
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
||||||
|
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
||||||
|
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
||||||
|
translation instead of copying `item_type` through.
|
||||||
|
|
||||||
|
### The domain model (Rust)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum MediaKind {
|
||||||
|
Track, Album, Artist, Playlist, // music
|
||||||
|
Movie, Series, Season, Episode, // video
|
||||||
|
Person, // cast/crew
|
||||||
|
Channel, Folder, // containers/live
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: MediaKind, // was: type: String
|
||||||
|
pub is_folder: bool,
|
||||||
|
pub server_id: String,
|
||||||
|
|
||||||
|
// Times in milliseconds — NEVER ticks.
|
||||||
|
pub duration_ms: Option<i64>, // was: run_time_ticks
|
||||||
|
|
||||||
|
// Image as a resolved identifier the frontend turns into a URL via the
|
||||||
|
// existing image command — no raw Jellyfin tag semantics leak.
|
||||||
|
pub image_id: Option<String>, // was: primary_image_tag
|
||||||
|
pub backdrop_image_ids: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub overview: Option<String>,
|
||||||
|
pub genres: Option<Vec<String>>,
|
||||||
|
pub production_year: Option<i32>,
|
||||||
|
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
||||||
|
pub community_rating: Option<f64>,
|
||||||
|
pub official_rating: Option<String>,
|
||||||
|
|
||||||
|
// Relationships — already neutral, kept.
|
||||||
|
pub album_id: Option<String>, pub album_name: Option<String>,
|
||||||
|
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
||||||
|
pub artist_items: Option<Vec<ArtistItem>>,
|
||||||
|
pub series_id: Option<String>, pub series_name: Option<String>,
|
||||||
|
pub season_id: Option<String>, pub season_name: Option<String>,
|
||||||
|
|
||||||
|
// Ordinal position — rename off Jellyfin's index vocabulary.
|
||||||
|
pub track_number: Option<i32>, // was: index_number
|
||||||
|
pub disc_number: Option<i32>, // was: parent_index_number
|
||||||
|
|
||||||
|
pub user_data: Option<UserData>,
|
||||||
|
pub media_streams: Option<Vec<MediaStream>>,
|
||||||
|
pub media_sources: Option<Vec<MediaSource>>,
|
||||||
|
pub people: Option<Vec<Person>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
||||||
|
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
||||||
|
private to that module. Its `to_media_item()` — today a near-passthrough that
|
||||||
|
copies `item_type` straight across — is enriched into the single, tested place
|
||||||
|
that:
|
||||||
|
|
||||||
|
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
||||||
|
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
||||||
|
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
||||||
|
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
||||||
|
`Other` variant — **decide at implementation; must not panic.**
|
||||||
|
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
||||||
|
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
||||||
|
|
||||||
|
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
||||||
|
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
||||||
|
|
||||||
|
### 🔴 The `search-event` / dual-payload rule applies again
|
||||||
|
|
||||||
|
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
||||||
|
and any other event payloads — emits the new domain shape. Both sides of a
|
||||||
|
twice-delivered result must match (same rule as
|
||||||
|
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
||||||
|
event definitions before declaring a phase done.
|
||||||
|
|
||||||
|
### Frontend after
|
||||||
|
|
||||||
|
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
||||||
|
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
||||||
|
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
||||||
|
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
||||||
|
they exist, which are not Jellyfin-specific).
|
||||||
|
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
||||||
|
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
||||||
|
- Assert with the boundary tripwire + a new grep (see acceptance).
|
||||||
|
|
||||||
|
## Phased migration
|
||||||
|
|
||||||
|
This is too large and too collision-prone for one change. Phases are independently
|
||||||
|
shippable, each keeps all tests green, and each is a reviewable PR:
|
||||||
|
|
||||||
|
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
||||||
|
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
||||||
|
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
||||||
|
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
||||||
|
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
||||||
|
wire shape is a superset of today's, so the frontend still compiles and
|
||||||
|
behaves identically. Lands the authority + full mapping unit coverage first,
|
||||||
|
with zero blast radius on the 52 construction sites (they set the old fields;
|
||||||
|
new ones default).
|
||||||
|
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
||||||
|
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
||||||
|
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
||||||
|
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
||||||
|
`bun run check` is the driver.
|
||||||
|
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
||||||
|
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
||||||
|
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
||||||
|
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
||||||
|
outside tests.
|
||||||
|
|
||||||
|
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
||||||
|
- Changing any user-visible behaviour, layout, or copy.
|
||||||
|
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
||||||
|
they carry the fields being renamed — align them in phase 2 only if the compiler
|
||||||
|
demands it.
|
||||||
|
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
||||||
|
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
||||||
|
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
||||||
|
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
||||||
|
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
||||||
|
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
||||||
|
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
||||||
|
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
||||||
|
item type (Rust test with a garbage type string).
|
||||||
|
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
||||||
|
same navigation, offline included.
|
||||||
|
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
||||||
|
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
||||||
|
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
||||||
|
critical surface —
|
||||||
|
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
||||||
|
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
||||||
|
- unknown type string → safe fallback, no panic;
|
||||||
|
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
||||||
|
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
||||||
|
|
||||||
|
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
||||||
|
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
||||||
|
deleted with their modules. Add a compose/render test proving `kind`-based
|
||||||
|
branching matches the old `type`-based branching for a representative mix.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
||||||
|
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
||||||
|
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **This is the highest-collision change in the repo's history** — it touches 36+
|
||||||
|
frontend files and the core Rust types. A parallel Claude session in any media
|
||||||
|
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
||||||
|
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
||||||
|
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
||||||
|
the disruptive flip. Resist the urge to skip it.
|
||||||
|
- IPC camelCase rules apply to the new enums/structs
|
||||||
|
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
||||||
|
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
||||||
|
`bindings.ts`, never hand-edit.
|
||||||
|
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
||||||
|
Layer assignment table above is the load-bearing section.
|
||||||
+428
-363
File diff suppressed because it is too large
Load Diff
@@ -1076,6 +1076,13 @@ flowchart TB
|
|||||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||||
- **Direct:** Navigate to `/settings` route
|
- **Direct:** Navigate to `/settings` route
|
||||||
|
|
||||||
|
**Settings apply instantly.** Every control on the Settings page persists the
|
||||||
|
moment the user changes it — toggling a switch, picking a level, or releasing a
|
||||||
|
slider writes that setting immediately. There is **no "Save" button** and no
|
||||||
|
save/dirty state to reason about; leaving the page never risks losing a change.
|
||||||
|
Sliders update their live readout while dragging but only persist on release
|
||||||
|
(`change`, not each `input` tick) to avoid flooding the backend.
|
||||||
|
|
||||||
### 8.2 Logout Flow
|
### 8.2 Logout Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
type?: "card" | "text" | "circle" | "banner" | "row";
|
|
||||||
count?: number;
|
|
||||||
width?: string;
|
|
||||||
height?: string;
|
|
||||||
aspectRatio?: "square" | "video" | "portrait";
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
type = "card",
|
|
||||||
count = 1,
|
|
||||||
width = "100%",
|
|
||||||
height = "auto",
|
|
||||||
aspectRatio = "square",
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const aspectClasses = {
|
|
||||||
square: "aspect-square",
|
|
||||||
video: "aspect-video",
|
|
||||||
portrait: "aspect-[2/3]",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if type === "card"}
|
|
||||||
<div class="flex gap-4 overflow-hidden">
|
|
||||||
{#each Array(count) as _, i (i)}
|
|
||||||
<div class="flex-shrink-0 w-36 animate-pulse">
|
|
||||||
<div class="w-full {aspectClasses[aspectRatio]} bg-[var(--color-surface)] rounded-lg shimmer"></div>
|
|
||||||
<div class="mt-2 space-y-2">
|
|
||||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 80%"></div>
|
|
||||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 60%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if type === "banner"}
|
|
||||||
<div class="animate-pulse">
|
|
||||||
<div class="h-[500px] bg-[var(--color-surface)] rounded-xl shimmer"></div>
|
|
||||||
</div>
|
|
||||||
{:else if type === "circle"}
|
|
||||||
<div class="flex gap-4">
|
|
||||||
{#each Array(count) as _, i (i)}
|
|
||||||
<div class="flex flex-col items-center animate-pulse">
|
|
||||||
<div class="w-20 h-20 rounded-full bg-[var(--color-surface)] shimmer"></div>
|
|
||||||
<div class="mt-2 h-3 w-16 bg-[var(--color-surface)] rounded shimmer"></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if type === "row"}
|
|
||||||
<div class="space-y-4">
|
|
||||||
{#each Array(count) as _, i (i)}
|
|
||||||
<div class="flex gap-4 animate-pulse">
|
|
||||||
<div class="w-16 h-16 rounded bg-[var(--color-surface)] shimmer flex-shrink-0"></div>
|
|
||||||
<div class="flex-1 space-y-2 py-2">
|
|
||||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 70%"></div>
|
|
||||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 50%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if type === "text"}
|
|
||||||
<div class="space-y-2 animate-pulse">
|
|
||||||
{#each Array(count) as _, i (i)}
|
|
||||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: {width}; height: {height}"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
background-position: -1000px 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 1000px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.shimmer {
|
|
||||||
animation: shimmer 2s infinite linear;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
var(--color-surface) 0%,
|
|
||||||
rgba(255, 255, 255, 0.05) 20%,
|
|
||||||
var(--color-surface) 40%,
|
|
||||||
var(--color-surface) 100%
|
|
||||||
);
|
|
||||||
background-size: 1000px 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { commands } from "$lib/api/bindings";
|
|
||||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import { downloads } from "$lib/stores/downloads";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
interface AlbumStorageInfo {
|
|
||||||
album_id: string;
|
|
||||||
album_name: string;
|
|
||||||
artist_name: string | null;
|
|
||||||
bytes_used: number;
|
|
||||||
track_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StorageStats {
|
|
||||||
total_bytes: number;
|
|
||||||
total_items: number;
|
|
||||||
albums: AlbumStorageInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats = $state<StorageStats | null>(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let deleting = $state(false);
|
|
||||||
let deletingAlbum = $state<string | null>(null);
|
|
||||||
let showDeleteAllConfirm = $state(false);
|
|
||||||
let showBreakdown = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
loading = true;
|
|
||||||
const userId = $auth.user?.id;
|
|
||||||
if (userId) {
|
|
||||||
stats = await commands.getDownloadStorageStats(userId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load storage stats:", error);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllDownloads() {
|
|
||||||
try {
|
|
||||||
deleting = true;
|
|
||||||
const userId = $auth.user?.id;
|
|
||||||
if (userId) {
|
|
||||||
await commands.deleteAllDownloads(userId);
|
|
||||||
await downloads.refresh(userId);
|
|
||||||
await loadStats();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete all downloads:", error);
|
|
||||||
} finally {
|
|
||||||
deleting = false;
|
|
||||||
showDeleteAllConfirm = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAlbumDownloads(albumId: string) {
|
|
||||||
try {
|
|
||||||
deletingAlbum = albumId;
|
|
||||||
const userId = $auth.user?.id;
|
|
||||||
if (userId) {
|
|
||||||
await commands.deleteAlbumDownloads(albumId, userId);
|
|
||||||
await downloads.refresh(userId);
|
|
||||||
await loadStats();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete album downloads:", error);
|
|
||||||
} finally {
|
|
||||||
deletingAlbum = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAlbumClick(albumId: string) {
|
|
||||||
if (albumId !== "unknown") {
|
|
||||||
goto(`/library/${albumId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bg-[var(--color-surface)] rounded-xl p-6 space-y-6">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-white">Storage</h2>
|
|
||||||
{#if stats && stats.total_items > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => (showDeleteAllConfirm = true)}
|
|
||||||
class="px-4 py-2 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
|
|
||||||
>
|
|
||||||
Delete All
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center justify-center py-8">
|
|
||||||
<div class="w-6 h-6 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
{:else if stats}
|
|
||||||
<!-- Storage Summary -->
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-16 h-16 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center">
|
|
||||||
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-bold text-white">{formatBytes(stats.total_bytes)}</p>
|
|
||||||
<p class="text-sm text-gray-400">
|
|
||||||
{stats.total_items} {stats.total_items === 1 ? "item" : "items"} downloaded
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Storage Breakdown Toggle -->
|
|
||||||
{#if stats.albums.length > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => (showBreakdown = !showBreakdown)}
|
|
||||||
class="w-full flex items-center justify-between py-3 px-4 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-gray-300">Storage by album</span>
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5 text-gray-400 transition-transform {showBreakdown ? 'rotate-180' : ''}"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Album Breakdown -->
|
|
||||||
{#if showBreakdown}
|
|
||||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
{#each stats.albums as album (album.album_id)}
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors">
|
|
||||||
<button
|
|
||||||
onclick={() => handleAlbumClick(album.album_id)}
|
|
||||||
class="flex-1 min-w-0 text-left"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
||||||
{truncateMiddle(album.album_name, 40)}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-400 truncate">
|
|
||||||
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-3 flex-shrink-0">
|
|
||||||
<span class="text-sm text-gray-400">{formatBytes(album.bytes_used)}</span>
|
|
||||||
<button
|
|
||||||
onclick={() => deleteAlbumDownloads(album.album_id)}
|
|
||||||
disabled={deletingAlbum === album.album_id}
|
|
||||||
class="p-1.5 rounded-full text-gray-400 hover:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
|
||||||
title="Delete album downloads"
|
|
||||||
>
|
|
||||||
{#if deletingAlbum === album.album_id}
|
|
||||||
<div class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
{:else}
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
{#if stats.total_items === 0}
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<p class="text-gray-400 text-sm">No downloads yet</p>
|
|
||||||
<p class="text-gray-500 text-xs mt-1">Downloaded media will appear here</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete All Confirmation Modal -->
|
|
||||||
{#if showDeleteAllConfirm}
|
|
||||||
<div class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
|
|
||||||
<div class="bg-[var(--color-surface)] rounded-2xl w-full max-w-sm shadow-2xl">
|
|
||||||
<div class="p-6 text-center">
|
|
||||||
<div class="mx-auto w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">Delete All Downloads?</h3>
|
|
||||||
<p class="text-sm text-gray-400 mb-6">
|
|
||||||
This will remove {stats?.total_items || 0} downloaded items and free up {formatBytes(stats?.total_bytes || 0)} of storage. This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
onclick={() => (showDeleteAllConfirm = false)}
|
|
||||||
class="flex-1 px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={deleteAllDownloads}
|
|
||||||
disabled={deleting}
|
|
||||||
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{#if deleting}
|
|
||||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
Deleting...
|
|
||||||
{:else}
|
|
||||||
Delete All
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- TRACES: UR-023, UR-029 | DR-048, DR-077 -->
|
<!-- TRACES: UR-023, UR-029, UR-057 | DR-048, DR-077, DR-086 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
@@ -63,8 +63,6 @@
|
|||||||
let networkDetectionSupported = $state(false);
|
let networkDetectionSupported = $state(false);
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let saving = $state(false);
|
|
||||||
let saveMessage = $state("");
|
|
||||||
|
|
||||||
// Image cache state
|
// Image cache state
|
||||||
let cacheStats = $state<ImageCacheStats | null>(null);
|
let cacheStats = $state<ImageCacheStats | null>(null);
|
||||||
@@ -153,55 +151,99 @@
|
|||||||
return Math.min(100, (cacheStats.totalSizeBytes / cacheStats.limitBytes) * 100);
|
return Math.min(100, (cacheStats.totalSizeBytes / cacheStats.limitBytes) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
// Settings apply the moment the user changes a control — there is no Save
|
||||||
|
// button. Each helper writes just the settings group it owns so a single
|
||||||
|
// toggle doesn't re-push unrelated state.
|
||||||
|
async function persistAudio() {
|
||||||
try {
|
try {
|
||||||
saving = true;
|
await commands.playerSetAudioSettings(settings);
|
||||||
saveMessage = "";
|
} catch (e) {
|
||||||
await Promise.all([
|
console.error("Failed to save audio settings:", e);
|
||||||
commands.playerSetAudioSettings(settings),
|
}
|
||||||
commands.playerSetVideoSettings(videoSettings),
|
}
|
||||||
updateCacheConfig(cacheConfig),
|
|
||||||
]);
|
async function persistVideo() {
|
||||||
|
try {
|
||||||
|
await commands.playerSetVideoSettings(videoSettings);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save video settings:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistCache() {
|
||||||
|
try {
|
||||||
|
await updateCacheConfig(cacheConfig);
|
||||||
// Re-report the network so the backend re-evaluates the gate against the
|
// Re-report the network so the backend re-evaluates the gate against the
|
||||||
// just-saved wifi-only preference, releasing or holding the queue now
|
// just-changed wifi-only preference, releasing or holding the queue now
|
||||||
// rather than at the next network change.
|
// rather than at the next network change.
|
||||||
await reportNetworkState();
|
await reportNetworkState();
|
||||||
saveMessage = "Settings saved successfully!";
|
|
||||||
setTimeout(() => {
|
|
||||||
saveMessage = "";
|
|
||||||
}, 3000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save settings:", e);
|
console.error("Failed to save download settings:", e);
|
||||||
saveMessage = "Failed to save settings";
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slider drags fire `input` on every tick; update the live display there but
|
||||||
|
// only persist on `change` (pointer release) so we don't spam the backend.
|
||||||
|
function handleCrossfadeInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
settings.crossfadeDuration = parseFloat(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
function handleCrossfadeChange(e: Event) {
|
function handleCrossfadeChange(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
settings.crossfadeDuration = parseFloat(target.value);
|
settings.crossfadeDuration = parseFloat(target.value);
|
||||||
|
persistAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGaplessToggle() {
|
function handleGaplessToggle() {
|
||||||
settings.gaplessPlayback = !settings.gaplessPlayback;
|
settings.gaplessPlayback = !settings.gaplessPlayback;
|
||||||
|
persistAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNormalizeToggle() {
|
function handleNormalizeToggle() {
|
||||||
settings.normalizeVolume = !settings.normalizeVolume;
|
settings.normalizeVolume = !settings.normalizeVolume;
|
||||||
|
persistAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVolumeLevelChange(level: VolumeLevel) {
|
function handleVolumeLevelChange(level: VolumeLevel) {
|
||||||
settings.volumeLevel = level;
|
settings.volumeLevel = level;
|
||||||
|
persistAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAutoPlayToggle() {
|
function handleAutoPlayToggle() {
|
||||||
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
||||||
|
persistVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCountdownInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCountdownChange(e: Event) {
|
function handleCountdownChange(e: Event) {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
||||||
|
persistVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEpisodeLimitChange(value: number) {
|
||||||
|
videoSettings.autoPlayMaxEpisodes = value;
|
||||||
|
persistVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSmartCachingToggle() {
|
||||||
|
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
|
||||||
|
persistCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueuePrecacheToggle() {
|
||||||
|
cacheConfig.queuePrecacheEnabled = !cacheConfig.queuePrecacheEnabled;
|
||||||
|
persistCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWifiOnlyToggle() {
|
||||||
|
cacheConfig.wifiOnly = !cacheConfig.wifiOnly;
|
||||||
|
persistCache();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -279,7 +321,8 @@
|
|||||||
max="12"
|
max="12"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={settings.crossfadeDuration}
|
value={settings.crossfadeDuration}
|
||||||
oninput={handleCrossfadeChange}
|
oninput={handleCrossfadeInput}
|
||||||
|
onchange={handleCrossfadeChange}
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||||
@@ -425,7 +468,8 @@
|
|||||||
max="30"
|
max="30"
|
||||||
step="5"
|
step="5"
|
||||||
value={videoSettings.autoPlayCountdownSeconds}
|
value={videoSettings.autoPlayCountdownSeconds}
|
||||||
oninput={handleCountdownChange}
|
oninput={handleCountdownInput}
|
||||||
|
onchange={handleCountdownChange}
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||||
@@ -445,7 +489,7 @@
|
|||||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-2">
|
<div class="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||||
{#each episodeLimitOptions as option}
|
{#each episodeLimitOptions as option}
|
||||||
<button
|
<button
|
||||||
onclick={() => { videoSettings.autoPlayMaxEpisodes = option.value; }}
|
onclick={() => handleEpisodeLimitChange(option.value)}
|
||||||
class="py-3 px-3 rounded-lg transition-all text-sm
|
class="py-3 px-3 rounded-lg transition-all text-sm
|
||||||
{videoSettings.autoPlayMaxEpisodes === option.value
|
{videoSettings.autoPlayMaxEpisodes === option.value
|
||||||
? 'bg-[var(--color-jellyfin)] text-white'
|
? 'bg-[var(--color-jellyfin)] text-white'
|
||||||
@@ -597,9 +641,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() =>
|
onclick={handleSmartCachingToggle}
|
||||||
(cacheConfig.albumAffinityEnabled =
|
|
||||||
!cacheConfig.albumAffinityEnabled)}
|
|
||||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.albumAffinityEnabled
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.albumAffinityEnabled
|
||||||
? 'bg-[var(--color-jellyfin)]'
|
? 'bg-[var(--color-jellyfin)]'
|
||||||
: 'bg-gray-600'}"
|
: 'bg-gray-600'}"
|
||||||
@@ -626,9 +668,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() =>
|
onclick={handleQueuePrecacheToggle}
|
||||||
(cacheConfig.queuePrecacheEnabled =
|
|
||||||
!cacheConfig.queuePrecacheEnabled)}
|
|
||||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.queuePrecacheEnabled
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.queuePrecacheEnabled
|
||||||
? 'bg-[var(--color-jellyfin)]'
|
? 'bg-[var(--color-jellyfin)]'
|
||||||
: 'bg-gray-600'}"
|
: 'bg-gray-600'}"
|
||||||
@@ -660,7 +700,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => (cacheConfig.wifiOnly = !cacheConfig.wifiOnly)}
|
onclick={handleWifiOnlyToggle}
|
||||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
|
||||||
? 'bg-[var(--color-jellyfin)]'
|
? 'bg-[var(--color-jellyfin)]'
|
||||||
: 'bg-gray-600'} {networkDetectionSupported
|
: 'bg-gray-600'} {networkDetectionSupported
|
||||||
@@ -680,28 +720,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Button -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-sm">
|
|
||||||
{#if saveMessage}
|
|
||||||
<span
|
|
||||||
class="text-{saveMessage.includes('success')
|
|
||||||
? 'green'
|
|
||||||
: 'red'}-400"
|
|
||||||
>
|
|
||||||
{saveMessage}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onclick={saveSettings}
|
|
||||||
disabled={saving}
|
|
||||||
class="px-6 py-3 bg-[var(--color-jellyfin)] text-white rounded-lg font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save Settings"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info Box -->
|
<!-- Info Box -->
|
||||||
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user