Navigation up/back split, faster startup, and CI versionCode fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m57s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m13s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Build & Release / Build Linux (push) Successful in 17m52s
Build & Release / Build Android (push) Failing after 58s
Build & Release / Create Release (push) Has been skipped

Navigation:
- Split conflated "back" into navigateUp (deterministic route parent) and a
  history-safe navigateBack that tracks in-app depth via afterNavigate instead
  of history.length. Fixes the resume-from-background trap where a stale WebView
  stack left the header arrow stuck on the current page.
- /library self-corrects for music/tv/movies (which have dedicated landing
  pages): a leftover currentLibrary no longer forces the inline content-list
  view, so "up"/back shows the libraries overview. Live TV / channels / other
  types still render inline.

Startup (unblock first paint):
- auth.initialize() no longer awaits security-status, player-config, or session
  verification before flipping isInitialized. These run fire-and-forget after the
  session is restored, so the library overview paints without waiting on several
  serial IPC round-trips.

Versioning / CI:
- tauri.conf.json + package.json aligned to 0.0.15 (the tag series had drifted to
  0.1.0, whose formula-derived versionCode 1000 outran the v0.0.x tags).
- Release workflow now pins a monotonic Android versionCode
  (1000 + major*10000 + minor*100 + patch) so tagged builds never downgrade
  below prior installs and always increase in semver order.

Tests: navigation (4), auth (29), playbackMode (23) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:12:36 +02:00
co-authored by Claude Opus 4.8
parent 1992a8187d
commit 2e479d05b3
13 changed files with 278 additions and 94 deletions
+76 -16
View File
@@ -1,35 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { navigateBack } from "./navigation";
const goto = vi.fn();
// Capture the afterNavigate callback so tests can simulate navigations and thus
// drive the in-app depth counter that canGoBack/navigateBack rely on.
let afterNavigateCb: ((nav: { from: unknown; to: unknown; delta?: number }) => void) | null =
null;
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
afterNavigate: (cb: (nav: any) => void) => {
afterNavigateCb = cb;
},
}));
describe("navigateBack", () => {
import {
navigateUp,
navigateBack,
canGoBack,
registerNavigationTracking,
__resetNavigationDepthForTest,
} from "./navigation";
/** Simulate a SvelteKit navigation to move the depth counter. */
function nav(opts: { from?: boolean; delta?: number }) {
afterNavigateCb?.({
from: opts.from === false ? null : {},
to: {},
delta: opts.delta,
});
}
describe("navigation", () => {
beforeEach(() => {
goto.mockClear();
// registerNavigationTracking is idempotent; the first call in the suite wins
// and wires afterNavigateCb. Ensure it is registered, then reset depth so
// each case starts from the entry page (module state persists otherwise).
registerNavigationTracking();
__resetNavigationDepthForTest();
});
it("pops real history when there is in-app history to go back to", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(3);
describe("navigateUp", () => {
it("always goes to the given parent path, never touching history", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
navigateBack("/library");
navigateUp("/library/music");
expect(back).toHaveBeenCalledOnce();
expect(goto).not.toHaveBeenCalled();
expect(goto).toHaveBeenCalledWith("/library/music");
expect(back).not.toHaveBeenCalled();
});
});
it("falls back to the given path on a fresh deep-link (no history)", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(1);
describe("navigateBack / canGoBack", () => {
it("falls back to the path when there is no in-app history yet", () => {
// Fresh session: only the initial load happened (from == null), so depth
// stays at 0 and there is nothing to pop.
nav({ from: false });
expect(canGoBack()).toBe(false);
navigateBack("/library/music");
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
expect(goto).toHaveBeenCalledWith("/library/music");
expect(back).not.toHaveBeenCalled();
navigateBack("/library");
expect(goto).toHaveBeenCalledWith("/library");
expect(back).not.toHaveBeenCalled();
});
it("pops history after a real in-app forward navigation", () => {
nav({ from: false }); // initial load
nav({}); // navigated deeper within the app
expect(canGoBack()).toBe(true);
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
navigateBack("/library");
expect(back).toHaveBeenCalledOnce();
expect(goto).not.toHaveBeenCalled();
});
it("does not go below zero when the user backs out to the entry page", () => {
nav({ from: false }); // load
nav({}); // forward → depth 1
nav({ delta: -1 }); // back → depth 0
nav({ delta: -1 }); // extra back (e.g. stale delta) must not underflow
expect(canGoBack()).toBe(false);
navigateBack("/library/music");
expect(goto).toHaveBeenCalledWith("/library/music");
});
});
});
+83 -21
View File
@@ -1,18 +1,90 @@
import { goto } from "$app/navigation";
import { goto, afterNavigate } from "$app/navigation";
/**
* Navigate "back" using real browser/Android history when possible, falling
* back to an explicit path otherwise.
* App navigation has two distinct affordances (per the Android guidelines):
*
* Hardcoded `goto(backPath)` always sends the user to a fixed screen, which
* loses track of where they actually came from (e.g. reaching the genres list
* from different entry points). Preferring `history.back()` keeps the back
* affordance consistent with the platform back gesture and the browser/Android
* hardware back button.
* - **Up** — move to the current screen's *logical parent* in the app
* hierarchy (e.g. `/library/music/albums` → `/library/music`). Deterministic,
* derived from the route, and never depends on how the user got here. This is
* what the in-app header arrows should do almost everywhere.
*
* We only use history when there is somewhere to go back to *within the app*.
* On a fresh deep-link (history length 1, or an external referrer) we fall back
* to `fallbackPath` so the user never gets stranded or bounced out of the app.
* - **Back** — pop the *actual* history stack: return to wherever the user came
* from, which may be a sibling branch (a detail page reached from search vs.
* from the library) or even outside the app. This is the hardware/gesture
* back button's job; use it in-app only where "return to origin" is genuinely
* better than Up (e.g. a detail page with many entry points).
*
* The old single `navigateBack` conflated the two: it called `history.back()`
* first and only fell back to a path. On resume-from-background the WebView can
* restore a history stack whose `length` is still > 1 but which cannot actually
* go back within the app — so `history.back()` no-ops and the user is trapped on
* the page. Splitting Up (pure `goto`) from Back (tracked in-app depth) removes
* that trap: Up can never get stuck, and Back only fires when we *know* there is
* an in-app entry to return to.
*/
// In-app navigation depth, maintained via the public `afterNavigate` hook rather
// than reading SvelteKit's internal history-state key. Starts at 0 (the entry
// page). Each forward in-app navigation increments it; a popstate (back/forward
// gesture) sets it to the delta-adjusted value. When it is > 0 we know a real
// in-app Back exists and won't strand the user — independent of the WebView's
// possibly-stale `history.length` after a background/restore.
let inAppDepth = 0;
let navHookRegistered = false;
/**
* Register the navigation-depth tracker. Call once from the root layout's
* component init (afterNavigate must run in a component context). Safe to call
* more than once — only the first registration takes effect.
*/
export function registerNavigationTracking(): void {
if (navHookRegistered) return;
navHookRegistered = true;
afterNavigate((nav) => {
// A popstate (hardware/gesture back or forward) carries a delta; apply it so
// depth tracks the true stack position. Programmatic goto/link navigations
// have no delta and move one step deeper.
const delta = nav.delta;
if (typeof delta === "number") {
inAppDepth = Math.max(0, inAppDepth + delta);
} else if (nav.from) {
// A real forward navigation from an existing page (not the initial load).
inAppDepth += 1;
}
});
}
/**
* Reset the tracked depth. Intended for tests only, so each case starts from a
* known baseline (module state persists across a test file otherwise).
*/
export function __resetNavigationDepthForTest(): void {
inAppDepth = 0;
}
/**
* True when there is at least one in-app history entry to pop. Unlike
* `history.length > 1`, this reflects navigations that happened *within this app
* session*, so a stale WebView stack after a background/restore can't fool it.
*/
export function canGoBack(): boolean {
return inAppDepth > 0;
}
/**
* **Up**: go to the given logical parent path. Always deterministic; never
* consults history, so it cannot trap the user. Prefer this for header arrows.
*/
export function navigateUp(parentPath: string): void {
goto(parentPath);
}
/**
* **Back**: return to the previous in-app page when there is one, otherwise fall
* back to `fallbackPath` (typically the logical parent) so the user is never
* stranded. Use only where returning to the exact origin is preferable to Up
* (e.g. a detail page reachable from multiple branches).
*/
export function navigateBack(fallbackPath: string): void {
if (canGoBack()) {
@@ -21,13 +93,3 @@ export function navigateBack(fallbackPath: string): void {
goto(fallbackPath);
}
}
/**
* True when there is in-app history to pop. `history.length > 1` means the user
* navigated here from another page in this session rather than landing here
* directly (deep link, refresh, or first load).
*/
function canGoBack(): boolean {
if (typeof history === "undefined") return false;
return history.length > 1;
}