Many improvemtns and fixes related to decoupling of svelte and rust on android.
This commit is contained in:
parent
07f3bf04ca
commit
e8e37649fa
@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
@ -1041,6 +1041,49 @@ graph TD
|
||||
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
|
||||
@ -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
|
||||
@ -1383,6 +1470,9 @@ flowchart LR
|
||||
- Listens for `player-event` Tauri events
|
||||
- Updates player/queue stores based on event type
|
||||
- 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)
|
||||
|
||||
@ -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<String, Bitmap><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
|
||||
|
||||
**Location**: `src-tauri/src/lib.rs`
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@ -35,7 +35,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest": ">=1.0.0 <5.0.0",
|
||||
"webdriverio": "^9.5.0"
|
||||
}
|
||||
},
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"test:rust": "./scripts/test-rust.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"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:dev": "./scripts/build-and-deploy.sh",
|
||||
"android:check": "./scripts/check-android.sh",
|
||||
|
||||
@ -18,6 +18,11 @@ echo ""
|
||||
# Build type: debug or release (default: 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
|
||||
echo "🔄 Syncing Android sources..."
|
||||
./scripts/sync-android-sources.sh
|
||||
|
||||
@ -1008,8 +1008,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
album = currentAlbum,
|
||||
duration = currentDurationMs,
|
||||
position = (exoPlayer.currentPosition).coerceAtLeast(0),
|
||||
isPlaying = isPlaying,
|
||||
artworkBitmap = currentArtworkBitmap
|
||||
isPlaying = isPlaying
|
||||
)
|
||||
} else {
|
||||
android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update")
|
||||
|
||||
@ -102,12 +102,18 @@ impl AuthManager {
|
||||
self.connectivity_monitor = Some(monitor);
|
||||
}
|
||||
|
||||
/// Normalize and validate server URL
|
||||
pub fn normalize_url(url: &str) -> String {
|
||||
/// Normalize and validate server URL.
|
||||
/// Enforces HTTPS — plain HTTP is rejected for security.
|
||||
pub fn normalize_url(url: &str) -> Result<String, 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
|
||||
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
||||
if !normalized.starts_with("https://") {
|
||||
normalized = format!("https://{}", normalized);
|
||||
}
|
||||
|
||||
@ -116,12 +122,12 @@ impl AuthManager {
|
||||
normalized.pop();
|
||||
}
|
||||
|
||||
normalized
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Connect to server and get server info
|
||||
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);
|
||||
|
||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||
@ -165,7 +171,7 @@ impl AuthManager {
|
||||
password: &str,
|
||||
device_id: &str,
|
||||
) -> Result<AuthResult, String> {
|
||||
let url = Self::normalize_url(server_url);
|
||||
let url = Self::normalize_url(server_url)?;
|
||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||
|
||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||
@ -227,7 +233,7 @@ impl AuthManager {
|
||||
access_token: &str,
|
||||
device_id: &str,
|
||||
) -> Result<User, String> {
|
||||
let url = Self::normalize_url(server_url);
|
||||
let url = Self::normalize_url(server_url)?;
|
||||
let endpoint = format!("{}/Users/{}", url, user_id);
|
||||
|
||||
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
||||
@ -290,7 +296,7 @@ impl AuthManager {
|
||||
access_token: &str,
|
||||
device_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let url = Self::normalize_url(server_url);
|
||||
let url = Self::normalize_url(server_url)?;
|
||||
let endpoint = format!("{}/Sessions/Logout", url);
|
||||
|
||||
log::info!("[AuthManager] Logging out");
|
||||
@ -337,43 +343,43 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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]
|
||||
fn test_normalize_url_adds_https() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("jellyfin.example.com"),
|
||||
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - preserves existing protocol
|
||||
/// Test URL normalization - preserves existing https
|
||||
#[test]
|
||||
fn test_normalize_url_preserves_protocol() {
|
||||
fn test_normalize_url_preserves_https() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("https://jellyfin.example.com"),
|
||||
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
|
||||
"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]
|
||||
fn test_normalize_url_removes_trailing_slash() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("https://jellyfin.example.com/"),
|
||||
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url("jellyfin.example.com/"),
|
||||
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
}
|
||||
@ -382,25 +388,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_normalize_url_trims_whitespace() {
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url(" jellyfin.example.com "),
|
||||
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
|
||||
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
|
||||
"https://jellyfin.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test URL normalization - complex 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 URL normalization - real world case
|
||||
#[test]
|
||||
fn test_normalize_url_real_world_case() {
|
||||
// User 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!(normalized.starts_with("https://"));
|
||||
|
||||
@ -39,7 +39,7 @@ pub async fn auth_initialize(
|
||||
};
|
||||
|
||||
// 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 {
|
||||
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?;
|
||||
|
||||
// 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 {
|
||||
user_id: result.user.id.clone(),
|
||||
@ -156,10 +156,13 @@ pub async fn auth_set_session(
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
// Normalize the server URL if session is provided
|
||||
let normalized_session = session.map(|mut s| {
|
||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
|
||||
s
|
||||
});
|
||||
let normalized_session = match session {
|
||||
Some(mut s) => {
|
||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
||||
Some(s)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
auth_manager.0.set_session(normalized_session).await;
|
||||
Ok(())
|
||||
|
||||
@ -956,7 +956,26 @@ pub async fn start_download(
|
||||
debug!("Download task started for download_id: {}", download_id);
|
||||
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) => {
|
||||
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
|
||||
|
||||
|
||||
@ -195,4 +195,64 @@ mod tests {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem {
|
||||
album: item.album.clone(),
|
||||
album_id: item.album_id.clone(),
|
||||
duration: item.duration,
|
||||
primary_image_tag: None,
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
media_type: match item.media_type {
|
||||
crate::player::MediaType::Audio => "audio".to_string(),
|
||||
crate::player::MediaType::Video => "video".to_string(),
|
||||
@ -138,43 +138,20 @@ pub enum VideoBackend {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayItemRequest {
|
||||
pub id: 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 jellyfin_item_id: Option<String>,
|
||||
/// Video codec (e.g., "h264", "hevc") for video media
|
||||
#[serde(default)]
|
||||
pub video_codec: Option<String>,
|
||||
pub video_codec: String,
|
||||
/// Whether the video requires server-side transcoding
|
||||
#[serde(default)]
|
||||
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?
|
||||
@ -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(
|
||||
req: PlayItemRequest,
|
||||
db: Option<&DatabaseWrapper>,
|
||||
) -> 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
|
||||
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 {
|
||||
None
|
||||
};
|
||||
@ -374,27 +355,27 @@ async fn create_media_item(
|
||||
Ok(MediaItem {
|
||||
id: req.id.clone(),
|
||||
title: req.title.clone(),
|
||||
name: Some(req.title), // Frontend compatibility
|
||||
artist: req.artist.clone(),
|
||||
album: req.album.clone(),
|
||||
album_name: req.album, // Frontend compatibility
|
||||
album_id: req.album_id,
|
||||
artist_items: None, // Not available from frontend request
|
||||
artists: req.artist.map(|a| vec![a]), // Convert single artist to array
|
||||
primary_image_tag: None, // Not available from frontend request
|
||||
item_type: None, // Not available from frontend request
|
||||
playlist_id: req.playlist_id,
|
||||
duration: req.duration,
|
||||
artwork_url: req.artwork_url,
|
||||
media_type: req.media_type,
|
||||
name: Some(req.title.clone()),
|
||||
artist: None, // Not available from video-only request
|
||||
album: None, // Not available from video-only request
|
||||
album_name: None, // Not available from video-only request
|
||||
album_id: None, // Not available from video-only request
|
||||
artist_items: None, // Not available from video-only request
|
||||
artists: None, // Not available from video-only request
|
||||
primary_image_tag: None, // Not available from video-only request
|
||||
item_type: None, // Not available from video-only request
|
||||
playlist_id: None, // Not available from video-only request
|
||||
duration: None, // Not available from video-only request
|
||||
artwork_url: None, // Not available from video-only request
|
||||
media_type: crate::player::MediaType::Video, // Video-only request
|
||||
source,
|
||||
video_codec: req.video_codec,
|
||||
video_codec: Some(req.video_codec),
|
||||
needs_transcoding: req.needs_transcoding,
|
||||
video_width: req.video_width,
|
||||
video_height: req.video_height,
|
||||
video_width: None, // Not available from video-only request
|
||||
video_height: None, // Not available from video-only request
|
||||
subtitles: vec![],
|
||||
series_id: req.series_id,
|
||||
server_id: req.server_id,
|
||||
series_id: None, // Not available from video-only request
|
||||
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)
|
||||
///
|
||||
/// 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-004 - Play audio uninterrupted
|
||||
/// @req: UR-005 - Control media playback (play operation)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! 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 serde::{Deserialize, Serialize};
|
||||
@ -1343,6 +1344,25 @@ pub async fn thumbnail_delete_item(
|
||||
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)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1385,32 +1405,21 @@ pub async fn image_get_url(
|
||||
&request.image_type,
|
||||
tag,
|
||||
).await {
|
||||
// Read file and return as base64
|
||||
let image_data = fs::read(&cached_path)
|
||||
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
|
||||
// Determine MIME type from file extension
|
||||
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);
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
|
||||
}
|
||||
|
||||
// Not cached - need to fetch from repository and cache
|
||||
info!("[ImageCache] Cache miss for {}/{}, downloading...", request.item_id, request.image_type);
|
||||
// Not cached — fetch from server and cache.
|
||||
// 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)
|
||||
.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() {
|
||||
"Primary" => ImageType::Primary,
|
||||
"Backdrop" => ImageType::Backdrop,
|
||||
@ -1420,7 +1429,6 @@ pub async fn image_get_url(
|
||||
_ => ImageType::Primary,
|
||||
};
|
||||
|
||||
// Build image options
|
||||
let options = ImageOptions {
|
||||
max_width: request.max_width,
|
||||
max_height: request.max_height,
|
||||
@ -1428,18 +1436,10 @@ pub async fn image_get_url(
|
||||
tag: request.tag.clone(),
|
||||
};
|
||||
|
||||
// Get image URL from repository
|
||||
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||
debug!("[ImageCache] Server URL: {}", server_url);
|
||||
|
||||
// Download image data
|
||||
let worker = ThumbnailWorker::new();
|
||||
let image_data = worker
|
||||
.download_with_retry(&server_url, 2)
|
||||
.await
|
||||
let image_data = repository.download_bytes(&server_url).await
|
||||
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||
|
||||
// Save to cache
|
||||
let cached_path = thumbnail_cache.0.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
@ -1450,18 +1450,9 @@ pub async fn image_get_url(
|
||||
request.max_height.map(|h| h as i32),
|
||||
).await?;
|
||||
|
||||
// Return as base64 data URL
|
||||
let base64_data = BASE64.encode(&image_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",
|
||||
};
|
||||
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
|
||||
info!("[ImageCache] Cached and returning base64 data URL");
|
||||
Ok(data_url)
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
Ok(format!("data:{};base64,{}", mime_type, base64_data))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@ -21,6 +21,7 @@ impl DownloadWorker {
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
||||
.https_only(true)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
@ -31,14 +32,18 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
/// Download a file with retry logic and progress tracking
|
||||
pub async fn download(
|
||||
pub async fn download<F>(
|
||||
&self,
|
||||
task: &DownloadTask,
|
||||
) -> Result<DownloadResult, DownloadError> {
|
||||
on_progress: F,
|
||||
) -> Result<DownloadResult, DownloadError>
|
||||
where
|
||||
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||
{
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
match self.try_download(task).await {
|
||||
match self.try_download(task, &on_progress).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||
retries += 1;
|
||||
@ -55,7 +60,10 @@ impl DownloadWorker {
|
||||
}
|
||||
|
||||
/// 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
|
||||
if let Some(parent) = task.target_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
@ -129,7 +137,7 @@ impl DownloadWorker {
|
||||
|| downloaded % (1024 * 1024) == 0
|
||||
{
|
||||
last_progress_emit = std::time::Instant::now();
|
||||
// Progress events will be emitted by the manager
|
||||
on_progress(downloaded, _total_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ impl JellyfinClient {
|
||||
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
||||
let http_client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.https_only(true)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ impl HttpClient {
|
||||
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
||||
let client = Client::builder()
|
||||
.timeout(config.timeout)
|
||||
.https_only(true)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
|
||||
@ -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.
|
||||
/// This method is online-only since offline playback uses local file paths.
|
||||
pub async fn get_video_stream_url(
|
||||
|
||||
@ -36,6 +36,29 @@ impl OnlineRepository {
|
||||
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
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
@ -1005,8 +1028,12 @@ impl MediaRepository for OnlineRepository {
|
||||
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 {
|
||||
let mut params = Vec::new();
|
||||
if let Some(width) = opts.max_width {
|
||||
params.push(format!("maxWidth={}", width));
|
||||
}
|
||||
@ -1019,12 +1046,12 @@ impl MediaRepository for OnlineRepository {
|
||||
if let Some(tag) = opts.tag {
|
||||
params.push(format!("tag={}", tag));
|
||||
}
|
||||
}
|
||||
|
||||
if !params.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(¶ms.join("&"));
|
||||
}
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
@ -30,7 +30,8 @@ mod tests {
|
||||
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(max_width) = opts.max_width {
|
||||
@ -304,14 +305,11 @@ mod tests {
|
||||
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
||||
let download_url = repo.get_video_download_url("item123", "720p");
|
||||
|
||||
// These URLs are constructed in BACKEND and returned to frontend
|
||||
// Frontend never receives this token directly
|
||||
assert!(image_url.contains("api_key=super_secret_token"));
|
||||
// Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
|
||||
assert!(!image_url.contains("api_key="));
|
||||
// Subtitle and download URLs still use api_key (used directly, not via download_bytes)
|
||||
assert!(subtitle_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]
|
||||
@ -335,10 +333,10 @@ mod tests {
|
||||
|
||||
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.contains("/Items/id123/Images/Primary"));
|
||||
assert!(url.contains("?api_key="));
|
||||
assert!(!url.contains("api_key="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -353,13 +351,13 @@ mod tests {
|
||||
|
||||
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();
|
||||
assert_eq!(question_marks, 1);
|
||||
|
||||
// Should have ampersands between params
|
||||
assert!(url.contains("?"));
|
||||
assert!(url.contains("&"));
|
||||
// Should have params for maxWidth and maxHeight
|
||||
assert!(url.contains("maxWidth=300"));
|
||||
assert!(url.contains("maxHeight=200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -368,8 +366,7 @@ mod tests {
|
||||
|
||||
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
||||
|
||||
// Should handle special characters in token and id
|
||||
assert!(url.contains("token_with_special-chars"));
|
||||
// Should handle special characters in id (no token in URL anymore)
|
||||
assert!(url.contains("item-with-special_chars"));
|
||||
}
|
||||
|
||||
@ -383,9 +380,9 @@ mod tests {
|
||||
// Backend generates full URL with credentials
|
||||
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.contains("api_key="));
|
||||
assert!(url.contains("/Items/item123/Images/Primary"));
|
||||
|
||||
// Frontend never constructs URLs directly
|
||||
// Frontend only receives pre-constructed URLs from backend
|
||||
@ -408,7 +405,6 @@ mod tests {
|
||||
assert!(url.contains("maxHeight=200"));
|
||||
assert!(url.contains("quality=90"));
|
||||
assert!(url.contains("tag=abc"));
|
||||
assert!(url.contains("api_key=token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -423,8 +419,8 @@ mod tests {
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||
|
||||
// Should only have api_key
|
||||
assert!(url.contains("api_key=token"));
|
||||
// Should have no query params (no api_key, no options)
|
||||
assert!(!url.contains("?"));
|
||||
assert!(!url.contains("maxWidth"));
|
||||
assert!(!url.contains("maxHeight"));
|
||||
assert!(!url.contains("quality"));
|
||||
|
||||
@ -12,6 +12,7 @@ impl ThumbnailWorker {
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.https_only(true)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
|
||||
@ -28,18 +28,6 @@
|
||||
<span class="text-xs">Home</span>
|
||||
</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 -->
|
||||
<button
|
||||
onclick={() => goto('/search')}
|
||||
@ -51,5 +39,17 @@
|
||||
</svg>
|
||||
<span class="text-xs">Search</span>
|
||||
</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>
|
||||
</nav>
|
||||
|
||||
@ -61,7 +61,6 @@
|
||||
imageUrl = dataUrl;
|
||||
error = false;
|
||||
} catch (e) {
|
||||
console.error(`Failed to load image ${itemId}:`, e);
|
||||
error = true;
|
||||
imageUrl = null;
|
||||
} finally {
|
||||
@ -78,10 +77,12 @@
|
||||
|
||||
{#if loading}
|
||||
<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">
|
||||
<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>
|
||||
{:else if imageUrl}
|
||||
{:else}
|
||||
<img src={imageUrl} {alt} class={className} />
|
||||
{/if}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
@ -13,7 +13,6 @@
|
||||
|
||||
let currentIndex = $state(0);
|
||||
let intervalId: number | null = null;
|
||||
let heroImageUrl = $state<string>("");
|
||||
|
||||
// Touch/swipe state
|
||||
let touchStartX = $state(0);
|
||||
@ -22,82 +21,45 @@
|
||||
|
||||
const currentItem = $derived(items[currentIndex] ?? null);
|
||||
|
||||
// Load hero image URL asynchronously based on item priority
|
||||
async function loadHeroImageUrl(): Promise<void> {
|
||||
if (!currentItem) {
|
||||
heroImageUrl = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
// Compute the best image source for the hero banner (no fetch, pure derivation)
|
||||
const heroImageSource = $derived.by(() => {
|
||||
if (!currentItem) return null;
|
||||
|
||||
// 1. Try backdrop image first (best for hero display)
|
||||
if (currentItem.backdropImageTags?.[0]) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] };
|
||||
}
|
||||
|
||||
// 2. For episodes, try to use series backdrop from parent
|
||||
// 2. For episodes, try series/season backdrops
|
||||
if (currentItem.type === "Episode") {
|
||||
// First try parent backdrop tags (includes image tag for caching)
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.parentBackdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
|
||||
}
|
||||
// Fallback: try series backdrop without tag (may not be cached optimally)
|
||||
if (currentItem.seriesId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
// Last resort for episodes: try season backdrop
|
||||
if (currentItem.seasonId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.seasonId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop first, then primary
|
||||
// 3. For music tracks, try album backdrop
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
// Try album backdrop first (more cinematic for hero)
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
|
||||
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
||||
// 4. Fall back to primary image
|
||||
if (currentItem.primaryImageTag) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.primaryImageTag,
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: try album primary image
|
||||
// 5. Last resort for audio: album primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
|
||||
}
|
||||
|
||||
heroImageUrl = "";
|
||||
} catch {
|
||||
heroImageUrl = "";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function next() {
|
||||
currentIndex = (currentIndex + 1) % items.length;
|
||||
@ -143,11 +105,6 @@
|
||||
touchEndX = 0;
|
||||
}
|
||||
|
||||
// Load hero image whenever current item changes
|
||||
$effect(() => {
|
||||
loadHeroImageUrl();
|
||||
});
|
||||
|
||||
// Auto-rotate logic
|
||||
$effect(() => {
|
||||
if (autoRotate && items.length > 1) {
|
||||
@ -166,10 +123,13 @@
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
{#if heroImageUrl}
|
||||
<img
|
||||
src={heroImageUrl}
|
||||
alt={currentItem?.name}
|
||||
{#if heroImageSource}
|
||||
<CachedImage
|
||||
itemId={heroImageSource.itemId}
|
||||
imageType={heroImageSource.imageType}
|
||||
tag={heroImageSource.tag}
|
||||
maxWidth={1920}
|
||||
alt={currentItem?.name ?? ""}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
@ -82,9 +83,32 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Download the album
|
||||
// Download the album: queue all tracks, then start each one
|
||||
const repo = auth.getRepository();
|
||||
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) {
|
||||
console.error("Album download operation failed:", error);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Person, PersonType } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
people: Person[];
|
||||
@ -10,9 +10,6 @@
|
||||
|
||||
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
|
||||
const groupedPeople = $derived.by(() => {
|
||||
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) {
|
||||
goto(`/library/${person.id}`);
|
||||
}
|
||||
@ -110,20 +82,14 @@
|
||||
>
|
||||
<!-- Person image -->
|
||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||
{#if person.primaryImageTag && personImageUrls.get(person.id)}
|
||||
<img
|
||||
src={personImageUrls.get(person.id)}
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
maxWidth={200}
|
||||
alt={person.name}
|
||||
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>
|
||||
|
||||
<!-- Name and role -->
|
||||
|
||||
@ -66,8 +66,8 @@
|
||||
class="transition-all"
|
||||
style="transition: stroke-dashoffset 0.3s ease;"
|
||||
/>
|
||||
<!-- Download Icon in Center -->
|
||||
<text x="18" y="20" text-anchor="middle" class="text-xs font-bold fill-current">
|
||||
<!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) -->
|
||||
<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)}%
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
@ -12,9 +12,6 @@
|
||||
|
||||
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)
|
||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||
if (ep.id === episode.id) return true;
|
||||
@ -73,72 +70,18 @@
|
||||
return allEpisodes.slice(start, end);
|
||||
});
|
||||
|
||||
// Load backdrop URL asynchronously
|
||||
async function loadBackdropUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Try episode backdrop first
|
||||
// Compute best backdrop source (no fetch, pure derivation)
|
||||
const backdropSource = $derived.by(() => {
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||
}
|
||||
|
||||
// Try episode primary (thumbnail)
|
||||
if (episode.primaryImageTag) {
|
||||
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
return;
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
|
||||
}
|
||||
|
||||
// Fall back to series backdrop
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: series.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
@ -178,9 +121,12 @@
|
||||
<div class="space-y-8">
|
||||
<!-- Hero section -->
|
||||
<div class="relative h-[450px] rounded-xl overflow-hidden">
|
||||
{#if backdropUrl}
|
||||
<img
|
||||
src={backdropUrl}
|
||||
{#if backdropSource}
|
||||
<CachedImage
|
||||
itemId={backdropSource.itemId}
|
||||
imageType={backdropSource.imageType}
|
||||
tag={backdropSource.tag}
|
||||
maxWidth={1920}
|
||||
alt={episode.name}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
@ -288,7 +234,6 @@
|
||||
{#each adjacentEpisodes() as ep (ep.id)}
|
||||
{@const isCurrent = isCurrentEpisode(ep)}
|
||||
{@const epProgress = getProgress(ep)}
|
||||
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
|
||||
<button
|
||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||
@ -296,20 +241,14 @@
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
{#if thumbUrl}
|
||||
<img
|
||||
src={thumbUrl}
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
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 -->
|
||||
{#if !isCurrent}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
@ -15,7 +15,6 @@
|
||||
let { episode, focused = false, onclick }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
onMount(() => {
|
||||
if (focused && buttonRef) {
|
||||
@ -37,24 +36,6 @@
|
||||
);
|
||||
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(() => {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
return 0;
|
||||
@ -74,20 +55,14 @@
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
tag={episode.primaryImageTag}
|
||||
maxWidth={320}
|
||||
alt={episode.name}
|
||||
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 -->
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import type { Genre, MediaItem } from "$lib/api/types";
|
||||
|
||||
@ -41,8 +42,6 @@
|
||||
let selectedGenre = $state<Genre | null>(null);
|
||||
let genreItems = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
let genreItemImageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadGenres();
|
||||
if (selectedGenre) {
|
||||
@ -80,7 +79,6 @@
|
||||
try {
|
||||
loadingItems = true;
|
||||
selectedGenre = genre;
|
||||
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
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() {
|
||||
let result = [...genres];
|
||||
|
||||
@ -245,19 +218,14 @@
|
||||
{#each genreItems as item (item.id)}
|
||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
|
||||
<img
|
||||
src={genreItemImageUrls.get(item.id)}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
maxWidth={300}
|
||||
alt={item.name}
|
||||
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>
|
||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
items: (MediaItem | Library)[];
|
||||
@ -13,36 +13,13 @@
|
||||
|
||||
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) {
|
||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||
}
|
||||
|
||||
// Load image URL for a single item
|
||||
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
|
||||
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, "");
|
||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URLs whenever items change
|
||||
$effect(() => {
|
||||
items.forEach((item) => {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
loadImageUrl(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
if (!("type" in item)) return "";
|
||||
@ -80,7 +57,6 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const imageUrl = imageUrls.get(item.id) ?? ""}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
@ -102,20 +78,14 @@
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={getImageTag(item)}
|
||||
maxWidth={80}
|
||||
alt={item.name}
|
||||
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 -->
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
@ -13,9 +13,6 @@
|
||||
|
||||
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
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||
@ -42,26 +39,11 @@
|
||||
return "aspect-video";
|
||||
});
|
||||
|
||||
// Load image URL asynchronously from backend
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
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);
|
||||
const imageTag = $derived(
|
||||
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
|
||||
);
|
||||
|
||||
imageUrl = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth,
|
||||
tag,
|
||||
});
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URL whenever item or size changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
@ -96,20 +78,14 @@
|
||||
{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">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={imageTag}
|
||||
maxWidth={maxWidth}
|
||||
alt={item.name}
|
||||
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 -->
|
||||
<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">
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { onMount } from "svelte";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Props {
|
||||
@ -14,7 +15,6 @@
|
||||
let movies = $state<MediaItem[]>([]);
|
||||
let series = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
onMount(async () => {
|
||||
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) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
@ -67,19 +49,14 @@
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Profile image -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if imageUrl && person.primaryImageTag}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={person.name}
|
||||
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>
|
||||
|
||||
<!-- Info -->
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
season: MediaItem;
|
||||
@ -13,26 +13,6 @@
|
||||
|
||||
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 seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
@ -45,20 +25,14 @@
|
||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||
<!-- Season poster -->
|
||||
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<CachedImage
|
||||
itemId={season.id}
|
||||
imageType="Primary"
|
||||
tag={season.primaryImageTag}
|
||||
maxWidth={200}
|
||||
alt={seasonName}
|
||||
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>
|
||||
|
||||
<!-- Season info -->
|
||||
|
||||
@ -38,6 +38,8 @@
|
||||
|
||||
let isProcessing = $state(false);
|
||||
let showQualityPicker = $state(false);
|
||||
let buttonEl: HTMLButtonElement;
|
||||
let dropdownPos = $state({ top: 0, left: 0 });
|
||||
|
||||
// Find download for this item
|
||||
const downloadInfo = $derived(
|
||||
@ -135,13 +137,26 @@
|
||||
}
|
||||
} else if (status === "failed") {
|
||||
// Show quality picker to retry
|
||||
showQualityPicker = true;
|
||||
openQualityPicker();
|
||||
} else {
|
||||
// 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 {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
@ -176,6 +191,7 @@
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing}
|
||||
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
||||
@ -242,10 +258,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#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">
|
||||
<!-- Quality picker dropdown (fixed position, viewport-clamped) -->
|
||||
{#if showQualityPicker}
|
||||
<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">
|
||||
Select Quality
|
||||
</div>
|
||||
@ -269,14 +294,4 @@
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/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}
|
||||
|
||||
@ -97,9 +97,9 @@
|
||||
await invoke("player_cycle_repeat");
|
||||
}
|
||||
|
||||
// Use track's own ID for artwork (primaryImageTag corresponds to track ID)
|
||||
// Album art is inherited from album, so all tracks show the same album cover
|
||||
const artworkItemId = $derived(displayMedia?.id);
|
||||
// Prefer album ID for artwork (all tracks in an album share the same cover)
|
||||
// Falls back to track ID if no album ID available
|
||||
const artworkItemId = $derived(displayMedia?.albumId || displayMedia?.id);
|
||||
|
||||
// Show optimistic position while seeking or waiting for backend confirmation
|
||||
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
|
||||
@ -137,12 +137,12 @@
|
||||
{#if displayMedia}
|
||||
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
|
||||
<!-- Background image (blurred) -->
|
||||
{#if artworkItemId && displayMedia?.primaryImageTag}
|
||||
{#if artworkItemId}
|
||||
<div class="fixed inset-0 z-0">
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
maxWidth={800}
|
||||
alt=""
|
||||
class="w-full h-full object-cover blur-3xl opacity-30"
|
||||
@ -217,11 +217,11 @@
|
||||
<!-- Artwork -->
|
||||
<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">
|
||||
{#if artworkItemId && displayMedia?.primaryImageTag}
|
||||
{#if artworkItemId}
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
maxWidth={500}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@ -145,6 +145,8 @@
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchEndX = touchStartX;
|
||||
touchEndY = touchStartY;
|
||||
isSwiping = true;
|
||||
}
|
||||
|
||||
@ -293,15 +295,13 @@
|
||||
>
|
||||
<!-- Media info -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<!-- Artwork (clickable to expand) -->
|
||||
<button
|
||||
onclick={onExpand}
|
||||
<!-- Artwork -->
|
||||
<div
|
||||
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
|
||||
itemId={displayMedia.id}
|
||||
itemId={displayMedia.albumId || displayMedia.id}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
maxWidth={100}
|
||||
@ -315,16 +315,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Title & Artist -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<button
|
||||
onclick={onExpand}
|
||||
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
|
||||
<div
|
||||
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||
>
|
||||
{displayMedia?.name}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
{#each displayMedia?.artistItems as artist, i}
|
||||
|
||||
@ -372,22 +372,14 @@
|
||||
|
||||
// Call Rust backend to start playback
|
||||
// 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", {
|
||||
item: {
|
||||
id: media.id,
|
||||
title: media.name,
|
||||
artist: null,
|
||||
album: null,
|
||||
duration: media.runTimeTicks ? media.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: null,
|
||||
mediaType: "video",
|
||||
streamUrl: currentStreamUrl,
|
||||
jellyfinItemId: media.id,
|
||||
title: media.name,
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding,
|
||||
videoWidth: null,
|
||||
videoHeight: null,
|
||||
subtitles: subtitleTracks,
|
||||
needsTranscoding: needsTranscoding,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Session } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
@ -10,31 +10,6 @@
|
||||
|
||||
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 {
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@ -73,13 +48,16 @@
|
||||
<!-- Now playing -->
|
||||
{#if nowPlaying && playState}
|
||||
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
||||
<CachedImage
|
||||
itemId={nowPlaying.id}
|
||||
imageType="Primary"
|
||||
tag={nowPlaying.primaryImageTag}
|
||||
maxWidth={80}
|
||||
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">
|
||||
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
||||
|
||||
58
src/lib/composables/useScrollGuard.ts
Normal file
58
src/lib/composables/useScrollGuard.ts
Normal 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 };
|
||||
}
|
||||
@ -234,7 +234,7 @@ async function updateQueueStatus(): Promise<void> {
|
||||
hasPrevious: boolean;
|
||||
shuffle: boolean;
|
||||
repeat: string;
|
||||
}>("player_get_queue_status");
|
||||
}>("player_get_queue");
|
||||
|
||||
// Import appState stores dynamically to avoid circular imports
|
||||
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
|
||||
|
||||
117
src/lib/stores/__mocks__/tauri.ts
Normal file
117
src/lib/stores/__mocks__/tauri.ts
Normal 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];
|
||||
};
|
||||
@ -12,7 +12,8 @@ interface LibraryState {
|
||||
currentLibrary: Library | null;
|
||||
items: MediaItem[];
|
||||
currentItem: MediaItem | null;
|
||||
isLoading: boolean;
|
||||
/** Counter for concurrent loading operations. isLoading = loadingCount > 0 */
|
||||
loadingCount: number;
|
||||
error: string | null;
|
||||
totalItems: number;
|
||||
searchQuery: string;
|
||||
@ -34,7 +35,7 @@ function createLibraryStore() {
|
||||
currentLibrary: null,
|
||||
items: [],
|
||||
currentItem: null,
|
||||
isLoading: false,
|
||||
loadingCount: 0,
|
||||
error: null,
|
||||
totalItems: 0,
|
||||
searchQuery: "",
|
||||
@ -50,7 +51,7 @@ function createLibraryStore() {
|
||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||
|
||||
async function loadLibraries() {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
@ -71,13 +72,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
libraries,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return libraries;
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -86,7 +87,7 @@ function createLibraryStore() {
|
||||
parentId: 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 {
|
||||
const startTime = performance.now();
|
||||
@ -115,19 +116,19 @@ function createLibraryStore() {
|
||||
...s,
|
||||
items: result.items,
|
||||
totalItems: result.totalRecordCount,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItem(itemId: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@ -144,13 +145,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
currentItem: item,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -161,7 +162,7 @@ function createLibraryStore() {
|
||||
return;
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, isLoading: true, error: null, searchQuery: query }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@ -179,13 +180,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
searchResults: result.items,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -273,7 +274,7 @@ export const library = createLibraryStore();
|
||||
export const libraries = derived(library, ($lib) => $lib.libraries);
|
||||
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
|
||||
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 viewMode = derived(library, ($lib) => $lib.viewMode);
|
||||
export const genres = derived(library, ($lib) => $lib.genres);
|
||||
|
||||
236
src/lib/stores/playbackMode.invoke.test.ts
Normal file
236
src/lib/stores/playbackMode.invoke.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -85,8 +85,10 @@ describe("playbackMode store", () => {
|
||||
// Call disconnect
|
||||
await playbackMode.disconnect();
|
||||
|
||||
// Verify Rust backend was notified with correct mode
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: "Idle" });
|
||||
// Verify Rust backend was notified with correct mode (mode is now an object with type field)
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", {
|
||||
mode: { type: "idle" },
|
||||
});
|
||||
|
||||
// Verify sessions.selectSession was called with null
|
||||
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
||||
|
||||
@ -185,38 +185,25 @@ function createPlaybackModeStore() {
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// TODO: After Phase 3 (repository migration), this will be handled by Rust
|
||||
// For now, we need to fetch playback info and start local playback from TypeScript
|
||||
|
||||
// Get repository to fetch playback info
|
||||
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
||||
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)
|
||||
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;
|
||||
|
||||
@ -323,7 +310,7 @@ function createPlaybackModeStore() {
|
||||
|
||||
try {
|
||||
// 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
|
||||
sessions.selectSession(null);
|
||||
|
||||
195
src/lib/utils/tauriCommandParams.test.ts
Normal file
195
src/lib/utils/tauriCommandParams.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
387
src/lib/utils/tauriIntegration.test.ts
Normal file
387
src/lib/utils/tauriIntegration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
348
src/lib/utils/tauriInvokeDebug.test.ts
Normal file
348
src/lib/utils/tauriInvokeDebug.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
src/lib/utils/tauriRealCalls.test.ts
Normal file
107
src/lib/utils/tauriRealCalls.test.ts
Normal 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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -84,6 +84,7 @@
|
||||
{: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="space-y-8">
|
||||
|
||||
<!-- Hero Banner -->
|
||||
{#if heroItems.length > 0}
|
||||
<HeroBanner items={heroItems} />
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, onDestroy, setContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
||||
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 MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||
import AudioPlayer from "$lib/components/player/AudioPlayer.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 searchQuery = $state("");
|
||||
@ -48,6 +53,7 @@
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
scrollGuard.cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
@ -241,8 +247,12 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content (with padding for 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 content (with padding for bottom nav bar and mini player) -->
|
||||
<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()}
|
||||
</main>
|
||||
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Library, MediaItem } from "$lib/api/types";
|
||||
import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import MediaCard from "$lib/components/library/MediaCard.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 searchQuery = $derived($library.searchQuery);
|
||||
|
||||
@ -48,6 +52,9 @@
|
||||
});
|
||||
|
||||
async function handleLibraryClick(lib: Library) {
|
||||
// Prevent accidental taps during scrolling (Android)
|
||||
if (scrollGuard.isScrollActive()) return;
|
||||
|
||||
// Route to dedicated music library page
|
||||
if (lib.collectionType === "music") {
|
||||
library.setCurrentLibrary(lib);
|
||||
@ -70,6 +77,9 @@
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem | Library) {
|
||||
// Prevent accidental taps during scrolling (Android)
|
||||
if (scrollGuard.isScrollActive()) return;
|
||||
|
||||
if ("type" in item) {
|
||||
// It's a MediaItem
|
||||
const mediaItem = item as MediaItem;
|
||||
@ -102,6 +112,8 @@
|
||||
}
|
||||
|
||||
function goBackToLibraries() {
|
||||
// Prevent accidental taps during scrolling (Android)
|
||||
if (scrollGuard.isScrollActive()) return;
|
||||
library.setCurrentLibrary(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -25,6 +25,13 @@
|
||||
connecting = true;
|
||||
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 {
|
||||
const info = await auth.connectToServer(serverUrl);
|
||||
serverName = info.name;
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { library } from "$lib/stores/library";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
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 AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
||||
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
||||
@ -126,6 +126,25 @@
|
||||
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)
|
||||
isVideo = item.type === "Movie" || item.type === "Episode";
|
||||
|
||||
@ -214,17 +233,20 @@
|
||||
} else {
|
||||
// Local audio playback via MPV backend
|
||||
console.log("loadAndPlay: Using MPV backend for offline audio");
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: null, // Local file may not have artwork
|
||||
mediaType: "audio",
|
||||
streamUrl: localUrl,
|
||||
jellyfinItemId: item.id,
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds: [item.id],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
if (startPosition) {
|
||||
@ -334,17 +356,20 @@
|
||||
} else {
|
||||
// Fallback to single item playback
|
||||
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
||||
mediaType: "audio",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: item.id,
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds: [item.id],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -353,17 +378,20 @@
|
||||
}
|
||||
} else {
|
||||
// No queue parameter - single item playback
|
||||
await invoke("player_play_item", {
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
artist: item.artists?.join(", ") || null,
|
||||
album: item.albumName || null,
|
||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
||||
mediaType: "audio",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: item.id,
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds: [item.id],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
{#if searchQuery.trim()}
|
||||
<SearchResults
|
||||
results={$library.searchResults}
|
||||
loading={$library.isLoading}
|
||||
loading={$library.loadingCount > 0}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user