Fix for offline mode
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s

This commit is contained in:
2026-07-03 19:37:34 +02:00
parent c58cc0cf46
commit 2d141e5bf4
13 changed files with 790 additions and 143 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
@@ -53,6 +54,16 @@
onMount(async () => {
await loadGenres();
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) {
const match = genres.find(
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
if (match) {
await loadGenreItems(match);
}
}
markLoaded();
});
+22 -4
View File
@@ -5,14 +5,34 @@
genres: string[];
maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true
itemType?: string; // Determines which genre browse page to open
}
let {
genres,
maxShow,
clickable = true
clickable = true,
itemType
}: Props = $props();
// Map the item type to its genre-browse route
function genreBasePath(type: string | undefined): string {
switch (type) {
case "MusicAlbum":
case "MusicArtist":
case "Audio":
return "/library/music/genres";
case "Series":
case "Season":
case "Episode":
return "/library/shows/genres";
case "Movie":
return "/library/movies/genres";
default:
return "/library/movies/genres";
}
}
const displayGenres = $derived(
maxShow ? genres.slice(0, maxShow) : genres
);
@@ -23,9 +43,7 @@
function handleGenreClick(genre: string) {
if (clickable) {
// Navigate to genre browse page
// For now, we'll use a simple navigation - could be enhanced with a proper genre browse page
goto(`/search?genre=${encodeURIComponent(genre)}`);
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
}
}
</script>
+23 -1
View File
@@ -19,6 +19,7 @@
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { commands } from "$lib/api/bindings";
import {
mergedMedia,
mergedIsPlaying,
@@ -70,6 +71,27 @@
// In remote mode, this automatically uses the remote session's nowPlayingItem
const displayMedia = $derived($mergedMedia || $currentQueueItem);
const displayIsPlaying = $derived($mergedIsPlaying);
// The player's MediaItem doesn't carry favorite state, so read it from the
// local user_data cache (offline-safe — same source the optimistic toggle
// writes to). Re-runs whenever the current track changes.
let isFavorite = $state(false);
let favoriteLoadedFor = "";
$effect(() => {
const id = displayMedia?.id;
if (!id || id === favoriteLoadedFor) return;
favoriteLoadedFor = id;
isFavorite = false;
const userId = auth.getUserId();
if (!userId) return;
commands
.storageGetPlaybackProgress(userId, id)
.then((p) => {
// Guard against a race if the track changed while awaiting.
if (displayMedia?.id === id) isFavorite = p?.isFavorite ?? false;
})
.catch(() => {});
});
const displayPosition = $derived($mergedPosition);
const displayDuration = $derived($mergedDuration);
@@ -358,7 +380,7 @@
{#if displayMedia}
<FavoriteButton
itemId={displayMedia?.id ?? ""}
isFavorite={displayMedia?.userData?.isFavorite ?? false}
bind:isFavorite
size="sm"
/>
{/if}
+26 -14
View File
@@ -1,8 +1,10 @@
// Favorites service - Handles toggling favorite status with optimistic updates
// TRACES: UR-017 | DR-021
import { get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
import { isConnected } from "$lib/stores/connectivity";
/**
* Toggle the favorite status of an item.
@@ -31,21 +33,31 @@ export async function toggleFavorite(
// 1. Update local database first (optimistic update)
await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
// 2. Sync to Jellyfin server
try {
const repo = auth.getRepository();
if (newIsFavorite) {
await repo.markFavorite(itemId);
} else {
await repo.unmarkFavorite(itemId);
}
// 2. Sync to Jellyfin server.
//
// Only attempt this when we're actually connected. When offline, the server
// call can hang on a long network timeout rather than failing fast — which
// blocks the caller (and leaves the favorite button greyed out with a wait
// cursor) until the request finally gives up, effectively only recovering
// once we're back online. The local DB write above keeps the pending_sync
// flag set, so the change still syncs later; we just don't block the UI on
// an unreachable server here.
if (get(isConnected)) {
try {
const repo = auth.getRepository();
if (newIsFavorite) {
await repo.markFavorite(itemId);
} else {
await repo.unmarkFavorite(itemId);
}
// 3. Mark as synced
await commands.storageMarkSynced(userId, itemId);
} catch (error) {
console.error("Failed to sync favorite to server:", error);
// Favorite is stored locally and will be synced later
// via sync queue (when implemented)
// 3. Mark as synced
await commands.storageMarkSynced(userId, itemId);
} catch (error) {
console.error("Failed to sync favorite to server:", error);
// Favorite is stored locally and will be synced later
// via sync queue (when implemented)
}
}
return newIsFavorite;
+13 -1
View File
@@ -36,7 +36,10 @@ function createHomeStore() {
try {
const repo = auth.getRepository();
const [resume, nextUp, latest, recentAudio, resumeMovies] = await Promise.all([
// Use allSettled so one failing section (e.g. Next Up is online-only and
// rejects offline) doesn't wipe out the whole homepage. Each section falls
// back to an empty list; cached sections (resume/latest/recent) still show.
const settled = await Promise.allSettled([
repo.getResumeItems(undefined, 12),
repo.getNextUpEpisodes(undefined, 12),
repo.getLatestItems("", 16),
@@ -44,6 +47,15 @@ function createHomeStore() {
repo.getResumeMovies(12),
]);
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
// Use resume items or latest as hero items
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);
+1 -1
View File
@@ -511,7 +511,7 @@
<!-- Genre Tags -->
{#if item.genres?.length}
<div>
<GenreTags genres={item.genres ?? undefined} maxShow={6} />
<GenreTags genres={item.genres ?? undefined} maxShow={6} itemType={item.type} />
</div>
{/if}