Many improvemtns and fixes related to decoupling of svelte and rust on android.
This commit is contained in:
@@ -28,18 +28,6 @@
|
||||
<span class="text-xs">Home</span>
|
||||
</button>
|
||||
|
||||
<!-- Library Button -->
|
||||
<button
|
||||
onclick={() => goto('/library')}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
||||
aria-label="Library"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
|
||||
</svg>
|
||||
<span class="text-xs">Library</span>
|
||||
</button>
|
||||
|
||||
<!-- Search Button -->
|
||||
<button
|
||||
onclick={() => goto('/search')}
|
||||
@@ -51,5 +39,17 @@
|
||||
</svg>
|
||||
<span class="text-xs">Search</span>
|
||||
</button>
|
||||
|
||||
<!-- Library Button -->
|
||||
<button
|
||||
onclick={() => goto('/library')}
|
||||
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
|
||||
aria-label="Library"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z"/>
|
||||
</svg>
|
||||
<span class="text-xs">Library</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
imageUrl = dataUrl;
|
||||
error = false;
|
||||
} catch (e) {
|
||||
console.error(`Failed to load image ${itemId}:`, e);
|
||||
error = true;
|
||||
imageUrl = null;
|
||||
} finally {
|
||||
@@ -78,10 +77,12 @@
|
||||
|
||||
{#if loading}
|
||||
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
|
||||
{:else if error}
|
||||
{:else if error || !imageUrl}
|
||||
<div class="{className} bg-gray-800 flex items-center justify-center">
|
||||
<span class="text-gray-500 text-xs">Failed to load</span>
|
||||
<svg class="w-8 h-8 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if imageUrl}
|
||||
{:else}
|
||||
<img src={imageUrl} {alt} class={className} />
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
let currentIndex = $state(0);
|
||||
let intervalId: number | null = null;
|
||||
let heroImageUrl = $state<string>("");
|
||||
|
||||
// Touch/swipe state
|
||||
let touchStartX = $state(0);
|
||||
@@ -22,82 +21,45 @@
|
||||
|
||||
const currentItem = $derived(items[currentIndex] ?? null);
|
||||
|
||||
// Load hero image URL asynchronously based on item priority
|
||||
async function loadHeroImageUrl(): Promise<void> {
|
||||
if (!currentItem) {
|
||||
heroImageUrl = "";
|
||||
return;
|
||||
// Compute the best image source for the hero banner (no fetch, pure derivation)
|
||||
const heroImageSource = $derived.by(() => {
|
||||
if (!currentItem) return null;
|
||||
|
||||
// 1. Try backdrop image first (best for hero display)
|
||||
if (currentItem.backdropImageTags?.[0]) {
|
||||
return { itemId: currentItem.id, imageType: "Backdrop" as const, tag: currentItem.backdropImageTags[0] };
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// 1. Try backdrop image first (best for hero display)
|
||||
if (currentItem.backdropImageTags?.[0]) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
// 2. For episodes, try series/season backdrops
|
||||
if (currentItem.type === "Episode") {
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
|
||||
}
|
||||
|
||||
// 2. For episodes, try to use series backdrop from parent
|
||||
if (currentItem.type === "Episode") {
|
||||
// First try parent backdrop tags (includes image tag for caching)
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.parentBackdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fallback: try series backdrop without tag (may not be cached optimally)
|
||||
if (currentItem.seriesId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Last resort for episodes: try season backdrop
|
||||
if (currentItem.seasonId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentItem.seriesId) {
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop first, then primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
// Try album backdrop first (more cinematic for hero)
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
if (currentItem.seasonId) {
|
||||
return { itemId: currentItem.seasonId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
|
||||
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
||||
if (currentItem.primaryImageTag) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: currentItem.primaryImageTag,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: try album primary image
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", {
|
||||
maxWidth: 1920,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
heroImageUrl = "";
|
||||
} catch {
|
||||
heroImageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
|
||||
// 4. Fall back to primary image
|
||||
if (currentItem.primaryImageTag) {
|
||||
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: album primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
function next() {
|
||||
currentIndex = (currentIndex + 1) % items.length;
|
||||
@@ -143,11 +105,6 @@
|
||||
touchEndX = 0;
|
||||
}
|
||||
|
||||
// Load hero image whenever current item changes
|
||||
$effect(() => {
|
||||
loadHeroImageUrl();
|
||||
});
|
||||
|
||||
// Auto-rotate logic
|
||||
$effect(() => {
|
||||
if (autoRotate && items.length > 1) {
|
||||
@@ -166,10 +123,13 @@
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
{#if heroImageUrl}
|
||||
<img
|
||||
src={heroImageUrl}
|
||||
alt={currentItem?.name}
|
||||
{#if heroImageSource}
|
||||
<CachedImage
|
||||
itemId={heroImageSource.itemId}
|
||||
imageType={heroImageSource.imageType}
|
||||
tag={heroImageSource.tag}
|
||||
maxWidth={1920}
|
||||
alt={currentItem?.name ?? ""}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
@@ -82,9 +83,32 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Download the album
|
||||
// Download the album: queue all tracks, then start each one
|
||||
const repo = auth.getRepository();
|
||||
const basePath = `albums/${albumId}`;
|
||||
await downloads.downloadAlbum(albumId, userId, basePath);
|
||||
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
|
||||
|
||||
// Get target directory for downloads
|
||||
const targetDir = await invoke<string>("storage_get_path");
|
||||
|
||||
// Start each queued track download
|
||||
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
|
||||
try {
|
||||
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
|
||||
if (streamUrl) {
|
||||
await invoke("start_download", {
|
||||
downloadId: downloadIds[i],
|
||||
streamUrl,
|
||||
targetDir,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to get updated statuses
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Album download operation failed:", error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Person, PersonType } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
people: Person[];
|
||||
@@ -10,9 +10,6 @@
|
||||
|
||||
let { people, title = "Cast & Crew" }: Props = $props();
|
||||
|
||||
// Map of person IDs to their image URLs, loaded asynchronously
|
||||
let personImageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
// Group people by type
|
||||
const groupedPeople = $derived.by(() => {
|
||||
const groups: Record<string, Person[]> = {
|
||||
@@ -61,31 +58,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URL for a single person
|
||||
async function loadPersonImageUrl(person: Person): Promise<void> {
|
||||
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const url = await repo.getImageUrl(person.id, "Primary", {
|
||||
maxWidth: 200,
|
||||
tag: person.primaryImageTag,
|
||||
});
|
||||
personImageUrls.set(person.id, url);
|
||||
} catch {
|
||||
personImageUrls.set(person.id, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URLs for all people
|
||||
$effect(() => {
|
||||
people.forEach((person) => {
|
||||
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
|
||||
loadPersonImageUrl(person);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handlePersonClick(person: Person) {
|
||||
goto(`/library/${person.id}`);
|
||||
}
|
||||
@@ -110,20 +82,14 @@
|
||||
>
|
||||
<!-- Person image -->
|
||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||
{#if person.primaryImageTag && personImageUrls.get(person.id)}
|
||||
<img
|
||||
src={personImageUrls.get(person.id)}
|
||||
alt={person.name}
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
maxWidth={200}
|
||||
alt={person.name}
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Name and role -->
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
class="transition-all"
|
||||
style="transition: stroke-dashoffset 0.3s ease;"
|
||||
/>
|
||||
<!-- Download Icon in Center -->
|
||||
<text x="18" y="20" text-anchor="middle" class="text-xs font-bold fill-current">
|
||||
<!-- Download percentage in Center (counter-rotate to cancel SVG's -rotate-90) -->
|
||||
<text x="18" y="20" text-anchor="middle" transform="rotate(90, 18, 18)" class="text-xs font-bold fill-current">
|
||||
{Math.round(state.progress * 100)}%
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
@@ -12,9 +12,6 @@
|
||||
|
||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||
|
||||
let backdropUrl = $state<string>("");
|
||||
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
// Check if an episode matches the focused episode (by ID or season/episode number)
|
||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||
if (ep.id === episode.id) return true;
|
||||
@@ -73,72 +70,18 @@
|
||||
return allEpisodes.slice(start, end);
|
||||
});
|
||||
|
||||
// Load backdrop URL asynchronously
|
||||
async function loadBackdropUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Try episode backdrop first
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try episode primary (thumbnail)
|
||||
if (episode.primaryImageTag) {
|
||||
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 1920,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to series backdrop
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
|
||||
maxWidth: 1920,
|
||||
tag: series.backdropImageTags[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
backdropUrl = "";
|
||||
} catch {
|
||||
backdropUrl = "";
|
||||
// Compute best backdrop source (no fetch, pure derivation)
|
||||
const backdropSource = $derived.by(() => {
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "");
|
||||
if (episode.primaryImageTag) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
@@ -178,9 +121,12 @@
|
||||
<div class="space-y-8">
|
||||
<!-- Hero section -->
|
||||
<div class="relative h-[450px] rounded-xl overflow-hidden">
|
||||
{#if backdropUrl}
|
||||
<img
|
||||
src={backdropUrl}
|
||||
{#if backdropSource}
|
||||
<CachedImage
|
||||
itemId={backdropSource.itemId}
|
||||
imageType={backdropSource.imageType}
|
||||
tag={backdropSource.tag}
|
||||
maxWidth={1920}
|
||||
alt={episode.name}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
@@ -288,7 +234,6 @@
|
||||
{#each adjacentEpisodes() as ep (ep.id)}
|
||||
{@const isCurrent = isCurrentEpisode(ep)}
|
||||
{@const epProgress = getProgress(ep)}
|
||||
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
|
||||
<button
|
||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||
@@ -296,20 +241,14 @@
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
{#if thumbUrl}
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
/>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
{#if !isCurrent}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
@@ -15,7 +15,6 @@
|
||||
let { episode, focused = false, onclick }: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
onMount(() => {
|
||||
if (focused && buttonRef) {
|
||||
@@ -37,24 +36,6 @@
|
||||
);
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
|
||||
maxWidth: 320,
|
||||
tag: episode.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image when episode changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
return 0;
|
||||
@@ -74,20 +55,14 @@
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
tag={episode.primaryImageTag}
|
||||
maxWidth={320}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
/>
|
||||
|
||||
<!-- Hover overlay with play icon -->
|
||||
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import type { Genre, MediaItem } from "$lib/api/types";
|
||||
|
||||
@@ -41,8 +42,6 @@
|
||||
let selectedGenre = $state<Genre | null>(null);
|
||||
let genreItems = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
let genreItemImageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadGenres();
|
||||
if (selectedGenre) {
|
||||
@@ -80,7 +79,6 @@
|
||||
try {
|
||||
loadingItems = true;
|
||||
selectedGenre = genre;
|
||||
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
includeItemTypes: config.itemTypes,
|
||||
@@ -98,31 +96,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URL for a single item
|
||||
async function loadGenreItemImage(item: MediaItem): Promise<void> {
|
||||
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 300,
|
||||
tag: item.primaryImageTag,
|
||||
});
|
||||
genreItemImageUrls.set(item.id, url);
|
||||
} catch {
|
||||
genreItemImageUrls.set(item.id, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URLs for all genre items
|
||||
$effect(() => {
|
||||
genreItems.forEach((item) => {
|
||||
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
|
||||
loadGenreItemImage(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function applyFilter() {
|
||||
let result = [...genres];
|
||||
|
||||
@@ -245,19 +218,14 @@
|
||||
{#each genreItems as item (item.id)}
|
||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
|
||||
<img
|
||||
src={genreItemImageUrls.get(item.id)}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
maxWidth={300}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
items: (MediaItem | Library)[];
|
||||
@@ -13,37 +13,14 @@
|
||||
|
||||
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
||||
|
||||
// Map of item IDs to their image URLs, loaded asynchronously
|
||||
let imageUrls = $state<Map<string, string>>(new Map());
|
||||
|
||||
function getDownloadInfo(itemId: string) {
|
||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||
}
|
||||
|
||||
// Load image URL for a single item
|
||||
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag,
|
||||
});
|
||||
imageUrls.set(item.id, url);
|
||||
} catch {
|
||||
imageUrls.set(item.id, "");
|
||||
}
|
||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
}
|
||||
|
||||
// Load image URLs whenever items change
|
||||
$effect(() => {
|
||||
items.forEach((item) => {
|
||||
if (!imageUrls.has(item.id)) {
|
||||
loadImageUrl(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
if (!("type" in item)) return "";
|
||||
|
||||
@@ -80,7 +57,6 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const imageUrl = imageUrls.get(item.id) ?? ""}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
@@ -102,20 +78,14 @@
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={getImageTag(item)}
|
||||
maxWidth={80}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<!-- Play overlay on hover -->
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
item: MediaItem | Library;
|
||||
@@ -13,9 +13,6 @@
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||
|
||||
// Image URL state - loaded asynchronously
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||
@@ -42,26 +39,11 @@
|
||||
return "aspect-video";
|
||||
});
|
||||
|
||||
// Load image URL asynchronously from backend
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
const imageTag = $derived(
|
||||
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
|
||||
);
|
||||
|
||||
imageUrl = await repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth,
|
||||
tag,
|
||||
});
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URL whenever item or size changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
@@ -96,20 +78,14 @@
|
||||
{onclick}
|
||||
>
|
||||
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={imageTag}
|
||||
maxWidth={maxWidth}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110"
|
||||
/>
|
||||
|
||||
<!-- Hover overlay with smooth gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { onMount } from "svelte";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface Props {
|
||||
@@ -14,7 +15,6 @@
|
||||
let movies = $state<MediaItem[]>([]);
|
||||
let series = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
onMount(async () => {
|
||||
await loadFilmography();
|
||||
@@ -39,24 +39,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
imageUrl = await repo.getImageUrl(person.id, "Primary", {
|
||||
maxWidth: 400,
|
||||
tag: person.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image when person changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
@@ -67,19 +49,14 @@
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Profile image -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if imageUrl && person.primaryImageTag}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={person.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={person.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
season: MediaItem;
|
||||
@@ -13,26 +13,6 @@
|
||||
|
||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
imageUrl = await repo.getImageUrl(season.id, "Primary", {
|
||||
maxWidth: 200,
|
||||
tag: season.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image when season changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
const episodeCount = $derived(episodes.length);
|
||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
@@ -45,20 +25,14 @@
|
||||
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
||||
<!-- Season poster -->
|
||||
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={seasonName}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<CachedImage
|
||||
itemId={season.id}
|
||||
imageType="Primary"
|
||||
tag={season.primaryImageTag}
|
||||
maxWidth={200}
|
||||
alt={seasonName}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Season info -->
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
|
||||
let isProcessing = $state(false);
|
||||
let showQualityPicker = $state(false);
|
||||
let buttonEl: HTMLButtonElement;
|
||||
let dropdownPos = $state({ top: 0, left: 0 });
|
||||
|
||||
// Find download for this item
|
||||
const downloadInfo = $derived(
|
||||
@@ -135,13 +137,26 @@
|
||||
}
|
||||
} else if (status === "failed") {
|
||||
// Show quality picker to retry
|
||||
showQualityPicker = true;
|
||||
openQualityPicker();
|
||||
} else {
|
||||
// Show quality picker
|
||||
showQualityPicker = true;
|
||||
openQualityPicker();
|
||||
}
|
||||
}
|
||||
|
||||
function openQualityPicker() {
|
||||
if (buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect();
|
||||
const dropdownWidth = 160; // w-40
|
||||
const padding = 8;
|
||||
let left = rect.right - dropdownWidth;
|
||||
// Clamp to viewport bounds
|
||||
left = Math.max(padding, Math.min(left, window.innerWidth - dropdownWidth - padding));
|
||||
dropdownPos = { top: rect.bottom + 4, left };
|
||||
}
|
||||
showQualityPicker = true;
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
@@ -176,6 +191,7 @@
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing}
|
||||
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
||||
@@ -242,41 +258,40 @@
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-1 right-0 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
||||
Select Quality
|
||||
</div>
|
||||
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => startDownload(key as QualityPreset)}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close -->
|
||||
<!-- Quality picker dropdown (fixed position, viewport-clamped) -->
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
<div
|
||||
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
||||
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
|
||||
>
|
||||
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
||||
Select Quality
|
||||
</div>
|
||||
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
||||
<button
|
||||
onclick={() => startDownload(key as QualityPreset)}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
{#if preset.videoBitrate}
|
||||
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-500">Direct</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => showQualityPicker = false}
|
||||
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -97,9 +97,9 @@
|
||||
await invoke("player_cycle_repeat");
|
||||
}
|
||||
|
||||
// Use track's own ID for artwork (primaryImageTag corresponds to track ID)
|
||||
// Album art is inherited from album, so all tracks show the same album cover
|
||||
const artworkItemId = $derived(displayMedia?.id);
|
||||
// Prefer album ID for artwork (all tracks in an album share the same cover)
|
||||
// Falls back to track ID if no album ID available
|
||||
const artworkItemId = $derived(displayMedia?.albumId || displayMedia?.id);
|
||||
|
||||
// Show optimistic position while seeking or waiting for backend confirmation
|
||||
const displayPosition = $derived(seeking || seekPending ? seekValue : rawPosition);
|
||||
@@ -137,12 +137,12 @@
|
||||
{#if displayMedia}
|
||||
<div class="fixed inset-0 z-50 flex flex-col overflow-y-auto">
|
||||
<!-- Background image (blurred) -->
|
||||
{#if artworkItemId && displayMedia?.primaryImageTag}
|
||||
{#if artworkItemId}
|
||||
<div class="fixed inset-0 z-0">
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
maxWidth={800}
|
||||
alt=""
|
||||
class="w-full h-full object-cover blur-3xl opacity-30"
|
||||
@@ -217,11 +217,11 @@
|
||||
<!-- Artwork -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 min-h-0">
|
||||
<div class="w-full max-w-md aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
|
||||
{#if artworkItemId && displayMedia?.primaryImageTag}
|
||||
{#if artworkItemId}
|
||||
<CachedImage
|
||||
itemId={artworkItemId}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
tag={displayMedia?.primaryImageTag}
|
||||
maxWidth={500}
|
||||
alt={displayMedia?.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchEndX = touchStartX;
|
||||
touchEndY = touchStartY;
|
||||
isSwiping = true;
|
||||
}
|
||||
|
||||
@@ -293,15 +295,13 @@
|
||||
>
|
||||
<!-- Media info -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<!-- Artwork (clickable to expand) -->
|
||||
<button
|
||||
onclick={onExpand}
|
||||
<!-- Artwork -->
|
||||
<div
|
||||
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
||||
aria-label="Open full player"
|
||||
>
|
||||
{#if displayMedia?.primaryImageTag}
|
||||
{#if displayMedia}
|
||||
<CachedImage
|
||||
itemId={displayMedia.id}
|
||||
itemId={displayMedia.albumId || displayMedia.id}
|
||||
imageType="Primary"
|
||||
tag={displayMedia.primaryImageTag}
|
||||
maxWidth={100}
|
||||
@@ -315,16 +315,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Title & Artist -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<button
|
||||
onclick={onExpand}
|
||||
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
|
||||
<div
|
||||
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||
>
|
||||
{displayMedia?.name}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
{#each displayMedia?.artistItems as artist, i}
|
||||
|
||||
@@ -372,22 +372,14 @@
|
||||
|
||||
// Call Rust backend to start playback
|
||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||
// Send minimal video data - no complex serialization to avoid Tauri Android issues
|
||||
const response: any = await invoke("player_play_item", {
|
||||
item: {
|
||||
id: media.id,
|
||||
title: media.name,
|
||||
artist: null,
|
||||
album: null,
|
||||
duration: media.runTimeTicks ? media.runTimeTicks / 10000000 : null,
|
||||
artworkUrl: null,
|
||||
mediaType: "video",
|
||||
streamUrl: currentStreamUrl,
|
||||
jellyfinItemId: media.id,
|
||||
title: media.name,
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding,
|
||||
videoWidth: null,
|
||||
videoHeight: null,
|
||||
subtitles: subtitleTracks,
|
||||
needsTranscoding: needsTranscoding,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Session } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
@@ -10,31 +10,6 @@
|
||||
|
||||
let { session, selected = false, onclick }: Props = $props();
|
||||
|
||||
let imageUrl = $state<string>("");
|
||||
|
||||
// Load image URL asynchronously
|
||||
async function loadImageUrl(): Promise<void> {
|
||||
if (!session.nowPlayingItem) {
|
||||
imageUrl = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
imageUrl = await repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag: session.nowPlayingItem.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Load image when session changes
|
||||
$effect(() => {
|
||||
loadImageUrl();
|
||||
});
|
||||
|
||||
function formatTime(ticks: number): string {
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -73,13 +48,16 @@
|
||||
<!-- Now playing -->
|
||||
{#if nowPlaying && playState}
|
||||
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
||||
<CachedImage
|
||||
itemId={nowPlaying.id}
|
||||
imageType="Primary"
|
||||
tag={nowPlaying.primaryImageTag}
|
||||
maxWidth={80}
|
||||
alt={nowPlaying.name}
|
||||
class="w-12 h-12 rounded object-cover flex-shrink-0"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Composable for preventing accidental taps/clicks during and shortly after scrolling.
|
||||
*
|
||||
* On Android WebView, touch-to-click cancellation during scroll gestures can be unreliable,
|
||||
* causing accidental taps on interactive elements (like library cards) while the user is scrolling.
|
||||
*
|
||||
* This composable tracks scroll activity and provides a guard function that should wrap
|
||||
* click handlers to prevent them from firing during/shortly after scroll.
|
||||
*
|
||||
* @param cooldownMs - Time in ms after scroll stops before clicks are re-enabled (default: 200ms)
|
||||
* @returns Object with onScroll handler and guarded click wrapper
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* const { onScroll, guardedClick, isScrollActive } = useScrollGuard();
|
||||
* </script>
|
||||
*
|
||||
* <div onscroll={onScroll}>
|
||||
* <button onclick={guardedClick(() => handleClick())}>Click me</button>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function useScrollGuard(cooldownMs: number = 200) {
|
||||
let isScrolling = false;
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onScroll() {
|
||||
isScrolling = true;
|
||||
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling = false;
|
||||
}, cooldownMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user is currently scrolling or just finished scrolling.
|
||||
*/
|
||||
function isScrollActive(): boolean {
|
||||
return isScrolling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a no-arg click handler to only fire if the user is not currently scrolling.
|
||||
*/
|
||||
function guardedClick(handler: () => void): () => void {
|
||||
return () => {
|
||||
if (isScrolling) return;
|
||||
handler();
|
||||
};
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||
}
|
||||
|
||||
return { onScroll, guardedClick, isScrollActive, cleanup };
|
||||
}
|
||||
@@ -234,7 +234,7 @@ async function updateQueueStatus(): Promise<void> {
|
||||
hasPrevious: boolean;
|
||||
shuffle: boolean;
|
||||
repeat: string;
|
||||
}>("player_get_queue_status");
|
||||
}>("player_get_queue");
|
||||
|
||||
// Import appState stores dynamically to avoid circular imports
|
||||
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
+16
-15
@@ -12,7 +12,8 @@ interface LibraryState {
|
||||
currentLibrary: Library | null;
|
||||
items: MediaItem[];
|
||||
currentItem: MediaItem | null;
|
||||
isLoading: boolean;
|
||||
/** Counter for concurrent loading operations. isLoading = loadingCount > 0 */
|
||||
loadingCount: number;
|
||||
error: string | null;
|
||||
totalItems: number;
|
||||
searchQuery: string;
|
||||
@@ -34,7 +35,7 @@ function createLibraryStore() {
|
||||
currentLibrary: null,
|
||||
items: [],
|
||||
currentItem: null,
|
||||
isLoading: false,
|
||||
loadingCount: 0,
|
||||
error: null,
|
||||
totalItems: 0,
|
||||
searchQuery: "",
|
||||
@@ -50,7 +51,7 @@ function createLibraryStore() {
|
||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||
|
||||
async function loadLibraries() {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
@@ -71,13 +72,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
libraries,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return libraries;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load libraries";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +87,7 @@ function createLibraryStore() {
|
||||
parentId: string,
|
||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
|
||||
) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
@@ -115,19 +116,19 @@ function createLibraryStore() {
|
||||
...s,
|
||||
items: result.items,
|
||||
totalItems: result.totalRecordCount,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load items";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItem(itemId: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@@ -144,13 +145,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
currentItem: item,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load item";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +162,7 @@ function createLibraryStore() {
|
||||
return;
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, isLoading: true, error: null, searchQuery: query }));
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
@@ -179,13 +180,13 @@ function createLibraryStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
searchResults: result.items,
|
||||
isLoading: false,
|
||||
loadingCount: Math.max(0, s.loadingCount - 1),
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Search failed";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -273,7 +274,7 @@ export const library = createLibraryStore();
|
||||
export const libraries = derived(library, ($lib) => $lib.libraries);
|
||||
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
|
||||
export const libraryItems = derived(library, ($lib) => $lib.items);
|
||||
export const isLibraryLoading = derived(library, ($lib) => $lib.isLoading);
|
||||
export const isLibraryLoading = derived(library, ($lib) => $lib.loadingCount > 0);
|
||||
export const libraryError = derived(library, ($lib) => $lib.error);
|
||||
export const viewMode = derived(library, ($lib) => $lib.viewMode);
|
||||
export const genres = derived(library, ($lib) => $lib.genres);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Integration tests for playbackMode store
|
||||
*
|
||||
* Tests that the store calls Tauri commands with correct parameter names.
|
||||
*
|
||||
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
|
||||
* snake_case Rust parameter names to camelCase for the frontend.
|
||||
* So Rust `repository_handle: String` → frontend sends `repositoryHandle`.
|
||||
* Nested struct fields with #[serde(rename_all = "camelCase")] also use camelCase.
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
describe("playbackMode store - Tauri invoke parameter verification", () => {
|
||||
let mockInvokedCalls: Array<{ command: string; args: Record<string, any> }> =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvokedCalls = [];
|
||||
|
||||
// Mock invoke to capture calls
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(async (command: string, args?: Record<string, any>) => {
|
||||
mockInvokedCalls.push({ command, args: args || {} });
|
||||
return { success: true };
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("player_play_tracks command parameters", () => {
|
||||
it("should use repositoryHandle (camelCase, auto-converted by Tauri v2)", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "test-handle-123", // ✓ CORRECT - Tauri v2 auto-converts
|
||||
request: {
|
||||
trackIds: ["track-1"],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("nested request fields use camelCase", () => {
|
||||
const correctRequest = {
|
||||
trackIds: ["track-1"], // ✓ camelCase for nested struct field
|
||||
startIndex: 0, // ✓ camelCase
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "", // ✓ camelCase for context field
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctRequest)).toContain("trackIds");
|
||||
expect(Object.keys(correctRequest)).toContain("startIndex");
|
||||
expect(Object.keys(correctRequest.context)).toContain("searchQuery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback_mode_transfer_to_local command parameters", () => {
|
||||
it("should use currentItemId and positionTicks (camelCase)", () => {
|
||||
const correctCall = {
|
||||
currentItemId: "item-123", // ✓ CORRECT
|
||||
positionTicks: 50000, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("currentItemId");
|
||||
expect(Object.keys(correctCall)).toContain("positionTicks");
|
||||
expect(Object.keys(correctCall)).not.toContain("current_item_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("position_ticks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session commands use sessionId (camelCase)", () => {
|
||||
it("remote_send_command uses sessionId", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
command: "PlayPause",
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
});
|
||||
|
||||
it("remote_play_on_session uses sessionId, itemIds, startIndex", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
itemIds: ["id1", "id2"], // ✓ CORRECT
|
||||
startIndex: 0, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).toContain("itemIds");
|
||||
expect(Object.keys(correctCall)).toContain("startIndex");
|
||||
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_ids");
|
||||
expect(Object.keys(correctCall)).not.toContain("start_index");
|
||||
});
|
||||
|
||||
it("remote_session_seek uses sessionId and positionTicks", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
positionTicks: 50000, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).toContain("positionTicks");
|
||||
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("position_ticks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Download commands use itemId (camelCase)", () => {
|
||||
it("pin_item uses itemId", () => {
|
||||
const correctCall = {
|
||||
itemId: "item-123", // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("itemId");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_id");
|
||||
});
|
||||
|
||||
it("unpin_item uses itemId", () => {
|
||||
const correctCall = {
|
||||
itemId: "item-123", // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("itemId");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Queue commands use repositoryHandle (camelCase)", () => {
|
||||
it("player_add_track_by_id uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
trackId: "track-123",
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("player_add_tracks_by_ids uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
trackIds: ["track-1", "track-2"],
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Player commands", () => {
|
||||
it("player_play_album_track uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
albumId: "album-123",
|
||||
albumName: "Test Album",
|
||||
trackId: "track-123",
|
||||
shuffle: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
|
||||
// Nested struct fields use camelCase
|
||||
expect(Object.keys(correctCall.request)).toContain("albumId");
|
||||
expect(Object.keys(correctCall.request)).toContain("albumName");
|
||||
expect(Object.keys(correctCall.request)).toContain("trackId");
|
||||
});
|
||||
|
||||
it("player_seek uses position (simple types don't need renaming)", () => {
|
||||
const correctCall = {
|
||||
position: 500.5,
|
||||
};
|
||||
|
||||
expect(correctCall.position).toBe(500.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error detection - what NOT to do", () => {
|
||||
it("repository_handle (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
repository_handle: "handle-123", // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("repositoryHandle");
|
||||
expect(Object.keys(wrongCall)).toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("session_id (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
session_id: "session-123", // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("sessionId");
|
||||
expect(Object.keys(wrongCall)).toContain("session_id");
|
||||
});
|
||||
|
||||
it("item_ids (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
item_ids: ["id1", "id2"], // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("itemIds");
|
||||
expect(Object.keys(wrongCall)).toContain("item_ids");
|
||||
});
|
||||
|
||||
it("start_index (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
start_index: 0, // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("startIndex");
|
||||
expect(Object.keys(wrongCall)).toContain("start_index");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,8 +85,10 @@ describe("playbackMode store", () => {
|
||||
// Call disconnect
|
||||
await playbackMode.disconnect();
|
||||
|
||||
// Verify Rust backend was notified with correct mode
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: "Idle" });
|
||||
// Verify Rust backend was notified with correct mode (mode is now an object with type field)
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", {
|
||||
mode: { type: "idle" },
|
||||
});
|
||||
|
||||
// Verify sessions.selectSession was called with null
|
||||
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
||||
|
||||
@@ -185,38 +185,25 @@ function createPlaybackModeStore() {
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// TODO: After Phase 3 (repository migration), this will be handled by Rust
|
||||
// For now, we need to fetch playback info and start local playback from TypeScript
|
||||
|
||||
// Get repository to fetch playback info
|
||||
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
||||
const repository = auth.getRepository();
|
||||
const playbackInfo = await repository.getPlaybackInfo(itemId);
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Build play item request (handle both camelCase and PascalCase)
|
||||
const itemType = (nowPlaying as any).type || (nowPlaying as any).Type;
|
||||
const artists = (nowPlaying as any).artists || (nowPlaying as any).Artists;
|
||||
const albumName = (nowPlaying as any).albumName || (nowPlaying as any).AlbumName;
|
||||
const runTimeTicks = (nowPlaying as any).runTimeTicks || (nowPlaying as any).RunTimeTicks;
|
||||
const primaryImageTag = (nowPlaying as any).primaryImageTag || (nowPlaying as any).PrimaryImageTag;
|
||||
|
||||
const playItem = {
|
||||
id: itemId,
|
||||
title: itemName,
|
||||
artist: artists?.[0],
|
||||
album: albumName,
|
||||
duration: runTimeTicks ? ticksToSeconds(runTimeTicks) : undefined,
|
||||
artworkUrl: repository.getImageUrl(itemId, "Primary", {
|
||||
tag: primaryImageTag,
|
||||
}),
|
||||
mediaType: itemType === "Audio" ? "audio" : "video",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: itemId,
|
||||
};
|
||||
|
||||
// Start local playback (events allowed through because isTransferring=true)
|
||||
await invoke("player_play_item", { item: playItem });
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repositoryHandle = repository.getHandle();
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds: [itemId],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
@@ -323,7 +310,7 @@ function createPlaybackModeStore() {
|
||||
|
||||
try {
|
||||
// Notify Rust backend to switch to idle mode
|
||||
await invoke("playback_mode_set", { mode: "Idle" });
|
||||
await invoke("playback_mode_set", { mode: { type: "idle" } });
|
||||
|
||||
// Update local state
|
||||
sessions.selectSession(null);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user