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.
|
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
||||||
|
|
||||||
**Last Updated:** 2026-01-26
|
**Last Updated:** 2026-02-28
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
@ -1041,6 +1041,49 @@ graph TD
|
|||||||
PlayerComps --> LibraryComps
|
PlayerComps --> LibraryComps
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.9 MiniPlayer Behavior
|
||||||
|
|
||||||
|
**Location**: `src/lib/components/player/MiniPlayer.svelte`
|
||||||
|
|
||||||
|
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
|
||||||
|
|
||||||
|
**Touch Gesture Handling:**
|
||||||
|
|
||||||
|
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchEndX = touchStartX; // Initialize to start position
|
||||||
|
touchEndY = touchStartY; // Prevents taps being treated as swipes
|
||||||
|
isSwiping = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
|
||||||
|
|
||||||
|
**Skip Button State:**
|
||||||
|
|
||||||
|
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
||||||
|
|
||||||
|
### 3.10 Player Page Navigation Guard
|
||||||
|
|
||||||
|
**Location**: `src/routes/player/[id]/+page.svelte`
|
||||||
|
|
||||||
|
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const alreadyPlayingMedia = get(storeCurrentMedia);
|
||||||
|
if (alreadyPlayingMedia?.id === id && !startPosition) {
|
||||||
|
// Track already playing — show UI without restarting playback
|
||||||
|
// Fetch queue status for hasNext/hasPrevious
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Data Flow
|
## 4. Data Flow
|
||||||
@ -1251,6 +1294,50 @@ interface MediaItem {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5.3 Tauri v2 IPC Parameter Naming Convention
|
||||||
|
|
||||||
|
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
|
||||||
|
|
||||||
|
**Rule**: Rust `fn cmd(repository_handle: String)` → Frontend sends `{ repositoryHandle: "..." }`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ CORRECT - Tauri v2 auto-converts snake_case → camelCase
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repositoryHandle: "handle-123", // Rust: repository_handle
|
||||||
|
request: { trackIds: ["id1"], startIndex: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("remote_send_command", {
|
||||||
|
sessionId: "session-123", // Rust: session_id
|
||||||
|
command: "PlayPause"
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("pin_item", {
|
||||||
|
itemId: "item-123" // Rust: item_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ WRONG - snake_case causes "invalid args request" error on Android
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repository_handle: "handle-123", // Will fail!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameter Name Mapping (Rust → Frontend)**:
|
||||||
|
|
||||||
|
| Rust Parameter | Frontend Parameter | Used By |
|
||||||
|
|----------------|-------------------|---------|
|
||||||
|
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
|
||||||
|
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
|
||||||
|
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
|
||||||
|
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
|
||||||
|
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
|
||||||
|
| `item_ids` | `itemIds` | `remote_play_on_session` |
|
||||||
|
| `start_index` | `startIndex` | `remote_play_on_session` |
|
||||||
|
|
||||||
|
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately — this is serde deserialization, not the command macro. Both layers convert independently.
|
||||||
|
|
||||||
|
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Thread Safety
|
## 6. Thread Safety
|
||||||
@ -1383,6 +1470,9 @@ flowchart LR
|
|||||||
- Listens for `player-event` Tauri events
|
- Listens for `player-event` Tauri events
|
||||||
- Updates player/queue stores based on event type
|
- Updates player/queue stores based on event type
|
||||||
- Auto-advances to next track on `PlaybackEnded`
|
- Auto-advances to next track on `PlaybackEnded`
|
||||||
|
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` — this enables MiniPlayer skip button state
|
||||||
|
|
||||||
|
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||||
|
|
||||||
### 7.2 MpvBackend (Linux)
|
### 7.2 MpvBackend (Linux)
|
||||||
|
|
||||||
@ -1664,6 +1754,120 @@ Remote volume is automatically enabled/disabled during playback mode transfers:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7.3.2 Android Album Art Caching
|
||||||
|
|
||||||
|
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
|
||||||
|
|
||||||
|
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
|
||||||
|
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
|
||||||
|
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cache["AlbumArtCache.kt"]
|
||||||
|
MemoryCache["LruCache<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
|
### 7.4 Backend Initialization
|
||||||
|
|
||||||
**Location**: `src-tauri/src/lib.rs`
|
**Location**: `src-tauri/src/lib.rs`
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@ -35,7 +35,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": ">=1.0.0 <5.0.0",
|
||||||
"webdriverio": "^9.5.0"
|
"webdriverio": "^9.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"test:rust": "./scripts/test-rust.sh",
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
|
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && npm install && npm run build",
|
||||||
"android:deploy": "./scripts/deploy-android.sh",
|
"android:deploy": "./scripts/deploy-android.sh",
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
"android:check": "./scripts/check-android.sh",
|
"android:check": "./scripts/check-android.sh",
|
||||||
|
|||||||
@ -18,6 +18,11 @@ echo ""
|
|||||||
# Build type: debug or release (default: debug)
|
# Build type: debug or release (default: debug)
|
||||||
BUILD_TYPE="${1:-debug}"
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
# Step 0: Clear build caches to ensure fresh builds
|
||||||
|
echo "🧹 Clearing build caches..."
|
||||||
|
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||||
|
npm install > /dev/null 2>&1
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
# Step 1: Sync Android source files
|
||||||
echo "🔄 Syncing Android sources..."
|
echo "🔄 Syncing Android sources..."
|
||||||
./scripts/sync-android-sources.sh
|
./scripts/sync-android-sources.sh
|
||||||
|
|||||||
@ -1008,8 +1008,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
album = currentAlbum,
|
album = currentAlbum,
|
||||||
duration = currentDurationMs,
|
duration = currentDurationMs,
|
||||||
position = (exoPlayer.currentPosition).coerceAtLeast(0),
|
position = (exoPlayer.currentPosition).coerceAtLeast(0),
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying
|
||||||
artworkBitmap = currentArtworkBitmap
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update")
|
android.util.Log.w("JellyTauPlayer", "Playback service not available for notification update")
|
||||||
|
|||||||
@ -102,12 +102,18 @@ impl AuthManager {
|
|||||||
self.connectivity_monitor = Some(monitor);
|
self.connectivity_monitor = Some(monitor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize and validate server URL
|
/// Normalize and validate server URL.
|
||||||
pub fn normalize_url(url: &str) -> String {
|
/// Enforces HTTPS — plain HTTP is rejected for security.
|
||||||
|
pub fn normalize_url(url: &str) -> Result<String, String> {
|
||||||
let mut normalized = url.trim().to_string();
|
let mut normalized = url.trim().to_string();
|
||||||
|
|
||||||
|
// Reject plain HTTP — all connections must use HTTPS
|
||||||
|
if normalized.starts_with("http://") {
|
||||||
|
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Add https:// if no protocol specified
|
// Add https:// if no protocol specified
|
||||||
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
if !normalized.starts_with("https://") {
|
||||||
normalized = format!("https://{}", normalized);
|
normalized = format!("https://{}", normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,12 +122,12 @@ impl AuthManager {
|
|||||||
normalized.pop();
|
normalized.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to server and get server info
|
/// Connect to server and get server info
|
||||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
let normalized_url = Self::normalize_url(server_url);
|
let normalized_url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
@ -165,7 +171,7 @@ impl AuthManager {
|
|||||||
password: &str,
|
password: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
@ -227,7 +233,7 @@ impl AuthManager {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<User, String> {
|
) -> Result<User, String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/{}", url, user_id);
|
let endpoint = format!("{}/Users/{}", url, user_id);
|
||||||
|
|
||||||
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
||||||
@ -290,7 +296,7 @@ impl AuthManager {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Sessions/Logout", url);
|
let endpoint = format!("{}/Sessions/Logout", url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Logging out");
|
log::info!("[AuthManager] Logging out");
|
||||||
@ -337,43 +343,43 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Test URL normalization - adds https:// when missing
|
/// Test URL normalization - adds https:// when missing
|
||||||
///
|
|
||||||
/// Ensures that URLs without protocol are normalized to https://
|
|
||||||
/// This prevents "builder error" when constructing HTTP requests.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_adds_https() {
|
fn test_normalize_url_adds_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("jellyfin.example.com"),
|
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("192.168.1.100:8096"),
|
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
|
||||||
"https://192.168.1.100:8096"
|
"https://192.168.1.100:8096"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - preserves existing protocol
|
/// Test URL normalization - preserves existing https
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_preserves_protocol() {
|
fn test_normalize_url_preserves_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("https://jellyfin.example.com"),
|
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
}
|
||||||
AuthManager::normalize_url("http://localhost:8096"),
|
|
||||||
"http://localhost:8096"
|
/// Test URL normalization - rejects HTTP
|
||||||
);
|
#[test]
|
||||||
|
fn test_normalize_url_rejects_http() {
|
||||||
|
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
|
||||||
|
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - removes trailing slash
|
/// Test URL normalization - removes trailing slash
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_removes_trailing_slash() {
|
fn test_normalize_url_removes_trailing_slash() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("https://jellyfin.example.com/"),
|
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("jellyfin.example.com/"),
|
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -382,25 +388,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_trims_whitespace() {
|
fn test_normalize_url_trims_whitespace() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url(" jellyfin.example.com "),
|
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
|
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - complex case
|
/// Test URL normalization - real world case
|
||||||
///
|
|
||||||
/// This is the bug that caused the login issue: user enters URL
|
|
||||||
/// without protocol, it gets stored in DB, then fails when building
|
|
||||||
/// HTTP requests.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_real_world_case() {
|
fn test_normalize_url_real_world_case() {
|
||||||
// User input: "jellyfin.tourolle.paris"
|
|
||||||
let input = "jellyfin.tourolle.paris";
|
let input = "jellyfin.tourolle.paris";
|
||||||
let normalized = AuthManager::normalize_url(input);
|
let normalized = AuthManager::normalize_url(input).unwrap();
|
||||||
|
|
||||||
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
||||||
assert!(normalized.starts_with("https://"));
|
assert!(normalized.starts_with("https://"));
|
||||||
|
|||||||
@ -39,7 +39,7 @@ pub async fn auth_initialize(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create session object from active session with normalized URL
|
// Create session object from active session with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
user_id: active_session.user_id,
|
user_id: active_session.user_id,
|
||||||
@ -80,7 +80,7 @@ pub async fn auth_login(
|
|||||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||||
|
|
||||||
// Create session from auth result with normalized URL
|
// Create session from auth result with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
user_id: result.user.id.clone(),
|
user_id: result.user.id.clone(),
|
||||||
@ -156,10 +156,13 @@ pub async fn auth_set_session(
|
|||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Normalize the server URL if session is provided
|
// Normalize the server URL if session is provided
|
||||||
let normalized_session = session.map(|mut s| {
|
let normalized_session = match session {
|
||||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
|
Some(mut s) => {
|
||||||
s
|
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
||||||
});
|
Some(s)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
auth_manager.0.set_session(normalized_session).await;
|
auth_manager.0.set_session(normalized_session).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -956,7 +956,26 @@ pub async fn start_download(
|
|||||||
debug!("Download task started for download_id: {}", download_id);
|
debug!("Download task started for download_id: {}", download_id);
|
||||||
let worker = DownloadWorker::new();
|
let worker = DownloadWorker::new();
|
||||||
|
|
||||||
match worker.download(&task).await {
|
// Progress callback that emits events to the frontend
|
||||||
|
let progress_app = app_clone.clone();
|
||||||
|
let progress_item_id = item_id_clone.clone();
|
||||||
|
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
||||||
|
let progress = total_bytes
|
||||||
|
.filter(|&t| t > 0)
|
||||||
|
.map(|t| bytes_downloaded as f64 / t as f64)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
let event = DownloadEvent::Progress {
|
||||||
|
download_id,
|
||||||
|
item_id: progress_item_id.clone(),
|
||||||
|
bytes_downloaded: bytes_downloaded as i64,
|
||||||
|
total_bytes: total_bytes.map(|t| t as i64),
|
||||||
|
progress,
|
||||||
|
};
|
||||||
|
let _ = progress_app.emit("download-event", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
match worker.download(&task, on_progress).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
|
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
|
||||||
|
|
||||||
|
|||||||
@ -195,4 +195,64 @@ mod tests {
|
|||||||
assert!(json.contains(&pos.to_string()));
|
assert!(json.contains(&pos.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_deserialization_from_frontend() {
|
||||||
|
// Test what frontend sends for Idle mode
|
||||||
|
let idle_json = r#"{"type":"idle"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||||
|
assert_eq!(mode, PlaybackMode::Idle);
|
||||||
|
|
||||||
|
// Test what frontend sends for Local mode
|
||||||
|
let local_json = r#"{"type":"local"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||||
|
assert_eq!(mode, PlaybackMode::Local);
|
||||||
|
|
||||||
|
// Test what frontend sends for Remote mode
|
||||||
|
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||||
|
match mode {
|
||||||
|
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||||
|
_ => panic!("Expected Remote mode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_play_tracks_context_deserialization() {
|
||||||
|
use crate::commands::PlayTracksContext;
|
||||||
|
|
||||||
|
// Test Search context (the recently fixed issue)
|
||||||
|
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(search_json)
|
||||||
|
.expect("Failed to deserialize search context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Search { search_query } => {
|
||||||
|
assert_eq!(search_query, "test query");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Search context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Playlist context
|
||||||
|
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
||||||
|
.expect("Failed to deserialize playlist context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
||||||
|
assert_eq!(playlist_id, "pl-123");
|
||||||
|
assert_eq!(playlist_name, "My Playlist");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Playlist context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Custom context
|
||||||
|
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
||||||
|
.expect("Failed to deserialize custom context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Custom { label } => {
|
||||||
|
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Custom context"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,7 +88,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem {
|
|||||||
album: item.album.clone(),
|
album: item.album.clone(),
|
||||||
album_id: item.album_id.clone(),
|
album_id: item.album_id.clone(),
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
primary_image_tag: None,
|
primary_image_tag: item.primary_image_tag.clone(),
|
||||||
media_type: match item.media_type {
|
media_type: match item.media_type {
|
||||||
crate::player::MediaType::Audio => "audio".to_string(),
|
crate::player::MediaType::Audio => "audio".to_string(),
|
||||||
crate::player::MediaType::Video => "video".to_string(),
|
crate::player::MediaType::Video => "video".to_string(),
|
||||||
@ -138,43 +138,20 @@ pub enum VideoBackend {
|
|||||||
Html5,
|
Html5,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request to play a single item
|
/// Request to play a single video item
|
||||||
|
///
|
||||||
|
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
||||||
|
/// to avoid Tauri Android serialization issues with complex objects.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayItemRequest {
|
pub struct PlayItemRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: Option<String>,
|
|
||||||
pub album: Option<String>,
|
|
||||||
/// Album ID (Jellyfin ID) for remote transfer context
|
|
||||||
#[serde(default)]
|
|
||||||
pub album_id: Option<String>,
|
|
||||||
/// Playlist ID (Jellyfin ID) for remote transfer context
|
|
||||||
#[serde(default)]
|
|
||||||
pub playlist_id: Option<String>,
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
pub artwork_url: Option<String>,
|
|
||||||
pub media_type: MediaType,
|
|
||||||
pub stream_url: String,
|
pub stream_url: String,
|
||||||
pub jellyfin_item_id: Option<String>,
|
|
||||||
/// Video codec (e.g., "h264", "hevc") for video media
|
/// Video codec (e.g., "h264", "hevc") for video media
|
||||||
#[serde(default)]
|
pub video_codec: String,
|
||||||
pub video_codec: Option<String>,
|
|
||||||
/// Whether the video requires server-side transcoding
|
/// Whether the video requires server-side transcoding
|
||||||
#[serde(default)]
|
|
||||||
pub needs_transcoding: bool,
|
pub needs_transcoding: bool,
|
||||||
/// Video width in pixels
|
|
||||||
#[serde(default)]
|
|
||||||
pub video_width: Option<u32>,
|
|
||||||
/// Video height in pixels
|
|
||||||
#[serde(default)]
|
|
||||||
pub video_height: Option<u32>,
|
|
||||||
/// Series ID (for TV show episodes) - used for series audio preferences
|
|
||||||
#[serde(default)]
|
|
||||||
pub series_id: Option<String>,
|
|
||||||
/// Server ID - used for series audio preferences
|
|
||||||
#[serde(default)]
|
|
||||||
pub server_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue context for remote transfer - what type of queue is this?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
@ -345,16 +322,20 @@ pub enum AudioTrackSwitchResponse {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to create MediaItem from request, checking for local downloads
|
/// Helper function to create MediaItem from video request
|
||||||
|
///
|
||||||
|
/// PlayItemRequest is now video-only, so we create a video MediaItem.
|
||||||
|
/// Audio playback uses player_play_tracks which fetches full metadata from backend.
|
||||||
async fn create_media_item(
|
async fn create_media_item(
|
||||||
req: PlayItemRequest,
|
req: PlayItemRequest,
|
||||||
db: Option<&DatabaseWrapper>,
|
db: Option<&DatabaseWrapper>,
|
||||||
) -> Result<MediaItem, String> {
|
) -> Result<MediaItem, String> {
|
||||||
let jellyfin_id = req.jellyfin_item_id.as_ref().unwrap_or(&req.id);
|
// For video-only requests, we use the item ID as the jellyfin ID
|
||||||
|
let jellyfin_id = req.id.clone();
|
||||||
|
|
||||||
// Check if item is downloaded locally
|
// Check if item is downloaded locally
|
||||||
let local_path = if let Some(db_wrapper) = db {
|
let local_path = if let Some(db_wrapper) = db {
|
||||||
check_for_local_download(db_wrapper, jellyfin_id).await?
|
check_for_local_download(db_wrapper, &jellyfin_id).await?
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@ -374,27 +355,27 @@ async fn create_media_item(
|
|||||||
Ok(MediaItem {
|
Ok(MediaItem {
|
||||||
id: req.id.clone(),
|
id: req.id.clone(),
|
||||||
title: req.title.clone(),
|
title: req.title.clone(),
|
||||||
name: Some(req.title), // Frontend compatibility
|
name: Some(req.title.clone()),
|
||||||
artist: req.artist.clone(),
|
artist: None, // Not available from video-only request
|
||||||
album: req.album.clone(),
|
album: None, // Not available from video-only request
|
||||||
album_name: req.album, // Frontend compatibility
|
album_name: None, // Not available from video-only request
|
||||||
album_id: req.album_id,
|
album_id: None, // Not available from video-only request
|
||||||
artist_items: None, // Not available from frontend request
|
artist_items: None, // Not available from video-only request
|
||||||
artists: req.artist.map(|a| vec![a]), // Convert single artist to array
|
artists: None, // Not available from video-only request
|
||||||
primary_image_tag: None, // Not available from frontend request
|
primary_image_tag: None, // Not available from video-only request
|
||||||
item_type: None, // Not available from frontend request
|
item_type: None, // Not available from video-only request
|
||||||
playlist_id: req.playlist_id,
|
playlist_id: None, // Not available from video-only request
|
||||||
duration: req.duration,
|
duration: None, // Not available from video-only request
|
||||||
artwork_url: req.artwork_url,
|
artwork_url: None, // Not available from video-only request
|
||||||
media_type: req.media_type,
|
media_type: crate::player::MediaType::Video, // Video-only request
|
||||||
source,
|
source,
|
||||||
video_codec: req.video_codec,
|
video_codec: Some(req.video_codec),
|
||||||
needs_transcoding: req.needs_transcoding,
|
needs_transcoding: req.needs_transcoding,
|
||||||
video_width: req.video_width,
|
video_width: None, // Not available from video-only request
|
||||||
video_height: req.video_height,
|
video_height: None, // Not available from video-only request
|
||||||
subtitles: vec![],
|
subtitles: vec![],
|
||||||
series_id: req.series_id,
|
series_id: None, // Not available from video-only request
|
||||||
server_id: req.server_id,
|
server_id: None, // Not available from video-only request
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,6 +414,9 @@ async fn check_for_local_download(
|
|||||||
|
|
||||||
/// Play a single media item (audio or video)
|
/// Play a single media item (audio or video)
|
||||||
///
|
///
|
||||||
|
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
|
||||||
|
/// This avoids Tauri's Android serialization issues with complex objects.
|
||||||
|
///
|
||||||
/// @req: UR-003 - Play videos
|
/// @req: UR-003 - Play videos
|
||||||
/// @req: UR-004 - Play audio uninterrupted
|
/// @req: UR-004 - Play audio uninterrupted
|
||||||
/// @req: UR-005 - Control media playback (play operation)
|
/// @req: UR-005 - Control media playback (play operation)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
//! Tauri commands for database/storage operations
|
//! Tauri commands for database/storage operations
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -1343,6 +1344,25 @@ pub async fn thumbnail_delete_item(
|
|||||||
thumbnail_cache.0.delete_item(db_service, &item_id).await
|
thumbnail_cache.0.delete_item(db_service, &item_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mime_from_ext(ext: Option<&str>) -> &'static str {
|
||||||
|
match ext {
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limit concurrent image downloads to avoid saturating the connection pool.
|
||||||
|
/// Without this, rendering a page with hundreds of album cards fires hundreds of
|
||||||
|
/// concurrent HTTP requests, starving API calls and causing timeouts.
|
||||||
|
static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
|
||||||
|
|
||||||
|
fn image_semaphore() -> &'static Semaphore {
|
||||||
|
IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
|
||||||
|
}
|
||||||
|
|
||||||
/// Request to get an image URL (with caching)
|
/// Request to get an image URL (with caching)
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -1385,32 +1405,21 @@ pub async fn image_get_url(
|
|||||||
&request.image_type,
|
&request.image_type,
|
||||||
tag,
|
tag,
|
||||||
).await {
|
).await {
|
||||||
// Read file and return as base64
|
|
||||||
let image_data = fs::read(&cached_path)
|
let image_data = fs::read(&cached_path)
|
||||||
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||||
let base64_data = BASE64.encode(&image_data);
|
let base64_data = BASE64.encode(&image_data);
|
||||||
|
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||||
// Determine MIME type from file extension
|
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
|
||||||
let mime_type = match cached_path.extension().and_then(|s| s.to_str()) {
|
|
||||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
|
||||||
Some("png") => "image/png",
|
|
||||||
Some("gif") => "image/gif",
|
|
||||||
Some("webp") => "image/webp",
|
|
||||||
_ => "image/jpeg", // default
|
|
||||||
};
|
|
||||||
|
|
||||||
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
|
|
||||||
debug!("[ImageCache] Cache hit for {}/{}", request.item_id, request.image_type);
|
|
||||||
return Ok(data_url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not cached - need to fetch from repository and cache
|
// Not cached — fetch from server and cache.
|
||||||
info!("[ImageCache] Cache miss for {}/{}, downloading...", request.item_id, request.image_type);
|
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
|
||||||
|
let _permit = image_semaphore().acquire().await
|
||||||
|
.map_err(|_| "Image download semaphore closed".to_string())?;
|
||||||
|
|
||||||
let repository = repository_manager.0.get(&repository_handle)
|
let repository = repository_manager.0.get(&repository_handle)
|
||||||
.ok_or("Repository not found - user may need to log in")?;
|
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
|
||||||
|
|
||||||
// Parse image type
|
|
||||||
let image_type_enum = match request.image_type.as_str() {
|
let image_type_enum = match request.image_type.as_str() {
|
||||||
"Primary" => ImageType::Primary,
|
"Primary" => ImageType::Primary,
|
||||||
"Backdrop" => ImageType::Backdrop,
|
"Backdrop" => ImageType::Backdrop,
|
||||||
@ -1420,7 +1429,6 @@ pub async fn image_get_url(
|
|||||||
_ => ImageType::Primary,
|
_ => ImageType::Primary,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build image options
|
|
||||||
let options = ImageOptions {
|
let options = ImageOptions {
|
||||||
max_width: request.max_width,
|
max_width: request.max_width,
|
||||||
max_height: request.max_height,
|
max_height: request.max_height,
|
||||||
@ -1428,18 +1436,10 @@ pub async fn image_get_url(
|
|||||||
tag: request.tag.clone(),
|
tag: request.tag.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get image URL from repository
|
|
||||||
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||||
debug!("[ImageCache] Server URL: {}", server_url);
|
let image_data = repository.download_bytes(&server_url).await
|
||||||
|
|
||||||
// Download image data
|
|
||||||
let worker = ThumbnailWorker::new();
|
|
||||||
let image_data = worker
|
|
||||||
.download_with_retry(&server_url, 2)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to download image: {}", e))?;
|
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||||
|
|
||||||
// Save to cache
|
|
||||||
let cached_path = thumbnail_cache.0.save_thumbnail(
|
let cached_path = thumbnail_cache.0.save_thumbnail(
|
||||||
db_service,
|
db_service,
|
||||||
&request.item_id,
|
&request.item_id,
|
||||||
@ -1450,18 +1450,9 @@ pub async fn image_get_url(
|
|||||||
request.max_height.map(|h| h as i32),
|
request.max_height.map(|h| h as i32),
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
// Return as base64 data URL
|
|
||||||
let base64_data = BASE64.encode(&image_data);
|
let base64_data = BASE64.encode(&image_data);
|
||||||
let mime_type = match cached_path.extension().and_then(|s| s.to_str()) {
|
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
Ok(format!("data:{};base64,{}", mime_type, base64_data))
|
||||||
Some("png") => "image/png",
|
|
||||||
Some("gif") => "image/gif",
|
|
||||||
Some("webp") => "image/webp",
|
|
||||||
_ => "image/jpeg",
|
|
||||||
};
|
|
||||||
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
|
|
||||||
info!("[ImageCache] Cached and returning base64 data URL");
|
|
||||||
Ok(data_url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@ -21,6 +21,7 @@ impl DownloadWorker {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
@ -31,14 +32,18 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file with retry logic and progress tracking
|
/// Download a file with retry logic and progress tracking
|
||||||
pub async fn download(
|
pub async fn download<F>(
|
||||||
&self,
|
&self,
|
||||||
task: &DownloadTask,
|
task: &DownloadTask,
|
||||||
) -> Result<DownloadResult, DownloadError> {
|
on_progress: F,
|
||||||
|
) -> Result<DownloadResult, DownloadError>
|
||||||
|
where
|
||||||
|
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||||
|
{
|
||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match self.try_download(task).await {
|
match self.try_download(task, &on_progress).await {
|
||||||
Ok(result) => return Ok(result),
|
Ok(result) => return Ok(result),
|
||||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||||
retries += 1;
|
retries += 1;
|
||||||
@ -55,7 +60,10 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt a single download
|
/// Attempt a single download
|
||||||
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> {
|
async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
|
||||||
|
where
|
||||||
|
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||||
|
{
|
||||||
// Create parent directories
|
// Create parent directories
|
||||||
if let Some(parent) = task.target_path.parent() {
|
if let Some(parent) = task.target_path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@ -129,7 +137,7 @@ impl DownloadWorker {
|
|||||||
|| downloaded % (1024 * 1024) == 0
|
|| downloaded % (1024 * 1024) == 0
|
||||||
{
|
{
|
||||||
last_progress_emit = std::time::Instant::now();
|
last_progress_emit = std::time::Instant::now();
|
||||||
// Progress events will be emitted by the manager
|
on_progress(downloaded, _total_bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ impl JellyfinClient {
|
|||||||
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
||||||
let http_client = Client::builder()
|
let http_client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,7 @@ impl HttpClient {
|
|||||||
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(config.timeout)
|
.timeout(config.timeout)
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,12 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||||
|
/// Delegates to online repository for connection reuse and proper auth.
|
||||||
|
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
|
self.online.download_bytes(url).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Get video stream URL with optional seeking support.
|
/// Get video stream URL with optional seeking support.
|
||||||
/// This method is online-only since offline playback uses local file paths.
|
/// This method is online-only since offline playback uses local file paths.
|
||||||
pub async fn get_video_stream_url(
|
pub async fn get_video_stream_url(
|
||||||
|
|||||||
@ -36,6 +36,29 @@ impl OnlineRepository {
|
|||||||
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
|
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||||
|
/// Used by thumbnail cache to download images with proper auth and connection reuse.
|
||||||
|
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let request = self.http_client.client.get(url)
|
||||||
|
.header("X-Emby-Authorization", self.auth_header())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| format!("Download failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
|
||||||
|
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.bytes().await
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.map_err(|e| format!("Failed to read bytes: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Make authenticated GET request
|
/// Make authenticated GET request
|
||||||
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
|
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
|
||||||
let url = format!("{}{}", self.server_url, endpoint);
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
@ -1005,8 +1028,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
image_type.as_str()
|
image_type.as_str()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Authentication is handled by X-Emby-Authorization header in download_bytes()
|
||||||
|
// Do NOT include api_key here — some Jellyfin servers reject requests when
|
||||||
|
// api_key is present but the token doesn't match the expected format.
|
||||||
|
let mut params: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(opts) = options {
|
if let Some(opts) = options {
|
||||||
let mut params = Vec::new();
|
|
||||||
if let Some(width) = opts.max_width {
|
if let Some(width) = opts.max_width {
|
||||||
params.push(format!("maxWidth={}", width));
|
params.push(format!("maxWidth={}", width));
|
||||||
}
|
}
|
||||||
@ -1019,12 +1046,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
if let Some(tag) = opts.tag {
|
if let Some(tag) = opts.tag {
|
||||||
params.push(format!("tag={}", tag));
|
params.push(format!("tag={}", tag));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !params.is_empty() {
|
if !params.is_empty() {
|
||||||
url.push('?');
|
url.push('?');
|
||||||
url.push_str(¶ms.join("&"));
|
url.push_str(¶ms.join("&"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,8 @@ mod tests {
|
|||||||
self.server_url, item_id, image_type
|
self.server_url, item_id, image_type
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut params = vec![("api_key", self.access_token.clone())];
|
// No api_key — image downloads use X-Emby-Authorization header
|
||||||
|
let mut params: Vec<(&str, String)> = Vec::new();
|
||||||
|
|
||||||
if let Some(opts) = options {
|
if let Some(opts) = options {
|
||||||
if let Some(max_width) = opts.max_width {
|
if let Some(max_width) = opts.max_width {
|
||||||
@ -304,14 +305,11 @@ mod tests {
|
|||||||
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
||||||
let download_url = repo.get_video_download_url("item123", "720p");
|
let download_url = repo.get_video_download_url("item123", "720p");
|
||||||
|
|
||||||
// These URLs are constructed in BACKEND and returned to frontend
|
// Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
|
||||||
// Frontend never receives this token directly
|
assert!(!image_url.contains("api_key="));
|
||||||
assert!(image_url.contains("api_key=super_secret_token"));
|
// Subtitle and download URLs still use api_key (used directly, not via download_bytes)
|
||||||
assert!(subtitle_url.contains("api_key=super_secret_token"));
|
assert!(subtitle_url.contains("api_key=super_secret_token"));
|
||||||
assert!(download_url.contains("api_key=super_secret_token"));
|
assert!(download_url.contains("api_key=super_secret_token"));
|
||||||
|
|
||||||
// In actual implementation, frontend would only get the URL string
|
|
||||||
// Frontend cannot construct its own URLs or extract the token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -335,10 +333,10 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", None);
|
let url = repo.get_image_url("id123", "Primary", None);
|
||||||
|
|
||||||
// Should be valid format
|
// Should be valid format (no api_key — auth via header)
|
||||||
assert!(url.starts_with("https://server.com"));
|
assert!(url.starts_with("https://server.com"));
|
||||||
assert!(url.contains("/Items/id123/Images/Primary"));
|
assert!(url.contains("/Items/id123/Images/Primary"));
|
||||||
assert!(url.contains("?api_key="));
|
assert!(!url.contains("api_key="));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -353,13 +351,13 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
||||||
|
|
||||||
// Should have single ? separator
|
// Should have single ? separator with params
|
||||||
let question_marks = url.matches('?').count();
|
let question_marks = url.matches('?').count();
|
||||||
assert_eq!(question_marks, 1);
|
assert_eq!(question_marks, 1);
|
||||||
|
|
||||||
// Should have ampersands between params
|
// Should have params for maxWidth and maxHeight
|
||||||
assert!(url.contains("?"));
|
assert!(url.contains("maxWidth=300"));
|
||||||
assert!(url.contains("&"));
|
assert!(url.contains("maxHeight=200"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -368,8 +366,7 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
||||||
|
|
||||||
// Should handle special characters in token and id
|
// Should handle special characters in id (no token in URL anymore)
|
||||||
assert!(url.contains("token_with_special-chars"));
|
|
||||||
assert!(url.contains("item-with-special_chars"));
|
assert!(url.contains("item-with-special_chars"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,9 +380,9 @@ mod tests {
|
|||||||
// Backend generates full URL with credentials
|
// Backend generates full URL with credentials
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
let url = repo.get_image_url("item123", "Primary", None);
|
||||||
|
|
||||||
// URL is complete and ready to use
|
// URL is complete and ready to use (auth via header, not api_key)
|
||||||
assert!(url.starts_with("https://"));
|
assert!(url.starts_with("https://"));
|
||||||
assert!(url.contains("api_key="));
|
assert!(url.contains("/Items/item123/Images/Primary"));
|
||||||
|
|
||||||
// Frontend never constructs URLs directly
|
// Frontend never constructs URLs directly
|
||||||
// Frontend only receives pre-constructed URLs from backend
|
// Frontend only receives pre-constructed URLs from backend
|
||||||
@ -408,7 +405,6 @@ mod tests {
|
|||||||
assert!(url.contains("maxHeight=200"));
|
assert!(url.contains("maxHeight=200"));
|
||||||
assert!(url.contains("quality=90"));
|
assert!(url.contains("quality=90"));
|
||||||
assert!(url.contains("tag=abc"));
|
assert!(url.contains("tag=abc"));
|
||||||
assert!(url.contains("api_key=token"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -423,8 +419,8 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||||
|
|
||||||
// Should only have api_key
|
// Should have no query params (no api_key, no options)
|
||||||
assert!(url.contains("api_key=token"));
|
assert!(!url.contains("?"));
|
||||||
assert!(!url.contains("maxWidth"));
|
assert!(!url.contains("maxWidth"));
|
||||||
assert!(!url.contains("maxHeight"));
|
assert!(!url.contains("maxHeight"));
|
||||||
assert!(!url.contains("quality"));
|
assert!(!url.contains("quality"));
|
||||||
|
|||||||
@ -12,6 +12,7 @@ impl ThumbnailWorker {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
|||||||
@ -28,18 +28,6 @@
|
|||||||
<span class="text-xs">Home</span>
|
<span class="text-xs">Home</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Library Button -->
|
|
||||||
<button
|
|
||||||
onclick={() => goto('/library')}
|
|
||||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
|
||||||
aria-label="Library"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs">Library</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Search Button -->
|
<!-- Search Button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/search')}
|
onclick={() => goto('/search')}
|
||||||
@ -51,5 +39,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs">Search</span>
|
<span class="text-xs">Search</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Library Button -->
|
||||||
|
<button
|
||||||
|
onclick={() => goto('/library')}
|
||||||
|
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
||||||
|
aria-label="Library"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">Library</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -61,7 +61,6 @@
|
|||||||
imageUrl = dataUrl;
|
imageUrl = dataUrl;
|
||||||
error = false;
|
error = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to load image ${itemId}:`, e);
|
|
||||||
error = true;
|
error = true;
|
||||||
imageUrl = null;
|
imageUrl = null;
|
||||||
} finally {
|
} finally {
|
||||||
@ -78,10 +77,12 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
|
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
|
||||||
{:else if error}
|
{:else if error || !imageUrl}
|
||||||
<div class="{className} bg-gray-800 flex items-center justify-center">
|
<div class="{className} bg-gray-800 flex items-center justify-center">
|
||||||
<span class="text-gray-500 text-xs">Failed to load</span>
|
<svg class="w-8 h-8 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{:else if imageUrl}
|
{:else}
|
||||||
<img src={imageUrl} {alt} class={className} />
|
<img src={imageUrl} {alt} class={className} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
let currentIndex = $state(0);
|
let currentIndex = $state(0);
|
||||||
let intervalId: number | null = null;
|
let intervalId: number | null = null;
|
||||||
let heroImageUrl = $state<string>("");
|
|
||||||
|
|
||||||
// Touch/swipe state
|
// Touch/swipe state
|
||||||
let touchStartX = $state(0);
|
let touchStartX = $state(0);
|
||||||
@ -22,82 +21,45 @@
|
|||||||
|
|
||||||
const currentItem = $derived(items[currentIndex] ?? null);
|
const currentItem = $derived(items[currentIndex] ?? null);
|
||||||
|
|
||||||
// Load hero image URL asynchronously based on item priority
|
// Compute the best image source for the hero banner (no fetch, pure derivation)
|
||||||
async function loadHeroImageUrl(): Promise<void> {
|
const heroImageSource = $derived.by(() => {
|
||||||
if (!currentItem) {
|
if (!currentItem) return null;
|
||||||
heroImageUrl = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
|
|
||||||
// 1. Try backdrop image first (best for hero display)
|
// 1. Try backdrop image first (best for hero display)
|
||||||
if (currentItem.backdropImageTags?.[0]) {
|
if (currentItem.backdropImageTags?.[0]) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", {
|
return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] };
|
||||||
maxWidth: 1920,
|
|
||||||
tag: currentItem.backdropImageTags[0],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. For episodes, try to use series backdrop from parent
|
// 2. For episodes, try series/season backdrops
|
||||||
if (currentItem.type === "Episode") {
|
if (currentItem.type === "Episode") {
|
||||||
// First try parent backdrop tags (includes image tag for caching)
|
|
||||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
|
||||||
maxWidth: 1920,
|
|
||||||
tag: currentItem.parentBackdropImageTags[0],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Fallback: try series backdrop without tag (may not be cached optimally)
|
|
||||||
if (currentItem.seriesId) {
|
if (currentItem.seriesId) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
|
||||||
maxWidth: 1920,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Last resort for episodes: try season backdrop
|
|
||||||
if (currentItem.seasonId) {
|
if (currentItem.seasonId) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
return { itemId: currentItem.seasonId, imageType: "Backdrop" as const, tag: undefined };
|
||||||
maxWidth: 1920,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. For music tracks, try album backdrop first, then primary
|
// 3. For music tracks, try album backdrop
|
||||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||||
// Try album backdrop first (more cinematic for hero)
|
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
|
||||||
maxWidth: 1920,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
// 4. Fall back to primary image
|
||||||
if (currentItem.primaryImageTag) {
|
if (currentItem.primaryImageTag) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", {
|
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
|
||||||
maxWidth: 1920,
|
|
||||||
tag: currentItem.primaryImageTag,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Last resort for audio: try album primary image
|
// 5. Last resort for audio: album primary
|
||||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", {
|
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
|
||||||
maxWidth: 1920,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
heroImageUrl = "";
|
return null;
|
||||||
} catch {
|
});
|
||||||
heroImageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
currentIndex = (currentIndex + 1) % items.length;
|
currentIndex = (currentIndex + 1) % items.length;
|
||||||
@ -143,11 +105,6 @@
|
|||||||
touchEndX = 0;
|
touchEndX = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load hero image whenever current item changes
|
|
||||||
$effect(() => {
|
|
||||||
loadHeroImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-rotate logic
|
// Auto-rotate logic
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (autoRotate && items.length > 1) {
|
if (autoRotate && items.length > 1) {
|
||||||
@ -166,10 +123,13 @@
|
|||||||
ontouchmove={handleTouchMove}
|
ontouchmove={handleTouchMove}
|
||||||
ontouchend={handleTouchEnd}
|
ontouchend={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{#if heroImageUrl}
|
{#if heroImageSource}
|
||||||
<img
|
<CachedImage
|
||||||
src={heroImageUrl}
|
itemId={heroImageSource.itemId}
|
||||||
alt={currentItem?.name}
|
imageType={heroImageSource.imageType}
|
||||||
|
tag={heroImageSource.tag}
|
||||||
|
maxWidth={1920}
|
||||||
|
alt={currentItem?.name ?? ""}
|
||||||
class="absolute inset-0 w-full h-full object-cover"
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -82,9 +83,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Download the album
|
// Download the album: queue all tracks, then start each one
|
||||||
|
const repo = auth.getRepository();
|
||||||
const basePath = `albums/${albumId}`;
|
const basePath = `albums/${albumId}`;
|
||||||
await downloads.downloadAlbum(albumId, userId, basePath);
|
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
|
||||||
|
|
||||||
|
// Get target directory for downloads
|
||||||
|
const targetDir = await invoke<string>("storage_get_path");
|
||||||
|
|
||||||
|
// Start each queued track download
|
||||||
|
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
|
||||||
|
try {
|
||||||
|
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
|
||||||
|
if (streamUrl) {
|
||||||
|
await invoke("start_download", {
|
||||||
|
downloadId: downloadIds[i],
|
||||||
|
streamUrl,
|
||||||
|
targetDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh to get updated statuses
|
||||||
|
await downloads.refresh(userId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Album download operation failed:", error);
|
console.error("Album download operation failed:", error);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Person, PersonType } from "$lib/api/types";
|
import type { Person, PersonType } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
people: Person[];
|
people: Person[];
|
||||||
@ -10,9 +10,6 @@
|
|||||||
|
|
||||||
let { people, title = "Cast & Crew" }: Props = $props();
|
let { people, title = "Cast & Crew" }: Props = $props();
|
||||||
|
|
||||||
// Map of person IDs to their image URLs, loaded asynchronously
|
|
||||||
let personImageUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
// Group people by type
|
// Group people by type
|
||||||
const groupedPeople = $derived.by(() => {
|
const groupedPeople = $derived.by(() => {
|
||||||
const groups: Record<string, Person[]> = {
|
const groups: Record<string, Person[]> = {
|
||||||
@ -61,31 +58,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL for a single person
|
|
||||||
async function loadPersonImageUrl(person: Person): Promise<void> {
|
|
||||||
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
const url = await repo.getImageUrl(person.id, "Primary", {
|
|
||||||
maxWidth: 200,
|
|
||||||
tag: person.primaryImageTag,
|
|
||||||
});
|
|
||||||
personImageUrls.set(person.id, url);
|
|
||||||
} catch {
|
|
||||||
personImageUrls.set(person.id, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image URLs for all people
|
|
||||||
$effect(() => {
|
|
||||||
people.forEach((person) => {
|
|
||||||
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
|
|
||||||
loadPersonImageUrl(person);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function handlePersonClick(person: Person) {
|
function handlePersonClick(person: Person) {
|
||||||
goto(`/library/${person.id}`);
|
goto(`/library/${person.id}`);
|
||||||
}
|
}
|
||||||
@ -110,20 +82,14 @@
|
|||||||
>
|
>
|
||||||
<!-- Person image -->
|
<!-- Person image -->
|
||||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||||
{#if person.primaryImageTag && personImageUrls.get(person.id)}
|
<CachedImage
|
||||||
<img
|
itemId={person.id}
|
||||||
src={personImageUrls.get(person.id)}
|
imageType="Primary"
|
||||||
|
tag={person.primaryImageTag}
|
||||||
|
maxWidth={200}
|
||||||
alt={person.name}
|
alt={person.name}
|
||||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center text-gray-500">
|
|
||||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name and role -->
|
<!-- Name and role -->
|
||||||
|
|||||||
@ -66,8 +66,8 @@
|
|||||||
class="transition-all"
|
class="transition-all"
|
||||||
style="transition: stroke-dashoffset 0.3s ease;"
|
style="transition: stroke-dashoffset 0.3s ease;"
|
||||||
/>
|
/>
|
||||||
<!-- Download Icon in Center -->
|
<!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) -->
|
||||||
<text x="18" y="20" text-anchor="middle" class="text-xs font-bold fill-current">
|
<text x="18" y="20" text-anchor="middle" transform="rotate(90, 18, 18)" class="text-xs font-bold fill-current">
|
||||||
{Math.round(state.progress * 100)}%
|
{Math.round(state.progress * 100)}%
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
episode: MediaItem;
|
episode: MediaItem;
|
||||||
@ -12,9 +12,6 @@
|
|||||||
|
|
||||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||||
|
|
||||||
let backdropUrl = $state<string>("");
|
|
||||||
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
// Check if an episode matches the focused episode (by ID or season/episode number)
|
// Check if an episode matches the focused episode (by ID or season/episode number)
|
||||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||||
if (ep.id === episode.id) return true;
|
if (ep.id === episode.id) return true;
|
||||||
@ -73,72 +70,18 @@
|
|||||||
return allEpisodes.slice(start, end);
|
return allEpisodes.slice(start, end);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load backdrop URL asynchronously
|
// Compute best backdrop source (no fetch, pure derivation)
|
||||||
async function loadBackdropUrl(): Promise<void> {
|
const backdropSource = $derived.by(() => {
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
|
|
||||||
// Try episode backdrop first
|
|
||||||
if (episode.backdropImageTags?.[0]) {
|
if (episode.backdropImageTags?.[0]) {
|
||||||
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
|
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||||
maxWidth: 1920,
|
|
||||||
tag: episode.backdropImageTags[0],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try episode primary (thumbnail)
|
|
||||||
if (episode.primaryImageTag) {
|
if (episode.primaryImageTag) {
|
||||||
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
|
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
|
||||||
maxWidth: 1920,
|
|
||||||
tag: episode.primaryImageTag,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to series backdrop
|
|
||||||
if (series.backdropImageTags?.[0]) {
|
if (series.backdropImageTags?.[0]) {
|
||||||
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
|
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||||
maxWidth: 1920,
|
|
||||||
tag: series.backdropImageTags[0],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
backdropUrl = "";
|
|
||||||
} catch {
|
|
||||||
backdropUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load episode thumbnail URL for a single episode
|
|
||||||
async function loadEpisodeThumbnailUrl(ep: MediaItem): Promise<void> {
|
|
||||||
if (!ep.primaryImageTag || episodeThumbnailUrls.has(ep.id)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
const url = await repo.getImageUrl(ep.id, "Primary", {
|
|
||||||
maxWidth: 400,
|
|
||||||
tag: ep.primaryImageTag,
|
|
||||||
});
|
|
||||||
episodeThumbnailUrls.set(ep.id, url);
|
|
||||||
} catch {
|
|
||||||
episodeThumbnailUrls.set(ep.id, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load backdrop when episode changes
|
|
||||||
$effect(() => {
|
|
||||||
loadBackdropUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load episode thumbnail URLs when adjacent episodes change
|
|
||||||
$effect(() => {
|
|
||||||
adjacentEpisodes().forEach((ep) => {
|
|
||||||
if (ep.primaryImageTag && !episodeThumbnailUrls.has(ep.id)) {
|
|
||||||
loadEpisodeThumbnailUrl(ep);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDuration(ticks?: number): string {
|
function formatDuration(ticks?: number): string {
|
||||||
@ -178,9 +121,12 @@
|
|||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<div class="relative h-[450px] rounded-xl overflow-hidden">
|
<div class="relative h-[450px] rounded-xl overflow-hidden">
|
||||||
{#if backdropUrl}
|
{#if backdropSource}
|
||||||
<img
|
<CachedImage
|
||||||
src={backdropUrl}
|
itemId={backdropSource.itemId}
|
||||||
|
imageType={backdropSource.imageType}
|
||||||
|
tag={backdropSource.tag}
|
||||||
|
maxWidth={1920}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
class="absolute inset-0 w-full h-full object-cover"
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@ -288,7 +234,6 @@
|
|||||||
{#each adjacentEpisodes() as ep (ep.id)}
|
{#each adjacentEpisodes() as ep (ep.id)}
|
||||||
{@const isCurrent = isCurrentEpisode(ep)}
|
{@const isCurrent = isCurrentEpisode(ep)}
|
||||||
{@const epProgress = getProgress(ep)}
|
{@const epProgress = getProgress(ep)}
|
||||||
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||||
@ -296,20 +241,14 @@
|
|||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||||
{#if thumbUrl}
|
<CachedImage
|
||||||
<img
|
itemId={ep.id}
|
||||||
src={thumbUrl}
|
imageType="Primary"
|
||||||
|
tag={ep.primaryImageTag}
|
||||||
|
maxWidth={400}
|
||||||
alt={ep.name}
|
alt={ep.name}
|
||||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
|
||||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Hover overlay -->
|
<!-- Hover overlay -->
|
||||||
{#if !isCurrent}
|
{#if !isCurrent}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
episode: MediaItem;
|
episode: MediaItem;
|
||||||
@ -15,7 +15,6 @@
|
|||||||
let { episode, focused = false, onclick }: Props = $props();
|
let { episode, focused = false, onclick }: Props = $props();
|
||||||
|
|
||||||
let buttonRef: HTMLButtonElement | null = null;
|
let buttonRef: HTMLButtonElement | null = null;
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (focused && buttonRef) {
|
if (focused && buttonRef) {
|
||||||
@ -37,24 +36,6 @@
|
|||||||
);
|
);
|
||||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||||
|
|
||||||
// Load image URL asynchronously
|
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
|
|
||||||
maxWidth: 320,
|
|
||||||
tag: episode.primaryImageTag,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
imageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image when episode changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
const progress = $derived(() => {
|
const progress = $derived(() => {
|
||||||
if (!episode.userData || !episode.runTimeTicks) {
|
if (!episode.userData || !episode.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -74,20 +55,14 @@
|
|||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||||
{#if imageUrl}
|
<CachedImage
|
||||||
<img
|
itemId={episode.id}
|
||||||
src={imageUrl}
|
imageType="Primary"
|
||||||
|
tag={episode.primaryImageTag}
|
||||||
|
maxWidth={320}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
|
||||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Hover overlay with play icon -->
|
<!-- Hover overlay with play icon -->
|
||||||
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
import type { Genre, MediaItem } from "$lib/api/types";
|
import type { Genre, MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
@ -41,8 +42,6 @@
|
|||||||
let selectedGenre = $state<Genre | null>(null);
|
let selectedGenre = $state<Genre | null>(null);
|
||||||
let genreItems = $state<MediaItem[]>([]);
|
let genreItems = $state<MediaItem[]>([]);
|
||||||
let loadingItems = $state(false);
|
let loadingItems = $state(false);
|
||||||
let genreItemImageUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||||
await loadGenres();
|
await loadGenres();
|
||||||
if (selectedGenre) {
|
if (selectedGenre) {
|
||||||
@ -80,7 +79,6 @@
|
|||||||
try {
|
try {
|
||||||
loadingItems = true;
|
loadingItems = true;
|
||||||
selectedGenre = genre;
|
selectedGenre = genre;
|
||||||
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const result = await repo.getItems($currentLibrary.id, {
|
const result = await repo.getItems($currentLibrary.id, {
|
||||||
includeItemTypes: config.itemTypes,
|
includeItemTypes: config.itemTypes,
|
||||||
@ -98,31 +96,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL for a single item
|
|
||||||
async function loadGenreItemImage(item: MediaItem): Promise<void> {
|
|
||||||
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
|
||||||
maxWidth: 300,
|
|
||||||
tag: item.primaryImageTag,
|
|
||||||
});
|
|
||||||
genreItemImageUrls.set(item.id, url);
|
|
||||||
} catch {
|
|
||||||
genreItemImageUrls.set(item.id, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image URLs for all genre items
|
|
||||||
$effect(() => {
|
|
||||||
genreItems.forEach((item) => {
|
|
||||||
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
|
|
||||||
loadGenreItemImage(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilter() {
|
||||||
let result = [...genres];
|
let result = [...genres];
|
||||||
|
|
||||||
@ -245,19 +218,14 @@
|
|||||||
{#each genreItems as item (item.id)}
|
{#each genreItems as item (item.id)}
|
||||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||||
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
|
<CachedImage
|
||||||
<img
|
itemId={item.id}
|
||||||
src={genreItemImageUrls.get(item.id)}
|
imageType="Primary"
|
||||||
|
tag={item.primaryImageTag}
|
||||||
|
maxWidth={300}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
|
||||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: (MediaItem | Library)[];
|
items: (MediaItem | Library)[];
|
||||||
@ -13,36 +13,13 @@
|
|||||||
|
|
||||||
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
||||||
|
|
||||||
// Map of item IDs to their image URLs, loaded asynchronously
|
|
||||||
let imageUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
function getDownloadInfo(itemId: string) {
|
function getDownloadInfo(itemId: string) {
|
||||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL for a single item
|
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||||
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
|
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
|
||||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
|
||||||
maxWidth: 80,
|
|
||||||
tag,
|
|
||||||
});
|
|
||||||
imageUrls.set(item.id, url);
|
|
||||||
} catch {
|
|
||||||
imageUrls.set(item.id, "");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Load image URLs whenever items change
|
|
||||||
$effect(() => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!imageUrls.has(item.id)) {
|
|
||||||
loadImageUrl(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getSubtitle(item: MediaItem | Library): string {
|
function getSubtitle(item: MediaItem | Library): string {
|
||||||
if (!("type" in item)) return "";
|
if (!("type" in item)) return "";
|
||||||
@ -80,7 +57,6 @@
|
|||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each items as item, index (item.id)}
|
{#each items as item, index (item.id)}
|
||||||
{@const imageUrl = imageUrls.get(item.id) ?? ""}
|
|
||||||
{@const subtitle = getSubtitle(item)}
|
{@const subtitle = getSubtitle(item)}
|
||||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||||
{@const progress = getProgress(item)}
|
{@const progress = getProgress(item)}
|
||||||
@ -102,20 +78,14 @@
|
|||||||
|
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
||||||
{#if imageUrl}
|
<CachedImage
|
||||||
<img
|
itemId={item.id}
|
||||||
src={imageUrl}
|
imageType="Primary"
|
||||||
|
tag={getImageTag(item)}
|
||||||
|
maxWidth={80}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Play overlay on hover -->
|
<!-- Play overlay on hover -->
|
||||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: MediaItem | Library;
|
item: MediaItem | Library;
|
||||||
@ -13,9 +13,6 @@
|
|||||||
|
|
||||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||||
|
|
||||||
// Image URL state - loaded asynchronously
|
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
// Check if this item is downloaded
|
// Check if this item is downloaded
|
||||||
const downloadInfo = $derived(
|
const downloadInfo = $derived(
|
||||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||||
@ -42,26 +39,11 @@
|
|||||||
return "aspect-video";
|
return "aspect-video";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load image URL asynchronously from backend
|
const imageTag = $derived(
|
||||||
async function loadImageUrl(): Promise<void> {
|
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
|
||||||
try {
|
);
|
||||||
const repo = auth.getRepository();
|
|
||||||
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
|
|
||||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
|
||||||
|
|
||||||
imageUrl = await repo.getImageUrl(item.id, "Primary", {
|
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||||
maxWidth,
|
|
||||||
tag,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
imageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image URL whenever item or size changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
const progress = $derived(() => {
|
const progress = $derived(() => {
|
||||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||||
@ -96,20 +78,14 @@
|
|||||||
{onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
|
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
|
||||||
{#if imageUrl}
|
<CachedImage
|
||||||
<img
|
itemId={item.id}
|
||||||
src={imageUrl}
|
imageType="Primary"
|
||||||
|
tag={imageTag}
|
||||||
|
maxWidth={maxWidth}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
|
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
|
||||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Hover overlay with smooth gradient -->
|
<!-- Hover overlay with smooth gradient -->
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -14,7 +15,6 @@
|
|||||||
let movies = $state<MediaItem[]>([]);
|
let movies = $state<MediaItem[]>([]);
|
||||||
let series = $state<MediaItem[]>([]);
|
let series = $state<MediaItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadFilmography();
|
await loadFilmography();
|
||||||
@ -39,24 +39,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL asynchronously
|
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
imageUrl = await repo.getImageUrl(person.id, "Primary", {
|
|
||||||
maxWidth: 400,
|
|
||||||
tag: person.primaryImageTag,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
imageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image when person changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleItemClick(item: MediaItem) {
|
function handleItemClick(item: MediaItem) {
|
||||||
goto(`/library/${item.id}`);
|
goto(`/library/${item.id}`);
|
||||||
}
|
}
|
||||||
@ -67,19 +49,14 @@
|
|||||||
<div class="flex gap-6 pt-4">
|
<div class="flex gap-6 pt-4">
|
||||||
<!-- Profile image -->
|
<!-- Profile image -->
|
||||||
<div class="flex-shrink-0 w-48">
|
<div class="flex-shrink-0 w-48">
|
||||||
{#if imageUrl && person.primaryImageTag}
|
<CachedImage
|
||||||
<img
|
itemId={person.id}
|
||||||
src={imageUrl}
|
imageType="Primary"
|
||||||
|
tag={person.primaryImageTag}
|
||||||
|
maxWidth={400}
|
||||||
alt={person.name}
|
alt={person.name}
|
||||||
class="w-full rounded-lg shadow-lg"
|
class="w-full rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
|
||||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import EpisodeRow from "./EpisodeRow.svelte";
|
import EpisodeRow from "./EpisodeRow.svelte";
|
||||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||||
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
season: MediaItem;
|
season: MediaItem;
|
||||||
@ -13,26 +13,6 @@
|
|||||||
|
|
||||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||||
|
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
// Load image URL asynchronously
|
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
imageUrl = await repo.getImageUrl(season.id, "Primary", {
|
|
||||||
maxWidth: 200,
|
|
||||||
tag: season.primaryImageTag,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
imageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image when season changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
const episodeCount = $derived(episodes.length);
|
const episodeCount = $derived(episodes.length);
|
||||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||||
const seasonName = $derived(
|
const seasonName = $derived(
|
||||||
@ -45,20 +25,14 @@
|
|||||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||||
<!-- Season poster -->
|
<!-- Season poster -->
|
||||||
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
|
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
|
||||||
{#if imageUrl}
|
<CachedImage
|
||||||
<img
|
itemId={season.id}
|
||||||
src={imageUrl}
|
imageType="Primary"
|
||||||
|
tag={season.primaryImageTag}
|
||||||
|
maxWidth={200}
|
||||||
alt={seasonName}
|
alt={seasonName}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
|
||||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Season info -->
|
<!-- Season info -->
|
||||||
|
|||||||
@ -38,6 +38,8 @@
|
|||||||
|
|
||||||
let isProcessing = $state(false);
|
let isProcessing = $state(false);
|
||||||
let showQualityPicker = $state(false);
|
let showQualityPicker = $state(false);
|
||||||
|
let buttonEl: HTMLButtonElement;
|
||||||
|
let dropdownPos = $state({ top: 0, left: 0 });
|
||||||
|
|
||||||
// Find download for this item
|
// Find download for this item
|
||||||
const downloadInfo = $derived(
|
const downloadInfo = $derived(
|
||||||
@ -135,13 +137,26 @@
|
|||||||
}
|
}
|
||||||
} else if (status === "failed") {
|
} else if (status === "failed") {
|
||||||
// Show quality picker to retry
|
// Show quality picker to retry
|
||||||
showQualityPicker = true;
|
openQualityPicker();
|
||||||
} else {
|
} else {
|
||||||
// Show quality picker
|
// Show quality picker
|
||||||
showQualityPicker = true;
|
openQualityPicker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openQualityPicker() {
|
||||||
|
if (buttonEl) {
|
||||||
|
const rect = buttonEl.getBoundingClientRect();
|
||||||
|
const dropdownWidth = 160; // w-40
|
||||||
|
const padding = 8;
|
||||||
|
let left = rect.right - dropdownWidth;
|
||||||
|
// Clamp to viewport bounds
|
||||||
|
left = Math.max(padding, Math.min(left, window.innerWidth - dropdownWidth - padding));
|
||||||
|
dropdownPos = { top: rect.bottom + 4, left };
|
||||||
|
}
|
||||||
|
showQualityPicker = true;
|
||||||
|
}
|
||||||
|
|
||||||
function getTitle(): string {
|
function getTitle(): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "completed":
|
case "completed":
|
||||||
@ -176,6 +191,7 @@
|
|||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
|
bind:this={buttonEl}
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
||||||
@ -242,10 +258,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quality picker dropdown -->
|
<!-- Quality picker dropdown (fixed position, viewport-clamped) -->
|
||||||
{#if showQualityPicker}
|
{#if showQualityPicker}
|
||||||
<div class="absolute z-50 mt-1 right-0 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
<button
|
||||||
|
class="fixed inset-0 z-40"
|
||||||
|
onclick={() => showQualityPicker = false}
|
||||||
|
aria-label="Close quality picker"
|
||||||
|
></button>
|
||||||
|
<div
|
||||||
|
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
||||||
|
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
|
||||||
|
>
|
||||||
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
||||||
Select Quality
|
Select Quality
|
||||||
</div>
|
</div>
|
||||||
@ -269,14 +294,4 @@
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|||||||
@ -97,9 +97,9 @@
|
|||||||
await invoke("player_cycle_repeat");
|
await invoke("player_cycle_repeat");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use track's own ID for artwork (primaryImageTag corresponds to track ID)
|
// Prefer album ID for artwork (all tracks in an album share the same cover)
|
||||||
// Album art is inherited from album, so all tracks show the same album cover
|
// Falls back to track ID if no album ID available
|
||||||
const artworkItemId = $derived(displayMedia?.id);
|
const artworkItemId = $derived(displayMedia?.albumId || displayMedia?.id);
|
||||||
|
|
||||||
// Show optimistic position while seeking or waiting for backend confirmation
|
// Show optimistic position while seeking or waiting for backend confirmation
|
||||||
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
|
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
|
||||||
@ -137,12 +137,12 @@
|
|||||||
{#if displayMedia}
|
{#if displayMedia}
|
||||||
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
|
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
|
||||||
<!-- Background image (blurred) -->
|
<!-- Background image (blurred) -->
|
||||||
{#if artworkItemId && displayMedia?.primaryImageTag}
|
{#if artworkItemId}
|
||||||
<div class="fixed inset-0 z-0">
|
<div class="fixed inset-0 z-0">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
itemId={artworkItemId}
|
itemId={artworkItemId}
|
||||||
imageType="Primary"
|
imageType="Primary"
|
||||||
tag={displayMedia.primaryImageTag}
|
tag={displayMedia?.primaryImageTag}
|
||||||
maxWidth={800}
|
maxWidth={800}
|
||||||
alt=""
|
alt=""
|
||||||
class="w-full h-full object-cover blur-3xl opacity-30"
|
class="w-full h-full object-cover blur-3xl opacity-30"
|
||||||
@ -217,11 +217,11 @@
|
|||||||
<!-- Artwork -->
|
<!-- Artwork -->
|
||||||
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
|
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
|
||||||
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
|
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
|
||||||
{#if artworkItemId && displayMedia?.primaryImageTag}
|
{#if artworkItemId}
|
||||||
<CachedImage
|
<CachedImage
|
||||||
itemId={artworkItemId}
|
itemId={artworkItemId}
|
||||||
imageType="Primary"
|
imageType="Primary"
|
||||||
tag={displayMedia.primaryImageTag}
|
tag={displayMedia?.primaryImageTag}
|
||||||
maxWidth={500}
|
maxWidth={500}
|
||||||
alt={displayMedia?.name}
|
alt={displayMedia?.name}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
|||||||
@ -145,6 +145,8 @@
|
|||||||
function handleTouchStart(e: TouchEvent) {
|
function handleTouchStart(e: TouchEvent) {
|
||||||
touchStartX = e.touches[0].clientX;
|
touchStartX = e.touches[0].clientX;
|
||||||
touchStartY = e.touches[0].clientY;
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchEndX = touchStartX;
|
||||||
|
touchEndY = touchStartY;
|
||||||
isSwiping = true;
|
isSwiping = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,15 +295,13 @@
|
|||||||
>
|
>
|
||||||
<!-- Media info -->
|
<!-- Media info -->
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<!-- Artwork (clickable to expand) -->
|
<!-- Artwork -->
|
||||||
<button
|
<div
|
||||||
onclick={onExpand}
|
|
||||||
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
||||||
aria-label="Open full player"
|
|
||||||
>
|
>
|
||||||
{#if displayMedia?.primaryImageTag}
|
{#if displayMedia}
|
||||||
<CachedImage
|
<CachedImage
|
||||||
itemId={displayMedia.id}
|
itemId={displayMedia.albumId || displayMedia.id}
|
||||||
imageType="Primary"
|
imageType="Primary"
|
||||||
tag={displayMedia.primaryImageTag}
|
tag={displayMedia.primaryImageTag}
|
||||||
maxWidth={100}
|
maxWidth={100}
|
||||||
@ -315,16 +315,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<!-- Title & Artist -->
|
<!-- Title & Artist -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<button
|
<div
|
||||||
onclick={onExpand}
|
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||||
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
|
|
||||||
>
|
>
|
||||||
{displayMedia?.name}
|
{displayMedia?.name}
|
||||||
</button>
|
</div>
|
||||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||||
{#if displayMedia?.artistItems?.length}
|
{#if displayMedia?.artistItems?.length}
|
||||||
{#each displayMedia?.artistItems as artist, i}
|
{#each displayMedia?.artistItems as artist, i}
|
||||||
|
|||||||
@ -372,22 +372,14 @@
|
|||||||
|
|
||||||
// Call Rust backend to start playback
|
// Call Rust backend to start playback
|
||||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||||
|
// Send minimal video data - no complex serialization to avoid Tauri Android issues
|
||||||
const response: any = await invoke("player_play_item", {
|
const response: any = await invoke("player_play_item", {
|
||||||
item: {
|
item: {
|
||||||
id: media.id,
|
|
||||||
title: media.name,
|
|
||||||
artist: null,
|
|
||||||
album: null,
|
|
||||||
duration: media.runTimeTicks ? media.runTimeTicks / 10000000 : null,
|
|
||||||
artworkUrl: null,
|
|
||||||
mediaType: "video",
|
|
||||||
streamUrl: currentStreamUrl,
|
streamUrl: currentStreamUrl,
|
||||||
jellyfinItemId: media.id,
|
title: media.name,
|
||||||
|
id: media.id,
|
||||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||||
needsTranscoding,
|
needsTranscoding: needsTranscoding,
|
||||||
videoWidth: null,
|
|
||||||
videoHeight: null,
|
|
||||||
subtitles: subtitleTracks,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
session: Session;
|
session: Session;
|
||||||
@ -10,31 +10,6 @@
|
|||||||
|
|
||||||
let { session, selected = false, onclick }: Props = $props();
|
let { session, selected = false, onclick }: Props = $props();
|
||||||
|
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
// Load image URL asynchronously
|
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
if (!session.nowPlayingItem) {
|
|
||||||
imageUrl = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
imageUrl = await repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
|
||||||
maxWidth: 80,
|
|
||||||
tag: session.nowPlayingItem.primaryImageTag,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
imageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image when session changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatTime(ticks: number): string {
|
function formatTime(ticks: number): string {
|
||||||
const seconds = Math.floor(ticks / 10000000);
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
@ -73,13 +48,16 @@
|
|||||||
<!-- Now playing -->
|
<!-- Now playing -->
|
||||||
{#if nowPlaying && playState}
|
{#if nowPlaying && playState}
|
||||||
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
||||||
{#if imageUrl}
|
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
||||||
<img
|
<CachedImage
|
||||||
src={imageUrl}
|
itemId={nowPlaying.id}
|
||||||
|
imageType="Primary"
|
||||||
|
tag={nowPlaying.primaryImageTag}
|
||||||
|
maxWidth={80}
|
||||||
alt={nowPlaying.name}
|
alt={nowPlaying.name}
|
||||||
class="w-12 h-12 rounded object-cover flex-shrink-0"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
||||||
|
|||||||
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;
|
hasPrevious: boolean;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
repeat: string;
|
repeat: string;
|
||||||
}>("player_get_queue_status");
|
}>("player_get_queue");
|
||||||
|
|
||||||
// Import appState stores dynamically to avoid circular imports
|
// Import appState stores dynamically to avoid circular imports
|
||||||
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
|
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
|
||||||
|
|||||||
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;
|
currentLibrary: Library | null;
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
currentItem: MediaItem | null;
|
currentItem: MediaItem | null;
|
||||||
isLoading: boolean;
|
/** Counter for concurrent loading operations. isLoading = loadingCount > 0 */
|
||||||
|
loadingCount: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@ -34,7 +35,7 @@ function createLibraryStore() {
|
|||||||
currentLibrary: null,
|
currentLibrary: null,
|
||||||
items: [],
|
items: [],
|
||||||
currentItem: null,
|
currentItem: null,
|
||||||
isLoading: false,
|
loadingCount: 0,
|
||||||
error: null,
|
error: null,
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
@ -50,7 +51,7 @@ function createLibraryStore() {
|
|||||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||||
|
|
||||||
async function loadLibraries() {
|
async function loadLibraries() {
|
||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
@ -71,13 +72,13 @@ function createLibraryStore() {
|
|||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
libraries,
|
libraries,
|
||||||
isLoading: false,
|
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return libraries;
|
return libraries;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load libraries";
|
const message = error instanceof Error ? error.message : "Failed to load libraries";
|
||||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -86,7 +87,7 @@ function createLibraryStore() {
|
|||||||
parentId: string,
|
parentId: string,
|
||||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
|
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
|
||||||
) {
|
) {
|
||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
@ -115,19 +116,19 @@ function createLibraryStore() {
|
|||||||
...s,
|
...s,
|
||||||
items: result.items,
|
items: result.items,
|
||||||
totalItems: result.totalRecordCount,
|
totalItems: result.totalRecordCount,
|
||||||
isLoading: false,
|
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load items";
|
const message = error instanceof Error ? error.message : "Failed to load items";
|
||||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadItem(itemId: string) {
|
async function loadItem(itemId: string) {
|
||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
@ -144,13 +145,13 @@ function createLibraryStore() {
|
|||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
currentItem: item,
|
currentItem: item,
|
||||||
isLoading: false,
|
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load item";
|
const message = error instanceof Error ? error.message : "Failed to load item";
|
||||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,7 +162,7 @@ function createLibraryStore() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
update((s) => ({ ...s, isLoading: true, error: null, searchQuery: query }));
|
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
@ -179,13 +180,13 @@ function createLibraryStore() {
|
|||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
searchResults: result.items,
|
searchResults: result.items,
|
||||||
isLoading: false,
|
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Search failed";
|
const message = error instanceof Error ? error.message : "Search failed";
|
||||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -273,7 +274,7 @@ export const library = createLibraryStore();
|
|||||||
export const libraries = derived(library, ($lib) => $lib.libraries);
|
export const libraries = derived(library, ($lib) => $lib.libraries);
|
||||||
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
|
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
|
||||||
export const libraryItems = derived(library, ($lib) => $lib.items);
|
export const libraryItems = derived(library, ($lib) => $lib.items);
|
||||||
export const isLibraryLoading = derived(library, ($lib) => $lib.isLoading);
|
export const isLibraryLoading = derived(library, ($lib) => $lib.loadingCount > 0);
|
||||||
export const libraryError = derived(library, ($lib) => $lib.error);
|
export const libraryError = derived(library, ($lib) => $lib.error);
|
||||||
export const viewMode = derived(library, ($lib) => $lib.viewMode);
|
export const viewMode = derived(library, ($lib) => $lib.viewMode);
|
||||||
export const genres = derived(library, ($lib) => $lib.genres);
|
export const genres = derived(library, ($lib) => $lib.genres);
|
||||||
|
|||||||
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
|
// Call disconnect
|
||||||
await playbackMode.disconnect();
|
await playbackMode.disconnect();
|
||||||
|
|
||||||
// Verify Rust backend was notified with correct mode
|
// Verify Rust backend was notified with correct mode (mode is now an object with type field)
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: "Idle" });
|
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", {
|
||||||
|
mode: { type: "idle" },
|
||||||
|
});
|
||||||
|
|
||||||
// Verify sessions.selectSession was called with null
|
// Verify sessions.selectSession was called with null
|
||||||
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
||||||
|
|||||||
@ -185,38 +185,25 @@ function createPlaybackModeStore() {
|
|||||||
|
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
|
|
||||||
// TODO: After Phase 3 (repository migration), this will be handled by Rust
|
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
||||||
// For now, we need to fetch playback info and start local playback from TypeScript
|
|
||||||
|
|
||||||
// Get repository to fetch playback info
|
|
||||||
const repository = auth.getRepository();
|
const repository = auth.getRepository();
|
||||||
const playbackInfo = await repository.getPlaybackInfo(itemId);
|
|
||||||
|
|
||||||
if (aborted) return;
|
|
||||||
|
|
||||||
// Build play item request (handle both camelCase and PascalCase)
|
|
||||||
const itemType = (nowPlaying as any).type || (nowPlaying as any).Type;
|
|
||||||
const artists = (nowPlaying as any).artists || (nowPlaying as any).Artists;
|
|
||||||
const albumName = (nowPlaying as any).albumName || (nowPlaying as any).AlbumName;
|
|
||||||
const runTimeTicks = (nowPlaying as any).runTimeTicks || (nowPlaying as any).RunTimeTicks;
|
|
||||||
const primaryImageTag = (nowPlaying as any).primaryImageTag || (nowPlaying as any).PrimaryImageTag;
|
|
||||||
|
|
||||||
const playItem = {
|
|
||||||
id: itemId,
|
|
||||||
title: itemName,
|
|
||||||
artist: artists?.[0],
|
|
||||||
album: albumName,
|
|
||||||
duration: runTimeTicks ? ticksToSeconds(runTimeTicks) : undefined,
|
|
||||||
artworkUrl: repository.getImageUrl(itemId, "Primary", {
|
|
||||||
tag: primaryImageTag,
|
|
||||||
}),
|
|
||||||
mediaType: itemType === "Audio" ? "audio" : "video",
|
|
||||||
streamUrl: playbackInfo.streamUrl,
|
|
||||||
jellyfinItemId: itemId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start local playback (events allowed through because isTransferring=true)
|
// Start local playback (events allowed through because isTransferring=true)
|
||||||
await invoke("player_play_item", { item: playItem });
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
|
const repositoryHandle = repository.getHandle();
|
||||||
|
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repositoryHandle,
|
||||||
|
request: {
|
||||||
|
trackIds: [itemId],
|
||||||
|
startIndex: 0,
|
||||||
|
shuffle: false,
|
||||||
|
context: {
|
||||||
|
type: "search",
|
||||||
|
searchQuery: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
|
|
||||||
@ -323,7 +310,7 @@ function createPlaybackModeStore() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Notify Rust backend to switch to idle mode
|
// Notify Rust backend to switch to idle mode
|
||||||
await invoke("playback_mode_set", { mode: "Idle" });
|
await invoke("playback_mode_set", { mode: { type: "idle" } });
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
sessions.selectSession(null);
|
sessions.selectSession(null);
|
||||||
|
|||||||
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}
|
{:else}
|
||||||
<div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
<div class="h-screen overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
|
||||||
<!-- Hero Banner -->
|
<!-- Hero Banner -->
|
||||||
{#if heroItems.length > 0}
|
{#if heroItems.length > 0}
|
||||||
<HeroBanner items={heroItems} />
|
<HeroBanner items={heroItems} />
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, onDestroy, setContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
|
import { currentMedia, isPlaying, playbackPosition, playbackDuration, shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
||||||
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||||
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
|
|
||||||
|
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
|
||||||
|
const scrollGuard = useScrollGuard(300);
|
||||||
|
setContext("scrollGuard", scrollGuard);
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
@ -48,6 +53,7 @@
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
scrollGuard.cleanup();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -241,8 +247,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Main content (with padding for nav bar and mini player) -->
|
<!-- Main content (with padding for bottom nav bar and mini player) -->
|
||||||
<main class="flex-1 overflow-y-auto p-4 pb-16 {$currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? (isAndroid ? 'pb-40' : 'pb-40 md:pb-24') : ''}">
|
<main
|
||||||
|
class="flex-1 overflow-y-auto p-4"
|
||||||
|
style="padding-bottom: {$shouldShowAudioMiniPlayer ? (isAndroid ? '11rem' : '7rem') : '5rem'}; overscroll-behavior: contain"
|
||||||
|
onscroll={scrollGuard.onScroll}
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { Library, MediaItem } from "$lib/api/types";
|
import type { Library, MediaItem } from "$lib/api/types";
|
||||||
import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library";
|
import { library, libraries, libraryItems, isLibraryLoading, currentLibrary, selectedGenres } from "$lib/stores/library";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
|
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
||||||
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
|
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
|
||||||
|
|
||||||
|
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
||||||
|
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
||||||
|
|
||||||
let searchResults = $derived($library.searchResults);
|
let searchResults = $derived($library.searchResults);
|
||||||
let searchQuery = $derived($library.searchQuery);
|
let searchQuery = $derived($library.searchQuery);
|
||||||
|
|
||||||
@ -48,6 +52,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleLibraryClick(lib: Library) {
|
async function handleLibraryClick(lib: Library) {
|
||||||
|
// Prevent accidental taps during scrolling (Android)
|
||||||
|
if (scrollGuard.isScrollActive()) return;
|
||||||
|
|
||||||
// Route to dedicated music library page
|
// Route to dedicated music library page
|
||||||
if (lib.collectionType === "music") {
|
if (lib.collectionType === "music") {
|
||||||
library.setCurrentLibrary(lib);
|
library.setCurrentLibrary(lib);
|
||||||
@ -70,6 +77,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(item: MediaItem | Library) {
|
function handleItemClick(item: MediaItem | Library) {
|
||||||
|
// Prevent accidental taps during scrolling (Android)
|
||||||
|
if (scrollGuard.isScrollActive()) return;
|
||||||
|
|
||||||
if ("type" in item) {
|
if ("type" in item) {
|
||||||
// It's a MediaItem
|
// It's a MediaItem
|
||||||
const mediaItem = item as MediaItem;
|
const mediaItem = item as MediaItem;
|
||||||
@ -102,6 +112,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBackToLibraries() {
|
function goBackToLibraries() {
|
||||||
|
// Prevent accidental taps during scrolling (Android)
|
||||||
|
if (scrollGuard.isScrollActive()) return;
|
||||||
library.setCurrentLibrary(null);
|
library.setCurrentLibrary(null);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -25,6 +25,13 @@
|
|||||||
connecting = true;
|
connecting = true;
|
||||||
localError = null;
|
localError = null;
|
||||||
|
|
||||||
|
// Reject plain HTTP — all connections must use HTTPS
|
||||||
|
if (serverUrl.trim().toLowerCase().startsWith("http://")) {
|
||||||
|
localError = "HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).";
|
||||||
|
connecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await auth.connectToServer(serverUrl);
|
const info = await auth.connectToServer(serverUrl);
|
||||||
serverName = info.name;
|
serverName = info.name;
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||||
import { playbackPosition, playbackDuration } from "$lib/stores/player";
|
import { playbackPosition, playbackDuration, currentMedia as storeCurrentMedia } from "$lib/stores/player";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
|
||||||
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
import VideoPlayer from "$lib/components/player/VideoPlayer.svelte";
|
||||||
@ -126,6 +126,25 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this track is already playing in the backend, just show the UI
|
||||||
|
// without restarting playback (e.g., when expanding from MiniPlayer)
|
||||||
|
const alreadyPlayingMedia = get(storeCurrentMedia);
|
||||||
|
if (alreadyPlayingMedia?.id === id && !startPosition) {
|
||||||
|
console.log("loadAndPlay: Track already playing, showing UI without restarting");
|
||||||
|
isVideo = item.type === "Movie" || item.type === "Episode";
|
||||||
|
isPlaying = true;
|
||||||
|
loading = false;
|
||||||
|
// Sync queue status
|
||||||
|
try {
|
||||||
|
const queueStatus = await invoke<{ hasNext: boolean; hasPrevious: boolean }>("player_get_queue");
|
||||||
|
hasNext = queueStatus.hasNext;
|
||||||
|
hasPrevious = queueStatus.hasPrevious;
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore - queue status will update via polling
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if this is video content (Movie and Episode are video types)
|
// Determine if this is video content (Movie and Episode are video types)
|
||||||
isVideo = item.type === "Movie" || item.type === "Episode";
|
isVideo = item.type === "Movie" || item.type === "Episode";
|
||||||
|
|
||||||
@ -214,17 +233,20 @@
|
|||||||
} else {
|
} else {
|
||||||
// Local audio playback via MPV backend
|
// Local audio playback via MPV backend
|
||||||
console.log("loadAndPlay: Using MPV backend for offline audio");
|
console.log("loadAndPlay: Using MPV backend for offline audio");
|
||||||
await invoke("player_play_item", {
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
item: {
|
const repo = auth.getRepository();
|
||||||
id: item.id,
|
const repositoryHandle = repo.getHandle();
|
||||||
title: item.name,
|
|
||||||
artist: item.artists?.join(", ") || null,
|
await invoke("player_play_tracks", {
|
||||||
album: item.albumName || null,
|
repositoryHandle,
|
||||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
request: {
|
||||||
artworkUrl: null, // Local file may not have artwork
|
trackIds: [item.id],
|
||||||
mediaType: "audio",
|
startIndex: 0,
|
||||||
streamUrl: localUrl,
|
shuffle: false,
|
||||||
jellyfinItemId: item.id,
|
context: {
|
||||||
|
type: "search",
|
||||||
|
searchQuery: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (startPosition) {
|
if (startPosition) {
|
||||||
@ -334,17 +356,20 @@
|
|||||||
} else {
|
} else {
|
||||||
// Fallback to single item playback
|
// Fallback to single item playback
|
||||||
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
||||||
await invoke("player_play_item", {
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
item: {
|
const repo = auth.getRepository();
|
||||||
id: item.id,
|
const repositoryHandle = repo.getHandle();
|
||||||
title: item.name,
|
|
||||||
artist: item.artists?.join(", ") || null,
|
await invoke("player_play_tracks", {
|
||||||
album: item.albumName || null,
|
repositoryHandle,
|
||||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
request: {
|
||||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
trackIds: [item.id],
|
||||||
mediaType: "audio",
|
startIndex: 0,
|
||||||
streamUrl: playbackInfo.streamUrl,
|
shuffle: false,
|
||||||
jellyfinItemId: item.id,
|
context: {
|
||||||
|
type: "search",
|
||||||
|
searchQuery: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -353,17 +378,20 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No queue parameter - single item playback
|
// No queue parameter - single item playback
|
||||||
await invoke("player_play_item", {
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
item: {
|
const repo = auth.getRepository();
|
||||||
id: item.id,
|
const repositoryHandle = repo.getHandle();
|
||||||
title: item.name,
|
|
||||||
artist: item.artists?.join(", ") || null,
|
await invoke("player_play_tracks", {
|
||||||
album: item.albumName || null,
|
repositoryHandle,
|
||||||
duration: item.runTimeTicks ? item.runTimeTicks / 10000000 : null,
|
request: {
|
||||||
artworkUrl: repo.getImageUrl(item.id, "Primary", { maxWidth: 500 }),
|
trackIds: [item.id],
|
||||||
mediaType: "audio",
|
startIndex: 0,
|
||||||
streamUrl: playbackInfo.streamUrl,
|
shuffle: false,
|
||||||
jellyfinItemId: item.id,
|
context: {
|
||||||
|
type: "search",
|
||||||
|
searchQuery: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
{#if searchQuery.trim()}
|
{#if searchQuery.trim()}
|
||||||
<SearchResults
|
<SearchResults
|
||||||
results={$library.searchResults}
|
results={$library.searchResults}
|
||||||
loading={$library.isLoading}
|
loading={$library.loadingCount > 0}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user