First working POC
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
// Favorites service - Handles toggling favorite status with optimistic updates
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
/**
|
||||
* Toggle the favorite status of an item.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Update local database immediately (optimistic update)
|
||||
* 2. Sync to Jellyfin server
|
||||
* 3. Mark as synced on success, or leave pending_sync flag on failure
|
||||
*
|
||||
* @param itemId - The Jellyfin item ID
|
||||
* @param currentIsFavorite - The current favorite status
|
||||
* @returns The new favorite status
|
||||
* @throws Error if not authenticated or database update fails
|
||||
*/
|
||||
export async function toggleFavorite(
|
||||
itemId: string,
|
||||
currentIsFavorite: boolean
|
||||
): Promise<boolean> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const newIsFavorite = !currentIsFavorite;
|
||||
|
||||
// 1. Update local database first (optimistic update)
|
||||
await invoke("storage_toggle_favorite", {
|
||||
userId,
|
||||
itemId,
|
||||
isFavorite: newIsFavorite,
|
||||
});
|
||||
|
||||
// 2. Sync to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
if (newIsFavorite) {
|
||||
await repo.markFavorite(itemId);
|
||||
} else {
|
||||
await repo.unmarkFavorite(itemId);
|
||||
}
|
||||
|
||||
// 3. Mark as synced
|
||||
await invoke("storage_mark_synced", { 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;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* Statistics about the thumbnail cache
|
||||
*/
|
||||
export interface ImageCacheStats {
|
||||
totalSizeBytes: number;
|
||||
itemCount: number;
|
||||
limitBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an image URL, checking cache first then falling back to server.
|
||||
* Triggers background caching if not cached.
|
||||
*
|
||||
* @param serverUrl - The Jellyfin server base URL
|
||||
* @param itemId - The Jellyfin item ID
|
||||
* @param imageType - The image type (Primary, Backdrop, etc.)
|
||||
* @param options - Image options (maxWidth, maxHeight, quality, tag)
|
||||
* @returns The image URL (local asset URL if cached, server URL otherwise)
|
||||
*/
|
||||
export async function getCachedImageUrl(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
imageType: string = "Primary",
|
||||
options: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
tag?: string;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const tag = options.tag || "default";
|
||||
|
||||
// Try to get cached version
|
||||
try {
|
||||
const cachedPath = await invoke<string | null>("thumbnail_get_cached", {
|
||||
itemId,
|
||||
imageType,
|
||||
tag,
|
||||
});
|
||||
|
||||
if (cachedPath) {
|
||||
// Convert file path to asset URL for Tauri
|
||||
return convertFileSrc(cachedPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Failed to check thumbnail cache:", e);
|
||||
}
|
||||
|
||||
// Build server URL
|
||||
const params = new URLSearchParams();
|
||||
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
||||
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
||||
if (options.quality) params.set("quality", options.quality.toString());
|
||||
if (options.tag) params.set("tag", options.tag);
|
||||
|
||||
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
||||
|
||||
// Trigger background caching (fire and forget)
|
||||
invoke("thumbnail_save", {
|
||||
itemId,
|
||||
imageType,
|
||||
tag,
|
||||
url: serverImageUrl,
|
||||
}).catch((e) => {
|
||||
// Silently fail - caching is best-effort
|
||||
console.debug("Background thumbnail cache failed:", e);
|
||||
});
|
||||
|
||||
// Return server URL for immediate display
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version that returns server URL immediately
|
||||
* and triggers background caching. Useful for initial render.
|
||||
*
|
||||
* @param serverUrl - The Jellyfin server base URL
|
||||
* @param itemId - The Jellyfin item ID
|
||||
* @param imageType - The image type (Primary, Backdrop, etc.)
|
||||
* @param options - Image options
|
||||
* @returns The server image URL
|
||||
*/
|
||||
export function getImageUrlSync(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
imageType: string = "Primary",
|
||||
options: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
tag?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const tag = options.tag || "default";
|
||||
|
||||
// Build server URL
|
||||
const params = new URLSearchParams();
|
||||
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
||||
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
||||
if (options.quality) params.set("quality", options.quality.toString());
|
||||
if (options.tag) params.set("tag", options.tag);
|
||||
|
||||
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
||||
|
||||
// Trigger background caching (fire and forget)
|
||||
invoke("thumbnail_save", {
|
||||
itemId,
|
||||
imageType,
|
||||
tag,
|
||||
url: serverImageUrl,
|
||||
}).catch(() => {
|
||||
// Silently fail
|
||||
});
|
||||
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail cache statistics
|
||||
*/
|
||||
export async function getCacheStats(): Promise<ImageCacheStats> {
|
||||
return invoke("thumbnail_get_stats");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache storage limit in bytes
|
||||
*
|
||||
* @param limitBytes - The maximum cache size in bytes (0 = unlimited)
|
||||
*/
|
||||
export async function setCacheLimit(limitBytes: number): Promise<void> {
|
||||
return invoke("thumbnail_set_limit", { limitBytes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached thumbnails
|
||||
*/
|
||||
export async function clearCache(): Promise<void> {
|
||||
return invoke("thumbnail_clear_cache");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached thumbnails for a specific item
|
||||
*
|
||||
* @param itemId - The Jellyfin item ID
|
||||
*/
|
||||
export async function deleteItemCache(itemId: string): Promise<void> {
|
||||
return invoke("thumbnail_delete_item", { itemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*
|
||||
* @param bytes - Number of bytes
|
||||
* @returns Human-readable string (e.g., "1.5 GB")
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert gigabytes to bytes
|
||||
*/
|
||||
export function gbToBytes(gb: number): number {
|
||||
return gb * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to gigabytes
|
||||
*/
|
||||
export function bytesToGb(bytes: number): number {
|
||||
return bytes / (1024 * 1024 * 1024);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Next Episode Service
|
||||
*
|
||||
* Handles user interactions with the next episode popup.
|
||||
* Backend manages countdown logic and autoplay decisions.
|
||||
*/
|
||||
|
||||
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay";
|
||||
import { nextEpisode } from "$lib/stores/nextEpisode";
|
||||
|
||||
/**
|
||||
* Cleanup next episode state (called on unmount/destroy)
|
||||
*/
|
||||
export function cleanup() {
|
||||
nextEpisode.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle episode ended event
|
||||
* Backend now handles autoplay decisions via on_playback_ended()
|
||||
* This function is kept for backwards compatibility but does nothing
|
||||
*/
|
||||
export async function handleEpisodeEnded(media: any) {
|
||||
// Backend now handles this - no action needed
|
||||
// The backend will emit ShowNextEpisodePopup event
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the autoplay countdown
|
||||
* Called when user clicks "Cancel" button on next episode popup
|
||||
*/
|
||||
export async function cancelAutoPlay() {
|
||||
await cancelAutoplayCountdown();
|
||||
nextEpisode.hidePopup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually play the next episode
|
||||
* Called when user clicks "Play Now" button on next episode popup
|
||||
*
|
||||
* @param nextEpisodeItem - The next episode to play
|
||||
*/
|
||||
export async function watchNextManually(nextEpisodeItem: any) {
|
||||
await playNextEpisode(nextEpisodeItem);
|
||||
nextEpisode.hidePopup();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Playback reporting service - syncs to both Jellyfin server and local DB
|
||||
//
|
||||
// This service handles:
|
||||
// - Updating local DB (always works, even offline)
|
||||
// - Reporting to Jellyfin server when online
|
||||
// - Queueing operations for sync when offline
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { syncService } from "./syncService";
|
||||
import { secondsToTicks } from "$lib/utils/playbackUnits";
|
||||
|
||||
/**
|
||||
* Report playback start to Jellyfin and local DB
|
||||
*/
|
||||
export async function reportPlaybackStart(
|
||||
itemId: string,
|
||||
positionSeconds: number,
|
||||
contextType: "container" | "single" = "single",
|
||||
contextId: string | null = null
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("reportPlaybackStart - itemId:", itemId, "positionSeconds:", positionSeconds, "context:", contextType, contextId, "userId:", userId);
|
||||
|
||||
// Update local DB with context (always works, even offline)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_update_playback_context", {
|
||||
userId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
contextType,
|
||||
contextId,
|
||||
});
|
||||
console.log("reportPlaybackStart - Local DB updated with context successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to update playback context:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("reportPlaybackStart - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStart(itemId, positionTicks);
|
||||
console.log("reportPlaybackStart - Reported to server successfully");
|
||||
|
||||
// Mark as synced
|
||||
if (userId) {
|
||||
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to report playback start to server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report playback progress to Jellyfin and local DB
|
||||
*
|
||||
* Note: Progress reports are frequent, so we don't queue them for sync.
|
||||
* The final position is captured by reportPlaybackStopped.
|
||||
*/
|
||||
export async function reportPlaybackProgress(
|
||||
itemId: string,
|
||||
positionSeconds: number,
|
||||
isPaused = false
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
// Reduce logging for frequent progress updates
|
||||
if (Math.floor(positionSeconds) % 30 === 0) {
|
||||
console.log("reportPlaybackProgress - itemId:", itemId, "positionSeconds:", positionSeconds, "isPaused:", isPaused);
|
||||
}
|
||||
|
||||
// Update local DB first (always works, even offline)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_update_playback_progress", {
|
||||
userId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update local playback progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
// Don't queue progress updates - too frequent. Just store locally.
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server (silent failure - progress reports are non-critical)
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackProgress(itemId, positionTicks);
|
||||
} catch {
|
||||
// Silent failure for progress reports - they're frequent and non-critical
|
||||
// The final position is captured by reportPlaybackStopped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report playback stopped to Jellyfin and local DB
|
||||
*/
|
||||
export async function reportPlaybackStopped(
|
||||
itemId: string,
|
||||
positionSeconds: number
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds, "userId:", userId);
|
||||
|
||||
// Update local DB first (always works, even offline)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_update_playback_progress", {
|
||||
userId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
});
|
||||
console.log("reportPlaybackStopped - Local DB updated successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to update local playback progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("reportPlaybackStopped - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStopped(itemId, positionTicks);
|
||||
console.log("reportPlaybackStopped - Reported to server successfully");
|
||||
|
||||
// Mark as synced
|
||||
if (userId) {
|
||||
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to report playback stopped to server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as played (100% progress)
|
||||
*/
|
||||
export async function markAsPlayed(itemId: string): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("markAsPlayed - itemId:", itemId, "userId:", userId);
|
||||
|
||||
// Update local DB first
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_played", { userId, itemId });
|
||||
console.log("markAsPlayed - Local DB updated successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as played in local DB:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("markAsPlayed - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("mark_played", itemId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For Jellyfin, we need to get the item's runtime and report stopped at 100%
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const item = await repo.getItem(itemId);
|
||||
|
||||
if (item.runTimeTicks) {
|
||||
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
|
||||
console.log("markAsPlayed - Reported to server successfully");
|
||||
|
||||
// Mark as synced
|
||||
if (userId) {
|
||||
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as played on server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("mark_played", itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Player Event Service
|
||||
*
|
||||
* Listens for Tauri events from the player backend and updates the
|
||||
* frontend stores accordingly. This enables push-based updates instead
|
||||
* of polling.
|
||||
*/
|
||||
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { player, playbackPosition } from "$lib/stores/player";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||
import { handleEpisodeEnded as showNextEpisodePopup } from "$lib/services/nextEpisodeService";
|
||||
import { preloadUpcomingTracks } from "$lib/services/preload";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
/**
|
||||
* Event types emitted by the player backend.
|
||||
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
|
||||
*/
|
||||
export type PlayerStatusEvent =
|
||||
| { type: "position_update"; position: number; duration: number }
|
||||
| { type: "state_changed"; state: string; media_id: string | null }
|
||||
| { type: "media_loaded"; duration: number }
|
||||
| { type: "playback_ended" }
|
||||
| { type: "buffering"; percent: number }
|
||||
| { type: "error"; message: string; recoverable: boolean }
|
||||
| { type: "volume_changed"; volume: number; muted: boolean }
|
||||
| { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number }
|
||||
| { type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean }
|
||||
| { type: "countdown_tick"; remaining_seconds: number };
|
||||
|
||||
// Sleep timer mode type
|
||||
export type SleepTimerMode =
|
||||
| { kind: "off" }
|
||||
| { kind: "time"; endTime: number }
|
||||
| { kind: "endOfTrack" }
|
||||
| { kind: "episodes"; remaining: number };
|
||||
|
||||
/** Event name for player status events from backend */
|
||||
const PLAYER_EVENT_NAME = "player-event";
|
||||
|
||||
let unlistenFn: UnlistenFn | null = null;
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the player event listener.
|
||||
* Should be called once when the app starts (e.g., in +layout.svelte).
|
||||
*/
|
||||
export async function initPlayerEvents(): Promise<void> {
|
||||
if (isInitialized) {
|
||||
console.warn("Player events already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
unlistenFn = await listen<PlayerStatusEvent>(
|
||||
PLAYER_EVENT_NAME,
|
||||
(event) => {
|
||||
handlePlayerEvent(event.payload);
|
||||
}
|
||||
);
|
||||
isInitialized = true;
|
||||
console.log("Player event listener initialized");
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize player events:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the player event listener.
|
||||
* Should be called when the app is destroyed.
|
||||
*/
|
||||
export function cleanupPlayerEvents(): void {
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
unlistenFn = null;
|
||||
}
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the event listener is initialized.
|
||||
*/
|
||||
export function isPlayerEventsInitialized(): boolean {
|
||||
return isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming player events and update stores.
|
||||
*/
|
||||
function handlePlayerEvent(event: PlayerStatusEvent): void {
|
||||
// Skip local player events when in remote mode to prevent conflicts
|
||||
// EXCEPT during transfer (when local playback is starting)
|
||||
const mode = get(playbackMode);
|
||||
if (mode.mode === "remote" && !mode.isTransferring) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "position_update":
|
||||
handlePositionUpdate(event.position, event.duration);
|
||||
break;
|
||||
|
||||
case "state_changed":
|
||||
handleStateChanged(event.state, event.media_id);
|
||||
break;
|
||||
|
||||
case "media_loaded":
|
||||
handleMediaLoaded(event.duration);
|
||||
break;
|
||||
|
||||
case "playback_ended":
|
||||
handlePlaybackEnded();
|
||||
break;
|
||||
|
||||
case "buffering":
|
||||
// Could show buffering indicator in UI
|
||||
console.debug(`Buffering: ${event.percent}%`);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
handleError(event.message, event.recoverable);
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
player.setVolume(event.volume);
|
||||
player.setMuted(event.muted);
|
||||
break;
|
||||
|
||||
case "sleep_timer_changed":
|
||||
handleSleepTimerChanged(event.mode, event.remaining_seconds);
|
||||
break;
|
||||
|
||||
case "show_next_episode_popup":
|
||||
handleShowNextEpisodePopup(
|
||||
event.current_episode,
|
||||
event.next_episode,
|
||||
event.countdown_seconds,
|
||||
event.auto_advance
|
||||
);
|
||||
break;
|
||||
|
||||
case "countdown_tick":
|
||||
handleCountdownTick(event.remaining_seconds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle position update events.
|
||||
*/
|
||||
function handlePositionUpdate(position: number, duration: number): void {
|
||||
player.updatePosition(position, duration);
|
||||
// Note: Sleep timer logic is now handled entirely in the Rust backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state change events.
|
||||
*/
|
||||
function handleStateChanged(state: string, mediaId: string | null): void {
|
||||
// Get current media from queue store
|
||||
const currentItem = get(currentQueueItem);
|
||||
|
||||
switch (state) {
|
||||
case "playing":
|
||||
case "paused":
|
||||
case "loading":
|
||||
// When local playback starts, ensure mode is set to local
|
||||
const mode = get(playbackMode);
|
||||
if (mode.mode !== "local") {
|
||||
console.log("Setting playback mode to local");
|
||||
playbackMode.setMode("local");
|
||||
}
|
||||
|
||||
if (state === "playing" && currentItem) {
|
||||
// Use 0 for position/duration - will be updated by position_update events
|
||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
player.setPlaying(currentItem, 0, initialDuration);
|
||||
|
||||
// Trigger preloading of upcoming tracks in the background
|
||||
preloadUpcomingTracks().catch(() => {
|
||||
// Preload failures are non-critical, already logged in the service
|
||||
});
|
||||
} else if (state === "paused" && currentItem) {
|
||||
// Keep current position from store
|
||||
const currentPosition = get(playbackPosition);
|
||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
player.setPaused(currentItem, currentPosition, initialDuration);
|
||||
} else if (state === "loading" && currentItem) {
|
||||
player.setLoading(currentItem);
|
||||
}
|
||||
break;
|
||||
|
||||
case "idle":
|
||||
case "stopped":
|
||||
player.setIdle();
|
||||
// When local playback stops, revert to idle mode
|
||||
const currentMode = get(playbackMode);
|
||||
if (currentMode.mode === "local") {
|
||||
console.log("Setting playback mode to idle");
|
||||
playbackMode.setMode("idle");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle media loaded event.
|
||||
*/
|
||||
function handleMediaLoaded(duration: number): void {
|
||||
// Media is now loaded and ready
|
||||
// The state_changed event will handle setting the playing state
|
||||
console.debug(`Media loaded, duration: ${duration}s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle playback ended event.
|
||||
* Calls backend to handle autoplay decisions (sleep timer, queue advance, episode popup).
|
||||
*/
|
||||
async function handlePlaybackEnded(): Promise<void> {
|
||||
// Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.)
|
||||
try {
|
||||
await invoke("player_on_playback_ended");
|
||||
} catch (e) {
|
||||
console.error("[playerEvents] Failed to handle playback ended:", e);
|
||||
// Fallback: set idle state on error
|
||||
player.setIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error events.
|
||||
*/
|
||||
function handleError(message: string, recoverable: boolean): void {
|
||||
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
||||
player.setError(message);
|
||||
|
||||
if (!recoverable) {
|
||||
// For non-recoverable errors, return to idle
|
||||
player.setIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sleep timer changed event.
|
||||
*/
|
||||
function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number): void {
|
||||
sleepTimer.set({ mode, remainingSeconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle show next episode popup event.
|
||||
*/
|
||||
function handleShowNextEpisodePopup(
|
||||
currentEpisode: MediaItem,
|
||||
nextEpisode: MediaItem,
|
||||
countdownSeconds: number,
|
||||
autoAdvance: boolean
|
||||
): void {
|
||||
// Update next episode store to show popup
|
||||
nextEpisode.showPopup(currentEpisode, nextEpisode, countdownSeconds, autoAdvance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle countdown tick event.
|
||||
*/
|
||||
function handleCountdownTick(remainingSeconds: number): void {
|
||||
// Update next episode store with new countdown value
|
||||
nextEpisode.updateCountdown(remainingSeconds);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Smart preloading service for upcoming tracks
|
||||
* Automatically queues downloads for the next few tracks in the queue
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
|
||||
interface PreloadResult {
|
||||
queuedCount: number;
|
||||
alreadyDownloaded: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface PreloadOptions {
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
/** Override user ID (defaults to current session user) */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger preloading for upcoming tracks in the queue
|
||||
* This should be called after playback starts or advances to the next track
|
||||
*/
|
||||
export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promise<void> {
|
||||
const { debug = false, userId: overrideUserId } = options;
|
||||
|
||||
try {
|
||||
// Get current user ID
|
||||
const userId = overrideUserId || auth.getUserId();
|
||||
|
||||
if (!userId) {
|
||||
if (debug) console.log('[Preload] No active user session, skipping preload');
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) console.log('[Preload] Triggering preload for user:', userId);
|
||||
|
||||
const result = await invoke<PreloadResult>('player_preload_upcoming', {
|
||||
userId,
|
||||
downloadBasePath: '/downloads' // This parameter is currently unused in the backend
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
console.log('[Preload] Result:', {
|
||||
queued: result.queuedCount,
|
||||
alreadyDownloaded: result.alreadyDownloaded,
|
||||
skipped: result.skipped
|
||||
});
|
||||
}
|
||||
|
||||
// Log meaningful results
|
||||
if (result.queuedCount > 0) {
|
||||
console.log(`[Preload] Queued ${result.queuedCount} track(s) for background download`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail silently - preloading is a background optimization
|
||||
// Don't interrupt the user's playback experience
|
||||
console.warn('[Preload] Failed to preload upcoming tracks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update smart cache configuration
|
||||
*/
|
||||
export async function updateCacheConfig(config: {
|
||||
queuePrecacheEnabled?: boolean;
|
||||
queuePrecacheCount?: number;
|
||||
albumAffinityEnabled?: boolean;
|
||||
albumAffinityThreshold?: number;
|
||||
storageLimit?: number;
|
||||
wifiOnly?: boolean;
|
||||
}): Promise<void> {
|
||||
await invoke('player_set_cache_config', { config });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache configuration
|
||||
*/
|
||||
export async function getCacheConfig(): Promise<{
|
||||
queuePrecacheEnabled: boolean;
|
||||
queuePrecacheCount: number;
|
||||
albumAffinityEnabled: boolean;
|
||||
albumAffinityThreshold: number;
|
||||
storageLimit: number;
|
||||
wifiOnly: boolean;
|
||||
}> {
|
||||
return await invoke('player_get_cache_config');
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Sync service - processes queued mutations when connectivity is restored
|
||||
//
|
||||
// This service handles:
|
||||
// - Queueing mutations (favorites, playback progress) when offline
|
||||
// - Processing queued mutations when connectivity is restored
|
||||
// - Retry with exponential backoff for failed operations
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { isServerReachable, connectivity } from "$lib/stores/connectivity";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
// Types matching Rust structs
|
||||
export interface SyncQueueItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
operation: string;
|
||||
itemId: string | null;
|
||||
payload: string | null;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
createdAt: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export type SyncOperation =
|
||||
| "mark_played"
|
||||
| "mark_unplayed"
|
||||
| "mark_favorite"
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
| "report_playback_start"
|
||||
| "report_playback_stopped";
|
||||
|
||||
// Maximum retries before giving up on an operation
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// Delay between sync attempts (exponential backoff)
|
||||
const BASE_RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Batch size for processing queue
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
class SyncService {
|
||||
private processing = false;
|
||||
private unsubscribeConnectivity: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Start the sync service - listens for connectivity changes
|
||||
*/
|
||||
start(): void {
|
||||
if (this.unsubscribeConnectivity) {
|
||||
return; // Already started
|
||||
}
|
||||
|
||||
console.log("[SyncService] Starting...");
|
||||
|
||||
// Listen for connectivity changes
|
||||
this.unsubscribeConnectivity = isServerReachable.subscribe((reachable) => {
|
||||
if (reachable && !this.processing) {
|
||||
console.log("[SyncService] Server became reachable, processing queue...");
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
|
||||
// Process queue on startup if online
|
||||
if (get(isServerReachable)) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the sync service
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.unsubscribeConnectivity) {
|
||||
this.unsubscribeConnectivity();
|
||||
this.unsubscribeConnectivity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a mutation for sync to server
|
||||
*/
|
||||
async queueMutation(
|
||||
operation: SyncOperation,
|
||||
itemId: string,
|
||||
payload?: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const id = await invoke<number>("sync_queue_mutation", {
|
||||
userId,
|
||||
operation,
|
||||
itemId,
|
||||
payload: payload ? JSON.stringify(payload) : null,
|
||||
});
|
||||
|
||||
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
|
||||
|
||||
// Try to process immediately if online
|
||||
if (get(isServerReachable) && !this.processing) {
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a favorite toggle
|
||||
*/
|
||||
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
||||
// Also update local state
|
||||
await invoke("storage_toggle_favorite", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
isFavorite,
|
||||
});
|
||||
|
||||
return this.queueMutation(
|
||||
isFavorite ? "mark_favorite" : "unmark_favorite",
|
||||
itemId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue playback progress update
|
||||
*/
|
||||
async queuePlaybackProgress(
|
||||
itemId: string,
|
||||
positionTicks: number
|
||||
): Promise<number> {
|
||||
// Also update local state
|
||||
await invoke("storage_update_playback_progress", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
positionTicks,
|
||||
});
|
||||
|
||||
return this.queueMutation("update_progress", itemId, { positionTicks });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue mark as played
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Also update local state
|
||||
await invoke("storage_mark_played", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
});
|
||||
|
||||
return this.queueMutation("mark_played", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending sync operations
|
||||
*/
|
||||
async getPendingCount(): Promise<number> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return invoke<number>("sync_get_pending_count", { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the sync queue
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
if (this.processing) {
|
||||
console.log("[SyncService] Already processing queue");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
console.log("[SyncService] Not authenticated, skipping queue processing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("[SyncService] Server not reachable, skipping queue processing");
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
console.log("[SyncService] Processing sync queue...");
|
||||
|
||||
try {
|
||||
// Get pending items
|
||||
const items = await invoke<SyncQueueItem[]>("sync_get_pending", {
|
||||
userId,
|
||||
limit: BATCH_SIZE,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log("[SyncService] No pending items in queue");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SyncService] Processing ${items.length} queued items`);
|
||||
|
||||
for (const item of items) {
|
||||
// Check connectivity before each item
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("[SyncService] Lost connectivity, stopping queue processing");
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've exceeded retries
|
||||
if (item.retryCount >= MAX_RETRIES) {
|
||||
console.warn(
|
||||
`[SyncService] Item ${item.id} exceeded max retries, marking as failed`
|
||||
);
|
||||
await invoke("sync_mark_failed", {
|
||||
id: item.id,
|
||||
error: "Exceeded maximum retry attempts",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processItem(item);
|
||||
}
|
||||
|
||||
// Check if there are more items to process
|
||||
const remaining = await this.getPendingCount();
|
||||
if (remaining > 0 && get(isServerReachable)) {
|
||||
// Process next batch after a short delay
|
||||
setTimeout(() => this.processQueue(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SyncService] Error processing queue:", error);
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single sync queue item
|
||||
*/
|
||||
private async processItem(item: SyncQueueItem): Promise<void> {
|
||||
console.log(`[SyncService] Processing item ${item.id}: ${item.operation}`);
|
||||
|
||||
try {
|
||||
// Mark as processing
|
||||
await invoke("sync_mark_processing", { id: item.id });
|
||||
|
||||
// Get repository for API calls
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Execute the operation
|
||||
switch (item.operation) {
|
||||
case "mark_favorite":
|
||||
if (item.itemId) {
|
||||
await repo.markFavorite(item.itemId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "unmark_favorite":
|
||||
if (item.itemId) {
|
||||
await repo.unmarkFavorite(item.itemId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "update_progress":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackProgress(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
case "mark_played":
|
||||
if (item.itemId) {
|
||||
// Jellyfin doesn't have a direct "mark played" endpoint,
|
||||
// we report playback stopped at 100%
|
||||
const itemData = await repo.getItem(item.itemId);
|
||||
if (itemData.runTimeTicks) {
|
||||
await repo.reportPlaybackStopped(item.itemId, itemData.runTimeTicks);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "report_playback_start":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackStart(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
case "report_playback_stopped":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackStopped(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[SyncService] Unknown operation: ${item.operation}`);
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
await invoke("sync_mark_completed", { id: item.id });
|
||||
|
||||
// Also mark local data as synced
|
||||
if (item.itemId) {
|
||||
await invoke("storage_mark_synced", {
|
||||
userId: item.userId,
|
||||
itemId: item.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SyncService] Successfully processed item ${item.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[SyncService] Failed to process item ${item.id}:`, error);
|
||||
|
||||
// Calculate retry delay with exponential backoff
|
||||
const retryDelay = BASE_RETRY_DELAY_MS * Math.pow(2, item.retryCount);
|
||||
|
||||
// Mark as failed
|
||||
await invoke("sync_mark_failed", {
|
||||
id: item.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Wait before continuing (gives server time to recover if overloaded)
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(retryDelay, 10000)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up completed operations older than specified days
|
||||
*/
|
||||
async cleanup(daysOld: number = 7): Promise<number> {
|
||||
const deleted = await invoke<number>("sync_cleanup_completed", { daysOld });
|
||||
console.log(`[SyncService] Cleaned up ${deleted} old completed operations`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync operations for the current user (called during logout)
|
||||
*/
|
||||
async clearUser(): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
if (userId) {
|
||||
await invoke("sync_clear_user", { userId });
|
||||
console.log("[SyncService] Cleared sync queue for user");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const syncService = new SyncService();
|
||||
Reference in New Issue
Block a user