Fix for offline mode
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user