fix(offline): one server-only rule for both library views

Two defects with one cause: "server only" was a private $derived inside
MediaCard.

The list view (what LibraryGrid renders when the stored view preference
is list) had no notion of it at all, so a library browsed as a list
offline showed every revealed item as an ordinary tappable row that plays
nothing, with no way to queue it.

And the rule asked the downloads store whether *this item id* was
downloaded — but only a playable leaf (Audio, Movie, Episode) ever has a
download row. An album's tracks carry them, the album does not, so a
fully downloaded album greyed itself out and offered to queue what was
already on the device.

The rule moves to the pure $lib/utils/serverOnly and both views call it.
The container half is answered by the backend rather than guessed at:
get_download_disk_usage().sizes already carries container subtotals
beside leaf sizes (DR-085), so deviceContentIds is membership in a
Rust-computed map, not a frontend list of which item types are
containers. That map was loaded only by the Downloads page, so the shell
primes it at startup and re-reads it whenever the offline gate settles.
Queueing is shared too, since the list view had no copy to diverge from.

TRACES: UR-052, UR-055 | DR-292 | UT-257, UT-258
This commit is contained in:
2026-09-22 21:29:38 -04:00
parent a90de67c54
commit bb7d5dc01a
10 changed files with 468 additions and 36 deletions
+45
View File
@@ -0,0 +1,45 @@
/**
* Tests for the "server only" card rule shared by the grid and the list view.
*
* TRACES: UR-052 | DR-292 | UT-257
*/
import { describe, it, expect } from "vitest";
import { isServerOnly, type ServerOnlyInput } from "./serverOnly";
const offlineReveal: ServerOnlyInput = {
isMediaItem: true,
isConnected: false,
revealServerCatalog: true,
isDownloaded: false,
isActivelyDownloading: false,
hasDeviceContent: false,
};
describe("isServerOnly", () => {
it("is true only offline, with the reveal on, for an item with nothing on the device", () => {
expect(isServerOnly(offlineReveal)).toBe(true);
expect(isServerOnly({ ...offlineReveal, isConnected: true })).toBe(false);
expect(isServerOnly({ ...offlineReveal, revealServerCatalog: false })).toBe(false);
});
it("never greys a library tile: there is nothing to queue", () => {
expect(isServerOnly({ ...offlineReveal, isMediaItem: false })).toBe(false);
});
it("does not grey the item's own completed or in-flight download", () => {
expect(isServerOnly({ ...offlineReveal, isDownloaded: true })).toBe(false);
expect(isServerOnly({ ...offlineReveal, isActivelyDownloading: true })).toBe(false);
});
it("does not grey a container whose children are on the device", () => {
// The regression: an album has no download row of its own — its *tracks*
// do — so a fully downloaded album greyed itself out and offered to queue
// what was already there.
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: true })).toBe(false);
});
it("still greys a container with nothing downloaded under it", () => {
expect(isServerOnly({ ...offlineReveal, hasDeviceContent: false })).toBe(true);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Is a card "server only" — revealed by the offline "Show all server media"
* toggle, but with nothing on the device behind it?
*
* Such cards render greyed out, are inert to tap (there is nothing to play),
* and offer to queue a download for the next reconnect instead.
*
* This lives in its own module because two surfaces answer the question — the
* grid (`MediaCard`) and the list (`LibraryListView`) — and they disagreed:
* the list had no notion of server-only at all, so switching a library to list
* view offline turned every non-downloaded item back into a normal, tappable
* row that plays nothing.
*
* `hasDeviceContent` is the fix for the second half of that defect. An item's
* *own* download row only ever exists for a playable leaf (Audio, Movie,
* Episode); an album or a season never has one, so a fully downloaded album
* greyed itself out and offered to queue what was already on the device. The
* caller passes the backend's answer — `get_download_disk_usage().sizes`
* carries container subtotals as well as leaf sizes — rather than the frontend
* deciding which item types are containers, which is taxonomy that belongs in
* Rust.
*
* TRACES: UR-052 | DR-292 | UT-257
*/
export interface ServerOnlyInput {
/** False for a `Library` tile — a library is never queued or greyed. */
isMediaItem: boolean;
/** Server reachability (`$isConnected`). */
isConnected: boolean;
/** The offline banner's "Show all server media" toggle. */
revealServerCatalog: boolean;
/** This item's own download row is `completed`. */
isDownloaded: boolean;
/** This item's own download row is actively transferring. */
isActivelyDownloading: boolean;
/** The device holds bytes at or under this item (leaf file or container). */
hasDeviceContent: boolean;
}
export function isServerOnly({
isMediaItem,
isConnected,
revealServerCatalog,
isDownloaded,
isActivelyDownloading,
hasDeviceContent,
}: ServerOnlyInput): boolean {
if (!isMediaItem) return false;
if (isConnected || !revealServerCatalog) return false;
return !isDownloaded && !isActivelyDownloading && !hasDeviceContent;
}