Files
jellytau/src/lib/services/syncService.ts
T
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00

204 lines
6.1 KiB
TypeScript

// Sync service - manages offline mutation queueing
//
// Simplified service that coordinates with the Rust backend.
// The Rust backend handles sync queue persistence and processing logic.
// This service provides a thin TypeScript API for queuing mutations.
//
// TRACES: UR-002, UR-017, UR-025 | DR-014
import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
// The queue row shape comes from the generated bindings rather than a
// hand-written mirror — the mirror had already drifted (it predates `itemName`),
// and a drifted duplicate is how a field silently stops reaching the UI.
import type { SyncQueueItem } from "$lib/api/bindings";
export type { SyncQueueItem };
export type SyncOperation =
| "mark_played"
| "mark_unplayed"
| "mark_favorite"
| "unmark_favorite"
| "update_progress"
| "report_playback_start"
| "report_playback_stopped"
| "playlist_create"
| "playlist_delete"
| "playlist_rename"
| "playlist_add_items"
| "playlist_remove_items"
| "playlist_reorder_item";
/**
* Simplified sync service - handles offline mutation queueing
*
* The Rust backend maintains the sync queue in SQLite and is responsible
* for processing queued items. This service provides a TypeScript API
* for queueing and managing sync operations.
*/
class SyncService {
/**
* Start the sync service (lifecycle managed by Rust backend)
*/
start(): void {
console.log("[SyncService] Started");
}
/**
* Stop the sync service (lifecycle managed by Rust backend)
*/
stop(): void {
console.log("[SyncService] Stopped");
}
/**
* Queue a mutation for sync to server
*
* TRACES: UR-017, UR-025 | DR-014
*/
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 commands.syncQueueMutation(
userId,
operation,
itemId,
payload ? JSON.stringify(payload) : null
);
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
return id;
}
// NOTE: `queueFavorite` is gone. Favourites are drained by Rust on the
// `connectivity:reconnected` signal (DR-120) — the local write already sets
// `pending_sync`, and a second queue here would push the same change twice.
// See src-tauri/src/commands/favorites.rs.
/**
* Queue playback progress update
* Also updates local state immediately
*/
async queuePlaybackProgress(
itemId: string,
positionMs: number
): Promise<number> {
// Update local state first
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs);
return this.queueMutation("update_progress", itemId, { positionMs });
}
/**
* Queue mark as played
* Also updates local state immediately
*/
async queueMarkPlayed(itemId: string): Promise<number> {
// storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
// it has to cover a season or series' episodes too. storageMarkPlayed stays
// the single-item "this finished playing" path.
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, true);
return this.queueMutation("mark_played", itemId);
}
/**
* Queue mark as unwatched, the inverse of {@link queueMarkPlayed}.
*
* Same shape deliberately: the watched toggle has to work in both directions
* offline, or un-marking would be the one half that needs a connection. The
* drain pushes this as `clear_watch_history` — Jellyfin's mark-unplayed, which
* is recursive over a season or series and also clears resume positions.
*
* TRACES: UR-073 | DR-158
*/
async queueMarkUnplayed(itemId: string): Promise<number> {
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, false);
return this.queueMutation("mark_unplayed", itemId);
}
/**
* Get count of pending sync operations
*/
async getPendingCount(): Promise<number> {
const userId = auth.getUserId();
if (!userId) {
return 0;
}
return commands.syncGetPendingCount(userId);
}
/**
* Get pending sync items (for debugging/monitoring)
*/
async getPending(limit?: number): Promise<SyncQueueItem[]> {
const userId = auth.getUserId();
if (!userId) {
return [];
}
return commands.syncGetPending(userId, limit ?? null);
}
/**
* Clean up completed operations older than specified days
*/
async cleanup(daysOld: number = 7): Promise<number> {
const deleted = await commands.syncCleanupCompleted(daysOld);
console.log(`[SyncService] Cleaned up ${deleted} old completed operations`);
return deleted;
}
// ===== Playlist sync operations =====
async queuePlaylistCreate(playlistId: string, name: string, itemIds: string[]): Promise<number> {
return this.queueMutation("playlist_create", playlistId, { name, itemIds });
}
async queuePlaylistDelete(playlistId: string): Promise<number> {
return this.queueMutation("playlist_delete", playlistId);
}
async queuePlaylistRename(playlistId: string, name: string): Promise<number> {
return this.queueMutation("playlist_rename", playlistId, { name });
}
async queuePlaylistAddItems(playlistId: string, itemIds: string[]): Promise<number> {
return this.queueMutation("playlist_add_items", playlistId, { itemIds });
}
async queuePlaylistRemoveItems(playlistId: string, entryIds: string[]): Promise<number> {
return this.queueMutation("playlist_remove_items", playlistId, { entryIds });
}
async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise<number> {
return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex });
}
/**
* Clear all sync operations for the current user (called during logout)
*
* TRACES: UR-017 | DR-014
*/
async clearUser(): Promise<void> {
const userId = auth.getUserId();
if (userId) {
await commands.syncClearUser(userId);
console.log("[SyncService] Cleared sync queue for user");
}
}
}
// Export singleton instance
export const syncService = new SyncService();