diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml index 1c7a8ff8..da16be36 100644 --- a/.gitea/workflows/build-release.yml +++ b/.gitea/workflows/build-release.yml @@ -161,10 +161,10 @@ jobs: - name: Set app version from tag run: | - REF="${GITHUB_REF#refs/tags/v}" - VERSION="${REF#refs/heads/}" - # On non-tag runs keep whatever is in tauri.conf.json + # On a tag build, the tag is the single source of truth for the + # version name. On non-tag runs keep whatever is in tauri.conf.json. if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then + VERSION="${GITHUB_REF#refs/tags/v}" echo "Setting version to $VERSION" sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json fi @@ -173,6 +173,30 @@ jobs: - name: Initialize Android project run: bun run tauri android init + - name: Pin a monotonic Android versionCode + run: | + # `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties + # with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That + # number is (a) tiny and (b) NOT monotonic across our history: earlier + # local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a + # plain 15 would be a *downgrade* and Android would refuse the update. + # + # Derive an explicit code that is both monotonic in semver order and + # always above the 1000 floor already in the field: + # code = 1000 + major*10000 + minor*100 + patch + # e.g. 0.0.15 -> 1015, 0.1.0 -> 11000, 1.0.0 -> 1010000. + PROPS="src-tauri/gen/android/app/tauri.properties" + VERSION=$(grep '"version"' src-tauri/tauri.conf.json | sed -E 's/.*"version"\s*:\s*"([^"]+)".*/\1/') + IFS='.' read -r MAJ MIN PAT <<< "$VERSION" + CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT )) + echo "version=$VERSION -> versionCode=$CODE" + if grep -q '^tauri.android.versionCode=' "$PROPS"; then + sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS" + else + echo "tauri.android.versionCode=$CODE" >> "$PROPS" + fi + cat "$PROPS" + - name: Sync custom Android sources & gradle config run: ./scripts/sync-android-sources.sh diff --git a/package.json b/package.json index 18135629..c7637946 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellytau", - "version": "0.1.0", + "version": "0.0.15", "description": "", "type": "module", "packageManager": "bun@1.3.5", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4d896585..e40219e6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "jellytau", - "version": "0.1.0", + "version": "0.0.15", "identifier": "com.dtourolle.jellytau", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/lib/components/library/GenericGenreBrowser.svelte b/src/lib/components/library/GenericGenreBrowser.svelte index 257825fb..f0df399e 100644 --- a/src/lib/components/library/GenericGenreBrowser.svelte +++ b/src/lib/components/library/GenericGenreBrowser.svelte @@ -3,7 +3,7 @@ import { page } from "$app/stores"; import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { goto } from "$app/navigation"; - import { navigateBack } from "$lib/utils/navigation"; + import { navigateUp } from "$lib/utils/navigation"; import { currentLibrary } from "$lib/stores/library"; import { auth } from "$lib/stores/auth"; import SearchBar from "$lib/components/common/SearchBar.svelte"; @@ -138,7 +138,7 @@ selectedGenre = null; genreItems = []; } else { - navigateBack(config.backPath); + navigateUp(config.backPath); } } diff --git a/src/lib/components/library/GenericMediaListPage.svelte b/src/lib/components/library/GenericMediaListPage.svelte index dbddad7d..aa97604b 100644 --- a/src/lib/components/library/GenericMediaListPage.svelte +++ b/src/lib/components/library/GenericMediaListPage.svelte @@ -3,7 +3,7 @@ import { onMount, onDestroy } from "svelte"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { goto } from "$app/navigation"; - import { navigateBack } from "$lib/utils/navigation"; + import { navigateUp } from "$lib/utils/navigation"; import { currentLibrary } from "$lib/stores/library"; import { auth } from "$lib/stores/auth"; import { shouldShowAudioMiniPlayer } from "$lib/stores/player"; @@ -176,7 +176,7 @@ } function goBack() { - navigateBack(config.backPath); + navigateUp(config.backPath); } const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`); diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 87da2d22..fed840b8 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -139,20 +139,23 @@ function createAuthStore() { update((s) => ({ ...s, isLoading: true, error: null })); try { - // Check security status - try { - const securityStatus = await commands.storageGetSecurityStatus(); - console.log("[Auth] Security status:", securityStatus); - if (!securityStatus.usingKeyring) { - update((s) => ({ - ...s, - securityWarning: - "Credentials are stored with reduced security (encrypted file instead of system keyring).", - })); + // Check security status — fire-and-forget. It only sets a warning banner, + // so it must not sit in front of session restore (and thus first paint). + void (async () => { + try { + const securityStatus = await commands.storageGetSecurityStatus(); + console.log("[Auth] Security status:", securityStatus); + if (!securityStatus.usingKeyring) { + update((s) => ({ + ...s, + securityWarning: + "Credentials are stored with reduced security (encrypted file instead of system keyring).", + })); + } + } catch (error) { + console.warn("[Auth] Failed to get security status:", error); } - } catch (error) { - console.warn("[Auth] Failed to get security status:", error); - } + })(); // Initialize auth manager and get session console.log("[Auth] Initializing auth manager..."); @@ -162,24 +165,30 @@ function createAuthStore() { if (session) { console.log("[Auth] Restoring session for user:", session.username, "on server:", session.serverUrl); - // Create RepositoryClient for cache-first access + // Create RepositoryClient for cache-first access. This IS required before + // we mark authenticated — the first screen (library overview) reads + // through it — so keep it awaited. repository = new RepositoryClient(); await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId); - // Configure Jellyfin client in Rust player for automatic playback reporting - const deviceId = await getDeviceId(); - try { - console.log("[Auth] Configuring Rust player with restored session..."); - await commands.playerConfigureJellyfin( - session.serverUrl, - session.accessToken, - session.userId, - deviceId - ); - console.log("[Auth] Rust player configured for automatic playback reporting"); - } catch (error) { - console.error("[Auth] Failed to configure Rust player:", error); - } + // Configure the Rust player for playback reporting. This is NOT needed to + // render the first screen (it only matters once playback starts), so run + // it fire-and-forget instead of blocking first paint on two more IPC + // round-trips (getDeviceId + playerConfigureJellyfin). + void (async () => { + try { + const deviceId = await getDeviceId(); + await commands.playerConfigureJellyfin( + session.serverUrl, + session.accessToken, + session.userId, + deviceId + ); + console.log("[Auth] Rust player configured for automatic playback reporting"); + } catch (error) { + console.error("[Auth] Failed to configure Rust player:", error); + } + })(); // Set authenticated immediately (offline-first) set({ @@ -211,14 +220,19 @@ function createAuthStore() { console.error("[Auth] Failed to start connectivity monitoring:", error); }); - // Start background session verification - try { - const verifyDeviceId = await getDeviceId(); - await commands.authStartVerification(verifyDeviceId); - console.log("[Auth] Background verification started"); - } catch (error) { - console.error("[Auth] Failed to start verification:", error); - } + // Start background session verification — fire-and-forget. This is + // already asynchronous work (results arrive via the auth:* events wired + // above), so awaiting getDeviceId + authStartVerification here only + // delayed first paint by two IPC round-trips for no UI benefit. + void (async () => { + try { + const verifyDeviceId = await getDeviceId(); + await commands.authStartVerification(verifyDeviceId); + console.log("[Auth] Background verification started"); + } catch (error) { + console.error("[Auth] Failed to start verification:", error); + } + })(); } else { // No stored session console.log("[Auth] No active session found"); diff --git a/src/lib/utils/navigation.test.ts b/src/lib/utils/navigation.test.ts index a2ee6094..800423db 100644 --- a/src/lib/utils/navigation.test.ts +++ b/src/lib/utils/navigation.test.ts @@ -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"); + }); }); }); diff --git a/src/lib/utils/navigation.ts b/src/lib/utils/navigation.ts index 9bb3670e..262f418d 100644 --- a/src/lib/utils/navigation.ts +++ b/src/lib/utils/navigation.ts @@ -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; -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7665677a..80fd5210 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -22,9 +22,16 @@ showGlobalMiniPlayer as computeShowGlobalMiniPlayer, routeOwnsLayout as computeRouteOwnsLayout, } from "$lib/utils/layoutShell"; + import { registerNavigationTracking } from "$lib/utils/navigation"; let { children } = $props(); + // Track in-app navigation depth so the header "back" affordance knows when a + // real in-app Back exists (vs. a stale WebView stack after a background / + // restore). Must run during component init — afterNavigate needs a component + // context, not the async onMount callback below. + registerNavigationTracking(); + // Layout-shell visibility rules live in one pure, unit-tested module // ($lib/utils/layoutShell) so they can't drift per route/platform. // diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index d89a8195..9d1301d5 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -17,6 +17,23 @@ const isMusicLibrary = $derived($currentLibrary?.collectionType === "music"); + // Music/TV/Movies libraries have their own dedicated landing pages + // (/library/music, /library/tv, /library/movies). When `currentLibrary` is one + // of those, any inline "library content" view here is a STALE leftover from + // navigating into that page — showing it makes "up"/back from that page render + // the library's item list instead of the libraries overview. Treat those types + // as "no inline content" so this page always shows the overview for them, + // whether we arrived via the header Up affordance or the hardware back button. + // Live TV / channels / other types still render their content inline here. + const currentLibraryHasDedicatedPage = $derived( + $currentLibrary?.collectionType === "music" || + $currentLibrary?.collectionType === "tvshows" || + $currentLibrary?.collectionType === "movies" + ); + const showInlineLibraryContent = $derived( + !!$currentLibrary && !currentLibraryHasDedicatedPage + ); + // Filter out Playlist libraries - they belong in Music sub-library const visibleLibraries = $derived.by(() => { return $libraries.filter(lib => lib.collectionType !== "playlists"); @@ -176,8 +193,8 @@ onItemClick={handleItemClick} /> - {:else if $currentLibrary} - + {:else if showInlineLibraryContent} +