Fixes for tests
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
let imageUrl = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
let lastLoadKey = "";
|
||||
|
||||
async function loadImage() {
|
||||
if (!itemId) {
|
||||
@@ -68,10 +69,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reload image when props change
|
||||
// Only reload when the image identity changes (not on parent re-renders)
|
||||
$effect(() => {
|
||||
imageUrl = null;
|
||||
loadImage();
|
||||
const loadKey = `${itemId}|${imageType}|${tag || ""}`;
|
||||
if (loadKey !== lastLoadKey) {
|
||||
lastLoadKey = loadKey;
|
||||
imageUrl = null;
|
||||
loadImage();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
// Only show skeleton on first load (no data yet)
|
||||
if (items.length === 0) loading = true;
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Use backend search if search query is provided, otherwise use getItems with sort
|
||||
@@ -192,7 +193,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if config.displayComponent === "grid"}
|
||||
<LibraryGrid items={items} onItemClick={handleItemClick} />
|
||||
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
|
||||
{:else if config.displayComponent === "tracklist"}
|
||||
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
||||
{/if}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
loading?: boolean;
|
||||
showViewToggle?: boolean;
|
||||
forceGrid?: boolean;
|
||||
musicContent?: boolean;
|
||||
onItemClick?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, onItemClick }: Props = $props();
|
||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -54,7 +55,7 @@
|
||||
<div class="flex gap-4 overflow-hidden">
|
||||
{#each Array(6) as _}
|
||||
<div class="w-36 flex-shrink-0 animate-pulse">
|
||||
<div class="aspect-[2/3] bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="{musicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
<div class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
|
||||
@@ -30,13 +30,16 @@
|
||||
large: "w-48",
|
||||
};
|
||||
|
||||
const isMusicType = $derived(
|
||||
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
|
||||
);
|
||||
|
||||
const aspectRatio = $derived(() => {
|
||||
if ("type" in item) {
|
||||
// MediaItem
|
||||
return item.type === "Audio" || item.type === "MusicAlbum" ? "aspect-square" : "aspect-[2/3]";
|
||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||
}
|
||||
// Library
|
||||
return "aspect-video";
|
||||
return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video";
|
||||
});
|
||||
|
||||
const imageTag = $derived(
|
||||
|
||||
@@ -133,10 +133,11 @@
|
||||
|
||||
{#if loading}
|
||||
<!-- Skeleton loading state -->
|
||||
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-[2/3] bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
||||
<div class="{isMusicContent ? 'aspect-square' : 'aspect-[2/3]'} bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
||||
let isSeeking = $state(false);
|
||||
let currentStreamUrl = $derived(streamUrl);
|
||||
let currentStreamUrl = $state(streamUrl);
|
||||
let hasReportedStart = $state(false);
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
||||
@@ -67,6 +67,7 @@
|
||||
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
|
||||
let swipeType = $state<"brightness" | null>(null);
|
||||
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
|
||||
let hlsFatalRecoveryAttempts = 0; // Track recovery attempts to prevent infinite restarts
|
||||
|
||||
// Audio track selection
|
||||
let showAudioTrackMenu = $state(false);
|
||||
@@ -260,14 +261,36 @@
|
||||
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
||||
});
|
||||
|
||||
// Reset recovery attempts for new HLS instance
|
||||
hlsFatalRecoveryAttempts = 0;
|
||||
|
||||
// Handle errors
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('[VideoPlayer] HLS error:', data);
|
||||
if (data.fatal) {
|
||||
// Check if we're near the end of the video - if so, this is likely
|
||||
// end-of-stream rather than a real error. Jellyfin transcoded HLS
|
||||
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
|
||||
const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration;
|
||||
const effectiveTime = currentTime + seekOffset;
|
||||
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
|
||||
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.error('[VideoPlayer] Fatal network error, trying to recover');
|
||||
hls!.startLoad();
|
||||
hlsFatalRecoveryAttempts++;
|
||||
if (isNearEnd) {
|
||||
// Near end of stream - treat as natural end, don't restart
|
||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
||||
if (onEnded) {
|
||||
onEnded();
|
||||
}
|
||||
} else if (hlsFatalRecoveryAttempts <= 3) {
|
||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||
hls!.startLoad();
|
||||
} else {
|
||||
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
||||
hls!.destroy();
|
||||
}
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
||||
@@ -528,17 +551,17 @@
|
||||
// Smooth time updates using requestAnimationFrame (60fps)
|
||||
function updateTimeLoop() {
|
||||
if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) {
|
||||
// Add seek offset to get actual position in the full video
|
||||
const newCurrentTime = seekOffset + videoElement.currentTime;
|
||||
|
||||
// Safety check: only update if the video element is actually playing
|
||||
// This prevents stale data from overwriting currentTime after failed seeks
|
||||
if (videoElement.readyState >= 2) { // HAVE_CURRENT_DATA or better
|
||||
if (videoElement.readyState >= 2) {
|
||||
currentTime = newCurrentTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue the loop while playing
|
||||
// Keep the loop alive while playing — stopped by handlePause/handleEnded
|
||||
if (isPlaying) {
|
||||
rafId = requestAnimationFrame(updateTimeLoop);
|
||||
} else {
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ function createHomeStore() {
|
||||
const { subscribe, set, update } = writable<HomeState>(initialState);
|
||||
|
||||
async function loadHomeSections() {
|
||||
update(s => ({ ...s, isLoading: true, error: null }));
|
||||
// Only show loading spinner when no data is available yet
|
||||
update(s => ({ ...s, isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, error: null }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
}
|
||||
|
||||
const heroItems = $derived($home.heroItems);
|
||||
const resumeItems = $derived($home.resumeItems);
|
||||
const resumeItems = $derived($home.resumeItems.filter(
|
||||
i => i.type === "Movie" || i.type === "Episode"
|
||||
));
|
||||
const nextUpItems = $derived($home.nextUpItems);
|
||||
const latestItems = $derived($home.latestItems);
|
||||
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -68,10 +68,15 @@
|
||||
|
||||
async function loadItem() {
|
||||
if (!itemId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
seasonData = [];
|
||||
directFetchedEpisode = null;
|
||||
// Only show spinner when navigating to a different item
|
||||
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
|
||||
const isNewItem = untrack(() => !item || item.id !== itemId);
|
||||
if (isNewItem) {
|
||||
loading = true;
|
||||
error = null;
|
||||
seasonData = [];
|
||||
directFetchedEpisode = null;
|
||||
}
|
||||
|
||||
try {
|
||||
item = await library.loadItem(itemId);
|
||||
@@ -274,6 +279,10 @@
|
||||
: null
|
||||
);
|
||||
|
||||
const isMusicItem = $derived(
|
||||
item?.type === "Audio" || item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Playlist"
|
||||
);
|
||||
|
||||
function handleBackToSeries() {
|
||||
// Navigate to series page without the episode param
|
||||
goto(`/library/${itemId}`);
|
||||
@@ -334,12 +343,16 @@
|
||||
tag={item.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={item.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full aspect-[2/3] bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
||||
<div class="w-full {isMusicItem ? 'aspect-square' : 'aspect-[2/3]'} 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="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"/>
|
||||
{#if isMusicItem}
|
||||
<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"/>
|
||||
{:else}
|
||||
<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"/>
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user