feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 A completed download's file_path is already absolute (the worker
rewrites it on completion), but the player rooted it a second time and
handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol answers a range-less request by reading the
whole file into memory, and only advertises Accept-Ranges from inside
its range branch, so the first request never learns ranges exist.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import "../app.css";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { connectivity, isConnected } from "$lib/stores/connectivity";
|
||||
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
|
||||
@@ -20,6 +21,7 @@
|
||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||
import AppHeader from "$lib/components/AppHeader.svelte";
|
||||
import PendingSyncModal from "$lib/components/sync/PendingSyncModal.svelte";
|
||||
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
|
||||
import {
|
||||
showBottomNav as computeShowBottomNav,
|
||||
@@ -34,6 +36,9 @@
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
/** Offline banner badge → the list of what is actually queued (DR-132). */
|
||||
let showPendingSync = $state(false);
|
||||
|
||||
/** Teardown for the network-transport reporter (WiFi-only gate). */
|
||||
let stopNetworkReporting: (() => void) | null = null;
|
||||
let stopFavoritesListener: UnlistenFn | null = null;
|
||||
@@ -149,6 +154,17 @@
|
||||
// Start sync service for offline mutation queue
|
||||
syncService.start();
|
||||
|
||||
// Kick the queue once at startup. The Rust drain otherwise only runs on an
|
||||
// offline→online transition, so a queue built up in a previous session sits
|
||||
// untouched for a whole run of the app if the server was reachable the
|
||||
// whole time. Safe when it isn't: an unreachable server leaves rows queued
|
||||
// without spending their retry budget (DR-131).
|
||||
if (get(auth).user?.id) {
|
||||
commands.syncProcessPending().catch((err) =>
|
||||
console.debug("[Layout] Startup sync drain skipped:", err)
|
||||
);
|
||||
}
|
||||
|
||||
// Load the last-sync hint for the offline banner. The catalog *index* is no
|
||||
// longer kicked off from here: the Rust background indexer (DR-109) owns
|
||||
// when to re-index, so a long session no longer searches a stale catalog and
|
||||
@@ -200,7 +216,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Update pending sync count periodically
|
||||
// Update pending sync count periodically, and immediately whenever the Rust
|
||||
// drain (DR-131) reports it pushed or gave up on rows — otherwise the badge
|
||||
// lags a reconnect by up to 10s and reads as if nothing happened.
|
||||
$effect(() => {
|
||||
if ($isAuthenticated) {
|
||||
const updateCount = async () => {
|
||||
@@ -210,7 +228,19 @@
|
||||
updateCount();
|
||||
// Update every 10 seconds
|
||||
const interval = setInterval(updateCount, 10000);
|
||||
return () => clearInterval(interval);
|
||||
let unlistenDrain: UnlistenFn | null = null;
|
||||
listen("sync-queue-changed", () => {
|
||||
void updateCount();
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenDrain = unlisten;
|
||||
})
|
||||
.catch((err) => console.debug("[Layout] sync-queue-changed listen failed:", err));
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
unlistenDrain?.();
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -236,10 +266,19 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
|
||||
</svg>
|
||||
<span>You're offline. Some features may be limited.</span>
|
||||
<!-- The badge is answerable: it opens the queue it counts. Read as
|
||||
"pending transfers" it used to send people to the Downloads page,
|
||||
which lists a different table entirely and can never show these.
|
||||
TRACES: UR-025 | DR-132 -->
|
||||
{#if $pendingSyncCount > 0}
|
||||
<span class="bg-white/20 px-2 py-0.5 rounded-full text-xs">
|
||||
{$pendingSyncCount} pending sync{$pendingSyncCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPendingSync = true)}
|
||||
class="bg-white/20 hover:bg-white/30 px-2 py-0.5 rounded-full text-xs transition-colors"
|
||||
title="Changes waiting to reach the server"
|
||||
>
|
||||
{$pendingSyncCount} waiting to sync
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
@@ -298,6 +337,12 @@
|
||||
isOpen={$showSleepTimerModal}
|
||||
onClose={() => showSleepTimerModal.set(false)}
|
||||
/>
|
||||
|
||||
<!-- What the offline banner's badge counts (DR-132) -->
|
||||
<PendingSyncModal
|
||||
isOpen={showPendingSync}
|
||||
onClose={() => (showPendingSync = false)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
|
||||
Reference in New Issue
Block a user