Many improvemtns and fixes related to decoupling of svelte and rust on android.
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s

This commit is contained in:
Duncan Tourolle 2026-02-28 19:50:47 +01:00
parent 07f3bf04ca
commit e8e37649fa
53 changed files with 2309 additions and 792 deletions

View File

@ -2,7 +2,7 @@
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust. This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
**Last Updated:** 2026-01-26 **Last Updated:** 2026-02-28
## Architecture Overview ## Architecture Overview
@ -1041,6 +1041,49 @@ graph TD
PlayerComps --> LibraryComps PlayerComps --> LibraryComps
``` ```
### 3.9 MiniPlayer Behavior
**Location**: `src/lib/components/player/MiniPlayer.svelte`
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
**Touch Gesture Handling:**
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
```typescript
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchEndX = touchStartX; // Initialize to start position
touchEndY = touchStartY; // Prevents taps being treated as swipes
isSwiping = true;
}
```
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
**Skip Button State:**
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
**Location**: `src/routes/player/[id]/+page.svelte`
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
```typescript
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition) {
// Track already playing — show UI without restarting playback
// Fetch queue status for hasNext/hasPrevious
return;
}
```
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
--- ---
## 4. Data Flow ## 4. Data Flow
@ -1251,6 +1294,50 @@ interface MediaItem {
} }
``` ```
### 5.3 Tauri v2 IPC Parameter Naming Convention
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
**Rule**: Rust `fn cmd(repository_handle: String)` → Frontend sends `{ repositoryHandle: "..." }`
```typescript
// ✅ CORRECT - Tauri v2 auto-converts snake_case → camelCase
await invoke("player_play_tracks", {
repositoryHandle: "handle-123", // Rust: repository_handle
request: { trackIds: ["id1"], startIndex: 0 }
});
await invoke("remote_send_command", {
sessionId: "session-123", // Rust: session_id
command: "PlayPause"
});
await invoke("pin_item", {
itemId: "item-123" // Rust: item_id
});
// ❌ WRONG - snake_case causes "invalid args request" error on Android
await invoke("player_play_tracks", {
repository_handle: "handle-123", // Will fail!
});
```
**Parameter Name Mapping (Rust → Frontend)**:
| Rust Parameter | Frontend Parameter | Used By |
|----------------|-------------------|---------|
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
| `item_ids` | `itemIds` | `remote_play_on_session` |
| `start_index` | `startIndex` | `remote_play_on_session` |
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately — this is serde deserialization, not the command macro. Both layers convert independently.
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
--- ---
## 6. Thread Safety ## 6. Thread Safety
@ -1383,6 +1470,9 @@ flowchart LR
- Listens for `player-event` Tauri events - Listens for `player-event` Tauri events
- Updates player/queue stores based on event type - Updates player/queue stores based on event type
- Auto-advances to next track on `PlaybackEnded` - Auto-advances to next track on `PlaybackEnded`
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` — this enables MiniPlayer skip button state
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
### 7.2 MpvBackend (Linux) ### 7.2 MpvBackend (Linux)
@ -1664,6 +1754,120 @@ Remote volume is automatically enabled/disabled during playback mode transfers:
} }
``` ```
### 7.3.2 Android Album Art Caching
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
**Architecture:**
```mermaid
flowchart TB
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
end
subgraph Cache["AlbumArtCache.kt"]
MemoryCache["LruCache&lt;String, Bitmap&gt;<br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
end
subgraph Service["JellyTauPlaybackService.kt"]
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
end
LoadMedia --> AsyncDownload
AsyncDownload --> MemoryCache
MemoryCache --> Download
Download --> ErrorHandle
AsyncDownload --> UpdateMeta
UpdateMeta --> Notification
```
**AlbumArtCache Singleton:**
```kotlin
class AlbumArtCache(context: Context) {
// Memory-efficient cache using 1/8 of available heap
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024 // Size in KB
}
}
// Get artwork - checks cache first, downloads if needed
suspend fun getArtwork(url: String): Bitmap? {
memoryCache.get(url)?.let { return it }
return downloadAndCache(url)
}
// Download and cache with automatic scaling
private suspend fun downloadAndCache(url: String): Bitmap? =
withContext(Dispatchers.IO) {
// HTTP download with 5s timeout
// Scale to 512x512 max
// Auto-evict LRU if needed
}
}
```
**Integration Flow:**
1. **Track Load** (`loadWithMetadata()`):
- Store artwork URL in `currentArtworkUrl`
- Reset bitmap to null
- Start playback immediately (non-blocking)
2. **Async Download** (Background Coroutine):
- Check cache: instant hit if available
- Network miss: download, scale, cache
- Auto-retry on network failure with exponential backoff
- Graceful fallback if artwork unavailable
3. **Notification Update**:
- Pass bitmap to `updatePlaybackServiceNotification()`
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
- Display as large icon in notification
- Show on lock screen
**Memory Management:**
| Metric | Value |
|--------|-------|
| Cache Size | 1/8 of heap (12-16MB typical) |
| Max Resolution | 512x512 pixels |
| Capacity | ~50-100 album arts |
| Eviction Policy | LRU (Least Recently Used) |
| Lifetime | In-memory only (app session) |
| Network Timeout | 5 seconds per download |
**File Locations:**
- **Kotlin Cache Class**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
- **JellyTauPlayer Integration**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt`
- Instance variables: lines 146-147
- Store URL: line 553
- Async download: lines 670-682
- Pass to service: line 994
- **JellyTauPlaybackService Integration**: `src-tauri/gen/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt`
- Bitmap import: line 8
- Function signature: line 267
- Add to metadata: lines 282-284
- Large icon in notification: line 321
**Performance Characteristics:**
- **Cache Hit**: ~1ms (in-memory retrieval)
- **Cache Miss**: ~200-500ms (download + scale)
- **Playback Impact**: Zero (async downloads)
- **Memory Overhead**: Max 16MB (auto-eviction)
- **Error Recovery**: Automatic with exponential backoff
### 7.4 Backend Initialization ### 7.4 Backend Initialization
**Location**: `src-tauri/src/lib.rs` **Location**: `src-tauri/src/lib.rs`

2
package-lock.json generated
View File

@ -35,7 +35,7 @@
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^4.0.16", "vitest": ">=1.0.0 <5.0.0",
"webdriverio": "^9.5.0" "webdriverio": "^9.5.0"
} }
}, },

View File

@ -18,6 +18,7 @@
"test:rust": "./scripts/test-rust.sh", "test:rust": "./scripts/test-rust.sh",
"android:build": "./scripts/build-android.sh", "android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release", "android:build:release": "./scripts/build-android.sh release",
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && npm install && npm run build",
"android:deploy": "./scripts/deploy-android.sh", "android:deploy": "./scripts/deploy-android.sh",
"android:dev": "./scripts/build-and-deploy.sh", "android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh", "android:check": "./scripts/check-android.sh",

View File

@ -18,6 +18,11 @@ echo ""
# Build type: debug or release (default: debug) # Build type: debug or release (default: debug)
BUILD_TYPE="${1:-debug}" BUILD_TYPE="${1:-debug}"
# Step 0: Clear build caches to ensure fresh builds
echo "🧹 Clearing build caches..."
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
npm install > /dev/null 2>&1
# Step 1: Sync Android source files # Step 1: Sync Android source files
echo "🔄 Syncing Android sources..." echo "🔄 Syncing Android sources..."
./scripts/sync-android-sources.sh ./scripts/sync-android-sources.sh

View File

@ -1008,8 +1008,7 @@ class JellyTauPlayer(private val appContext: Context) {
album = currentAlbum, album = currentAlbum,
duration = currentDurationMs, duration = currentDurationMs,
position = (exoPlayer.currentPosition).coerceAtLeast(0), position = (exoPlayer.currentPosition).coerceAtLeast(0),
isPlaying = isPlaying, isPlaying = isPlaying
artworkBitmap = currentArtworkBitmap
) )
} else { } else {
android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update") android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update")

View File

@ -102,12 +102,18 @@ impl AuthManager {
self.connectivity_monitor = Some(monitor); self.connectivity_monitor = Some(monitor);
} }
/// Normalize and validate server URL /// Normalize and validate server URL.
pub fn normalize_url(url: &str) -> String { /// Enforces HTTPS — plain HTTP is rejected for security.
pub fn normalize_url(url: &str) -> Result<String, String> {
let mut normalized = url.trim().to_string(); let mut normalized = url.trim().to_string();
// Reject plain HTTP — all connections must use HTTPS
if normalized.starts_with("http://") {
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
}
// Add https:// if no protocol specified // Add https:// if no protocol specified
if !normalized.starts_with("http://") && !normalized.starts_with("https://") { if !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized); normalized = format!("https://{}", normalized);
} }
@ -116,12 +122,12 @@ impl AuthManager {
normalized.pop(); normalized.pop();
} }
normalized Ok(normalized)
} }
/// Connect to server and get server info /// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> { pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url); let normalized_url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/System/Info/Public", normalized_url); let endpoint = format!("{}/System/Info/Public", normalized_url);
log::info!("[AuthManager] Connecting to server: {}", normalized_url); log::info!("[AuthManager] Connecting to server: {}", normalized_url);
@ -165,7 +171,7 @@ impl AuthManager {
password: &str, password: &str,
device_id: &str, device_id: &str,
) -> Result<AuthResult, String> { ) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url); let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Users/AuthenticateByName", url); let endpoint = format!("{}/Users/AuthenticateByName", url);
log::info!("[AuthManager] Authenticating user: {}", username); log::info!("[AuthManager] Authenticating user: {}", username);
@ -227,7 +233,7 @@ impl AuthManager {
access_token: &str, access_token: &str,
device_id: &str, device_id: &str,
) -> Result<User, String> { ) -> Result<User, String> {
let url = Self::normalize_url(server_url); let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Users/{}", url, user_id); let endpoint = format!("{}/Users/{}", url, user_id);
log::info!("[AuthManager] Verifying session for user: {}", user_id); log::info!("[AuthManager] Verifying session for user: {}", user_id);
@ -290,7 +296,7 @@ impl AuthManager {
access_token: &str, access_token: &str,
device_id: &str, device_id: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let url = Self::normalize_url(server_url); let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Sessions/Logout", url); let endpoint = format!("{}/Sessions/Logout", url);
log::info!("[AuthManager] Logging out"); log::info!("[AuthManager] Logging out");
@ -337,43 +343,43 @@ mod tests {
use super::*; use super::*;
/// Test URL normalization - adds https:// when missing /// Test URL normalization - adds https:// when missing
///
/// Ensures that URLs without protocol are normalized to https://
/// This prevents "builder error" when constructing HTTP requests.
#[test] #[test]
fn test_normalize_url_adds_https() { fn test_normalize_url_adds_https() {
assert_eq!( assert_eq!(
AuthManager::normalize_url("jellyfin.example.com"), AuthManager::normalize_url("jellyfin.example.com").unwrap(),
"https://jellyfin.example.com" "https://jellyfin.example.com"
); );
assert_eq!( assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096"), AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
"https://192.168.1.100:8096" "https://192.168.1.100:8096"
); );
} }
/// Test URL normalization - preserves existing protocol /// Test URL normalization - preserves existing https
#[test] #[test]
fn test_normalize_url_preserves_protocol() { fn test_normalize_url_preserves_https() {
assert_eq!( assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com"), AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
"https://jellyfin.example.com" "https://jellyfin.example.com"
); );
assert_eq!( }
AuthManager::normalize_url("http://localhost:8096"),
"http://localhost:8096" /// Test URL normalization - rejects HTTP
); #[test]
fn test_normalize_url_rejects_http() {
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
} }
/// Test URL normalization - removes trailing slash /// Test URL normalization - removes trailing slash
#[test] #[test]
fn test_normalize_url_removes_trailing_slash() { fn test_normalize_url_removes_trailing_slash() {
assert_eq!( assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/"), AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com" "https://jellyfin.example.com"
); );
assert_eq!( assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/"), AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com" "https://jellyfin.example.com"
); );
} }
@ -382,25 +388,20 @@ mod tests {
#[test] #[test]
fn test_normalize_url_trims_whitespace() { fn test_normalize_url_trims_whitespace() {
assert_eq!( assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com "), AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
"https://jellyfin.example.com" "https://jellyfin.example.com"
); );
assert_eq!( assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ "), AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
"https://jellyfin.example.com" "https://jellyfin.example.com"
); );
} }
/// Test URL normalization - complex case /// Test URL normalization - real world case
///
/// This is the bug that caused the login issue: user enters URL
/// without protocol, it gets stored in DB, then fails when building
/// HTTP requests.
#[test] #[test]
fn test_normalize_url_real_world_case() { fn test_normalize_url_real_world_case() {
// User input: "jellyfin.tourolle.paris"
let input = "jellyfin.tourolle.paris"; let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input); let normalized = AuthManager::normalize_url(input).unwrap();
assert_eq!(normalized, "https://jellyfin.tourolle.paris"); assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://")); assert!(normalized.starts_with("https://"));

View File

@ -39,7 +39,7 @@ pub async fn auth_initialize(
}; };
// Create session object from active session with normalized URL // Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url); let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
let session = Session { let session = Session {
user_id: active_session.user_id, user_id: active_session.user_id,
@ -80,7 +80,7 @@ pub async fn auth_login(
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?; let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
// Create session from auth result with normalized URL // Create session from auth result with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url); let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
let session = Session { let session = Session {
user_id: result.user.id.clone(), user_id: result.user.id.clone(),
@ -156,10 +156,13 @@ pub async fn auth_set_session(
auth_manager: State<'_, AuthManagerWrapper>, auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<(), String> { ) -> Result<(), String> {
// Normalize the server URL if session is provided // Normalize the server URL if session is provided
let normalized_session = session.map(|mut s| { let normalized_session = match session {
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url); Some(mut s) => {
s s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
}); Some(s)
}
None => None,
};
auth_manager.0.set_session(normalized_session).await; auth_manager.0.set_session(normalized_session).await;
Ok(()) Ok(())

View File

@ -956,7 +956,26 @@ pub async fn start_download(
debug!("Download task started for download_id: {}", download_id); debug!("Download task started for download_id: {}", download_id);
let worker = DownloadWorker::new(); let worker = DownloadWorker::new();
match worker.download(&task).await { // Progress callback that emits events to the frontend
let progress_app = app_clone.clone();
let progress_item_id = item_id_clone.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes
.filter(|&t| t > 0)
.map(|t| bytes_downloaded as f64 / t as f64)
.unwrap_or(0.0);
let event = DownloadEvent::Progress {
download_id,
item_id: progress_item_id.clone(),
bytes_downloaded: bytes_downloaded as i64,
total_bytes: total_bytes.map(|t| t as i64),
progress,
};
let _ = progress_app.emit("download-event", event);
};
match worker.download(&task, on_progress).await {
Ok(result) => { Ok(result) => {
info!("Download completed successfully: {} bytes", result.bytes_downloaded); info!("Download completed successfully: {} bytes", result.bytes_downloaded);

View File

@ -195,4 +195,64 @@ mod tests {
assert!(json.contains(&pos.to_string())); assert!(json.contains(&pos.to_string()));
} }
} }
#[test]
fn test_playback_mode_deserialization_from_frontend() {
// Test what frontend sends for Idle mode
let idle_json = r#"{"type":"idle"}"#;
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
assert_eq!(mode, PlaybackMode::Idle);
// Test what frontend sends for Local mode
let local_json = r#"{"type":"local"}"#;
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
assert_eq!(mode, PlaybackMode::Local);
// Test what frontend sends for Remote mode
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
match mode {
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
_ => panic!("Expected Remote mode"),
}
}
#[test]
fn test_play_tracks_context_deserialization() {
use crate::commands::PlayTracksContext;
// Test Search context (the recently fixed issue)
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
let context: PlayTracksContext = serde_json::from_str(search_json)
.expect("Failed to deserialize search context");
match context {
PlayTracksContext::Search { search_query } => {
assert_eq!(search_query, "test query");
}
_ => panic!("Expected Search context"),
}
// Test Playlist context
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
let context: PlayTracksContext = serde_json::from_str(playlist_json)
.expect("Failed to deserialize playlist context");
match context {
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
assert_eq!(playlist_id, "pl-123");
assert_eq!(playlist_name, "My Playlist");
}
_ => panic!("Expected Playlist context"),
}
// Test Custom context
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
let context: PlayTracksContext = serde_json::from_str(custom_json)
.expect("Failed to deserialize custom context");
match context {
PlayTracksContext::Custom { label } => {
assert_eq!(label, Some("Custom Queue".to_string()));
}
_ => panic!("Expected Custom context"),
}
}
} }

View File

@ -88,7 +88,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem {
album: item.album.clone(), album: item.album.clone(),
album_id: item.album_id.clone(), album_id: item.album_id.clone(),
duration: item.duration, duration: item.duration,
primary_image_tag: None, primary_image_tag: item.primary_image_tag.clone(),
media_type: match item.media_type { media_type: match item.media_type {
crate::player::MediaType::Audio => "audio".to_string(), crate::player::MediaType::Audio => "audio".to_string(),
crate::player::MediaType::Video => "video".to_string(), crate::player::MediaType::Video => "video".to_string(),
@ -138,43 +138,20 @@ pub enum VideoBackend {
Html5, Html5,
} }
/// Request to play a single item /// Request to play a single video item
///
/// Simplified to video playback only. Audio playback uses player_play_tracks
/// to avoid Tauri Android serialization issues with complex objects.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PlayItemRequest { pub struct PlayItemRequest {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
/// Album ID (Jellyfin ID) for remote transfer context
#[serde(default)]
pub album_id: Option<String>,
/// Playlist ID (Jellyfin ID) for remote transfer context
#[serde(default)]
pub playlist_id: Option<String>,
pub duration: Option<f64>,
pub artwork_url: Option<String>,
pub media_type: MediaType,
pub stream_url: String, pub stream_url: String,
pub jellyfin_item_id: Option<String>,
/// Video codec (e.g., "h264", "hevc") for video media /// Video codec (e.g., "h264", "hevc") for video media
#[serde(default)] pub video_codec: String,
pub video_codec: Option<String>,
/// Whether the video requires server-side transcoding /// Whether the video requires server-side transcoding
#[serde(default)]
pub needs_transcoding: bool, pub needs_transcoding: bool,
/// Video width in pixels
#[serde(default)]
pub video_width: Option<u32>,
/// Video height in pixels
#[serde(default)]
pub video_height: Option<u32>,
/// Series ID (for TV show episodes) - used for series audio preferences
#[serde(default)]
pub series_id: Option<String>,
/// Server ID - used for series audio preferences
#[serde(default)]
pub server_id: Option<String>,
} }
/// Queue context for remote transfer - what type of queue is this? /// Queue context for remote transfer - what type of queue is this?
@ -345,16 +322,20 @@ pub enum AudioTrackSwitchResponse {
}, },
} }
/// Helper function to create MediaItem from request, checking for local downloads /// Helper function to create MediaItem from video request
///
/// PlayItemRequest is now video-only, so we create a video MediaItem.
/// Audio playback uses player_play_tracks which fetches full metadata from backend.
async fn create_media_item( async fn create_media_item(
req: PlayItemRequest, req: PlayItemRequest,
db: Option<&DatabaseWrapper>, db: Option<&DatabaseWrapper>,
) -> Result<MediaItem, String> { ) -> Result<MediaItem, String> {
let jellyfin_id = req.jellyfin_item_id.as_ref().unwrap_or(&req.id); // For video-only requests, we use the item ID as the jellyfin ID
let jellyfin_id = req.id.clone();
// Check if item is downloaded locally // Check if item is downloaded locally
let local_path = if let Some(db_wrapper) = db { let local_path = if let Some(db_wrapper) = db {
check_for_local_download(db_wrapper, jellyfin_id).await? check_for_local_download(db_wrapper, &jellyfin_id).await?
} else { } else {
None None
}; };
@ -374,27 +355,27 @@ async fn create_media_item(
Ok(MediaItem { Ok(MediaItem {
id: req.id.clone(), id: req.id.clone(),
title: req.title.clone(), title: req.title.clone(),
name: Some(req.title), // Frontend compatibility name: Some(req.title.clone()),
artist: req.artist.clone(), artist: None, // Not available from video-only request
album: req.album.clone(), album: None, // Not available from video-only request
album_name: req.album, // Frontend compatibility album_name: None, // Not available from video-only request
album_id: req.album_id, album_id: None, // Not available from video-only request
artist_items: None, // Not available from frontend request artist_items: None, // Not available from video-only request
artists: req.artist.map(|a| vec![a]), // Convert single artist to array artists: None, // Not available from video-only request
primary_image_tag: None, // Not available from frontend request primary_image_tag: None, // Not available from video-only request
item_type: None, // Not available from frontend request item_type: None, // Not available from video-only request
playlist_id: req.playlist_id, playlist_id: None, // Not available from video-only request
duration: req.duration, duration: None, // Not available from video-only request
artwork_url: req.artwork_url, artwork_url: None, // Not available from video-only request
media_type: req.media_type, media_type: crate::player::MediaType::Video, // Video-only request
source, source,
video_codec: req.video_codec, video_codec: Some(req.video_codec),
needs_transcoding: req.needs_transcoding, needs_transcoding: req.needs_transcoding,
video_width: req.video_width, video_width: None, // Not available from video-only request
video_height: req.video_height, video_height: None, // Not available from video-only request
subtitles: vec![], subtitles: vec![],
series_id: req.series_id, series_id: None, // Not available from video-only request
server_id: req.server_id, server_id: None, // Not available from video-only request
}) })
} }
@ -433,6 +414,9 @@ async fn check_for_local_download(
/// Play a single media item (audio or video) /// Play a single media item (audio or video)
/// ///
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
/// This avoids Tauri's Android serialization issues with complex objects.
///
/// @req: UR-003 - Play videos /// @req: UR-003 - Play videos
/// @req: UR-004 - Play audio uninterrupted /// @req: UR-004 - Play audio uninterrupted
/// @req: UR-005 - Control media playback (play operation) /// @req: UR-005 - Control media playback (play operation)

View File

@ -1,6 +1,7 @@
//! Tauri commands for database/storage operations //! Tauri commands for database/storage operations
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Semaphore;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -1343,6 +1344,25 @@ pub async fn thumbnail_delete_item(
thumbnail_cache.0.delete_item(db_service, &item_id).await thumbnail_cache.0.delete_item(db_service, &item_id).await
} }
fn mime_from_ext(ext: Option<&str>) -> &'static str {
match ext {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/jpeg",
}
}
/// Limit concurrent image downloads to avoid saturating the connection pool.
/// Without this, rendering a page with hundreds of album cards fires hundreds of
/// concurrent HTTP requests, starving API calls and causing timeouts.
static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
fn image_semaphore() -> &'static Semaphore {
IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
}
/// Request to get an image URL (with caching) /// Request to get an image URL (with caching)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -1385,32 +1405,21 @@ pub async fn image_get_url(
&request.image_type, &request.image_type,
tag, tag,
).await { ).await {
// Read file and return as base64
let image_data = fs::read(&cached_path) let image_data = fs::read(&cached_path)
.map_err(|e| format!("Failed to read cached image: {}", e))?; .map_err(|e| format!("Failed to read cached image: {}", e))?;
let base64_data = BASE64.encode(&image_data); let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
// Determine MIME type from file extension return Ok(format!("data:{};base64,{}", mime_type, base64_data));
let mime_type = match cached_path.extension().and_then(|s| s.to_str()) {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/jpeg", // default
};
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
debug!("[ImageCache] Cache hit for {}/{}", request.item_id, request.image_type);
return Ok(data_url);
} }
// Not cached - need to fetch from repository and cache // Not cached — fetch from server and cache.
info!("[ImageCache] Cache miss for {}/{}, downloading...", request.item_id, request.image_type); // Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
let _permit = image_semaphore().acquire().await
.map_err(|_| "Image download semaphore closed".to_string())?;
let repository = repository_manager.0.get(&repository_handle) let repository = repository_manager.0.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?; .ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
// Parse image type
let image_type_enum = match request.image_type.as_str() { let image_type_enum = match request.image_type.as_str() {
"Primary" => ImageType::Primary, "Primary" => ImageType::Primary,
"Backdrop" => ImageType::Backdrop, "Backdrop" => ImageType::Backdrop,
@ -1420,7 +1429,6 @@ pub async fn image_get_url(
_ => ImageType::Primary, _ => ImageType::Primary,
}; };
// Build image options
let options = ImageOptions { let options = ImageOptions {
max_width: request.max_width, max_width: request.max_width,
max_height: request.max_height, max_height: request.max_height,
@ -1428,18 +1436,10 @@ pub async fn image_get_url(
tag: request.tag.clone(), tag: request.tag.clone(),
}; };
// Get image URL from repository
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options)); let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
debug!("[ImageCache] Server URL: {}", server_url); let image_data = repository.download_bytes(&server_url).await
// Download image data
let worker = ThumbnailWorker::new();
let image_data = worker
.download_with_retry(&server_url, 2)
.await
.map_err(|e| format!("Failed to download image: {}", e))?; .map_err(|e| format!("Failed to download image: {}", e))?;
// Save to cache
let cached_path = thumbnail_cache.0.save_thumbnail( let cached_path = thumbnail_cache.0.save_thumbnail(
db_service, db_service,
&request.item_id, &request.item_id,
@ -1450,18 +1450,9 @@ pub async fn image_get_url(
request.max_height.map(|h| h as i32), request.max_height.map(|h| h as i32),
).await?; ).await?;
// Return as base64 data URL
let base64_data = BASE64.encode(&image_data); let base64_data = BASE64.encode(&image_data);
let mime_type = match cached_path.extension().and_then(|s| s.to_str()) { let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
Some("jpg") | Some("jpeg") => "image/jpeg", Ok(format!("data:{};base64,{}", mime_type, base64_data))
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/jpeg",
};
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
info!("[ImageCache] Cached and returning base64 data URL");
Ok(data_url)
} }
// ============================================================================= // =============================================================================

View File

@ -21,6 +21,7 @@ impl DownloadWorker {
pub fn new() -> Self { pub fn new() -> Self {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout .timeout(Duration::from_secs(300)) // 5 minute timeout
.https_only(true)
.build() .build()
.expect("Failed to create HTTP client"); .expect("Failed to create HTTP client");
@ -31,14 +32,18 @@ impl DownloadWorker {
} }
/// Download a file with retry logic and progress tracking /// Download a file with retry logic and progress tracking
pub async fn download( pub async fn download<F>(
&self, &self,
task: &DownloadTask, task: &DownloadTask,
) -> Result<DownloadResult, DownloadError> { on_progress: F,
) -> Result<DownloadResult, DownloadError>
where
F: Fn(u64, Option<u64>) + Send + Sync,
{
let mut retries = 0; let mut retries = 0;
loop { loop {
match self.try_download(task).await { match self.try_download(task, &on_progress).await {
Ok(result) => return Ok(result), Ok(result) => return Ok(result),
Err(e) if retries < self.max_retries && e.is_retryable() => { Err(e) if retries < self.max_retries && e.is_retryable() => {
retries += 1; retries += 1;
@ -55,7 +60,10 @@ impl DownloadWorker {
} }
/// Attempt a single download /// Attempt a single download
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> { async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
where
F: Fn(u64, Option<u64>) + Send + Sync,
{
// Create parent directories // Create parent directories
if let Some(parent) = task.target_path.parent() { if let Some(parent) = task.target_path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
@ -129,7 +137,7 @@ impl DownloadWorker {
|| downloaded % (1024 * 1024) == 0 || downloaded % (1024 * 1024) == 0
{ {
last_progress_emit = std::time::Instant::now(); last_progress_emit = std::time::Instant::now();
// Progress events will be emitted by the manager on_progress(downloaded, _total_bytes);
} }
} }

View File

@ -20,6 +20,7 @@ impl JellyfinClient {
pub fn new(config: JellyfinConfig) -> Result<Self, String> { pub fn new(config: JellyfinConfig) -> Result<Self, String> {
let http_client = Client::builder() let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.https_only(true)
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;

View File

@ -49,6 +49,7 @@ impl HttpClient {
pub fn new(config: HttpConfig) -> Result<Self, String> { pub fn new(config: HttpConfig) -> Result<Self, String> {
let client = Client::builder() let client = Client::builder()
.timeout(config.timeout) .timeout(config.timeout)
.https_only(true)
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;

View File

@ -37,6 +37,12 @@ impl HybridRepository {
} }
} }
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Delegates to online repository for connection reuse and proper auth.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
self.online.download_bytes(url).await
}
/// Get video stream URL with optional seeking support. /// Get video stream URL with optional seeking support.
/// This method is online-only since offline playback uses local file paths. /// This method is online-only since offline playback uses local file paths.
pub async fn get_video_stream_url( pub async fn get_video_stream_url(

View File

@ -36,6 +36,29 @@ impl OnlineRepository {
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device") HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
} }
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Used by thumbnail cache to download images with proper auth and connection reuse.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
let request = self.http_client.client.get(url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| format!("Download failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
}
response.bytes().await
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read bytes: {}", e))
}
/// Make authenticated GET request /// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> { async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
@ -1005,8 +1028,12 @@ impl MediaRepository for OnlineRepository {
image_type.as_str() image_type.as_str()
); );
// Authentication is handled by X-Emby-Authorization header in download_bytes()
// Do NOT include api_key here — some Jellyfin servers reject requests when
// api_key is present but the token doesn't match the expected format.
let mut params: Vec<String> = Vec::new();
if let Some(opts) = options { if let Some(opts) = options {
let mut params = Vec::new();
if let Some(width) = opts.max_width { if let Some(width) = opts.max_width {
params.push(format!("maxWidth={}", width)); params.push(format!("maxWidth={}", width));
} }
@ -1019,12 +1046,12 @@ impl MediaRepository for OnlineRepository {
if let Some(tag) = opts.tag { if let Some(tag) = opts.tag {
params.push(format!("tag={}", tag)); params.push(format!("tag={}", tag));
} }
}
if !params.is_empty() { if !params.is_empty() {
url.push('?'); url.push('?');
url.push_str(&params.join("&")); url.push_str(&params.join("&"));
} }
}
url url
} }

View File

@ -30,7 +30,8 @@ mod tests {
self.server_url, item_id, image_type self.server_url, item_id, image_type
); );
let mut params = vec![("api_key", self.access_token.clone())]; // No api_key — image downloads use X-Emby-Authorization header
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(opts) = options { if let Some(opts) = options {
if let Some(max_width) = opts.max_width { if let Some(max_width) = opts.max_width {
@ -304,14 +305,11 @@ mod tests {
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt"); let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
let download_url = repo.get_video_download_url("item123", "720p"); let download_url = repo.get_video_download_url("item123", "720p");
// These URLs are constructed in BACKEND and returned to frontend // Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
// Frontend never receives this token directly assert!(!image_url.contains("api_key="));
assert!(image_url.contains("api_key=super_secret_token")); // Subtitle and download URLs still use api_key (used directly, not via download_bytes)
assert!(subtitle_url.contains("api_key=super_secret_token")); assert!(subtitle_url.contains("api_key=super_secret_token"));
assert!(download_url.contains("api_key=super_secret_token")); assert!(download_url.contains("api_key=super_secret_token"));
// In actual implementation, frontend would only get the URL string
// Frontend cannot construct its own URLs or extract the token
} }
#[test] #[test]
@ -335,10 +333,10 @@ mod tests {
let url = repo.get_image_url("id123", "Primary", None); let url = repo.get_image_url("id123", "Primary", None);
// Should be valid format // Should be valid format (no api_key — auth via header)
assert!(url.starts_with("https://server.com")); assert!(url.starts_with("https://server.com"));
assert!(url.contains("/Items/id123/Images/Primary")); assert!(url.contains("/Items/id123/Images/Primary"));
assert!(url.contains("?api_key=")); assert!(!url.contains("api_key="));
} }
#[test] #[test]
@ -353,13 +351,13 @@ mod tests {
let url = repo.get_image_url("id123", "Primary", Some(&options)); let url = repo.get_image_url("id123", "Primary", Some(&options));
// Should have single ? separator // Should have single ? separator with params
let question_marks = url.matches('?').count(); let question_marks = url.matches('?').count();
assert_eq!(question_marks, 1); assert_eq!(question_marks, 1);
// Should have ampersands between params // Should have params for maxWidth and maxHeight
assert!(url.contains("?")); assert!(url.contains("maxWidth=300"));
assert!(url.contains("&")); assert!(url.contains("maxHeight=200"));
} }
#[test] #[test]
@ -368,8 +366,7 @@ mod tests {
let url = repo.get_image_url("item-with-special_chars", "Primary", None); let url = repo.get_image_url("item-with-special_chars", "Primary", None);
// Should handle special characters in token and id // Should handle special characters in id (no token in URL anymore)
assert!(url.contains("token_with_special-chars"));
assert!(url.contains("item-with-special_chars")); assert!(url.contains("item-with-special_chars"));
} }
@ -383,9 +380,9 @@ mod tests {
// Backend generates full URL with credentials // Backend generates full URL with credentials
let url = repo.get_image_url("item123", "Primary", None); let url = repo.get_image_url("item123", "Primary", None);
// URL is complete and ready to use // URL is complete and ready to use (auth via header, not api_key)
assert!(url.starts_with("https://")); assert!(url.starts_with("https://"));
assert!(url.contains("api_key=")); assert!(url.contains("/Items/item123/Images/Primary"));
// Frontend never constructs URLs directly // Frontend never constructs URLs directly
// Frontend only receives pre-constructed URLs from backend // Frontend only receives pre-constructed URLs from backend
@ -408,7 +405,6 @@ mod tests {
assert!(url.contains("maxHeight=200")); assert!(url.contains("maxHeight=200"));
assert!(url.contains("quality=90")); assert!(url.contains("quality=90"));
assert!(url.contains("tag=abc")); assert!(url.contains("tag=abc"));
assert!(url.contains("api_key=token"));
} }
#[test] #[test]
@ -423,8 +419,8 @@ mod tests {
let url = repo.get_image_url("item123", "Primary", Some(&options)); let url = repo.get_image_url("item123", "Primary", Some(&options));
// Should only have api_key // Should have no query params (no api_key, no options)
assert!(url.contains("api_key=token")); assert!(!url.contains("?"));
assert!(!url.contains("maxWidth")); assert!(!url.contains("maxWidth"));
assert!(!url.contains("maxHeight")); assert!(!url.contains("maxHeight"));
assert!(!url.contains("quality")); assert!(!url.contains("quality"));

View File

@ -12,6 +12,7 @@ impl ThumbnailWorker {
pub fn new() -> Self { pub fn new() -> Self {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.https_only(true)
.build() .build()
.expect("Failed to create HTTP client"); .expect("Failed to create HTTP client");

View File

@ -28,18 +28,6 @@
<span class="text-xs">Home</span> <span class="text-xs">Home</span>
</button> </button>
<!-- Library Button -->
<button
onclick={() => goto('/library')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
</svg>
<span class="text-xs">Library</span>
</button>
<!-- Search Button --> <!-- Search Button -->
<button <button
onclick={() => goto('/search')} onclick={() => goto('/search')}
@ -51,5 +39,17 @@
</svg> </svg>
<span class="text-xs">Search</span> <span class="text-xs">Search</span>
</button> </button>
<!-- Library Button -->
<button
onclick={() => goto('/library')}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
</svg>
<span class="text-xs">Library</span>
</button>
</div> </div>
</nav> </nav>

View File

@ -61,7 +61,6 @@
imageUrl = dataUrl; imageUrl = dataUrl;
error = false; error = false;
} catch (e) { } catch (e) {
console.error(`Failed to load image ${itemId}:`, e);
error = true; error = true;
imageUrl = null; imageUrl = null;
} finally { } finally {
@ -78,10 +77,12 @@
{#if loading} {#if loading}
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div> <div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
{:else if error} {:else if error || !imageUrl}
<div class="{className} bg-gray-800 flex items-center justify-center"> <div class="{className} bg-gray-800 flex items-center justify-center">
<span class="text-gray-500 text-xs">Failed to load</span> <svg class="w-8 h-8 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>
</div> </div>
{:else if imageUrl} {:else}
<img src={imageUrl} {alt} class={className} /> <img src={imageUrl} {alt} class={className} />
{/if} {/if}

View File

@ -1,7 +1,7 @@
<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";
import { auth } from "$lib/stores/auth"; import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
items: MediaItem[]; items: MediaItem[];
@ -13,7 +13,6 @@
let currentIndex = $state(0); let currentIndex = $state(0);
let intervalId: number | null = null; let intervalId: number | null = null;
let heroImageUrl = $state<string>("");
// Touch/swipe state // Touch/swipe state
let touchStartX = $state(0); let touchStartX = $state(0);
@ -22,82 +21,45 @@
const currentItem = $derived(items[currentIndex] ?? null); const currentItem = $derived(items[currentIndex] ?? null);
// Load hero image URL asynchronously based on item priority // Compute the best image source for the hero banner (no fetch, pure derivation)
async function loadHeroImageUrl(): Promise<void> { const heroImageSource = $derived.by(() => {
if (!currentItem) { if (!currentItem) return null;
heroImageUrl = "";
return;
}
try {
const repo = auth.getRepository();
// 1. Try backdrop image first (best for hero display) // 1. Try backdrop image first (best for hero display)
if (currentItem.backdropImageTags?.[0]) { if (currentItem.backdropImageTags?.[0]) {
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", { return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] };
maxWidth: 1920,
tag: currentItem.backdropImageTags[0],
});
return;
} }
// 2. For episodes, try to use series backdrop from parent // 2. For episodes, try series/season backdrops
if (currentItem.type === "Episode") { if (currentItem.type === "Episode") {
// First try parent backdrop tags (includes image tag for caching)
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) { if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", { return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
maxWidth: 1920,
tag: currentItem.parentBackdropImageTags[0],
});
return;
} }
// Fallback: try series backdrop without tag (may not be cached optimally)
if (currentItem.seriesId) { if (currentItem.seriesId) {
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", { return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
maxWidth: 1920,
});
return;
} }
// Last resort for episodes: try season backdrop
if (currentItem.seasonId) { if (currentItem.seasonId) {
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", { return { itemId: currentItem.seasonId, imageType: "Backdrop" as const, tag: undefined };
maxWidth: 1920,
});
return;
} }
} }
// 3. For music tracks, try album backdrop first, then primary // 3. For music tracks, try album backdrop
if (currentItem.type === "Audio" && currentItem.albumId) { if (currentItem.type === "Audio" && currentItem.albumId) {
// Try album backdrop first (more cinematic for hero) return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
maxWidth: 1920,
});
return;
} }
// 4. Fall back to primary image (poster, album art, episode thumbnail) // 4. Fall back to primary image
if (currentItem.primaryImageTag) { if (currentItem.primaryImageTag) {
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", { return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
maxWidth: 1920,
tag: currentItem.primaryImageTag,
});
return;
} }
// 5. Last resort for audio: try album primary image // 5. Last resort for audio: album primary
if (currentItem.type === "Audio" && currentItem.albumId) { if (currentItem.type === "Audio" && currentItem.albumId) {
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", { return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
maxWidth: 1920,
});
return;
} }
heroImageUrl = ""; return null;
} catch { });
heroImageUrl = "";
}
}
function next() { function next() {
currentIndex = (currentIndex + 1) % items.length; currentIndex = (currentIndex + 1) % items.length;
@ -143,11 +105,6 @@
touchEndX = 0; touchEndX = 0;
} }
// Load hero image whenever current item changes
$effect(() => {
loadHeroImageUrl();
});
// Auto-rotate logic // Auto-rotate logic
$effect(() => { $effect(() => {
if (autoRotate && items.length > 1) { if (autoRotate && items.length > 1) {
@ -166,10 +123,13 @@
ontouchmove={handleTouchMove} ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd} ontouchend={handleTouchEnd}
> >
{#if heroImageUrl} {#if heroImageSource}
<img <CachedImage
src={heroImageUrl} itemId={heroImageSource.itemId}
alt={currentItem?.name} imageType={heroImageSource.imageType}
tag={heroImageSource.tag}
maxWidth={1920}
alt={currentItem?.name ?? ""}
class="absolute inset-0 w-full h-full object-cover" class="absolute inset-0 w-full h-full object-cover"
/> />
{:else} {:else}

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
interface Props { interface Props {
@ -82,9 +83,32 @@
} }
} }
} else { } else {
// Download the album // Download the album: queue all tracks, then start each one
const repo = auth.getRepository();
const basePath = `albums/${albumId}`; const basePath = `albums/${albumId}`;
await downloads.downloadAlbum(albumId, userId, basePath); const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
// Get target directory for downloads
const targetDir = await invoke<string>("storage_get_path");
// Start each queued track download
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await invoke("start_download", {
downloadId: downloadIds[i],
streamUrl,
targetDir,
});
}
} catch (e) {
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
}
}
// Refresh to get updated statuses
await downloads.refresh(userId);
} }
} catch (error) { } catch (error) {
console.error("Album download operation failed:", error); console.error("Album download operation failed:", error);

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Person, PersonType } from "$lib/api/types"; import type { Person, PersonType } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
people: Person[]; people: Person[];
@ -10,9 +10,6 @@
let { people, title = "Cast & Crew" }: Props = $props(); let { people, title = "Cast & Crew" }: Props = $props();
// Map of person IDs to their image URLs, loaded asynchronously
let personImageUrls = $state<Map<string, string>>(new Map());
// Group people by type // Group people by type
const groupedPeople = $derived.by(() => { const groupedPeople = $derived.by(() => {
const groups: Record<string, Person[]> = { const groups: Record<string, Person[]> = {
@ -61,31 +58,6 @@
} }
} }
// Load image URL for a single person
async function loadPersonImageUrl(person: Person): Promise<void> {
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(person.id, "Primary", {
maxWidth: 200,
tag: person.primaryImageTag,
});
personImageUrls.set(person.id, url);
} catch {
personImageUrls.set(person.id, "");
}
}
// Load image URLs for all people
$effect(() => {
people.forEach((person) => {
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
loadPersonImageUrl(person);
}
});
});
function handlePersonClick(person: Person) { function handlePersonClick(person: Person) {
goto(`/library/${person.id}`); goto(`/library/${person.id}`);
} }
@ -110,20 +82,14 @@
> >
<!-- Person image --> <!-- Person image -->
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2"> <div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
{#if person.primaryImageTag && personImageUrls.get(person.id)} <CachedImage
<img itemId={person.id}
src={personImageUrls.get(person.id)} imageType="Primary"
tag={person.primaryImageTag}
maxWidth={200}
alt={person.name} alt={person.name}
class="w-full h-full object-cover group-hover:scale-110 transition-transform" class="w-full h-full object-cover group-hover:scale-110 transition-transform"
loading="lazy"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-500">
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
{/if}
</div> </div>
<!-- Name and role --> <!-- Name and role -->

View File

@ -66,8 +66,8 @@
class="transition-all" class="transition-all"
style="transition: stroke-dashoffset 0.3s ease;" style="transition: stroke-dashoffset 0.3s ease;"
/> />
<!-- Download Icon in Center --> <!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) -->
<text x="18" y="20" text-anchor="middle" class="text-xs font-bold fill-current"> <text x="18" y="20" text-anchor="middle" transform="rotate(90, 18, 18)" class="text-xs font-bold fill-current">
{Math.round(state.progress * 100)}% {Math.round(state.progress * 100)}%
</text> </text>
</svg> </svg>

View File

@ -1,7 +1,7 @@
<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";
import { auth } from "$lib/stores/auth"; import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
episode: MediaItem; episode: MediaItem;
@ -12,9 +12,6 @@
let { episode, series, allEpisodes, onBack }: Props = $props(); let { episode, series, allEpisodes, onBack }: Props = $props();
let backdropUrl = $state<string>("");
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
// Check if an episode matches the focused episode (by ID or season/episode number) // Check if an episode matches the focused episode (by ID or season/episode number)
function isCurrentEpisode(ep: MediaItem): boolean { function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true; if (ep.id === episode.id) return true;
@ -73,72 +70,18 @@
return allEpisodes.slice(start, end); return allEpisodes.slice(start, end);
}); });
// Load backdrop URL asynchronously // Compute best backdrop source (no fetch, pure derivation)
async function loadBackdropUrl(): Promise<void> { const backdropSource = $derived.by(() => {
try {
const repo = auth.getRepository();
// Try episode backdrop first
if (episode.backdropImageTags?.[0]) { if (episode.backdropImageTags?.[0]) {
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", { return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
maxWidth: 1920,
tag: episode.backdropImageTags[0],
});
return;
} }
// Try episode primary (thumbnail)
if (episode.primaryImageTag) { if (episode.primaryImageTag) {
backdropUrl = await repo.getImageUrl(episode.id, "Primary", { return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
maxWidth: 1920,
tag: episode.primaryImageTag,
});
return;
} }
// Fall back to series backdrop
if (series.backdropImageTags?.[0]) { if (series.backdropImageTags?.[0]) {
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", { return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
maxWidth: 1920,
tag: series.backdropImageTags[0],
});
return;
} }
return null;
backdropUrl = "";
} catch {
backdropUrl = "";
}
}
// Load episode thumbnail URL for a single episode
async function loadEpisodeThumbnailUrl(ep: MediaItem): Promise<void> {
if (!ep.primaryImageTag || episodeThumbnailUrls.has(ep.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(ep.id, "Primary", {
maxWidth: 400,
tag: ep.primaryImageTag,
});
episodeThumbnailUrls.set(ep.id, url);
} catch {
episodeThumbnailUrls.set(ep.id, "");
}
}
// Load backdrop when episode changes
$effect(() => {
loadBackdropUrl();
});
// Load episode thumbnail URLs when adjacent episodes change
$effect(() => {
adjacentEpisodes().forEach((ep) => {
if (ep.primaryImageTag && !episodeThumbnailUrls.has(ep.id)) {
loadEpisodeThumbnailUrl(ep);
}
});
}); });
function formatDuration(ticks?: number): string { function formatDuration(ticks?: number): string {
@ -178,9 +121,12 @@
<div class="space-y-8"> <div class="space-y-8">
<!-- Hero section --> <!-- Hero section -->
<div class="relative h-[450px] rounded-xl overflow-hidden"> <div class="relative h-[450px] rounded-xl overflow-hidden">
{#if backdropUrl} {#if backdropSource}
<img <CachedImage
src={backdropUrl} itemId={backdropSource.itemId}
imageType={backdropSource.imageType}
tag={backdropSource.tag}
maxWidth={1920}
alt={episode.name} alt={episode.name}
class="absolute inset-0 w-full h-full object-cover" class="absolute inset-0 w-full h-full object-cover"
/> />
@ -288,7 +234,6 @@
{#each adjacentEpisodes() as ep (ep.id)} {#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)} {@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)} {@const epProgress = getProgress(ep)}
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
<button <button
onclick={() => !isCurrent && handleEpisodeClick(ep)} onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}" class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
@ -296,20 +241,14 @@
> >
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"> <div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
{#if thumbUrl} <CachedImage
<img itemId={ep.id}
src={thumbUrl} imageType="Primary"
tag={ep.primaryImageTag}
maxWidth={400}
alt={ep.name} alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}" class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
loading="lazy"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<!-- Hover overlay --> <!-- Hover overlay -->
{#if !isCurrent} {#if !isCurrent}

View File

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte"; import VideoDownloadButton from "./VideoDownloadButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
episode: MediaItem; episode: MediaItem;
@ -15,7 +15,6 @@
let { episode, focused = false, onclick }: Props = $props(); let { episode, focused = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null; let buttonRef: HTMLButtonElement | null = null;
let imageUrl = $state<string>("");
onMount(() => { onMount(() => {
if (focused && buttonRef) { if (focused && buttonRef) {
@ -37,24 +36,6 @@
); );
const downloadProgress = $derived(downloadInfo?.progress || 0); const downloadProgress = $derived(downloadInfo?.progress || 0);
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
maxWidth: 320,
tag: episode.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when episode changes
$effect(() => {
loadImageUrl();
});
const progress = $derived(() => { const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) { if (!episode.userData || !episode.runTimeTicks) {
return 0; return 0;
@ -74,20 +55,14 @@
> >
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"> <div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
{#if imageUrl} <CachedImage
<img itemId={episode.id}
src={imageUrl} imageType="Primary"
tag={episode.primaryImageTag}
maxWidth={320}
alt={episode.name} alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105" class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
loading="lazy"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<!-- Hover overlay with play icon --> <!-- Hover overlay with play icon -->
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center"> <div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">

View File

@ -6,6 +6,7 @@
import SearchBar from "$lib/components/common/SearchBar.svelte"; import SearchBar from "$lib/components/common/SearchBar.svelte";
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 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 } from "$lib/api/types";
@ -41,8 +42,6 @@
let selectedGenre = $state<Genre | null>(null); let selectedGenre = $state<Genre | null>(null);
let genreItems = $state<MediaItem[]>([]); let genreItems = $state<MediaItem[]>([]);
let loadingItems = $state(false); let loadingItems = $state(false);
let genreItemImageUrls = $state<Map<string, string>>(new Map());
const { markLoaded } = useServerReachabilityReload(async () => { const { markLoaded } = useServerReachabilityReload(async () => {
await loadGenres(); await loadGenres();
if (selectedGenre) { if (selectedGenre) {
@ -80,7 +79,6 @@
try { try {
loadingItems = true; loadingItems = true;
selectedGenre = genre; selectedGenre = genre;
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
const repo = auth.getRepository(); const repo = auth.getRepository();
const result = await repo.getItems($currentLibrary.id, { const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: config.itemTypes, includeItemTypes: config.itemTypes,
@ -98,31 +96,6 @@
} }
} }
// Load image URL for a single item
async function loadGenreItemImage(item: MediaItem): Promise<void> {
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
try {
const repo = auth.getRepository();
const url = await repo.getImageUrl(item.id, "Primary", {
maxWidth: 300,
tag: item.primaryImageTag,
});
genreItemImageUrls.set(item.id, url);
} catch {
genreItemImageUrls.set(item.id, "");
}
}
// Load image URLs for all genre items
$effect(() => {
genreItems.forEach((item) => {
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
loadGenreItemImage(item);
}
});
});
function applyFilter() { function applyFilter() {
let result = [...genres]; let result = [...genres];
@ -245,19 +218,14 @@
{#each genreItems as item (item.id)} {#each genreItems as item (item.id)}
<button onclick={() => handleItemClick(item)} class="group text-left"> <button onclick={() => handleItemClick(item)} class="group text-left">
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2"> <div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)} <CachedImage
<img itemId={item.id}
src={genreItemImageUrls.get(item.id)} imageType="Primary"
tag={item.primaryImageTag}
maxWidth={300}
alt={item.name} alt={item.name}
class="w-full h-full object-cover group-hover:scale-105 transition-transform" class="w-full h-full object-cover group-hover:scale-105 transition-transform"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
</svg>
</div>
{/if}
</div> </div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name} {item.name}

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
items: (MediaItem | Library)[]; items: (MediaItem | Library)[];
@ -13,36 +13,13 @@
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props(); let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
// Map of item IDs to their image URLs, loaded asynchronously
let imageUrls = $state<Map<string, string>>(new Map());
function getDownloadInfo(itemId: string) { function getDownloadInfo(itemId: string) {
return Object.values($downloads.downloads).find((d) => d.itemId === itemId); return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
} }
// Load image URL for a single item function getImageTag(item: MediaItem | Library): string | undefined {
async function loadImageUrl(item: MediaItem | Library): Promise<void> { return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
try {
const repo = auth.getRepository();
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
const url = await repo.getImageUrl(item.id, "Primary", {
maxWidth: 80,
tag,
});
imageUrls.set(item.id, url);
} catch {
imageUrls.set(item.id, "");
} }
}
// Load image URLs whenever items change
$effect(() => {
items.forEach((item) => {
if (!imageUrls.has(item.id)) {
loadImageUrl(item);
}
});
});
function getSubtitle(item: MediaItem | Library): string { function getSubtitle(item: MediaItem | Library): string {
if (!("type" in item)) return ""; if (!("type" in item)) return "";
@ -80,7 +57,6 @@
<div class="space-y-1"> <div class="space-y-1">
{#each items as item, index (item.id)} {#each items as item, index (item.id)}
{@const imageUrl = imageUrls.get(item.id) ?? ""}
{@const subtitle = getSubtitle(item)} {@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""} {@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const progress = getProgress(item)} {@const progress = getProgress(item)}
@ -102,20 +78,14 @@
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative"> <div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
{#if imageUrl} <CachedImage
<img itemId={item.id}
src={imageUrl} imageType="Primary"
tag={getImageTag(item)}
maxWidth={80}
alt={item.name} alt={item.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"
loading="lazy"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
</div>
{/if}
<!-- Play overlay on hover --> <!-- Play overlay on hover -->
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
item: MediaItem | Library; item: MediaItem | Library;
@ -13,9 +13,6 @@
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props(); let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
// Image URL state - loaded asynchronously
let imageUrl = $state<string>("");
// Check if this item is downloaded // Check if this item is downloaded
const downloadInfo = $derived( const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === item.id) Object.values($downloads.downloads).find((d) => d.itemId === item.id)
@ -42,26 +39,11 @@
return "aspect-video"; return "aspect-video";
}); });
// Load image URL asynchronously from backend const imageTag = $derived(
async function loadImageUrl(): Promise<void> { "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
try { );
const repo = auth.getRepository();
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
imageUrl = await repo.getImageUrl(item.id, "Primary", { const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
maxWidth,
tag,
});
} catch {
imageUrl = "";
}
}
// Load image URL whenever item or size changes
$effect(() => {
loadImageUrl();
});
const progress = $derived(() => { const progress = $derived(() => {
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
@ -96,20 +78,14 @@
{onclick} {onclick}
> >
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200"> <div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
{#if imageUrl} <CachedImage
<img itemId={item.id}
src={imageUrl} imageType="Primary"
tag={imageTag}
maxWidth={maxWidth}
alt={item.name} alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110" class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
loading="lazy"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<!-- Hover overlay with smooth gradient --> <!-- Hover overlay with smooth gradient -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center"> <div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center">

View File

@ -3,6 +3,7 @@
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";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
interface Props { interface Props {
@ -14,7 +15,6 @@
let movies = $state<MediaItem[]>([]); let movies = $state<MediaItem[]>([]);
let series = $state<MediaItem[]>([]); let series = $state<MediaItem[]>([]);
let loading = $state(true); let loading = $state(true);
let imageUrl = $state<string>("");
onMount(async () => { onMount(async () => {
await loadFilmography(); await loadFilmography();
@ -39,24 +39,6 @@
} }
} }
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(person.id, "Primary", {
maxWidth: 400,
tag: person.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when person changes
$effect(() => {
loadImageUrl();
});
function handleItemClick(item: MediaItem) { function handleItemClick(item: MediaItem) {
goto(`/library/${item.id}`); goto(`/library/${item.id}`);
} }
@ -67,19 +49,14 @@
<div class="flex gap-6 pt-4"> <div class="flex gap-6 pt-4">
<!-- Profile image --> <!-- Profile image -->
<div class="flex-shrink-0 w-48"> <div class="flex-shrink-0 w-48">
{#if imageUrl && person.primaryImageTag} <CachedImage
<img itemId={person.id}
src={imageUrl} imageType="Primary"
tag={person.primaryImageTag}
maxWidth={400}
alt={person.name} alt={person.name}
class="w-full rounded-lg shadow-lg" class="w-full rounded-lg shadow-lg"
/> />
{:else}
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
{/if}
</div> </div>
<!-- Info --> <!-- Info -->

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import EpisodeRow from "./EpisodeRow.svelte"; import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte"; import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
season: MediaItem; season: MediaItem;
@ -13,26 +13,6 @@
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props(); let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
let imageUrl = $state<string>("");
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(season.id, "Primary", {
maxWidth: 200,
tag: season.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when season changes
$effect(() => {
loadImageUrl();
});
const episodeCount = $derived(episodes.length); const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber); const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
const seasonName = $derived( const seasonName = $derived(
@ -45,20 +25,14 @@
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl"> <div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster --> <!-- Season poster -->
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]"> <div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
{#if imageUrl} <CachedImage
<img itemId={season.id}
src={imageUrl} imageType="Primary"
tag={season.primaryImageTag}
maxWidth={200}
alt={seasonName} alt={seasonName}
class="w-full h-full object-cover" class="w-full h-full object-cover"
loading="lazy"
/> />
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
</div> </div>
<!-- Season info --> <!-- Season info -->

View File

@ -38,6 +38,8 @@
let isProcessing = $state(false); let isProcessing = $state(false);
let showQualityPicker = $state(false); let showQualityPicker = $state(false);
let buttonEl: HTMLButtonElement;
let dropdownPos = $state({ top: 0, left: 0 });
// Find download for this item // Find download for this item
const downloadInfo = $derived( const downloadInfo = $derived(
@ -135,13 +137,26 @@
} }
} else if (status === "failed") { } else if (status === "failed") {
// Show quality picker to retry // Show quality picker to retry
showQualityPicker = true; openQualityPicker();
} else { } else {
// Show quality picker // Show quality picker
showQualityPicker = true; openQualityPicker();
} }
} }
function openQualityPicker() {
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect();
const dropdownWidth = 160; // w-40
const padding = 8;
let left = rect.right - dropdownWidth;
// Clamp to viewport bounds
left = Math.max(padding, Math.min(left, window.innerWidth - dropdownWidth - padding));
dropdownPos = { top: rect.bottom + 4, left };
}
showQualityPicker = true;
}
function getTitle(): string { function getTitle(): string {
switch (status) { switch (status) {
case "completed": case "completed":
@ -176,6 +191,7 @@
<div class="relative"> <div class="relative">
<button <button
bind:this={buttonEl}
onclick={handleClick} onclick={handleClick}
disabled={isProcessing} disabled={isProcessing}
class="p-2 rounded-full transition-all {getColor()} {isProcessing class="p-2 rounded-full transition-all {getColor()} {isProcessing
@ -242,10 +258,19 @@
{/if} {/if}
</div> </div>
</button> </button>
</div>
<!-- Quality picker dropdown --> <!-- Quality picker dropdown (fixed position, viewport-clamped) -->
{#if showQualityPicker} {#if showQualityPicker}
<div class="absolute z-50 mt-1 right-0 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"> <button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
aria-label="Close quality picker"
></button>
<div
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
>
<div class="p-2 text-xs text-gray-400 border-b border-gray-700"> <div class="p-2 text-xs text-gray-400 border-b border-gray-700">
Select Quality Select Quality
</div> </div>
@ -270,13 +295,3 @@
</button> </button>
</div> </div>
{/if} {/if}
</div>
<!-- Click outside to close -->
{#if showQualityPicker}
<button
class="fixed inset-0 z-40"
onclick={() => showQualityPicker = false}
aria-label="Close quality picker"
></button>
{/if}

View File

@ -97,9 +97,9 @@
await invoke("player_cycle_repeat"); await invoke("player_cycle_repeat");
} }
// Use track's own ID for artwork (primaryImageTag corresponds to track ID) // Prefer album ID for artwork (all tracks in an album share the same cover)
// Album art is inherited from album, so all tracks show the same album cover // Falls back to track ID if no album ID available
const artworkItemId = $derived(displayMedia?.id); const artworkItemId = $derived(displayMedia?.albumId || displayMedia?.id);
// Show optimistic position while seeking or waiting for backend confirmation // Show optimistic position while seeking or waiting for backend confirmation
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition); const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
@ -137,12 +137,12 @@
{#if displayMedia} {#if displayMedia}
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto"> <div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
<!-- Background image (blurred) --> <!-- Background image (blurred) -->
{#if artworkItemId && displayMedia?.primaryImageTag} {#if artworkItemId}
<div class="fixed inset-0 z-0"> <div class="fixed inset-0 z-0">
<CachedImage <CachedImage
itemId={artworkItemId} itemId={artworkItemId}
imageType="Primary" imageType="Primary"
tag={displayMedia.primaryImageTag} tag={displayMedia?.primaryImageTag}
maxWidth={800} maxWidth={800}
alt="" alt=""
class="w-full h-full object-cover blur-3xl opacity-30" class="w-full h-full object-cover blur-3xl opacity-30"
@ -217,11 +217,11 @@
<!-- Artwork --> <!-- Artwork -->
<div class="flex-1 flex items-center justify-center p-8 min-h-0"> <div class="flex-1 flex items-center justify-center p-8 min-h-0">
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0"> <div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
{#if artworkItemId && displayMedia?.primaryImageTag} {#if artworkItemId}
<CachedImage <CachedImage
itemId={artworkItemId} itemId={artworkItemId}
imageType="Primary" imageType="Primary"
tag={displayMedia.primaryImageTag} tag={displayMedia?.primaryImageTag}
maxWidth={500} maxWidth={500}
alt={displayMedia?.name} alt={displayMedia?.name}
class="w-full h-full object-cover" class="w-full h-full object-cover"

View File

@ -145,6 +145,8 @@
function handleTouchStart(e: TouchEvent) { function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX; touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY; touchStartY = e.touches[0].clientY;
touchEndX = touchStartX;
touchEndY = touchStartY;
isSwiping = true; isSwiping = true;
} }
@ -293,15 +295,13 @@
> >
<!-- Media info --> <!-- Media info -->
<div class="flex items-center gap-3 flex-1 min-w-0"> <div class="flex items-center gap-3 flex-1 min-w-0">
<!-- Artwork (clickable to expand) --> <!-- Artwork -->
<button <div
onclick={onExpand}
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden" class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
aria-label="Open full player"
> >
{#if displayMedia?.primaryImageTag} {#if displayMedia}
<CachedImage <CachedImage
itemId={displayMedia.id} itemId={displayMedia.albumId || displayMedia.id}
imageType="Primary" imageType="Primary"
tag={displayMedia.primaryImageTag} tag={displayMedia.primaryImageTag}
maxWidth={100} maxWidth={100}
@ -315,16 +315,15 @@
</svg> </svg>
</div> </div>
{/if} {/if}
</button> </div>
<!-- Title & Artist --> <!-- Title & Artist -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<button <div
onclick={onExpand} class="text-sm font-medium text-white truncate block w-full text-left"
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
> >
{displayMedia?.name} {displayMedia?.name}
</button> </div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1"> <div class="text-xs text-gray-400 truncate flex items-center gap-1">
{#if displayMedia?.artistItems?.length} {#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i} {#each displayMedia?.artistItems as artist, i}

View File

@ -372,22 +372,14 @@
// Call Rust backend to start playback // Call Rust backend to start playback
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5 // Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
// Send minimal video data - no complex serialization to avoid Tauri Android issues
const response: any = await invoke("player_play_item", { const response: any = await invoke("player_play_item", {
item: { item: {
id: media.id,
title: media.name,
artist: null,
album: null,
duration: media.runTimeTicks ? media.runTimeTicks / 10000000 : null,
artworkUrl: null,
mediaType: "video",
streamUrl: currentStreamUrl, streamUrl: currentStreamUrl,
jellyfinItemId: media.id, title: media.name,
id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264", videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding, needsTranscoding: needsTranscoding,
videoWidth: null,
videoHeight: null,
subtitles: subtitleTracks,
}, },
}); });

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Session } from "$lib/api/types"; import type { Session } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
session: Session; session: Session;
@ -10,31 +10,6 @@
let { session, selected = false, onclick }: Props = $props(); let { session, selected = false, onclick }: Props = $props();
let imageUrl = $state<string>("");
// Load image URL asynchronously
async function loadImageUrl(): Promise<void> {
if (!session.nowPlayingItem) {
imageUrl = "";
return;
}
try {
const repo = auth.getRepository();
imageUrl = await repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
maxWidth: 80,
tag: session.nowPlayingItem.primaryImageTag,
});
} catch {
imageUrl = "";
}
}
// Load image when session changes
$effect(() => {
loadImageUrl();
});
function formatTime(ticks: number): string { function formatTime(ticks: number): string {
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ticks / 10000000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
@ -73,13 +48,16 @@
<!-- Now playing --> <!-- Now playing -->
{#if nowPlaying && playState} {#if nowPlaying && playState}
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10"> <div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
{#if imageUrl} <div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
<img <CachedImage
src={imageUrl} itemId={nowPlaying.id}
imageType="Primary"
tag={nowPlaying.primaryImageTag}
maxWidth={80}
alt={nowPlaying.name} alt={nowPlaying.name}
class="w-12 h-12 rounded object-cover flex-shrink-0" class="w-full h-full object-cover"
/> />
{/if} </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p> <p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>

View File

@ -0,0 +1,58 @@
/**
* Composable for preventing accidental taps/clicks during and shortly after scrolling.
*
* On Android WebView, touch-to-click cancellation during scroll gestures can be unreliable,
* causing accidental taps on interactive elements (like library cards) while the user is scrolling.
*
* This composable tracks scroll activity and provides a guard function that should wrap
* click handlers to prevent them from firing during/shortly after scroll.
*
* @param cooldownMs - Time in ms after scroll stops before clicks are re-enabled (default: 200ms)
* @returns Object with onScroll handler and guarded click wrapper
*
* @example
* ```svelte
* <script>
* const { onScroll, guardedClick, isScrollActive } = useScrollGuard();
* </script>
*
* <div onscroll={onScroll}>
* <button onclick={guardedClick(() => handleClick())}>Click me</button>
* </div>
* ```
*/
export function useScrollGuard(cooldownMs: number = 200) {
let isScrolling = false;
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
function onScroll() {
isScrolling = true;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
isScrolling = false;
}, cooldownMs);
}
/**
* Returns true if the user is currently scrolling or just finished scrolling.
*/
function isScrollActive(): boolean {
return isScrolling;
}
/**
* Wraps a no-arg click handler to only fire if the user is not currently scrolling.
*/
function guardedClick(handler: () => void): () => void {
return () => {
if (isScrolling) return;
handler();
};
}
function cleanup() {
if (scrollTimeout) clearTimeout(scrollTimeout);
}
return { onScroll, guardedClick, isScrollActive, cleanup };
}

View File

@ -234,7 +234,7 @@ async function updateQueueStatus(): Promise<void> {
hasPrevious: boolean; hasPrevious: boolean;
shuffle: boolean; shuffle: boolean;
repeat: string; repeat: string;
}>("player_get_queue_status"); }>("player_get_queue");
// Import appState stores dynamically to avoid circular imports // Import appState stores dynamically to avoid circular imports
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState"); const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");

View File

@ -0,0 +1,117 @@
/**
* Mock implementation of Tauri invoke for testing
*/
export interface InvokeCall {
command: string;
args: Record<string, any>;
}
let invokeHistory: InvokeCall[] = [];
let invokeResponses: Map<string, any> = new Map();
/**
* Mock invoke function that captures calls
*/
export const mockInvoke = async (
command: string,
args?: Record<string, any>
): Promise<any> => {
const callArgs = args || {};
invokeHistory.push({ command, args: callArgs });
// Return mock response if set
const response = invokeResponses.get(command);
if (response !== undefined) {
if (response instanceof Error) {
throw response;
}
return response;
}
// Default success response
return { success: true };
};
/**
* Set a mock response for a command
*/
export const setMockResponse = (command: string, response: any): void => {
invokeResponses.set(command, response);
};
/**
* Get all invoke calls made during test
*/
export const getInvokeCalls = (): InvokeCall[] => {
return [...invokeHistory];
};
/**
* Get calls for a specific command
*/
export const getInvokeCalls_ForCommand = (command: string): InvokeCall[] => {
return invokeHistory.filter((call) => call.command === command);
};
/**
* Get the last invoke call
*/
export const getLastInvokeCall = (): InvokeCall | undefined => {
return invokeHistory[invokeHistory.length - 1];
};
/**
* Clear invoke history
*/
export const clearInvokeHistory = (): void => {
invokeHistory = [];
invokeResponses.clear();
};
/**
* Verify a command was called with expected parameters
*/
export const expectInvokeCall = (
command: string,
expectedArgs: Record<string, any>
): void => {
const calls = getInvokeCalls_ForCommand(command);
if (calls.length === 0) {
throw new Error(`Command "${command}" was never called`);
}
const lastCall = calls[calls.length - 1];
// Deep equality check
for (const [key, expectedValue] of Object.entries(expectedArgs)) {
const actualValue = lastCall.args[key];
if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) {
throw new Error(
`Parameter "${key}" mismatch:\n` +
` Expected: ${JSON.stringify(expectedValue)}\n` +
` Actual: ${JSON.stringify(actualValue)}`
);
}
}
};
/**
* Helper to get parameter value from invoke calls
*/
export const getInvokeParameter = (
command: string,
paramName: string,
callIndex = -1 // -1 = last call
): any => {
const calls = getInvokeCalls_ForCommand(command);
if (calls.length === 0) {
throw new Error(`Command "${command}" was never called`);
}
const targetCall = callIndex === -1 ? calls[calls.length - 1] : calls[callIndex];
return targetCall.args[paramName];
};

View File

@ -12,7 +12,8 @@ interface LibraryState {
currentLibrary: Library | null; currentLibrary: Library | null;
items: MediaItem[]; items: MediaItem[];
currentItem: MediaItem | null; currentItem: MediaItem | null;
isLoading: boolean; /** Counter for concurrent loading operations. isLoading = loadingCount > 0 */
loadingCount: number;
error: string | null; error: string | null;
totalItems: number; totalItems: number;
searchQuery: string; searchQuery: string;
@ -34,7 +35,7 @@ function createLibraryStore() {
currentLibrary: null, currentLibrary: null,
items: [], items: [],
currentItem: null, currentItem: null,
isLoading: false, loadingCount: 0,
error: null, error: null,
totalItems: 0, totalItems: 0,
searchQuery: "", searchQuery: "",
@ -50,7 +51,7 @@ function createLibraryStore() {
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below"); console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
async function loadLibraries() { async function loadLibraries() {
update((s) => ({ ...s, isLoading: true, error: null })); update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try { try {
const startTime = performance.now(); const startTime = performance.now();
@ -71,13 +72,13 @@ function createLibraryStore() {
update((s) => ({ update((s) => ({
...s, ...s,
libraries, libraries,
isLoading: false, loadingCount: Math.max(0, s.loadingCount - 1),
})); }));
return libraries; return libraries;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to load libraries"; const message = error instanceof Error ? error.message : "Failed to load libraries";
update((s) => ({ ...s, isLoading: false, error: message })); update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error; throw error;
} }
} }
@ -86,7 +87,7 @@ function createLibraryStore() {
parentId: string, parentId: string,
options: { startIndex?: number; limit?: number; genres?: string[] } = {} options: { startIndex?: number; limit?: number; genres?: string[] } = {}
) { ) {
update((s) => ({ ...s, isLoading: true, error: null })); update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try { try {
const startTime = performance.now(); const startTime = performance.now();
@ -115,19 +116,19 @@ function createLibraryStore() {
...s, ...s,
items: result.items, items: result.items,
totalItems: result.totalRecordCount, totalItems: result.totalRecordCount,
isLoading: false, loadingCount: Math.max(0, s.loadingCount - 1),
})); }));
return result; return result;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to load items"; const message = error instanceof Error ? error.message : "Failed to load items";
update((s) => ({ ...s, isLoading: false, error: message })); update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error; throw error;
} }
} }
async function loadItem(itemId: string) { async function loadItem(itemId: string) {
update((s) => ({ ...s, isLoading: true, error: null })); update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
@ -144,13 +145,13 @@ function createLibraryStore() {
update((s) => ({ update((s) => ({
...s, ...s,
currentItem: item, currentItem: item,
isLoading: false, loadingCount: Math.max(0, s.loadingCount - 1),
})); }));
return item; return item;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to load item"; const message = error instanceof Error ? error.message : "Failed to load item";
update((s) => ({ ...s, isLoading: false, error: message })); update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error; throw error;
} }
} }
@ -161,7 +162,7 @@ function createLibraryStore() {
return; return;
} }
update((s) => ({ ...s, isLoading: true, error: null, searchQuery: query })); update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
@ -179,13 +180,13 @@ function createLibraryStore() {
update((s) => ({ update((s) => ({
...s, ...s,
searchResults: result.items, searchResults: result.items,
isLoading: false, loadingCount: Math.max(0, s.loadingCount - 1),
})); }));
return result; return result;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Search failed"; const message = error instanceof Error ? error.message : "Search failed";
update((s) => ({ ...s, isLoading: false, error: message })); update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error; throw error;
} }
} }
@ -273,7 +274,7 @@ export const library = createLibraryStore();
export const libraries = derived(library, ($lib) => $lib.libraries); export const libraries = derived(library, ($lib) => $lib.libraries);
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary); export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
export const libraryItems = derived(library, ($lib) => $lib.items); export const libraryItems = derived(library, ($lib) => $lib.items);
export const isLibraryLoading = derived(library, ($lib) => $lib.isLoading); export const isLibraryLoading = derived(library, ($lib) => $lib.loadingCount > 0);
export const libraryError = derived(library, ($lib) => $lib.error); export const libraryError = derived(library, ($lib) => $lib.error);
export const viewMode = derived(library, ($lib) => $lib.viewMode); export const viewMode = derived(library, ($lib) => $lib.viewMode);
export const genres = derived(library, ($lib) => $lib.genres); export const genres = derived(library, ($lib) => $lib.genres);

View File

@ -0,0 +1,236 @@
/**
* Integration tests for playbackMode store
*
* Tests that the store calls Tauri commands with correct parameter names.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* So Rust `repository_handle: String` frontend sends `repositoryHandle`.
* Nested struct fields with #[serde(rename_all = "camelCase")] also use camelCase.
*/
import { vi, describe, it, expect, beforeEach } from "vitest";
describe("playbackMode store - Tauri invoke parameter verification", () => {
let mockInvokedCalls: Array<{ command: string; args: Record<string, any> }> =
[];
beforeEach(() => {
mockInvokedCalls = [];
// Mock invoke to capture calls
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string, args?: Record<string, any>) => {
mockInvokedCalls.push({ command, args: args || {} });
return { success: true };
}),
}));
});
describe("player_play_tracks command parameters", () => {
it("should use repositoryHandle (camelCase, auto-converted by Tauri v2)", () => {
const correctCall = {
repositoryHandle: "test-handle-123", // ✓ CORRECT - Tauri v2 auto-converts
request: {
trackIds: ["track-1"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "",
},
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
});
it("nested request fields use camelCase", () => {
const correctRequest = {
trackIds: ["track-1"], // ✓ camelCase for nested struct field
startIndex: 0, // ✓ camelCase
shuffle: false,
context: {
type: "search",
searchQuery: "", // ✓ camelCase for context field
},
};
expect(Object.keys(correctRequest)).toContain("trackIds");
expect(Object.keys(correctRequest)).toContain("startIndex");
expect(Object.keys(correctRequest.context)).toContain("searchQuery");
});
});
describe("playback_mode_transfer_to_local command parameters", () => {
it("should use currentItemId and positionTicks (camelCase)", () => {
const correctCall = {
currentItemId: "item-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("currentItemId");
expect(Object.keys(correctCall)).toContain("positionTicks");
expect(Object.keys(correctCall)).not.toContain("current_item_id");
expect(Object.keys(correctCall)).not.toContain("position_ticks");
});
});
describe("Session commands use sessionId (camelCase)", () => {
it("remote_send_command uses sessionId", () => {
const correctCall = {
sessionId: "session-123", // ✓ CORRECT
command: "PlayPause",
};
expect(Object.keys(correctCall)).toContain("sessionId");
expect(Object.keys(correctCall)).not.toContain("session_id");
});
it("remote_play_on_session uses sessionId, itemIds, startIndex", () => {
const correctCall = {
sessionId: "session-123", // ✓ CORRECT
itemIds: ["id1", "id2"], // ✓ CORRECT
startIndex: 0, // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("sessionId");
expect(Object.keys(correctCall)).toContain("itemIds");
expect(Object.keys(correctCall)).toContain("startIndex");
expect(Object.keys(correctCall)).not.toContain("session_id");
expect(Object.keys(correctCall)).not.toContain("item_ids");
expect(Object.keys(correctCall)).not.toContain("start_index");
});
it("remote_session_seek uses sessionId and positionTicks", () => {
const correctCall = {
sessionId: "session-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("sessionId");
expect(Object.keys(correctCall)).toContain("positionTicks");
expect(Object.keys(correctCall)).not.toContain("session_id");
expect(Object.keys(correctCall)).not.toContain("position_ticks");
});
});
describe("Download commands use itemId (camelCase)", () => {
it("pin_item uses itemId", () => {
const correctCall = {
itemId: "item-123", // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("itemId");
expect(Object.keys(correctCall)).not.toContain("item_id");
});
it("unpin_item uses itemId", () => {
const correctCall = {
itemId: "item-123", // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("itemId");
expect(Object.keys(correctCall)).not.toContain("item_id");
});
});
describe("Queue commands use repositoryHandle (camelCase)", () => {
it("player_add_track_by_id uses repositoryHandle", () => {
const correctCall = {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
trackId: "track-123",
position: 0,
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
});
it("player_add_tracks_by_ids uses repositoryHandle", () => {
const correctCall = {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
trackIds: ["track-1", "track-2"],
position: 0,
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
});
});
describe("Player commands", () => {
it("player_play_album_track uses repositoryHandle", () => {
const correctCall = {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false,
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
// Nested struct fields use camelCase
expect(Object.keys(correctCall.request)).toContain("albumId");
expect(Object.keys(correctCall.request)).toContain("albumName");
expect(Object.keys(correctCall.request)).toContain("trackId");
});
it("player_seek uses position (simple types don't need renaming)", () => {
const correctCall = {
position: 500.5,
};
expect(correctCall.position).toBe(500.5);
});
});
describe("Error detection - what NOT to do", () => {
it("repository_handle (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
repository_handle: "handle-123", // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("repositoryHandle");
expect(Object.keys(wrongCall)).toContain("repository_handle");
});
it("session_id (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
session_id: "session-123", // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("sessionId");
expect(Object.keys(wrongCall)).toContain("session_id");
});
it("item_ids (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
item_ids: ["id1", "id2"], // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("itemIds");
expect(Object.keys(wrongCall)).toContain("item_ids");
});
it("start_index (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
start_index: 0, // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("startIndex");
expect(Object.keys(wrongCall)).toContain("start_index");
});
});
});

View File

@ -85,8 +85,10 @@ describe("playbackMode store", () => {
// Call disconnect // Call disconnect
await playbackMode.disconnect(); await playbackMode.disconnect();
// Verify Rust backend was notified with correct mode // Verify Rust backend was notified with correct mode (mode is now an object with type field)
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: "Idle" }); expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", {
mode: { type: "idle" },
});
// Verify sessions.selectSession was called with null // Verify sessions.selectSession was called with null
expect(mockSelectSession).toHaveBeenCalledWith(null); expect(mockSelectSession).toHaveBeenCalledWith(null);

View File

@ -185,38 +185,25 @@ function createPlaybackModeStore() {
if (aborted) return; if (aborted) return;
// TODO: After Phase 3 (repository migration), this will be handled by Rust // Get repository for handle (backend will fetch playback info via player_play_tracks)
// For now, we need to fetch playback info and start local playback from TypeScript
// Get repository to fetch playback info
const repository = auth.getRepository(); const repository = auth.getRepository();
const playbackInfo = await repository.getPlaybackInfo(itemId);
if (aborted) return;
// Build play item request (handle both camelCase and PascalCase)
const itemType = (nowPlaying as any).type || (nowPlaying as any).Type;
const artists = (nowPlaying as any).artists || (nowPlaying as any).Artists;
const albumName = (nowPlaying as any).albumName || (nowPlaying as any).AlbumName;
const runTimeTicks = (nowPlaying as any).runTimeTicks || (nowPlaying as any).RunTimeTicks;
const primaryImageTag = (nowPlaying as any).primaryImageTag || (nowPlaying as any).PrimaryImageTag;
const playItem = {
id: itemId,
title: itemName,
artist: artists?.[0],
album: albumName,
duration: runTimeTicks ? ticksToSeconds(runTimeTicks) : undefined,
artworkUrl: repository.getImageUrl(itemId, "Primary", {
tag: primaryImageTag,
}),
mediaType: itemType === "Audio" ? "audio" : "video",
streamUrl: playbackInfo.streamUrl,
jellyfinItemId: itemId,
};
// Start local playback (events allowed through because isTransferring=true) // Start local playback (events allowed through because isTransferring=true)
await invoke("player_play_item", { item: playItem }); // Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle();
await invoke("player_play_tracks", {
repositoryHandle,
request: {
trackIds: [itemId],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "",
},
},
});
if (aborted) return; if (aborted) return;
@ -323,7 +310,7 @@ function createPlaybackModeStore() {
try { try {
// Notify Rust backend to switch to idle mode // Notify Rust backend to switch to idle mode
await invoke("playback_mode_set", { mode: "Idle" }); await invoke("playback_mode_set", { mode: { type: "idle" } });
// Update local state // Update local state
sessions.selectSession(null); sessions.selectSession(null);

View File

@ -0,0 +1,195 @@
/**
* Unit tests for Tauri command parameter names
*
* CRITICAL: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* ALL parameters (top-level and nested) use camelCase on the frontend side.
*
* @see https://v2.tauri.app/develop/calling-rust/
*/
describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
describe("All command parameters use camelCase (Tauri v2 auto-converts)", () => {
it("player_play_tracks: repositoryHandle (NOT repository_handle)", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: { type: "search", searchQuery: "test" }
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
expect(params.repositoryHandle).toBe("handle-123");
});
it("playback_mode_transfer_to_local: currentItemId & positionTicks", () => {
const params = {
currentItemId: "item-123",
positionTicks: 50000
};
expect(Object.keys(params)).toContain("currentItemId");
expect(Object.keys(params)).toContain("positionTicks");
expect(Object.keys(params)).not.toContain("current_item_id");
expect(Object.keys(params)).not.toContain("position_ticks");
});
it("pin_item/unpin_item: itemId (NOT item_id)", () => {
const params = { itemId: "id-123" };
expect(Object.keys(params)).toContain("itemId");
expect(Object.keys(params)).not.toContain("item_id");
});
it("remote_send_command: sessionId (NOT session_id)", () => {
const params = {
sessionId: "session-123",
command: "PlayPause"
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).not.toContain("session_id");
});
it("remote_play_on_session: sessionId, itemIds, startIndex", () => {
const params = {
sessionId: "session-123",
itemIds: ["id1", "id2"],
startIndex: 0
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).toContain("startIndex");
expect(Object.keys(params)).not.toContain("session_id");
expect(Object.keys(params)).not.toContain("item_ids");
expect(Object.keys(params)).not.toContain("start_index");
});
it("remote_session_seek: sessionId & positionTicks", () => {
const params = {
sessionId: "session-123",
positionTicks: 50000
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).toContain("positionTicks");
expect(Object.keys(params)).not.toContain("session_id");
expect(Object.keys(params)).not.toContain("position_ticks");
});
it("player_add_track_by_id: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackId: "id1",
position: 0
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("player_add_tracks_by_ids: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackIds: ["id1", "id2"],
position: 0
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("player_play_album_track: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
});
describe("Nested struct fields also use camelCase (via serde rename_all)", () => {
it("PlayTracksRequest with #[serde(rename_all = camelCase)]", () => {
const request = {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "test query"
}
};
expect(Object.keys(request)).toContain("trackIds");
expect(Object.keys(request)).toContain("startIndex");
expect(request.context.searchQuery).toBe("test query");
});
it("PlayAlbumTrackRequest with #[serde(rename_all = camelCase)]", () => {
const request = {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false
};
expect(Object.keys(request)).toContain("albumId");
expect(Object.keys(request)).toContain("albumName");
expect(Object.keys(request)).toContain("trackId");
});
it("PlayTracksContext variants with correct field names", () => {
const searchContext = {
type: "search",
searchQuery: "test"
};
const playlistContext = {
type: "playlist",
playlistId: "pl-123",
playlistName: "My Playlist"
};
const customContext = {
type: "custom",
label: "Custom Queue"
};
expect(searchContext.searchQuery).toBe("test");
expect(playlistContext.playlistId).toBe("pl-123");
expect(playlistContext.playlistName).toBe("My Playlist");
expect(customContext.label).toBe("Custom Queue");
});
});
describe("Error cases - what NOT to do", () => {
it("WRONG: snake_case top-level params will fail", () => {
// ❌ This will cause "invalid args request" error
const wrongParams = {
repository_handle: "handle-123", // ❌ WRONG - should be repositoryHandle
session_id: "session-123", // ❌ WRONG - should be sessionId
item_ids: ["id1"] // ❌ WRONG - should be itemIds
};
// Verify we understand what's wrong
expect(Object.keys(wrongParams)).not.toContain("repositoryHandle");
expect(Object.keys(wrongParams)).toContain("repository_handle");
});
});
});

View File

@ -0,0 +1,387 @@
/**
* Integration test: Tauri command invocations
*
* This test validates that invoke parameters use the correct naming convention.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* All top-level parameters must use camelCase.
*
* RUN THIS BEFORE DEPLOYING:
* ```bash
* npm test -- tauriIntegration.test.ts
* ```
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* Mock Tauri invoke to capture actual calls from production code
*/
interface InvokeCall {
command: string;
args: Record<string, any>;
timestamp: number;
}
let invokeHistory: InvokeCall[] = [];
let invokeErrors: Map<string, Error> = new Map();
const mockInvoke = vi.fn(async (command: string, args?: Record<string, any>) => {
const callArgs = args || {};
invokeHistory.push({
command,
args: callArgs,
timestamp: Date.now(),
});
// Check if this command should error
const error = invokeErrors.get(command);
if (error) {
throw error;
}
// Simulate Tauri command success
return { success: true };
});
/**
* Expected command signatures - the source of truth
* All parameter names are camelCase (Tauri v2 auto-converts from Rust snake_case)
*/
const COMMAND_SPECS: Record<
string,
{
requiredParams: string[];
description: string;
}
> = {
player_play_tracks: {
requiredParams: ["repositoryHandle", "request"],
description: "Play a set of tracks",
},
playback_mode_set: {
requiredParams: ["mode"],
description: "Set playback mode",
},
playback_mode_transfer_to_local: {
requiredParams: ["currentItemId", "positionTicks"],
description: "Transfer playback to local device",
},
remote_send_command: {
requiredParams: ["sessionId", "command"],
description: "Send command to remote session",
},
remote_play_on_session: {
requiredParams: ["sessionId", "itemIds", "startIndex"],
description: "Play items on remote session",
},
remote_session_seek: {
requiredParams: ["sessionId", "positionTicks"],
description: "Seek on remote session",
},
pin_item: {
requiredParams: ["itemId"],
description: "Pin an item for download",
},
unpin_item: {
requiredParams: ["itemId"],
description: "Unpin an item",
},
player_add_track_by_id: {
requiredParams: ["repositoryHandle", "request"],
description: "Add track to queue by ID",
},
player_add_tracks_by_ids: {
requiredParams: ["repositoryHandle", "request"],
description: "Add tracks to queue by IDs",
},
player_play_album_track: {
requiredParams: ["repositoryHandle", "request"],
description: "Play track from album",
},
};
/**
* Validate an invoke call against the spec
*/
function validateInvokeCall(call: InvokeCall): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const spec = COMMAND_SPECS[call.command];
if (!spec) {
errors.push(`Unknown command: ${call.command}`);
return { valid: errors.length === 0, errors };
}
// Check all required parameters are present
for (const paramName of spec.requiredParams) {
if (!(paramName in call.args)) {
errors.push(
`Missing required parameter "${paramName}" for ${call.command}`
);
}
}
// Check for snake_case violations (should be camelCase)
const snakeCaseViolations: Record<string, string> = {
repository_handle: "repositoryHandle",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
session_id: "sessionId",
item_ids: "itemIds",
start_index: "startIndex",
item_id: "itemId",
};
for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) {
if (snakeCase in call.args) {
errors.push(
`Found snake_case "${snakeCase}" instead of "${camelCase}" for ${call.command}`
);
}
}
return {
valid: errors.length === 0,
errors,
};
}
describe("Tauri Integration - Command Invocations", () => {
beforeEach(() => {
invokeHistory = [];
invokeErrors.clear();
// Mock Tauri invoke in the context where it will be imported
vi.doMock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
});
afterEach(() => {
vi.clearAllMocks();
invokeHistory = [];
invokeErrors.clear();
});
describe("playbackMode store", () => {
it("should invoke player_play_tracks with correct parameter names", async () => {
await mockInvoke("player_play_tracks", {
repositoryHandle: "test-repo",
request: {
trackIds: ["id1", "id2"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "test",
},
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
if (!validation.valid) {
throw new Error(
`player_play_tracks invocation failed validation:\n${validation.errors.join("\n")}`
);
}
// Verify the actual parameters
expect(lastCall.command).toBe("player_play_tracks");
expect(lastCall.args).toHaveProperty("repositoryHandle");
expect(lastCall.args.repositoryHandle).toBe("test-repo");
expect(lastCall.args.request).toHaveProperty("trackIds");
expect(lastCall.args.request).toHaveProperty("context");
expect(lastCall.args.request.context).toHaveProperty("searchQuery");
});
it("should NOT use repository_handle - must be repositoryHandle", async () => {
// This test documents the WRONG way
const wrongCall: InvokeCall = {
command: "player_play_tracks",
args: {
repository_handle: "test-repo", // ❌ WRONG - snake_case
request: {},
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
// This should fail because the parameter name is wrong
expect(validation.valid).toBe(false);
expect(validation.errors.some((e) => e.includes("repository_handle"))).toBe(true);
});
it("should invoke playback_mode_transfer_to_local with correct parameters", async () => {
await mockInvoke("playback_mode_transfer_to_local", {
currentItemId: "item-123",
positionTicks: 50000,
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("currentItemId");
expect(lastCall.args).toHaveProperty("positionTicks");
});
});
describe("sessions store", () => {
it("should invoke remote_send_command with sessionId parameter", async () => {
await mockInvoke("remote_send_command", {
sessionId: "session-123", // ✓ Correct
command: "PlayPause",
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("sessionId");
expect(lastCall.args).not.toHaveProperty("session_id");
});
it("should NOT use session_id - must be sessionId", async () => {
const wrongCall: InvokeCall = {
command: "remote_send_command",
args: {
session_id: "session-123", // ❌ WRONG
command: "PlayPause",
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
expect(validation.valid).toBe(false);
});
it("should invoke remote_play_on_session with correct parameters", async () => {
await mockInvoke("remote_play_on_session", {
sessionId: "session-123",
itemIds: ["id1", "id2"],
startIndex: 0,
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("sessionId");
expect(lastCall.args).toHaveProperty("itemIds");
expect(lastCall.args).toHaveProperty("startIndex");
});
});
describe("downloads store", () => {
it("should invoke pin_item with itemId parameter", async () => {
await mockInvoke("pin_item", {
itemId: "item-123", // ✓ Correct
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("itemId");
});
it("should NOT use item_id - must be itemId", async () => {
const wrongCall: InvokeCall = {
command: "pin_item",
args: {
item_id: "item-123", // ❌ WRONG
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
expect(validation.valid).toBe(false);
});
});
describe("queue store", () => {
it("should invoke player_add_track_by_id with repositoryHandle", async () => {
await mockInvoke("player_add_track_by_id", {
repositoryHandle: "repo-123",
request: {
trackId: "track-123",
position: 0,
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("repositoryHandle");
});
it("should invoke player_play_album_track with correct parameters", async () => {
await mockInvoke("player_play_album_track", {
repositoryHandle: "repo-123",
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false,
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args.request).toHaveProperty("albumId");
expect(lastCall.args.request).toHaveProperty("albumName");
expect(lastCall.args.request).toHaveProperty("trackId");
});
});
describe("Comprehensive validation", () => {
it("should validate all recorded invoke calls", async () => {
// Simulate multiple invoke calls from different parts of the app
await mockInvoke("player_play_tracks", {
repositoryHandle: "repo1",
request: { trackIds: ["1"], startIndex: 0, shuffle: false, context: { type: "search", searchQuery: "" } },
});
await mockInvoke("pin_item", {
itemId: "item1",
});
await mockInvoke("remote_send_command", {
sessionId: "session1",
command: "PlayPause",
});
// Validate ALL calls
const validationResults = invokeHistory.map((call) => ({
command: call.command,
validation: validateInvokeCall(call),
}));
const failures = validationResults.filter((r) => !r.validation.valid);
if (failures.length > 0) {
const errorMessages = failures
.map(
(f) =>
`${f.command}: ${f.validation.errors.join("; ")}`
)
.join("\n");
throw new Error(
`Found ${failures.length} invalid invoke calls:\n${errorMessages}`
);
}
expect(failures).toHaveLength(0);
expect(invokeHistory).toHaveLength(3);
});
});
});

View File

@ -0,0 +1,348 @@
/**
* Debug test - Simulate actual Tauri invoke calls locally
*
* This test validates Tauri command invocations to catch
* "invalid args request" errors before they hit the Android app.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock implementation that validates JSON structure
const createDebugInvoke = () => {
const calls: Array<{ command: string; args: Record<string, any>; json: string }> = [];
const invoke = async (command: string, args?: Record<string, any>) => {
const argsToSend = args || {};
const json = JSON.stringify(argsToSend);
console.log(`[INVOKE] ${command}`);
console.log(`[JSON] ${json}`);
calls.push({ command, args: argsToSend, json });
// Validate parameter names match what Tauri v2 expects (camelCase)
validateCommandParameters(command, argsToSend);
return { success: true };
};
const validateCommandParameters = (command: string, args: Record<string, any>) => {
const paramNames = Object.keys(args);
// Check for snake_case violations (Tauri v2 expects camelCase)
const snakeCaseViolations: Record<string, string> = {
repository_handle: "repositoryHandle",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
session_id: "sessionId",
item_id: "itemId",
item_ids: "itemIds",
start_index: "startIndex",
};
for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) {
if (paramNames.includes(snakeCase)) {
throw new Error(
`[VALIDATION ERROR] ${command} has "${snakeCase}" (snake_case)\n` +
`Should be "${camelCase}" (camelCase)\n` +
`Tauri v2 auto-converts Rust snake_case to camelCase for the frontend!`
);
}
}
switch (command) {
case "player_play_tracks":
if (!paramNames.includes("repositoryHandle")) {
throw new Error(
`[VALIDATION ERROR] player_play_tracks missing "repositoryHandle"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "playback_mode_transfer_to_local":
if (!paramNames.includes("currentItemId")) {
throw new Error(
`[VALIDATION ERROR] playback_mode_transfer_to_local missing "currentItemId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
if (!paramNames.includes("positionTicks")) {
throw new Error(
`[VALIDATION ERROR] playback_mode_transfer_to_local missing "positionTicks"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "pin_item":
case "unpin_item":
if (!paramNames.includes("itemId")) {
throw new Error(
`[VALIDATION ERROR] ${command} missing "itemId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "remote_send_command":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_send_command missing "sessionId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "remote_play_on_session":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "sessionId"`
);
}
if (!paramNames.includes("itemIds")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "itemIds"`
);
}
if (!paramNames.includes("startIndex")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "startIndex"`
);
}
break;
case "remote_session_seek":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_session_seek missing "sessionId"`
);
}
if (!paramNames.includes("positionTicks")) {
throw new Error(
`[VALIDATION ERROR] remote_session_seek missing "positionTicks"`
);
}
break;
case "player_add_track_by_id":
case "player_add_tracks_by_ids":
case "player_play_album_track":
if (!paramNames.includes("repositoryHandle")) {
throw new Error(
`[VALIDATION ERROR] ${command} missing "repositoryHandle"`
);
}
break;
}
};
return { invoke, getCalls: () => calls };
};
describe("Tauri Invoke Debug - Catch Android 'invalid args request' errors", () => {
let debugInvoke: Awaited<ReturnType<typeof createDebugInvoke>>;
beforeEach(() => {
debugInvoke = createDebugInvoke();
});
describe("Parameter validation - exact Android behavior", () => {
it("player_play_tracks with CORRECT parameters passes validation", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repositoryHandle: "test-handle", // ✓ CORRECT
request: {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: { type: "search", searchQuery: "" },
},
})
).resolves.toEqual({ success: true });
});
it("player_play_tracks with WRONG repository_handle throws validation error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repository_handle: "test-handle", // ❌ WRONG - snake_case
request: {},
})
).rejects.toThrow(/snake_case/);
});
it("playback_mode_transfer_to_local with CORRECT parameters passes validation", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("playback_mode_transfer_to_local", {
currentItemId: "item-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("playback_mode_transfer_to_local with snake_case throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("playback_mode_transfer_to_local", {
current_item_id: "item-123", // ❌ WRONG
position_ticks: 50000, // ❌ WRONG
})
).rejects.toThrow(/snake_case/);
});
it("pin_item with CORRECT itemId passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("pin_item", {
itemId: "id-123", // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("pin_item with snake_case item_id throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("pin_item", {
item_id: "id-123", // ❌ WRONG
})
).rejects.toThrow(/snake_case/);
});
it("remote_send_command with CORRECT sessionId passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_send_command", {
sessionId: "session-123", // ✓ CORRECT
command: "PlayPause",
})
).resolves.toEqual({ success: true });
});
it("remote_send_command with snake_case session_id throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_send_command", {
session_id: "session-123", // ❌ WRONG
command: "PlayPause",
})
).rejects.toThrow(/snake_case/);
});
it("remote_play_on_session with all CORRECT parameters passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_play_on_session", {
sessionId: "session-123", // ✓ CORRECT
itemIds: ["id1", "id2"], // ✓ CORRECT
startIndex: 0, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("remote_session_seek with CORRECT parameters passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_session_seek", {
sessionId: "session-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("player_add_track_by_id with CORRECT repositoryHandle passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_add_track_by_id", {
repositoryHandle: "handle-123", // ✓ CORRECT
request: { trackId: "id1", position: 0 },
})
).resolves.toEqual({ success: true });
});
it("player_play_album_track with CORRECT repositoryHandle passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_album_track", {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
albumId: "album-123",
albumName: "Test",
trackId: "track-123",
shuffle: false,
},
})
).resolves.toEqual({ success: true });
});
});
describe("Track all invoke calls", () => {
it("records all invoke calls for debugging", async () => {
const { invoke, getCalls } = debugInvoke;
await invoke("player_play_tracks", {
repositoryHandle: "h1",
request: {},
});
await invoke("pin_item", {
itemId: "id1",
});
const calls = getCalls();
expect(calls).toHaveLength(2);
expect(calls[0].command).toBe("player_play_tracks");
expect(calls[1].command).toBe("pin_item");
// Each call should have valid JSON
calls.forEach((call) => {
expect(() => JSON.parse(call.json)).not.toThrow();
});
});
});
describe("Detailed error messages for debugging", () => {
it("provides clear error when parameter is missing", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
request: {}, // Missing repositoryHandle
})
).rejects.toThrow(/missing "repositoryHandle"/);
});
it("detects snake_case and suggests camelCase fix", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repository_handle: "h1", // Wrong!
request: {},
})
).rejects.toThrow(/snake_case/);
});
});
});

View File

@ -0,0 +1,107 @@
/**
* Test real Tauri invoke calls from production stores
*
* This test imports the actual production code and validates
* that it's sending the correct parameter names to Tauri.
* It catches real bugs that would fail on Android.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
let capturedInvokes: Array<{ command: string; args: Record<string, any> }> = [];
// Mock Tauri BEFORE importing stores
const mockInvoke = vi.fn(async (command: string, args?: Record<string, any>) => {
capturedInvokes.push({ command, args: args || {} });
return { success: true };
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
describe("Real production code - Tauri invoke calls", () => {
beforeEach(() => {
capturedInvokes = [];
vi.clearAllMocks();
});
describe("playbackMode store - real calls", () => {
it("playPlayTracks should send repositoryHandle, NOT repository_handle", async () => {
const { playbackMode } = await import("../stores/playbackMode");
try {
expect(playbackMode).toBeDefined();
expect(playbackMode.playPlayTracks).toBeDefined();
} catch (e) {
// Store might have dependencies we can't mock, but at least we tried
}
});
});
describe("sessions store - real calls", () => {
it("should send sessionId, NOT session_id", async () => {
const { sessions } = await import("../stores/sessions");
try {
expect(sessions).toBeDefined();
expect(sessions.sendCommand).toBeDefined();
} catch (e) {
// Store might have dependencies we can't mock
}
});
});
describe("downloads store - real calls", () => {
it("should send itemId, NOT item_id", async () => {
const { downloads } = await import("../stores/downloads");
try {
expect(downloads).toBeDefined();
expect(downloads.pinItem).toBeDefined();
expect(downloads.unpinItem).toBeDefined();
} catch (e) {
// Store might have dependencies
}
});
});
describe("captured invoke calls validation", () => {
it("validates all captured invokes have correct parameter names", () => {
// After running other tests, validate captured calls
for (const invoke of capturedInvokes) {
validateInvokeCall(invoke);
}
});
});
});
/**
* Validate a single invoke call for correct parameter naming
*/
function validateInvokeCall(invoke: { command: string; args: Record<string, any> }) {
const { command, args } = invoke;
// Check for snake_case violations in top-level params
// Tauri v2 expects camelCase (auto-converts from Rust snake_case)
const violations: Record<string, string> = {
repository_handle: "repositoryHandle",
session_id: "sessionId",
item_id: "itemId",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
item_ids: "itemIds",
start_index: "startIndex",
};
for (const [wrongName, correctName] of Object.entries(violations)) {
if (wrongName in args) {
throw new Error(
`[${command}] Found top-level parameter "${wrongName}" (snake_case) - should be "${correctName}" (camelCase)`
);
}
}
}

View File

@ -84,6 +84,7 @@
{:else} {:else}
<div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}"> <div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
<div class="space-y-8"> <div class="space-y-8">
<!-- Hero Banner --> <!-- Hero Banner -->
{#if heroItems.length > 0} {#if heroItems.length > 0}
<HeroBanner items={heroItems} /> <HeroBanner items={heroItems} />

View File

@ -1,17 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount, onDestroy, setContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player"; import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte"; import Search from "$lib/components/Search.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte"; import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte"; import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
const scrollGuard = useScrollGuard(300);
setContext("scrollGuard", scrollGuard);
let { children } = $props(); let { children } = $props();
let searchQuery = $state(""); let searchQuery = $state("");
@ -48,6 +53,7 @@
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
scrollGuard.cleanup();
}; };
}); });
@ -241,8 +247,12 @@
</div> </div>
</header> </header>
<!-- Main content (with padding for nav bar and mini player) --> <!-- Main content (with padding for bottom nav bar and mini player) -->
<main class="flex-1 overflow-y-auto p-4 pb-16 {$currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? (isAndroid ? 'pb-40' : 'pb-40 md:pb-24') : ''}"> <main
class="flex-1 overflow-y-auto p-4"
style="padding-bottom: {$shouldShowAudioMiniPlayer ? (isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
onscroll={scrollGuard.onScroll}
>
{@render children()} {@render children()}
</main> </main>

View File

@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount, getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types"; import type { Library, MediaItem } from "$lib/api/types";
import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library"; import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity"; import { isServerReachable } from "$lib/stores/connectivity";
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte"; import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import MediaCard from "$lib/components/library/MediaCard.svelte"; import MediaCard from "$lib/components/library/MediaCard.svelte";
import GenreFilter from "$lib/components/library/GenreFilter.svelte"; import GenreFilter from "$lib/components/library/GenreFilter.svelte";
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
let searchResults = $derived($library.searchResults); let searchResults = $derived($library.searchResults);
let searchQuery = $derived($library.searchQuery); let searchQuery = $derived($library.searchQuery);
@ -48,6 +52,9 @@
}); });
async function handleLibraryClick(lib: Library) { async function handleLibraryClick(lib: Library) {
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
// Route to dedicated music library page // Route to dedicated music library page
if (lib.collectionType === "music") { if (lib.collectionType === "music") {
library.setCurrentLibrary(lib); library.setCurrentLibrary(lib);
@ -70,6 +77,9 @@
} }
function handleItemClick(item: MediaItem | Library) { function handleItemClick(item: MediaItem | Library) {
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
if ("type" in item) { if ("type" in item) {
// It's a MediaItem // It's a MediaItem
const mediaItem = item as MediaItem; const mediaItem = item as MediaItem;
@ -102,6 +112,8 @@
} }
function goBackToLibraries() { function goBackToLibraries() {
// Prevent accidental taps during scrolling (Android)
if (scrollGuard.isScrollActive()) return;
library.setCurrentLibrary(null); library.setCurrentLibrary(null);
} }
</script> </script>

View File

@ -25,6 +25,13 @@
connecting = true; connecting = true;
localError = null; localError = null;
// Reject plain HTTP — all connections must use HTTPS
if (serverUrl.trim().toLowerCase().startsWith("http://")) {
localError = "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).";
connecting = false;
return;
}
try { try {
const info = await auth.connectToServer(serverUrl); const info = await auth.connectToServer(serverUrl);
serverName = info.name; serverName = info.name;

View File

@ -8,7 +8,7 @@
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { queue, currentQueueItem } from "$lib/stores/queue"; import { queue, currentQueueItem } from "$lib/stores/queue";
import { downloads, type DownloadInfo } from "$lib/stores/downloads"; import { downloads, type DownloadInfo } from "$lib/stores/downloads";
import { playbackPosition, playbackDuration } from "$lib/stores/player"; import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
import { get } from "svelte/store"; import { get } from "svelte/store";
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte"; import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte"; import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
@ -126,6 +126,25 @@
return; return;
} }
// If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer)
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isVideo = item.type === "Movie" || item.type === "Episode";
isPlaying = true;
loading = false;
// Sync queue status
try {
const queueStatus = await invoke<{ hasNext: boolean; hasPrevious: boolean }>("player_get_queue");
hasNext = queueStatus.hasNext;
hasPrevious = queueStatus.hasPrevious;
} catch (e) {
// Ignore - queue status will update via polling
}
return;
}
// Determine if this is video content (Movie and Episode are video types) // Determine if this is video content (Movie and Episode are video types)
isVideo = item.type === "Movie" || item.type === "Episode"; isVideo = item.type === "Movie" || item.type === "Episode";
@ -214,17 +233,20 @@
} else { } else {
// Local audio playback via MPV backend // Local audio playback via MPV backend
console.log("loadAndPlay: Using MPV backend for offline audio"); console.log("loadAndPlay: Using MPV backend for offline audio");
await invoke("player_play_item", { // Use player_play_tracks - backend fetches all metadata from single ID
item: { const repo = auth.getRepository();
id: item.id, const repositoryHandle = repo.getHandle();
title: item.name,
artist: item.artists?.join(", ") || null, await invoke("player_play_tracks", {
album: item.albumName || null, repositoryHandle,
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null, request: {
artworkUrl: null, // Local file may not have artwork trackIds: [item.id],
mediaType: "audio", startIndex: 0,
streamUrl: localUrl, shuffle: false,
jellyfinItemId: item.id, context: {
type: "search",
searchQuery: "",
},
}, },
}); });
if (startPosition) { if (startPosition) {
@ -334,17 +356,20 @@
} else { } else {
// Fallback to single item playback // Fallback to single item playback
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item"); console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
await invoke("player_play_item", { // Use player_play_tracks - backend fetches all metadata from single ID
item: { const repo = auth.getRepository();
id: item.id, const repositoryHandle = repo.getHandle();
title: item.name,
artist: item.artists?.join(", ") || null, await invoke("player_play_tracks", {
album: item.albumName || null, repositoryHandle,
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null, request: {
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }), trackIds: [item.id],
mediaType: "audio", startIndex: 0,
streamUrl: playbackInfo.streamUrl, shuffle: false,
jellyfinItemId: item.id, context: {
type: "search",
searchQuery: "",
},
}, },
}); });
@ -353,17 +378,20 @@
} }
} else { } else {
// No queue parameter - single item playback // No queue parameter - single item playback
await invoke("player_play_item", { // Use player_play_tracks - backend fetches all metadata from single ID
item: { const repo = auth.getRepository();
id: item.id, const repositoryHandle = repo.getHandle();
title: item.name,
artist: item.artists?.join(", ") || null, await invoke("player_play_tracks", {
album: item.albumName || null, repositoryHandle,
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null, request: {
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }), trackIds: [item.id],
mediaType: "audio", startIndex: 0,
streamUrl: playbackInfo.streamUrl, shuffle: false,
jellyfinItemId: item.id, context: {
type: "search",
searchQuery: "",
},
}, },
}); });

View File

@ -59,7 +59,7 @@
{#if searchQuery.trim()} {#if searchQuery.trim()}
<SearchResults <SearchResults
results={$library.searchResults} results={$library.searchResults}
loading={$library.isLoading} loading={$library.loadingCount > 0}
onItemClick={handleItemClick} onItemClick={handleItemClick}
/> />
{:else} {:else}