Fix warnings and update tracability
This commit is contained in:
parent
c5be9eb18c
commit
3a9c126dfe
34
README.md
34
README.md
@ -19,7 +19,7 @@ A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
|||||||
| UR-003 | Play videos | High | Done |
|
| UR-003 | Play videos | High | Done |
|
||||||
| UR-004 | Play audio uninterrupted | High | Done |
|
| UR-004 | Play audio uninterrupted | High | Done |
|
||||||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
||||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | In Progress |
|
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
||||||
| UR-007 | Navigate media in library | High | Done |
|
| UR-007 | Navigate media in library | High | Done |
|
||||||
| UR-008 | Search media across libraries | High | Done |
|
| UR-008 | Search media across libraries | High | Done |
|
||||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||||
@ -33,8 +33,8 @@ A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
|||||||
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
||||||
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
||||||
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
||||||
| UR-020 | Select subtitles for video content | High | Planned |
|
| UR-020 | Select subtitles for video content | High | Done |
|
||||||
| UR-021 | Select audio track for video content | High | Planned |
|
| UR-021 | Select audio track for video content | High | Done |
|
||||||
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
||||||
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
||||||
| UR-024 | View recently added content on server | Medium | Done |
|
| UR-024 | View recently added content on server | Medium | Done |
|
||||||
@ -80,7 +80,7 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
||||||
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
||||||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||||||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Planned |
|
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||||
@ -102,8 +102,8 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
||||||
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
||||||
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
||||||
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Planned |
|
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
|
||||||
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Planned |
|
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
|
||||||
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
||||||
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
||||||
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
||||||
@ -115,17 +115,17 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
||||||
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned |
|
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned |
|
||||||
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
|
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
|
||||||
| JA-021 | Get active sessions list | Sessions | UR-010 | Planned |
|
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
|
||||||
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Planned |
|
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
|
||||||
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Planned |
|
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
|
||||||
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Planned |
|
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
|
||||||
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Planned |
|
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
|
||||||
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Planned |
|
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
|
||||||
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
||||||
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
||||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Planned |
|
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
||||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Planned |
|
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Planned |
|
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
@ -155,8 +155,8 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
||||||
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
||||||
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
||||||
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Planned |
|
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
|
||||||
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Planned |
|
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
|
||||||
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
||||||
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
||||||
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
||||||
|
|||||||
@ -551,16 +551,23 @@ The `PlayerController` orchestrates playback:
|
|||||||
pub struct PlayerController {
|
pub struct PlayerController {
|
||||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||||
queue: Arc<Mutex<QueueManager>>,
|
queue: Arc<Mutex<QueueManager>>,
|
||||||
volume: f32,
|
|
||||||
muted: bool,
|
muted: bool,
|
||||||
|
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
||||||
|
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
||||||
|
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
|
||||||
|
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
||||||
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||||
|
// ... other fields
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `play_item(item)`: Load and play single item
|
- `play_item(item)`: Load and play single item (resets autoplay counter)
|
||||||
- `play_queue(items, start_index)`: Load queue and start playback
|
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
|
||||||
- `next()` / `previous()`: Queue navigation
|
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
|
||||||
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
|
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
|
||||||
|
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
|
||||||
|
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)
|
||||||
|
|
||||||
### 2.9 Tauri Commands
|
### 2.9 Tauri Commands
|
||||||
|
|
||||||
@ -584,6 +591,13 @@ pub struct PlayerController {
|
|||||||
| `player_get_queue` | - | `QueueStatus` |
|
| `player_get_queue` | - | `QueueStatus` |
|
||||||
| `player_get_session` | - | `MediaSessionType` |
|
| `player_get_session` | - | `MediaSessionType` |
|
||||||
| `player_dismiss_session` | - | `()` |
|
| `player_dismiss_session` | - | `()` |
|
||||||
|
| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` |
|
||||||
|
| `player_cancel_sleep_timer` | - | `()` |
|
||||||
|
| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` |
|
||||||
|
| `player_get_video_settings` | - | `VideoSettings` |
|
||||||
|
| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` |
|
||||||
|
| `player_get_autoplay_settings` | - | `AutoplaySettings` |
|
||||||
|
| `player_on_playback_ended` | - | `()` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -998,9 +1012,12 @@ graph TD
|
|||||||
|
|
||||||
subgraph PlayerComps["Player Components"]
|
subgraph PlayerComps["Player Components"]
|
||||||
AudioPlayer["AudioPlayer"]
|
AudioPlayer["AudioPlayer"]
|
||||||
|
VideoPlayer["VideoPlayer"]
|
||||||
MiniPlayer["MiniPlayer"]
|
MiniPlayer["MiniPlayer"]
|
||||||
Controls["Controls"]
|
Controls["Controls"]
|
||||||
Queue["Queue"]
|
Queue["Queue"]
|
||||||
|
SleepTimerModal["SleepTimerModal"]
|
||||||
|
SleepTimerIndicator["SleepTimerIndicator"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph SessionComps["Sessions Components"]
|
subgraph SessionComps["Sessions Components"]
|
||||||
@ -1019,6 +1036,10 @@ graph TD
|
|||||||
MediaCard["MediaCard"]
|
MediaCard["MediaCard"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
subgraph CommonComps["Common Components"]
|
||||||
|
ScrollPicker["ScrollPicker"]
|
||||||
|
end
|
||||||
|
|
||||||
subgraph OtherComps["Other Components"]
|
subgraph OtherComps["Other Components"]
|
||||||
Search["Search"]
|
Search["Search"]
|
||||||
FavoriteBtn["FavoriteButton"]
|
FavoriteBtn["FavoriteButton"]
|
||||||
@ -1038,6 +1059,7 @@ graph TD
|
|||||||
|
|
||||||
MiniPlayer --> CastButton
|
MiniPlayer --> CastButton
|
||||||
CastButton --> SessionModal
|
CastButton --> SessionModal
|
||||||
|
SleepTimerModal --> ScrollPicker
|
||||||
PlayerComps --> LibraryComps
|
PlayerComps --> LibraryComps
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1067,7 +1089,127 @@ function handleTouchStart(e: TouchEvent) {
|
|||||||
|
|
||||||
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
||||||
|
|
||||||
### 3.10 Player Page Navigation Guard
|
### 3.10 Sleep Timer Architecture
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`
|
||||||
|
|
||||||
|
**TRACES**: UR-026 | DR-029
|
||||||
|
|
||||||
|
The sleep timer supports three modes for stopping playback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
|
pub enum SleepTimerMode {
|
||||||
|
Off,
|
||||||
|
Time { end_time: i64 }, // Unix timestamp in milliseconds
|
||||||
|
EndOfTrack, // Stop after current track/episode
|
||||||
|
Episodes { remaining: u32 }, // Stop after N more episodes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timer Modes:**
|
||||||
|
|
||||||
|
| Mode | Trigger | How It Stops |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` |
|
||||||
|
| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` |
|
||||||
|
| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 |
|
||||||
|
|
||||||
|
**Time-Based Timer Flow:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as SleepTimerModal
|
||||||
|
participant Store as sleepTimer store
|
||||||
|
participant Rust as PlayerController
|
||||||
|
participant Thread as Timer Thread
|
||||||
|
participant Backend as PlayerBackend
|
||||||
|
|
||||||
|
UI->>Store: setTimeTimer(30)
|
||||||
|
Store->>Rust: invoke("player_set_sleep_timer", {mode})
|
||||||
|
Rust->>Rust: Set SleepTimerMode::Time { end_time }
|
||||||
|
Rust->>UI: Emit SleepTimerChanged event
|
||||||
|
|
||||||
|
loop Every 1 second
|
||||||
|
Thread->>Thread: update_remaining_seconds()
|
||||||
|
Thread->>UI: Emit SleepTimerChanged (countdown)
|
||||||
|
alt remaining_seconds == 0
|
||||||
|
Thread->>Backend: stop()
|
||||||
|
Thread->>UI: Emit SleepTimerChanged (Off)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend Components:**
|
||||||
|
|
||||||
|
- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
|
||||||
|
- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections — time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
|
||||||
|
- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
|
||||||
|
- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
|
||||||
|
1. **All logic in Rust**: Frontend only displays state and invokes commands
|
||||||
|
2. **Background timer thread**: Handles time-based countdown independently of track boundaries
|
||||||
|
3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
|
||||||
|
4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display
|
||||||
|
|
||||||
|
### 3.11 Auto-Play Episode Limit
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`
|
||||||
|
|
||||||
|
**TRACES**: UR-023 | DR-049
|
||||||
|
|
||||||
|
Limits how many episodes auto-play consecutively before requiring manual intervention.
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In AutoplaySettings (runtime, in PlayerController)
|
||||||
|
pub struct AutoplaySettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub countdown_seconds: u32,
|
||||||
|
pub max_episodes: u32, // 0 = unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
// In VideoSettings (persisted, settings page)
|
||||||
|
pub struct VideoSettings {
|
||||||
|
pub auto_play_next_episode: bool,
|
||||||
|
pub auto_play_countdown_seconds: u32,
|
||||||
|
pub auto_play_max_episodes: u32, // 0 = unlimited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session-Based Counter:**
|
||||||
|
|
||||||
|
The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:
|
||||||
|
|
||||||
|
- **Incremented**: In `on_playback_ended()` when auto-playing next episode
|
||||||
|
- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
|
||||||
|
- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` — user must manually click "Play Now" to continue
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
|
||||||
|
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
|
||||||
|
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
|
||||||
|
FetchNext --> IncrementCount["increment_autoplay_count()"]
|
||||||
|
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
|
||||||
|
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
|
||||||
|
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
|
||||||
|
ShowPopupManual --> UserClick["User clicks 'Play Now'"]
|
||||||
|
UserClick --> PlayItem["play_item() → resets counter"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Settings Sync:**
|
||||||
|
|
||||||
|
`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.
|
||||||
|
|
||||||
|
**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.
|
||||||
|
|
||||||
|
**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.
|
||||||
|
|
||||||
|
### 3.12 Player Page Navigation Guard
|
||||||
|
|
||||||
**Location**: `src/routes/player/[id]/+page.svelte`
|
**Location**: `src/routes/player/[id]/+page.svelte`
|
||||||
|
|
||||||
|
|||||||
1087
docs/TRACEABILITY.md
1087
docs/TRACEABILITY.md
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
|
||||||
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
// Tauri commands for repository access
|
//! Tauri commands for repository access
|
||||||
// Uses handle-based system: UUID -> Arc<HybridRepository>
|
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-010 | JA-021 | DR-037
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
//!
|
//!
|
||||||
//! The fallback is less secure as the encryption key is derived from machine
|
//! The fallback is less secure as the encryption key is derived from machine
|
||||||
//! identifiers, but provides functionality on headless systems.
|
//! identifiers, but provides functionality on headless systems.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-012 | IR-014
|
||||||
|
|
||||||
use aes_gcm::{
|
use aes_gcm::{
|
||||||
aead::{Aead, KeyInit},
|
aead::{Aead, KeyInit},
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011
|
||||||
|
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
|
//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
|
||||||
//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
|
//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-010 | JA-021
|
||||||
|
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Volume normalization levels matching Spotify's presets
|
/// Volume normalization levels matching Spotify's presets
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
//! Database schema and migrations
|
//! Database schema and migrations
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-002 | DR-012 | IR-013
|
||||||
|
|
||||||
/// List of migrations to apply in order.
|
/// List of migrations to apply in order.
|
||||||
/// Each migration is a tuple of (name, sql).
|
/// Each migration is a tuple of (name, sql).
|
||||||
|
|||||||
@ -17,6 +17,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdownSeconds: 10,
|
countdownSeconds: 10,
|
||||||
|
maxEpisodes: 5,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (command === "player_set_autoplay_settings") {
|
if (command === "player_set_autoplay_settings") {
|
||||||
@ -61,6 +62,7 @@ describe("autoplay API", () => {
|
|||||||
const settings: AutoplaySettings = {
|
const settings: AutoplaySettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdownSeconds: 15,
|
countdownSeconds: 15,
|
||||||
|
maxEpisodes: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await setAutoplaySettings(settings);
|
const result = await setAutoplaySettings(settings);
|
||||||
@ -72,6 +74,7 @@ describe("autoplay API", () => {
|
|||||||
const settings: AutoplaySettings = {
|
const settings: AutoplaySettings = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
countdownSeconds: 10,
|
countdownSeconds: 10,
|
||||||
|
maxEpisodes: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await setAutoplaySettings(settings);
|
const result = await setAutoplaySettings(settings);
|
||||||
@ -86,6 +89,7 @@ describe("autoplay API", () => {
|
|||||||
const settings: AutoplaySettings = {
|
const settings: AutoplaySettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdownSeconds: 20,
|
countdownSeconds: 20,
|
||||||
|
maxEpisodes: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setAutoplaySettings(settings);
|
await setAutoplaySettings(settings);
|
||||||
@ -104,6 +108,7 @@ describe("autoplay API", () => {
|
|||||||
const settings: AutoplaySettings = {
|
const settings: AutoplaySettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdownSeconds: countdown,
|
countdownSeconds: countdown,
|
||||||
|
maxEpisodes: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await setAutoplaySettings(settings);
|
const result = await setAutoplaySettings(settings);
|
||||||
|
|||||||
@ -350,7 +350,7 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
|||||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
|
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
|
||||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
(invoke as any).mockResolvedValueOnce(backendUrl);
|
||||||
|
|
||||||
const url = await client.getVideoDownloadUrl("item123", "720p");
|
const url = await client.getVideoDownloadUrl("item123", "medium");
|
||||||
|
|
||||||
expect(url).toBe(backendUrl);
|
expect(url).toBe(backendUrl);
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
@ -454,8 +454,8 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
|||||||
it("should support complete flow: load → sort → display", async () => {
|
it("should support complete flow: load → sort → display", async () => {
|
||||||
(invoke as any).mockResolvedValueOnce({
|
(invoke as any).mockResolvedValueOnce({
|
||||||
items: [
|
items: [
|
||||||
{ id: "id1", name: "Album A", sortName: "A" },
|
{ id: "id1", name: "Album A" },
|
||||||
{ id: "id2", name: "Album B", sortName: "B" },
|
{ id: "id2", name: "Album B" },
|
||||||
],
|
],
|
||||||
totalRecordCount: 2,
|
totalRecordCount: 2,
|
||||||
});
|
});
|
||||||
@ -467,8 +467,8 @@ describe("Backend Integration - Refactored Business Logic", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Backend returned pre-sorted items
|
// Backend returned pre-sorted items
|
||||||
expect(result.items[0].sortName).toBe("A");
|
expect(result.items[0].name).toBe("Album A");
|
||||||
expect(result.items[1].sortName).toBe("B");
|
expect(result.items[1].name).toBe("Album B");
|
||||||
|
|
||||||
// Frontend just displays them
|
// Frontend just displays them
|
||||||
// No compareFn, no local sorting
|
// No compareFn, no local sorting
|
||||||
|
|||||||
@ -202,13 +202,13 @@ describe("RepositoryClient", () => {
|
|||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1920&api_key=token";
|
const mockUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1920&api_key=token";
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||||
|
|
||||||
const downloadUrl = await client.getVideoDownloadUrl("item123", "1080p");
|
const downloadUrl = await client.getVideoDownloadUrl("item123", "high");
|
||||||
|
|
||||||
expect(downloadUrl).toBe(mockUrl);
|
expect(downloadUrl).toBe(mockUrl);
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
||||||
handle: "test-handle-123",
|
handle: "test-handle-123",
|
||||||
itemId: "item123",
|
itemId: "item123",
|
||||||
quality: "1080p",
|
quality: "high",
|
||||||
mediaSourceId: null,
|
mediaSourceId: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -228,7 +228,7 @@ describe("RepositoryClient", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should support quality presets", async () => {
|
it("should support quality presets", async () => {
|
||||||
const qualities = ["original", "1080p", "720p", "480p"];
|
const qualities = ["original", "high", "medium", "low"];
|
||||||
|
|
||||||
for (const quality of qualities) {
|
for (const quality of qualities) {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -252,12 +252,12 @@ describe("RepositoryClient", () => {
|
|||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
(invoke as any).mockResolvedValueOnce(mockUrl);
|
||||||
|
|
||||||
await client.getVideoDownloadUrl("item123", "720p", "source789");
|
await client.getVideoDownloadUrl("item123", "medium", "source789");
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
||||||
handle: expect.any(String),
|
handle: expect.any(String),
|
||||||
itemId: "item123",
|
itemId: "item123",
|
||||||
quality: "720p",
|
quality: "medium",
|
||||||
mediaSourceId: "source789",
|
mediaSourceId: "source789",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -67,6 +67,9 @@ export interface MediaItem {
|
|||||||
|
|
||||||
// Cast & Crew
|
// Cast & Crew
|
||||||
people?: Person[];
|
people?: Person[];
|
||||||
|
|
||||||
|
// Genres
|
||||||
|
genres?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemType =
|
export type ItemType =
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-039 | DR-045 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|||||||
@ -11,10 +11,16 @@
|
|||||||
|
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let localError = $state<string | null>(null);
|
let localError = $state<string | null>(null);
|
||||||
|
let username = $state("User");
|
||||||
|
let serverName = $state("Jellyfin Server");
|
||||||
|
|
||||||
const session = auth.getCurrentSession();
|
// Load session info asynchronously
|
||||||
const username = session?.username ?? "User";
|
auth.getCurrentSession().then(session => {
|
||||||
const serverName = session?.serverName ?? "Jellyfin Server";
|
if (session) {
|
||||||
|
username = session.username ?? "User";
|
||||||
|
serverName = session.serverName ?? "Jellyfin Server";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleSubmit(event: Event) {
|
async function handleSubmit(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -35,15 +41,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDismiss() {
|
function handleDismiss() {
|
||||||
auth.dismissReauth();
|
|
||||||
password = "";
|
password = "";
|
||||||
localError = null;
|
localError = null;
|
||||||
onDismiss?.();
|
onDismiss?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBackdropClick(event: MouseEvent) {
|
function handleBackdropClick(event?: MouseEvent) {
|
||||||
// Don't close on backdrop click - require explicit action
|
// Don't close on backdrop click - require explicit action
|
||||||
event.stopPropagation();
|
event?.stopPropagation();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-034 | DR-039 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-034 | DR-038 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|||||||
@ -67,9 +67,9 @@
|
|||||||
// Load related artists (by genre)
|
// Load related artists (by genre)
|
||||||
try {
|
try {
|
||||||
if (artist.genres && artist.genres.length > 0) {
|
if (artist.genres && artist.genres.length > 0) {
|
||||||
const relatedResult = await repo.getItems(undefined, {
|
const relatedResult = await repo.getItems(artist.id, {
|
||||||
includeItemTypes: ["MusicArtist"],
|
includeItemTypes: ["MusicArtist"],
|
||||||
genreIds: artist.genres.slice(0, 2),
|
genres: artist.genres.slice(0, 2),
|
||||||
limit: 12,
|
limit: 12,
|
||||||
sortBy: "CommunityRating",
|
sortBy: "CommunityRating",
|
||||||
sortOrder: "Descending"
|
sortOrder: "Descending"
|
||||||
|
|||||||
@ -297,7 +297,7 @@ describe.skip("Async Image Loading Pattern", () => {
|
|||||||
|
|
||||||
const promises = items.map(item =>
|
const promises = items.map(item =>
|
||||||
mockRepository.getImageUrl(item.id, "Primary")
|
mockRepository.getImageUrl(item.id, "Primary")
|
||||||
.then(url => imageUrls.set(item.id, url))
|
.then((url: string) => imageUrls.set(item.id, url))
|
||||||
.catch(() => imageUrls.set(item.id, ""))
|
.catch(() => imageUrls.set(item.id, ""))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-035 | JA-029 | DR-040, DR-044 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Person, PersonType } from "$lib/api/types";
|
import type { Person, PersonType } from "$lib/api/types";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-035 | JA-029 | DR-040 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { Person } from "$lib/api/types";
|
import type { Person } from "$lib/api/types";
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
import type { Genre, MediaItem } from "$lib/api/types";
|
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
||||||
@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface GenreConfig {
|
export interface GenreConfig {
|
||||||
itemTypes: string[]; // ["Movie"] or ["MusicAlbum"] or ["Series"]
|
itemTypes: ItemType[]; // ["Movie"] or ["MusicAlbum"] or ["Series"]
|
||||||
title: string; // "Movie Genres" or "Genres" or "TV Genres"
|
title: string; // "Movie Genres" or "Genres" or "TV Genres"
|
||||||
backPath: string; // "/library" or "/library/music"
|
backPath: string; // "/library" or "/library/music"
|
||||||
genreIcon: string; // SVG path for genre icon
|
genreIcon: string; // SVG path for genre icon
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@ -9,7 +10,7 @@
|
|||||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem, Library, ItemType } from "$lib/api/types";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
import TrackList from "./TrackList.svelte";
|
import TrackList from "./TrackList.svelte";
|
||||||
|
|
||||||
@ -23,7 +24,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MediaListConfig {
|
export interface MediaListConfig {
|
||||||
itemType: string; // "MusicAlbum", "MusicArtist", "Playlist", "Audio"
|
itemType: ItemType; // "MusicAlbum", "MusicArtist", "Playlist", "Audio"
|
||||||
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
||||||
backPath: string; // "/library/music"
|
backPath: string; // "/library/music"
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
@ -127,7 +128,7 @@
|
|||||||
|
|
||||||
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
||||||
|
|
||||||
function handleItemClick(item: MediaItem) {
|
function handleItemClick(item: MediaItem | Library) {
|
||||||
// Navigate to detail page for browseable items
|
// Navigate to detail page for browseable items
|
||||||
goto(`/library/${item.id}`);
|
goto(`/library/${item.id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
describe("Component Initialization", () => {
|
describe("Component Initialization", () => {
|
||||||
it("should render with title and search bar", () => {
|
it("should render with title and search bar", () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -67,7 +67,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
|
|
||||||
it("should load items on mount", async () => {
|
it("should load items on mount", async () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -87,7 +87,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
|
|
||||||
it("should display sort options", () => {
|
it("should display sort options", () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "MusicAlbum",
|
itemType: "MusicAlbum" as const,
|
||||||
title: "Albums",
|
title: "Albums",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search albums...",
|
searchPlaceholder: "Search albums...",
|
||||||
@ -115,7 +115,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -169,7 +169,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -215,7 +215,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -260,7 +260,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -309,7 +309,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "MusicAlbum",
|
itemType: "MusicAlbum" as const,
|
||||||
title: "Albums",
|
title: "Albums",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search albums...",
|
searchPlaceholder: "Search albums...",
|
||||||
@ -349,7 +349,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -395,7 +395,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -433,7 +433,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "MusicAlbum",
|
itemType: "MusicAlbum" as const,
|
||||||
title: "Albums",
|
title: "Albums",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search albums...",
|
searchPlaceholder: "Search albums...",
|
||||||
@ -479,7 +479,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -515,7 +515,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -558,7 +558,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -581,7 +581,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
describe("Display Component Props", () => {
|
describe("Display Component Props", () => {
|
||||||
it("should support grid display component", () => {
|
it("should support grid display component", () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "MusicAlbum",
|
itemType: "MusicAlbum" as const,
|
||||||
title: "Albums",
|
title: "Albums",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search albums...",
|
searchPlaceholder: "Search albums...",
|
||||||
@ -599,7 +599,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
|
|
||||||
it("should support tracklist display component", () => {
|
it("should support tracklist display component", () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -619,7 +619,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
describe("Config Simplification", () => {
|
describe("Config Simplification", () => {
|
||||||
it("should not require searchFields in config", () => {
|
it("should not require searchFields in config", () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
@ -639,7 +639,7 @@ describe.skip("GenericMediaListPage", () => {
|
|||||||
|
|
||||||
it("should not require compareFn in sort options", () => {
|
it("should not require compareFn in sort options", () => {
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks...",
|
searchPlaceholder: "Search tracks...",
|
||||||
|
|||||||
@ -115,9 +115,11 @@
|
|||||||
<!-- Download indicator -->
|
<!-- Download indicator -->
|
||||||
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
||||||
{#if isDownloaded}
|
{#if isDownloaded}
|
||||||
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" title="Downloaded">
|
<span title="Downloaded">
|
||||||
|
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-label="Downloaded">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
{:else if isDownloading}
|
{:else if isDownloading}
|
||||||
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
|
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
|
||||||
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -35,7 +35,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,7 +68,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -100,7 +102,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem1 = {
|
const mediaItem1 = {
|
||||||
id: "item1",
|
id: "item1",
|
||||||
name: "Album 1",
|
name: "Album 1",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "tag1",
|
primaryImageTag: "tag1",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,7 +121,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem2 = {
|
const mediaItem2 = {
|
||||||
id: "item2",
|
id: "item2",
|
||||||
name: "Album 2",
|
name: "Album 2",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "tag2",
|
primaryImageTag: "tag2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -136,7 +140,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -159,7 +164,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
// primaryImageTag is undefined
|
// primaryImageTag is undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -182,7 +188,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -206,7 +213,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -231,7 +239,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "tag123",
|
primaryImageTag: "tag123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -259,7 +268,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -292,14 +302,16 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const item1 = {
|
const item1 = {
|
||||||
id: "item1",
|
id: "item1",
|
||||||
name: "Album 1",
|
name: "Album 1",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "tag1",
|
primaryImageTag: "tag1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const item2 = {
|
const item2 = {
|
||||||
id: "item2",
|
id: "item2",
|
||||||
name: "Album 2",
|
name: "Album 2",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "tag2",
|
primaryImageTag: "tag2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -331,7 +343,8 @@ describe.skip("MediaCard - Async Image Loading", () => {
|
|||||||
const mediaItem = {
|
const mediaItem = {
|
||||||
id: "item123",
|
id: "item123",
|
||||||
name: "Test Album",
|
name: "Test Album",
|
||||||
type: "MusicAlbum",
|
type: "MusicAlbum" as const,
|
||||||
|
serverId: "server-1",
|
||||||
primaryImageTag: "abc123",
|
primaryImageTag: "abc123",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
<!-- TRACES: UR-036 | JA-030, JA-031 | DR-041 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
@ -39,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(item: MediaItem) {
|
function handleItemClick(item: MediaItem | Library) {
|
||||||
goto(`/library/${item.id}`);
|
goto(`/library/${item.id}`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -322,16 +322,10 @@ describe("TrackList Logic Tests", () => {
|
|||||||
it("should invoke player when no custom callback", async () => {
|
it("should invoke player when no custom callback", async () => {
|
||||||
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
const invokeMock = (invoke as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
// Simulate unified handler without custom callback
|
// Simulate unified handler without custom callback - default handler runs
|
||||||
const onTrackClick = undefined;
|
|
||||||
if (onTrackClick) {
|
|
||||||
await onTrackClick(mockTracks[0], 0);
|
|
||||||
} else {
|
|
||||||
// This branch executes - default handler
|
|
||||||
await invoke("player_play_queue", {
|
await invoke("player_play_queue", {
|
||||||
request: { items: [], startIndex: 0, shuffle: false },
|
request: { items: [], startIndex: 0, shuffle: false },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
expect(invokeMock).toHaveBeenCalled();
|
expect(invokeMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -332,8 +332,6 @@ describe.skip("TrackList", () => {
|
|||||||
// NOTE: This test is skipped because stream URLs are no longer fetched by frontend.
|
// NOTE: This test is skipped because stream URLs are no longer fetched by frontend.
|
||||||
// The code now uses player_play_tracks which sends trackIds to backend.
|
// The code now uses player_play_tracks which sends trackIds to backend.
|
||||||
// Backend handles all stream URL generation, so this error path no longer exists.
|
// Backend handles all stream URL generation, so this error path no longer exists.
|
||||||
|
|
||||||
alertSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-005 | DR-009 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-005, UR-017, UR-028 | DR-009 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* MiniPlayer component - Always-visible bottom bar audio player
|
* MiniPlayer component - Always-visible bottom bar audio player
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-026 | DR-029, DR-050 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
sleepTimer,
|
sleepTimer,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@ -312,8 +313,8 @@
|
|||||||
console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
||||||
|
|
||||||
// DIAGNOSTIC: Check if video has audio tracks
|
// DIAGNOSTIC: Check if video has audio tracks
|
||||||
if (videoElement.audioTracks) {
|
if ((videoElement as any).audioTracks) {
|
||||||
console.log("[VideoPlayer] Audio tracks count:", videoElement.audioTracks.length);
|
console.log("[VideoPlayer] Audio tracks count:", (videoElement as any).audioTracks.length);
|
||||||
|
|
||||||
// Set initial audio track (prefer default track)
|
// Set initial audio track (prefer default track)
|
||||||
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
||||||
@ -322,11 +323,11 @@
|
|||||||
console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
|
console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (videoElement.mozHasAudio !== undefined) {
|
if ((videoElement as any).mozHasAudio !== undefined) {
|
||||||
console.log("[VideoPlayer] mozHasAudio:", videoElement.mozHasAudio);
|
console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
|
||||||
}
|
}
|
||||||
if (videoElement.webkitAudioDecodedByteCount !== undefined) {
|
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
||||||
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", videoElement.webkitAudioDecodedByteCount);
|
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -359,7 +360,7 @@
|
|||||||
const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle");
|
const subtitles = media.mediaStreams.filter(s => s.type === "Subtitle");
|
||||||
for (const sub of subtitles) {
|
for (const sub of subtitles) {
|
||||||
try {
|
try {
|
||||||
const url = getSubtitleUrl(sub.index);
|
const url = await getSubtitleUrl(sub.index);
|
||||||
if (url) {
|
if (url) {
|
||||||
subtitleTracks.push({
|
subtitleTracks.push({
|
||||||
index: sub.index,
|
index: sub.index,
|
||||||
@ -617,7 +618,7 @@
|
|||||||
videoElement?.removeEventListener("seeked", onSeeked);
|
videoElement?.removeEventListener("seeked", onSeeked);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
videoElement.addEventListener("seeked", onSeeked);
|
videoElement!.addEventListener("seeked", onSeeked);
|
||||||
// Fallback timeout in case seeked event doesn't fire
|
// Fallback timeout in case seeked event doesn't fire
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoElement?.removeEventListener("seeked", onSeeked);
|
videoElement?.removeEventListener("seeked", onSeeked);
|
||||||
@ -951,7 +952,7 @@
|
|||||||
target: {
|
target: {
|
||||||
value: newTime.toString()
|
value: newTime.toString()
|
||||||
}
|
}
|
||||||
} as Event;
|
} as unknown as Event;
|
||||||
|
|
||||||
await handleSeekBarChange(syntheticEvent);
|
await handleSeekBarChange(syntheticEvent);
|
||||||
}
|
}
|
||||||
@ -1120,7 +1121,7 @@
|
|||||||
videoElement?.removeEventListener("canplay", onCanPlay);
|
videoElement?.removeEventListener("canplay", onCanPlay);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
videoElement.addEventListener("canplay", onCanPlay);
|
videoElement!.addEventListener("canplay", onCanPlay);
|
||||||
|
|
||||||
// Timeout fallback
|
// Timeout fallback
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -1214,11 +1215,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get subtitle URL for a given stream index
|
// Get subtitle URL for a given stream index
|
||||||
function getSubtitleUrl(streamIndex: number): string {
|
async function getSubtitleUrl(streamIndex: number): Promise<string> {
|
||||||
if (!media || !mediaSourceId) return "";
|
if (!media || !mediaSourceId) return "";
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
return repo.getSubtitleUrl(media.id, mediaSourceId ?? "", streamIndex, "vtt");
|
return await repo.getSubtitleUrl(media.id, mediaSourceId ?? "", streamIndex, "vtt");
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-010 | DR-037 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-010 | JA-022, JA-023, JA-024, JA-026 | DR-037 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
import { sessions } from "$lib/stores";
|
import { sessions } from "$lib/stores";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-010 | DR-037 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
||||||
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
||||||
@ -86,7 +87,7 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
class="fixed inset-0 bg-black/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') handleBackdropClick(); }}
|
onkeydown={(e) => { if (e.key === 'Escape' && onClose) onClose(); }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="session-picker-title"
|
aria-labelledby="session-picker-title"
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-010 | JA-021 | DR-037 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sessions, controllableSessions } from "$lib/stores";
|
import { sessions, controllableSessions } from "$lib/stores";
|
||||||
import SessionCard from "./SessionCard.svelte";
|
import SessionCard from "./SessionCard.svelte";
|
||||||
|
|||||||
@ -38,6 +38,20 @@ export type SyncOperation =
|
|||||||
* for queueing and managing sync operations.
|
* for queueing and managing sync operations.
|
||||||
*/
|
*/
|
||||||
class SyncService {
|
class SyncService {
|
||||||
|
/**
|
||||||
|
* Start the sync service (lifecycle managed by Rust backend)
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
console.log("[SyncService] Started");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the sync service (lifecycle managed by Rust backend)
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
console.log("[SyncService] Stopped");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a mutation for sync to server
|
* Queue a mutation for sync to server
|
||||||
*
|
*
|
||||||
|
|||||||
@ -145,10 +145,10 @@ function createQueueStore() {
|
|||||||
|
|
||||||
// Get repository handle from auth store
|
// Get repository handle from auth store
|
||||||
const authState = get(auth);
|
const authState = get(auth);
|
||||||
if (!authState.isAuthenticated || !authState.repository) {
|
if (!authState.isAuthenticated) {
|
||||||
throw new Error("User not authenticated");
|
throw new Error("User not authenticated");
|
||||||
}
|
}
|
||||||
const repositoryHandle = authState.repository.getHandle();
|
const repositoryHandle = auth.getRepository().getHandle();
|
||||||
|
|
||||||
// Use new Rust commands that accept IDs only
|
// Use new Rust commands that accept IDs only
|
||||||
if (trackIds.length === 1) {
|
if (trackIds.length === 1) {
|
||||||
@ -170,6 +170,10 @@ function createQueueStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
set(initialState);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
next,
|
next,
|
||||||
@ -182,6 +186,7 @@ function createQueueStore() {
|
|||||||
moveInQueue,
|
moveInQueue,
|
||||||
syncFromRust,
|
syncFromRust,
|
||||||
cleanup,
|
cleanup,
|
||||||
|
clear,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -146,87 +146,13 @@ describe("sessions store", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("polling", () => {
|
describe("refresh", () => {
|
||||||
it.skip("should set isPolling to true when polling starts", async () => {
|
it("should expose a refresh method for manual session fetching", async () => {
|
||||||
const { sessions } = await import("./sessions");
|
const { sessions } = await import("./sessions");
|
||||||
|
|
||||||
// Mock the refresh to prevent actual API calls
|
// The store uses event-driven updates via Tauri events,
|
||||||
vi.spyOn(sessions, "refresh").mockResolvedValue();
|
// but also exposes refresh() for manual/on-demand fetching
|
||||||
|
expect(typeof sessions.refresh).toBe("function");
|
||||||
// Note: startPolling is not yet implemented
|
|
||||||
// sessions.startPolling(5000);
|
|
||||||
// const state = get(sessions);
|
|
||||||
|
|
||||||
// expect(state.isPolling).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip("should set isPolling to false when polling stops", async () => {
|
|
||||||
const { sessions } = await import("./sessions");
|
|
||||||
|
|
||||||
vi.spyOn(sessions, "refresh").mockResolvedValue();
|
|
||||||
|
|
||||||
// Note: startPolling/stopPolling are not yet implemented
|
|
||||||
// sessions.startPolling(5000);
|
|
||||||
// sessions.stopPolling();
|
|
||||||
// const state = get(sessions);
|
|
||||||
|
|
||||||
// expect(state.isPolling).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Cannot spy on internal refresh() function as it's not exported
|
|
||||||
it.skip("should call refresh immediately when polling starts", async () => {
|
|
||||||
const { sessions } = await import("./sessions");
|
|
||||||
|
|
||||||
const refreshSpy = vi.spyOn(sessions, "refresh").mockResolvedValue();
|
|
||||||
|
|
||||||
sessions.startPolling(5000);
|
|
||||||
|
|
||||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Cannot spy on internal refresh() function as it's not exported
|
|
||||||
it.skip("should call refresh at intervals", async () => {
|
|
||||||
const { sessions } = await import("./sessions");
|
|
||||||
|
|
||||||
const refreshSpy = vi.spyOn(sessions, "refresh").mockResolvedValue();
|
|
||||||
|
|
||||||
sessions.startPolling(5000);
|
|
||||||
|
|
||||||
// Initial call
|
|
||||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Advance timers by 5 seconds
|
|
||||||
await vi.advanceTimersByTime(5000);
|
|
||||||
expect(refreshSpy).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
// Advance another 5 seconds
|
|
||||||
await vi.advanceTimersByTime(5000);
|
|
||||||
expect(refreshSpy).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
sessions.stopPolling();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Cannot spy on internal refresh() function as it's not exported
|
|
||||||
it.skip("should stop previous polling when starting new polling", async () => {
|
|
||||||
const { sessions } = await import("./sessions");
|
|
||||||
|
|
||||||
const refreshSpy = vi.spyOn(sessions, "refresh").mockResolvedValue();
|
|
||||||
|
|
||||||
sessions.startPolling(5000);
|
|
||||||
await vi.advanceTimersByTime(5000);
|
|
||||||
|
|
||||||
const callsAfterFirst = refreshSpy.mock.calls.length;
|
|
||||||
|
|
||||||
// Start new polling - should stop the old one
|
|
||||||
sessions.startPolling(3000);
|
|
||||||
|
|
||||||
// Advance by the old interval
|
|
||||||
await vi.advanceTimersByTime(5000);
|
|
||||||
|
|
||||||
// Should have been called once for the new startPolling, and once after 3s
|
|
||||||
expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
|
||||||
|
|
||||||
sessions.stopPolling();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
*
|
*
|
||||||
* @see https://v2.tauri.app/develop/calling-rust/
|
* @see https://v2.tauri.app/develop/calling-rust/
|
||||||
*/
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
|
describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
|
||||||
describe("All command parameters use camelCase (Tauri v2 auto-converts)", () => {
|
describe("All command parameters use camelCase (Tauri v2 auto-converts)", () => {
|
||||||
|
|||||||
@ -30,12 +30,12 @@ describe("Real production code - Tauri invoke calls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("playbackMode store - real calls", () => {
|
describe("playbackMode store - real calls", () => {
|
||||||
it("playPlayTracks should send repositoryHandle, NOT repository_handle", async () => {
|
it("playbackMode store should be importable and defined", async () => {
|
||||||
const { playbackMode } = await import("../stores/playbackMode");
|
const { playbackMode } = await import("../stores/playbackMode");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
expect(playbackMode).toBeDefined();
|
expect(playbackMode).toBeDefined();
|
||||||
expect(playbackMode.playPlayTracks).toBeDefined();
|
expect(playbackMode.subscribe).toBeDefined();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Store might have dependencies we can't mock, but at least we tried
|
// Store might have dependencies we can't mock, but at least we tried
|
||||||
}
|
}
|
||||||
@ -48,7 +48,7 @@ describe("Real production code - Tauri invoke calls", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
expect(sessions).toBeDefined();
|
expect(sessions).toBeDefined();
|
||||||
expect(sessions.sendCommand).toBeDefined();
|
expect(sessions.sendPlayPause).toBeDefined();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Store might have dependencies we can't mock
|
// Store might have dependencies we can't mock
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,10 +33,10 @@
|
|||||||
let failedAttempts = 0;
|
let failedAttempts = 0;
|
||||||
const MAX_SILENT_FAILURES = 3; // Don't log errors for first 3 attempts
|
const MAX_SILENT_FAILURES = 3; // Don't log errors for first 3 attempts
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
// Detect platform
|
// Detect platform
|
||||||
try {
|
try {
|
||||||
const platformName = await platform();
|
const platformName = platform();
|
||||||
isAndroid = platformName === "android";
|
isAndroid = platformName === "android";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Platform detection failed:", err);
|
console.error("Platform detection failed:", err);
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
|
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
@ -66,6 +66,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadItem() {
|
async function loadItem() {
|
||||||
|
if (!itemId) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
seasonData = [];
|
seasonData = [];
|
||||||
@ -171,7 +172,12 @@
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(clickedItem: MediaItem) {
|
function handleItemClick(clickedItem: MediaItem | Library) {
|
||||||
|
if (!("type" in clickedItem)) {
|
||||||
|
// Library item - navigate to library
|
||||||
|
goto(`/library/${clickedItem.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (clickedItem.type) {
|
switch (clickedItem.type) {
|
||||||
case "Series":
|
case "Series":
|
||||||
case "Season":
|
case "Season":
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemTypes: ["Movie"],
|
itemTypes: ["Movie" as const],
|
||||||
title: "Movie Genres",
|
title: "Movie Genres",
|
||||||
backPath: "/library",
|
backPath: "/library",
|
||||||
genreIcon:
|
genreIcon:
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "MusicAlbum",
|
itemType: "MusicAlbum" as const,
|
||||||
title: "Albums",
|
title: "Albums",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search albums or artists...",
|
searchPlaceholder: "Search albums or artists...",
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "MusicArtist",
|
itemType: "MusicArtist" as const,
|
||||||
title: "Artists",
|
title: "Artists",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search artists...",
|
searchPlaceholder: "Search artists...",
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemTypes: ["MusicAlbum"],
|
itemTypes: ["MusicAlbum" as const],
|
||||||
title: "Genres",
|
title: "Genres",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
genreIcon:
|
genreIcon:
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Playlist",
|
itemType: "Playlist" as const,
|
||||||
title: "Playlists",
|
title: "Playlists",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search playlists...",
|
searchPlaceholder: "Search playlists...",
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemType: "Audio",
|
itemType: "Audio" as const,
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
backPath: "/library/music",
|
backPath: "/library/music",
|
||||||
searchPlaceholder: "Search tracks or artists...",
|
searchPlaceholder: "Search tracks or artists...",
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
itemTypes: ["Series"],
|
itemTypes: ["Series" as const],
|
||||||
title: "TV Genres",
|
title: "TV Genres",
|
||||||
backPath: "/library",
|
backPath: "/library",
|
||||||
genreIcon:
|
genreIcon:
|
||||||
|
|||||||
@ -143,6 +143,10 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore - queue status will update via polling
|
// Ignore - queue status will update via polling
|
||||||
}
|
}
|
||||||
|
// Fetch next episode for video skip button
|
||||||
|
if (isVideo) {
|
||||||
|
fetchNextEpisode(item);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,8 +415,11 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
// Fetch next episode for video episodes (for skip button)
|
// Fetch next episode for video episodes (for skip button)
|
||||||
|
console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.type, currentMedia?.name);
|
||||||
if (isVideo && currentMedia) {
|
if (isVideo && currentMedia) {
|
||||||
fetchNextEpisode(currentMedia);
|
fetchNextEpisode(currentMedia);
|
||||||
|
} else {
|
||||||
|
console.log("[NextEpisode] Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("loadAndPlay error:", e);
|
console.error("loadAndPlay error:", e);
|
||||||
@ -528,21 +535,35 @@
|
|||||||
|
|
||||||
async function fetchNextEpisode(media: MediaItem) {
|
async function fetchNextEpisode(media: MediaItem) {
|
||||||
nextEpisode = null;
|
nextEpisode = null;
|
||||||
if (media.type !== "Episode" || !media.seriesId) return;
|
console.log("[NextEpisode] fetchNextEpisode called:", { type: media.type, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
|
||||||
|
if (media.type !== "Episode" || !media.seasonId || media.indexNumber == null) {
|
||||||
|
console.log("[NextEpisode] Skipping - not an episode or missing seasonId/indexNumber");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const episodes = await repo.getNextUpEpisodes(media.seriesId, 1);
|
// Fetch all episodes in the season sorted by episode number
|
||||||
if (episodes.length > 0 && episodes[0].id !== media.id) {
|
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
|
||||||
nextEpisode = episodes[0];
|
const episodes = result.items.filter(e => e.type === "Episode");
|
||||||
|
console.log("[NextEpisode] Season has", episodes.length, "episodes, current index:", media.indexNumber);
|
||||||
|
|
||||||
|
// Find the episode after the current one by index number
|
||||||
|
const currentIdx = episodes.findIndex(e => e.id === media.id);
|
||||||
|
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
|
||||||
|
nextEpisode = episodes[currentIdx + 1];
|
||||||
|
console.log("[NextEpisode] Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber);
|
||||||
|
} else {
|
||||||
|
console.log("[NextEpisode] No next episode in season (current position:", currentIdx, "of", episodes.length, ")");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch next episode:", e);
|
console.error("[NextEpisode] Failed to fetch next episode:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSkipToNextEpisode() {
|
function handleSkipToNextEpisode() {
|
||||||
if (nextEpisode) {
|
if (nextEpisode) {
|
||||||
goto(`/player/${nextEpisode.id}`);
|
// Use replaceState so "close/back" returns to the library, not the previous episode
|
||||||
|
goto(`/player/${nextEpisode.id}`, { replaceState: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -605,7 +626,7 @@
|
|||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
media={currentMedia}
|
media={currentMedia}
|
||||||
{streamUrl}
|
{streamUrl}
|
||||||
mediaSourceId={mediaSourceId}
|
mediaSourceId={mediaSourceId ?? undefined}
|
||||||
initialPosition={videoInitialPosition}
|
initialPosition={videoInitialPosition}
|
||||||
needsTranscoding={videoNeedsTranscoding}
|
needsTranscoding={videoNeedsTranscoding}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
<!-- TRACES: UR-023 | DR-048 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { defineConfig } from "vite";
|
|||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user