Fixes for tests
This commit is contained in:
parent
d6aa74fc85
commit
c959c07ab4
@ -987,14 +987,8 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Countdown finished - emit final tick at 0
|
// Countdown finished (final tick at 0 was already emitted inside the loop)
|
||||||
log::info!("[PlayerController] Autoplay countdown finished");
|
log::info!("[PlayerController] Autoplay countdown finished");
|
||||||
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
|
||||||
emitter.emit(PlayerStatusEvent::CountdownTick {
|
|
||||||
remaining_seconds: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The frontend can listen for countdown reaching 0 and trigger playback
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,14 +55,12 @@ impl HybridRepository {
|
|||||||
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Race cache vs server, return first valid result
|
/// Cache-first query: try cache, fall back to server on miss.
|
||||||
/// Prefer cache if it has meaningful content, otherwise use server
|
|
||||||
///
|
///
|
||||||
/// Core algorithm of the cache-first parallel racing strategy.
|
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
|
||||||
/// Runs both cache and server queries concurrently, then:
|
/// 2. If cache has meaningful content → return immediately (fast path)
|
||||||
/// 1. If cache has meaningful content → return cache (fast path)
|
/// 3. If cache is empty/stale → query server (fresh data)
|
||||||
/// 2. If cache is empty/stale → return server (fresh data)
|
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||||
/// 3. If server fails → return cache even if empty (offline fallback)
|
|
||||||
///
|
///
|
||||||
/// @req: UR-002 - Access media when online or offline
|
/// @req: UR-002 - Access media when online or offline
|
||||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||||
@ -76,24 +74,20 @@ impl HybridRepository {
|
|||||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
{
|
{
|
||||||
// Wait for both to complete (cache has 100ms timeout)
|
// Try cache first (100ms timeout already applied by callers)
|
||||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
let cache_result = cache_future.await;
|
||||||
|
|
||||||
// Prefer cache if it has meaningful content
|
|
||||||
if let Ok(data) = &cache_result {
|
if let Ok(data) = &cache_result {
|
||||||
if data.has_content() {
|
if data.has_content() {
|
||||||
debug!("[HybridRepo] Using cache result (has content)");
|
debug!("[HybridRepo] Cache hit, returning immediately");
|
||||||
return Ok(data.clone());
|
return Ok(data.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to server result
|
// Cache miss — fall back to server
|
||||||
match server_result {
|
debug!("[HybridRepo] Cache miss, querying server");
|
||||||
Ok(data) => {
|
match server_future.await {
|
||||||
debug!("[HybridRepo] Using server result");
|
Ok(data) => Ok(data),
|
||||||
// TODO: Spawn background cache update
|
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Server failed, try to return cache even if empty
|
// Server failed, try to return cache even if empty
|
||||||
cache_result.or(Err(e))
|
cache_result.or(Err(e))
|
||||||
@ -135,46 +129,57 @@ impl MediaRepository for HybridRepository {
|
|||||||
let parent_id_for_save = parent_id.clone();
|
let parent_id_for_save = parent_id.clone();
|
||||||
let opts_clone = options.clone();
|
let opts_clone = options.clone();
|
||||||
|
|
||||||
// Check cache first to see if we have data
|
// Start server request in background (non-blocking)
|
||||||
let cache_future = self.cache_with_timeout(async move {
|
let server_handle = tokio::spawn(async move {
|
||||||
offline.get_items(&parent_id, opts_clone).await
|
online.get_items(&parent_id_clone, options).await
|
||||||
});
|
});
|
||||||
|
|
||||||
let server_future = async move {
|
// Check cache first (fast, 100ms timeout)
|
||||||
online.get_items(&parent_id_clone, options).await
|
let cache_result = self.cache_with_timeout(async move {
|
||||||
};
|
offline.get_items(&parent_id, opts_clone).await
|
||||||
|
}).await;
|
||||||
|
|
||||||
// Wait for both, prefer cache if available
|
// Cache hit: return immediately, update cache in background
|
||||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
if let Ok(data) = &cache_result {
|
||||||
|
if data.has_content() {
|
||||||
// Check if cache had meaningful content
|
debug!("[HybridRepo] Cache hit for get_items, returning immediately for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||||
let cache_had_content = cache_result.as_ref()
|
// Background: save server result to cache when it arrives
|
||||||
.map(|data| data.has_content())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
// Prefer cache if it has content
|
|
||||||
let result = if cache_had_content {
|
|
||||||
debug!("[HybridRepo] Using cached data for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
|
||||||
cache_result?
|
|
||||||
} else {
|
|
||||||
// Use server result and save to cache for next time
|
|
||||||
let server_data = server_result?;
|
|
||||||
|
|
||||||
if !server_data.items.is_empty() {
|
|
||||||
let items_clone = server_data.items.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
|
match server_handle.await {
|
||||||
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
|
Ok(Ok(server_data)) if !server_data.items.is_empty() => {
|
||||||
} else {
|
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &server_data.items).await {
|
||||||
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
warn!("[HybridRepo] Background cache update failed: {:?}", e);
|
||||||
|
} else {
|
||||||
|
debug!("[HybridRepo] Background updated {} cached items for parent {}", server_data.items.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // Server failed or returned empty — keep existing cache
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return Ok(data.clone());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server_data
|
// Cache miss — wait for server result
|
||||||
};
|
match server_handle.await {
|
||||||
|
Ok(Ok(server_data)) => {
|
||||||
Ok(result)
|
if !server_data.items.is_empty() {
|
||||||
|
let items_clone = server_data.items.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
|
||||||
|
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
|
||||||
|
} else {
|
||||||
|
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(server_data)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||||
|
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||||
|
message: format!("Server task failed: {}", join_err),
|
||||||
|
})),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
@ -447,35 +452,34 @@ impl MediaRepository for HybridRepository {
|
|||||||
let playlist_id_clone = playlist_id.clone();
|
let playlist_id_clone = playlist_id.clone();
|
||||||
let playlist_id_for_save = playlist_id.clone();
|
let playlist_id_for_save = playlist_id.clone();
|
||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move {
|
// Start server request in background (non-blocking)
|
||||||
offline.get_playlist_items(&playlist_id).await
|
let server_handle = tokio::spawn(async move {
|
||||||
|
online.get_playlist_items(&playlist_id_clone).await
|
||||||
});
|
});
|
||||||
|
|
||||||
let server_future = async move {
|
// Check cache first (fast, 100ms timeout)
|
||||||
online.get_playlist_items(&playlist_id_clone).await
|
let cache_result = self.cache_with_timeout(async move {
|
||||||
};
|
offline.get_playlist_items(&playlist_id).await
|
||||||
|
}).await;
|
||||||
|
|
||||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
// Cache hit: return immediately, update cache in background
|
||||||
|
if let Ok(data) = &cache_result {
|
||||||
let cache_had_content = cache_result.as_ref()
|
if data.has_content() {
|
||||||
.map(|data| data.has_content())
|
debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if cache_had_content {
|
|
||||||
// If server also succeeded, update cache in background
|
|
||||||
if let Ok(server_entries) = server_result {
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await {
|
if let Ok(Ok(server_entries)) = server_handle.await {
|
||||||
warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
|
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await {
|
||||||
|
warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return cache_result;
|
||||||
}
|
}
|
||||||
return cache_result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss - use server result
|
// Cache miss — wait for server result
|
||||||
match server_result {
|
match server_handle.await {
|
||||||
Ok(entries) => {
|
Ok(Ok(entries)) => {
|
||||||
let entries_clone = entries.clone();
|
let entries_clone = entries.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await {
|
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await {
|
||||||
@ -484,7 +488,10 @@ impl MediaRepository for HybridRepository {
|
|||||||
});
|
});
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
Err(e) => cache_result.or(Err(e)),
|
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||||
|
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||||
|
message: format!("Server task failed: {}", join_err),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -630,7 +630,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_val = limit.unwrap_or(12);
|
let limit_val = limit.unwrap_or(12);
|
||||||
|
|
||||||
// Resume items are always playable items (Audio, Movie, Episode), so simple JOIN with downloads
|
// Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
|
||||||
let (sql, params) = if let Some(pid) = parent_id {
|
let (sql, params) = if let Some(pid) = parent_id {
|
||||||
(
|
(
|
||||||
format!(
|
format!(
|
||||||
@ -646,6 +646,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
|
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
|
||||||
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
||||||
AND d.status = 'completed'
|
AND d.status = 'completed'
|
||||||
|
AND i.item_type IN ('Movie', 'Episode')
|
||||||
ORDER BY ud.last_played_at DESC
|
ORDER BY ud.last_played_at DESC
|
||||||
LIMIT {}", limit_val
|
LIMIT {}", limit_val
|
||||||
),
|
),
|
||||||
@ -670,6 +671,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
WHERE i.server_id = ? AND ud.user_id = ?
|
WHERE i.server_id = ? AND ud.user_id = ?
|
||||||
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
||||||
AND d.status = 'completed'
|
AND d.status = 'completed'
|
||||||
|
AND i.item_type IN ('Movie', 'Episode')
|
||||||
ORDER BY ud.last_played_at DESC
|
ORDER BY ud.last_played_at DESC
|
||||||
LIMIT {}", limit_val
|
LIMIT {}", limit_val
|
||||||
),
|
),
|
||||||
|
|||||||
@ -545,7 +545,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let mut endpoint = format!(
|
let mut endpoint = format!(
|
||||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video,Audio&Fields=BackdropImageTags,ParentBackdropImageTags",
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
self.user_id, limit_str
|
self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
let imageUrl = $state<string | null>(null);
|
let imageUrl = $state<string | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state(false);
|
let error = $state(false);
|
||||||
|
let lastLoadKey = "";
|
||||||
|
|
||||||
async function loadImage() {
|
async function loadImage() {
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
@ -68,10 +69,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload image when props change
|
// Only reload when the image identity changes (not on parent re-renders)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
imageUrl = null;
|
const loadKey = `${itemId}|${imageType}|${tag || ""}`;
|
||||||
loadImage();
|
if (loadKey !== lastLoadKey) {
|
||||||
|
lastLoadKey = loadKey;
|
||||||
|
imageUrl = null;
|
||||||
|
loadImage();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading = true;
|
// Only show skeleton on first load (no data yet)
|
||||||
|
if (items.length === 0) loading = true;
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
// Use backend search if search query is provided, otherwise use getItems with sort
|
// Use backend search if search query is provided, otherwise use getItems with sort
|
||||||
@ -192,7 +193,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if config.displayComponent === "grid"}
|
{#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"}
|
{:else if config.displayComponent === "tracklist"}
|
||||||
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -10,10 +10,11 @@
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
showViewToggle?: boolean;
|
showViewToggle?: boolean;
|
||||||
forceGrid?: boolean;
|
forceGrid?: boolean;
|
||||||
|
musicContent?: boolean;
|
||||||
onItemClick?: (item: MediaItem | Library) => void;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@ -54,7 +55,7 @@
|
|||||||
<div class="flex gap-4 overflow-hidden">
|
<div class="flex gap-4 overflow-hidden">
|
||||||
{#each Array(6) as _}
|
{#each Array(6) as _}
|
||||||
<div class="w-36 flex-shrink-0 animate-pulse">
|
<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-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 class="mt-1 h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,13 +30,16 @@
|
|||||||
large: "w-48",
|
large: "w-48",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMusicType = $derived(
|
||||||
|
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
|
||||||
|
);
|
||||||
|
|
||||||
const aspectRatio = $derived(() => {
|
const aspectRatio = $derived(() => {
|
||||||
if ("type" in item) {
|
if ("type" in item) {
|
||||||
// MediaItem
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||||
return item.type === "Audio" || item.type === "MusicAlbum" ? "aspect-square" : "aspect-[2/3]";
|
|
||||||
}
|
}
|
||||||
// Library
|
// Library
|
||||||
return "aspect-video";
|
return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video";
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageTag = $derived(
|
const imageTag = $derived(
|
||||||
|
|||||||
@ -133,10 +133,11 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<!-- Skeleton loading state -->
|
<!-- Skeleton loading state -->
|
||||||
|
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
|
||||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||||
{#each Array(6) as _}
|
{#each Array(6) as _}
|
||||||
<div class="animate-pulse">
|
<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-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 class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
||||||
let isSeeking = $state(false);
|
let isSeeking = $state(false);
|
||||||
let currentStreamUrl = $derived(streamUrl);
|
let currentStreamUrl = $state(streamUrl);
|
||||||
let hasReportedStart = $state(false);
|
let hasReportedStart = $state(false);
|
||||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
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 didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
|
||||||
let swipeType = $state<"brightness" | null>(null);
|
let swipeType = $state<"brightness" | null>(null);
|
||||||
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
|
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
|
// Audio track selection
|
||||||
let showAudioTrackMenu = $state(false);
|
let showAudioTrackMenu = $state(false);
|
||||||
@ -260,14 +261,36 @@
|
|||||||
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset recovery attempts for new HLS instance
|
||||||
|
hlsFatalRecoveryAttempts = 0;
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
console.error('[VideoPlayer] HLS error:', data);
|
console.error('[VideoPlayer] HLS error:', data);
|
||||||
if (data.fatal) {
|
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) {
|
switch (data.type) {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
console.error('[VideoPlayer] Fatal network error, trying to recover');
|
hlsFatalRecoveryAttempts++;
|
||||||
hls!.startLoad();
|
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;
|
break;
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
||||||
@ -528,17 +551,17 @@
|
|||||||
// Smooth time updates using requestAnimationFrame (60fps)
|
// Smooth time updates using requestAnimationFrame (60fps)
|
||||||
function updateTimeLoop() {
|
function updateTimeLoop() {
|
||||||
if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) {
|
if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) {
|
||||||
// Add seek offset to get actual position in the full video
|
|
||||||
const newCurrentTime = seekOffset + videoElement.currentTime;
|
const newCurrentTime = seekOffset + videoElement.currentTime;
|
||||||
|
if (videoElement.readyState >= 2) {
|
||||||
// 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
|
|
||||||
currentTime = newCurrentTime;
|
currentTime = newCurrentTime;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Continue the loop while playing
|
// Keep the loop alive while playing — stopped by handlePause/handleEnded
|
||||||
|
if (isPlaying) {
|
||||||
rafId = requestAnimationFrame(updateTimeLoop);
|
rafId = requestAnimationFrame(updateTimeLoop);
|
||||||
|
} else {
|
||||||
|
rafId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,8 @@ function createHomeStore() {
|
|||||||
const { subscribe, set, update } = writable<HomeState>(initialState);
|
const { subscribe, set, update } = writable<HomeState>(initialState);
|
||||||
|
|
||||||
async function loadHomeSections() {
|
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 {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|||||||
@ -69,7 +69,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const heroItems = $derived($home.heroItems);
|
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 nextUpItems = $derived($home.nextUpItems);
|
||||||
const latestItems = $derived($home.latestItems);
|
const latestItems = $derived($home.latestItems);
|
||||||
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
|
const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, untrack } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@ -68,10 +68,15 @@
|
|||||||
|
|
||||||
async function loadItem() {
|
async function loadItem() {
|
||||||
if (!itemId) return;
|
if (!itemId) return;
|
||||||
loading = true;
|
// Only show spinner when navigating to a different item
|
||||||
error = null;
|
// untrack prevents $effect from tracking `item` as a dependency (avoids infinite loop)
|
||||||
seasonData = [];
|
const isNewItem = untrack(() => !item || item.id !== itemId);
|
||||||
directFetchedEpisode = null;
|
if (isNewItem) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
seasonData = [];
|
||||||
|
directFetchedEpisode = null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
item = await library.loadItem(itemId);
|
item = await library.loadItem(itemId);
|
||||||
@ -274,6 +279,10 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isMusicItem = $derived(
|
||||||
|
item?.type === "Audio" || item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Playlist"
|
||||||
|
);
|
||||||
|
|
||||||
function handleBackToSeries() {
|
function handleBackToSeries() {
|
||||||
// Navigate to series page without the episode param
|
// Navigate to series page without the episode param
|
||||||
goto(`/library/${itemId}`);
|
goto(`/library/${itemId}`);
|
||||||
@ -334,12 +343,16 @@
|
|||||||
tag={item.primaryImageTag}
|
tag={item.primaryImageTag}
|
||||||
maxWidth={400}
|
maxWidth={400}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
class="w-full rounded-lg shadow-lg"
|
class="w-full {isMusicItem ? 'aspect-square' : ''} rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{: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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user