Compare commits

...
3 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 4.8 7b8a8f66e5 CI: make versionCode step POSIX sh compatible
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m24s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m31s
Build & Release / Build Linux (push) Successful in 17m40s
Build & Release / Build Android (push) Successful in 22m33s
Build & Release / Create Release (push) Successful in 14s
The runner executes workflow steps with /bin/sh (dash), which has no
here-strings: `IFS='.' read -r MAJ MIN PAT <<< "$VERSION"` failed with
"Syntax error: redirection unexpected" and aborted the Android release build.

Parse the semver with `cut` instead, drop the GNU-only `\s` from the sed
expression in favour of [[:space:]], and default any missing component to 0 so a
malformed version can never emit versionCode 0. Verified under sh:
0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000 (monotonic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:23:04 +02:00
dtourolleandClaude Opus 4.8 2e479d05b3 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>
2026-07-17 21:12:36 +02:00
dtourolle 1992a8187d layout and remote fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s
2026-07-16 22:53:03 +02:00
22 changed files with 554 additions and 285 deletions
+32 -3
View File
@@ -161,10 +161,10 @@ jobs:
- name: Set app version from tag - name: Set app version from tag
run: | run: |
REF="${GITHUB_REF#refs/tags/v}" # On a tag build, the tag is the single source of truth for the
VERSION="${REF#refs/heads/}" # version name. On non-tag runs keep whatever is in tauri.conf.json.
# On non-tag runs keep whatever is in tauri.conf.json
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION" echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi fi
@@ -173,6 +173,35 @@ jobs:
- name: Initialize Android project - name: Initialize Android project
run: bun run tauri android init 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.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
PROPS="src-tauri/gen/android/app/tauri.properties"
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
MAJ=$(echo "$VERSION" | cut -d. -f1)
MIN=$(echo "$VERSION" | cut -d. -f2)
PAT=$(echo "$VERSION" | cut -d. -f3)
# Guard against a malformed/missing component so we never emit code 0.
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
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 - name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh run: ./scripts/sync-android-sources.sh
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellytau", "name": "jellytau",
"version": "0.1.0", "version": "0.0.15",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
+27 -8
View File
@@ -113,6 +113,26 @@ impl PlaybackModeManager {
} }
} }
/// Start the Android playback service and hand it remote-volume control.
///
/// Must run on EVERY transition into remote mode, because it is what starts
/// the foreground service. Without a running service there is no media
/// notification (the lockscreen card is missing) AND system volume buttons
/// aren't intercepted for the remote session (remote volume control dead).
/// Both symptoms share this one cause, so this must not be skipped on any
/// remote-entry path (notably the empty-queue early return in
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
#[allow(unused_variables)]
fn enable_remote_control(&self) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume/service: {}", e);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
}
/// Check if currently transferring /// Check if currently transferring
pub fn is_transferring(&self) -> bool { pub fn is_transferring(&self) -> bool {
self.is_transferring.load(Ordering::Relaxed) self.is_transferring.load(Ordering::Relaxed)
@@ -334,6 +354,10 @@ impl PlaybackModeManager {
self.set_mode(PlaybackMode::Remote { self.set_mode(PlaybackMode::Remote {
session_id: session_id.to_string(), session_id: session_id.to_string(),
}); });
// Start the service + remote-volume control here too — otherwise this
// early return leaves remote mode with no media notification and no
// volume interception (lockscreen card missing + remote volume dead).
self.enable_remote_control();
return Ok(()); return Ok(());
} }
@@ -589,14 +613,9 @@ impl PlaybackModeManager {
session_id: session_id.to_string(), session_id: session_id.to_string(),
}); });
// Enable remote volume control on Android (intercepts volume buttons) // Start the service + remote-volume control (intercepts volume buttons,
#[cfg(target_os = "android")] // and starts the foreground service that renders the lockscreen card).
{ self.enable_remote_control();
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
log::info!("[PlaybackMode] Successfully transferred to remote"); log::info!("[PlaybackMode] Successfully transferred to remote");
Ok(()) Ok(())
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau", "productName": "jellytau",
"version": "0.1.0", "version": "0.0.15",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+64
View File
@@ -0,0 +1,64 @@
<!--
BottomUi — the app's bottom UI (mini player stacked over the bottom nav).
Rendered as an IN-FLOW flex child at the bottom of a full-height flex column,
NOT a fixed overlay. This is the whole point: because it is a normal flex
sibling below the scroll container (which is `flex-1 min-h-0 overflow-y-auto`),
the scroller is physically bounded above it and can never render behind it.
This replaces the old ResizeObserver + `bottomUiHeight` + padding-reservation
scheme, which started at 0, updated async, and repeatedly regressed into the
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
the browser's flex layout does it exactly, every frame.
The Android system gesture bar is cleared via `env(safe-area-inset-bottom)`.
TRACES: UR-005 | DR-009
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext, hasPrevious } from "$lib/stores/queue";
import { showSleepTimerModal } from "$lib/stores/appState";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import BottomNav from "$lib/components/BottomNav.svelte";
let {
showMiniPlayer = true,
showNav = true,
onExpand,
}: {
showMiniPlayer?: boolean;
showNav?: boolean;
// Where "expand mini player" goes. Defaults to the full player route.
onExpand?: () => void;
} = $props();
function expand() {
if (onExpand) return onExpand();
if ($currentMedia) goto(`/player/${$currentMedia.id}`);
}
</script>
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
<div class="flex-shrink-0 pb-[env(safe-area-inset-bottom)] bg-[var(--color-surface)]">
{#if showMiniPlayer}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
shuffle={$isShuffle}
repeat={$repeatMode}
hasNext={$hasNext}
hasPrevious={$hasPrevious}
className="flex-shrink-0"
onExpand={expand}
onSleepTimerClick={() => showSleepTimerModal.set(true)}
/>
{/if}
{#if showNav}
<BottomNav className="flex-shrink-0" />
{/if}
</div>
@@ -3,7 +3,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte"; import SearchBar from "$lib/components/common/SearchBar.svelte";
@@ -138,7 +138,7 @@
selectedGenre = null; selectedGenre = null;
genreItems = []; genreItems = [];
} else { } else {
navigateBack(config.backPath); navigateUp(config.backPath);
} }
} }
@@ -3,7 +3,7 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player"; import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
@@ -176,7 +176,7 @@
} }
function goBack() { function goBack() {
navigateBack(config.backPath); navigateUp(config.backPath);
} }
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`); const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
-4
View File
@@ -9,10 +9,6 @@ export const isAndroid = writable(false);
// Shuffle/repeat/next/previous state now lives in the event-driven queue store // Shuffle/repeat/next/previous state now lives in the event-driven queue store
// ($lib/stores/queue), the single source of truth. // ($lib/stores/queue), the single source of truth.
export const showSleepTimerModal = writable(false); export const showSleepTimerModal = writable(false);
// Measured height (px) of the fixed bottom UI on Android: BottomNav stacked with
// the global mini player. Published by the root layout via ResizeObserver so the
// library list can reserve exactly that much bottom padding (no magic rem guesses).
export const bottomUiHeight = writable(0);
// Library-specific state // Library-specific state
export const librarySearchQuery = writable(""); export const librarySearchQuery = writable("");
+50 -36
View File
@@ -139,20 +139,23 @@ function createAuthStore() {
update((s) => ({ ...s, isLoading: true, error: null })); update((s) => ({ ...s, isLoading: true, error: null }));
try { try {
// Check security status // Check security status — fire-and-forget. It only sets a warning banner,
try { // so it must not sit in front of session restore (and thus first paint).
const securityStatus = await commands.storageGetSecurityStatus(); void (async () => {
console.log("[Auth] Security status:", securityStatus); try {
if (!securityStatus.usingKeyring) { const securityStatus = await commands.storageGetSecurityStatus();
update((s) => ({ console.log("[Auth] Security status:", securityStatus);
...s, if (!securityStatus.usingKeyring) {
securityWarning: update((s) => ({
"Credentials are stored with reduced security (encrypted file instead of system keyring).", ...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 // Initialize auth manager and get session
console.log("[Auth] Initializing auth manager..."); console.log("[Auth] Initializing auth manager...");
@@ -162,24 +165,30 @@ function createAuthStore() {
if (session) { if (session) {
console.log("[Auth] Restoring session for user:", session.username, "on server:", session.serverUrl); 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(); repository = new RepositoryClient();
await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId); await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId);
// Configure Jellyfin client in Rust player for automatic playback reporting // Configure the Rust player for playback reporting. This is NOT needed to
const deviceId = await getDeviceId(); // render the first screen (it only matters once playback starts), so run
try { // it fire-and-forget instead of blocking first paint on two more IPC
console.log("[Auth] Configuring Rust player with restored session..."); // round-trips (getDeviceId + playerConfigureJellyfin).
await commands.playerConfigureJellyfin( void (async () => {
session.serverUrl, try {
session.accessToken, const deviceId = await getDeviceId();
session.userId, await commands.playerConfigureJellyfin(
deviceId session.serverUrl,
); session.accessToken,
console.log("[Auth] Rust player configured for automatic playback reporting"); session.userId,
} catch (error) { deviceId
console.error("[Auth] Failed to configure Rust player:", error); );
} 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 authenticated immediately (offline-first)
set({ set({
@@ -211,14 +220,19 @@ function createAuthStore() {
console.error("[Auth] Failed to start connectivity monitoring:", error); console.error("[Auth] Failed to start connectivity monitoring:", error);
}); });
// Start background session verification // Start background session verification — fire-and-forget. This is
try { // already asynchronous work (results arrive via the auth:* events wired
const verifyDeviceId = await getDeviceId(); // above), so awaiting getDeviceId + authStartVerification here only
await commands.authStartVerification(verifyDeviceId); // delayed first paint by two IPC round-trips for no UI benefit.
console.log("[Auth] Background verification started"); void (async () => {
} catch (error) { try {
console.error("[Auth] Failed to start verification:", error); 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 { } else {
// No stored session // No stored session
console.log("[Auth] No active session found"); console.log("[Auth] No active session found");
+63
View File
@@ -26,6 +26,18 @@ vi.mock("./sessions", () => ({
}, },
})); }));
// Capture the playerStatusEvent listener so tests can drive backend
// `playback_mode_changed` events through the reconciler. The commands still flow
// to the real bindings (which call the mocked `invoke`), so the existing
// refresh/transfer tests keep exercising the true command path.
let capturedStatusListener: ((event: { payload: any }) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn((_name: string, cb: (event: { payload: any }) => void) => {
capturedStatusListener = cb;
return Promise.resolve(() => {});
}),
}));
// Mock auth store // Mock auth store
const mockGetHandle = vi.fn(() => "repo-handle-1"); const mockGetHandle = vi.fn(() => "repo-handle-1");
vi.mock("./auth", () => ({ vi.mock("./auth", () => ({
@@ -42,6 +54,7 @@ describe("playbackMode store", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
currentSelectedSession = null; currentSelectedSession = null;
capturedStatusListener = null;
}); });
afterEach(() => { afterEach(() => {
@@ -327,6 +340,56 @@ describe("playbackMode store", () => {
}); });
}); });
describe("backend playback_mode_changed reconciler", () => {
// Regression: commit 2a1f168 made Rust re-broadcast PlaybackModeChanged on
// every set_mode. Local playback drives set_mode("local") from both the
// frontend and Rust, so the same mode arrives repeatedly. The reconciler
// used to run selectSession(null) on each one, deselecting the remote
// session mid-cast and tripping the disconnect watchdog — which broke the
// lockscreen card, remote volume, and (via the mode flap) local audio.
async function initListener() {
const { playbackMode } = await import("./playbackMode");
playbackMode.initializeSessionMonitoring();
expect(capturedStatusListener).not.toBeNull();
return playbackMode;
}
it("ignores a no-op remote re-broadcast (no session churn)", async () => {
currentSelectedSession = { id: "sess-1" };
const playbackMode = await initListener();
playbackMode.setMode("remote", "sess-1");
mockSelectSession.mockClear();
// Rust re-broadcasts the SAME remote mode (e.g. a position tick path).
capturedStatusListener!({
payload: { type: "playback_mode_changed", mode: "remote", session_id: "sess-1" },
});
const state = get(playbackMode);
expect(state.mode).toBe("remote");
expect(state.remoteSessionId).toBe("sess-1");
// Must NOT re-select (which would churn the watchdog) on a no-op.
expect(mockSelectSession).not.toHaveBeenCalled();
});
it("adopts a genuine remote→local change and clears the session", async () => {
currentSelectedSession = { id: "sess-1" };
const playbackMode = await initListener();
playbackMode.setMode("remote", "sess-1");
mockSelectSession.mockClear();
capturedStatusListener!({
payload: { type: "playback_mode_changed", mode: "local", session_id: null },
});
const state = get(playbackMode);
expect(state.mode).toBe("local");
expect(state.remoteSessionId).toBeNull();
expect(mockSelectSession).toHaveBeenCalledWith(null);
});
});
describe("transfer reconciles to Rust on completion", () => { describe("transfer reconciles to Rust on completion", () => {
it("refreshes from Rust after a successful transferToRemote", async () => { it("refreshes from Rust after a successful transferToRemote", async () => {
const { playbackMode } = await import("./playbackMode"); const { playbackMode } = await import("./playbackMode");
+23 -2
View File
@@ -316,10 +316,31 @@ function createPlaybackModeStore() {
const mode = event.payload.mode as PlaybackMode; const mode = event.payload.mode as PlaybackMode;
const remoteSessionId = const remoteSessionId =
mode === "remote" ? event.payload.session_id ?? null : null; mode === "remote" ? event.payload.session_id ?? null : null;
// Ignore no-op re-broadcasts. The backend re-emits on every set_mode, and
// local playback drives set_mode("local") from BOTH the frontend
// (handleStateChanged) and Rust, so the same mode arrives repeatedly. If
// we reconciled unconditionally we'd re-run selectSession(null) on each
// one, deselecting the remote session mid-cast and tripping the
// disconnect-to-idle watchdog (breaking the lockscreen card, remote
// volume, and — via the resulting mode flap — local audio).
if (
currentState.mode === mode &&
currentState.remoteSessionId === remoteSessionId
) {
return;
}
console.log("[PlaybackMode] Backend mode changed →", mode, remoteSessionId); console.log("[PlaybackMode] Backend mode changed →", mode, remoteSessionId);
update((s) => ({ ...s, mode, remoteSessionId })); update((s) => ({ ...s, mode, remoteSessionId }));
// Keep the selected session in step so the merged UI stores follow. // Keep the selected session in step so the merged UI stores follow, but
sessions.selectSession(remoteSessionId); // only touch the selection when it actually differs — re-selecting the
// same id (or clearing on a non-remote emit that isn't a real change)
// would needlessly churn the session watchdog.
const selected = get(selectedSession);
if ((selected?.id ?? null) !== remoteSessionId) {
sessions.selectSession(remoteSessionId);
}
} }
}); });
+38 -47
View File
@@ -1,13 +1,15 @@
/** /**
* Regression tests for the app's fixed bottom-UI (mini player + bottom nav) * Tests for the app's bottom-UI (mini player + bottom nav) visibility rules.
* layout rules.
* *
* The bug these guard against: on the library route the layout used to render * The overlap bug these guard against: on the library page the last rows were
* its OWN in-flow mini player while the root ALSO painted a fixed bottom nav on * hidden behind the bottom nav. It was caused by rendering the bottom UI as a
* top of it, and the library scroller only reserved 1rem — so the last row hid * FIXED overlay and trying to reserve its (async-measured, initially-0) height
* behind the nav. The fix unified everything onto the root: the root owns the * as padding. The fix renders the bottom UI as an in-flow flex child below the
* single fixed bottom UI on every route/platform, and every scroll container * scroller, so overlap is structurally impossible — no measurement, no padding.
* reserves the measured `bottomUiHeight`. *
* These pure functions only decide *whether* each piece shows on a route. The
* structural guarantee (flex sibling below the scroller) is exercised by
* running the app, not by jsdom (which has no layout engine).
* *
* TRACES: UR-005 | DR-009 * TRACES: UR-005 | DR-009
*/ */
@@ -18,7 +20,6 @@ import {
showGlobalMiniPlayer, showGlobalMiniPlayer,
routeOwnsLayout, routeOwnsLayout,
showBottomUi, showBottomUi,
reservedBottomPadding,
} from "./layoutShell"; } from "./layoutShell";
const authed = (pathname: string) => ({ pathname, isAuthenticated: true }); const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
@@ -44,15 +45,14 @@ describe("showGlobalMiniPlayer", () => {
expect(showGlobalMiniPlayer({ pathname: "/settings" })).toBe(false); expect(showGlobalMiniPlayer({ pathname: "/settings" })).toBe(false);
}); });
it("does NOT depend on platform or on /library — the root owns it everywhere", () => { it("does NOT depend on platform or on /library — one code path everywhere", () => {
// The signature intentionally has no `isAndroid` input: the old bug was a // The old bug was a platform/route split that let a second mini player exist.
// platform/route split that let a second in-flow mini player exist.
expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true); expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true);
}); });
}); });
describe("showBottomNav", () => { describe("showBottomNav", () => {
it("shows on authenticated content routes including library", () => { it("shows on authenticated content routes including library and settings", () => {
expect(showBottomNav(authed("/library"))).toBe(true); expect(showBottomNav(authed("/library"))).toBe(true);
expect(showBottomNav(authed("/"))).toBe(true); expect(showBottomNav(authed("/"))).toBe(true);
expect(showBottomNav(authed("/settings"))).toBe(true); expect(showBottomNav(authed("/settings"))).toBe(true);
@@ -69,64 +69,55 @@ describe("showBottomNav", () => {
}); });
describe("routeOwnsLayout", () => { describe("routeOwnsLayout", () => {
it("is true for library/settings/player/login (they manage their own scroll)", () => { it("is true for library/player/login (they render their own flex column + BottomUi)", () => {
expect(routeOwnsLayout({ pathname: "/library" })).toBe(true); expect(routeOwnsLayout({ pathname: "/library" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/library/abc" })).toBe(true); expect(routeOwnsLayout({ pathname: "/library/abc" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/settings" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true); expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
expect(routeOwnsLayout({ pathname: "/login" })).toBe(true); expect(routeOwnsLayout({ pathname: "/login" })).toBe(true);
}); });
it("is false for routes that render into the root scroller", () => { it("is false for routes that render into the root scroller (incl. settings)", () => {
// Settings has no +layout of its own; it must flow through the root scroller
// so the root's in-flow BottomUi renders below it (otherwise settings loses
// its nav, since the fixed-overlay nav no longer exists).
expect(routeOwnsLayout({ pathname: "/" })).toBe(false); expect(routeOwnsLayout({ pathname: "/" })).toBe(false);
expect(routeOwnsLayout({ pathname: "/search" })).toBe(false); expect(routeOwnsLayout({ pathname: "/search" })).toBe(false);
expect(routeOwnsLayout({ pathname: "/downloads" })).toBe(false); expect(routeOwnsLayout({ pathname: "/downloads" })).toBe(false);
expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false);
}); });
}); });
describe("layout invariant: a reservation owner exists wherever bottom UI shows", () => { describe("structural invariant: every route that shows bottom UI has a scroller above it", () => {
// The core anti-regression check. Every route falls into exactly one of two // With the in-flow model, "the bottom UI is a flex sibling below a scroller"
// reservation regimes: // must hold on every route where it shows. That scroller is provided by
// - route owns its layout -> the route's own scroller reserves bottomUiHeight // exactly one owner:
// - route does NOT own it -> the root scroller reserves bottomUiHeight // - routeOwnsLayout === true -> the route's own column (header + main + BottomUi)
// The bug was that the library route was implicitly a THIRD regime: it owned // - routeOwnsLayout === false -> the root column (scroller + BottomUi)
// its layout, showed a fixed nav from the root, but reserved only 1rem. That // The forbidden state — bottom UI shows but no owning column renders a
// can't recur now because library both owns its layout (so it reserves // scroller + BottomUi pair — cannot occur because the two branches are total.
// internally) and the mini player is root-owned (no second in-flow bar).
const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"]; const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"];
for (const pathname of routes) { for (const pathname of routes) {
it(`${pathname}: exactly one reservation owner`, () => { it(`${pathname}: bottom UI shows and has a defined layout owner`, () => {
if (!showBottomUi(authed(pathname))) return; // no bottom UI -> nothing to reserve expect(showBottomUi(authed(pathname))).toBe(true);
// Ownership is a total boolean, so exactly one regime always applies —
// there is no route that shows bottom UI with no reservation owner.
expect(typeof routeOwnsLayout({ pathname })).toBe("boolean"); expect(typeof routeOwnsLayout({ pathname })).toBe("boolean");
}); });
} }
it("library shows bottom UI AND owns its layout, so it reserves internally", () => { it("library owns its layout, so it renders its own in-flow BottomUi", () => {
// Directly pins the regression: library must NOT rely on the root scroller // Directly pins the original regression: library must render BottomUi inside
// (it has none — the root gives owning routes a clipped, non-scrolling box). // its own column (the root gives owning routes a clipped, non-scrolling box).
expect(showBottomUi(authed("/library"))).toBe(true); expect(showBottomUi(authed("/library"))).toBe(true);
expect(routeOwnsLayout({ pathname: "/library" })).toBe(true); expect(routeOwnsLayout({ pathname: "/library" })).toBe(true);
}); });
});
describe("reservedBottomPadding", () => { it("settings does NOT own its layout, so the root scroller + BottomUi cover it", () => {
it("returns an exact px fit when no extra room requested", () => { expect(showBottomUi(authed("/settings"))).toBe(true);
expect(reservedBottomPadding(120)).toBe("120px"); expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false);
}); });
it("adds breathing room via calc for layout-owning routes", () => { it("the full-screen player shows no bottom UI and owns its layout", () => {
expect(reservedBottomPadding(120, 1)).toBe("calc(120px + 1rem)"); expect(showBottomUi(authed("/player/x"))).toBe(false);
}); expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
it("never returns negative padding", () => {
expect(reservedBottomPadding(-50)).toBe("0px");
expect(reservedBottomPadding(-50, 1)).toBe("calc(0px + 1rem)");
});
it("reserves 1rem-only when the bottom UI is collapsed to 0 (nothing playing, nav-only measured elsewhere)", () => {
expect(reservedBottomPadding(0, 1)).toBe("calc(0px + 1rem)");
}); });
}); });
+17 -36
View File
@@ -1,20 +1,18 @@
/** /**
* Pure layout-shell logic for the app's fixed bottom UI (mini player stacked * Pure layout-shell visibility rules for the app's bottom UI (mini player
* over the bottom nav). * stacked over the bottom nav).
* *
* These rules used to live as inline `$derived` booleans scattered across the * These rules used to live as inline `$derived` booleans scattered across the
* root and library `+layout.svelte` files, and diverged per platform/route * root and library `+layout.svelte` files and diverged per platform/route.
* which is exactly how the "last row hidden behind the nav" bug kept coming
* back. The invariant is now a single source of truth:
* *
* - The ROOT layout owns the single fixed bottom UI on every route/platform. * The overlap bug ("last row hidden behind the nav") is now solved
* There is no per-route/per-platform second mini player. * STRUCTURALLY, not by these rules: the bottom UI is rendered as an in-flow
* - Whatever fixed bottom UI is showing has a live-measured height * flex child below the scroller (see BottomUi.svelte), so the scroller is
* (`bottomUiHeight`), and every scroll container reserves exactly that much * physically bounded above it and can never render behind it. There is no
* bottom space so the last row can never render behind the nav. * measurement and no reserved padding. These functions only decide *whether*
* each piece is visible on a given route.
* *
* Keeping this pure makes the invariant unit-testable (jsdom has no layout * Keeping them pure makes the visibility contract unit-testable.
* engine, so the geometry itself can't be tested — but the decision logic can).
* *
* TRACES: UR-005 | DR-009 * TRACES: UR-005 | DR-009
*/ */
@@ -56,41 +54,24 @@ export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolea
} }
/** /**
* Routes that own their own full-height layout (their own scroll container + * Routes that render their own full-height flex column (header + scroller +
* bottom-space reservation). The root leaves these as a plain non-scrolling box * their own in-flow BottomUi). The root leaves these as a plain clipped box and
* and does NOT add bottom padding — the route reserves `bottomUiHeight` itself. * does not render its own BottomUi. Every other route renders into the root's
* Every other route scrolls in the root wrapper, which reserves the space. * scroller, with the root's in-flow BottomUi as a flex sibling below it.
*/ */
export function routeOwnsLayout({ pathname }: { pathname: string }): boolean { export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
return ( return (
pathname.startsWith("/library") || pathname.startsWith("/library") ||
pathname.startsWith("/settings") ||
pathname.startsWith("/player/") || pathname.startsWith("/player/") ||
pathname.startsWith("/login") pathname.startsWith("/login")
); );
} }
/** /**
* Whether any fixed bottom UI is showing for this route (mini player, nav, or * Whether any bottom UI is showing for this route (mini player, nav, or both).
* both). When true, the active scroll container must reserve `bottomUiHeight`. * The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte),
* so this is purely a visibility question — there is no padding to reserve.
*/ */
export function showBottomUi(input: BottomUiVisibilityInput): boolean { export function showBottomUi(input: BottomUiVisibilityInput): boolean {
return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname }); return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname });
} }
/**
* The bottom padding (in CSS) a scroll container must reserve so its last row
* clears the fixed bottom UI. `bottomUiHeight` is the live-measured height of
* the root's fixed bottom UI wrapper.
*
* @param bottomUiHeight measured height in px of the fixed bottom UI (0 if none)
* @param extraRem breathing room added on top (routes that own their
* layout add 1rem; the root wrapper reserves an exact fit)
*/
export function reservedBottomPadding(
bottomUiHeight: number,
extraRem = 0,
): string {
const px = Math.max(0, bottomUiHeight);
return extraRem > 0 ? `calc(${px}px + ${extraRem}rem)` : `${px}px`;
}
+76 -16
View File
@@ -1,35 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { navigateBack } from "./navigation";
const goto = vi.fn(); 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", () => ({ vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args), 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(() => { beforeEach(() => {
goto.mockClear(); 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", () => { describe("navigateUp", () => {
const back = vi.fn(); it("always goes to the given parent path, never touching history", () => {
vi.spyOn(history, "back").mockImplementation(back); const back = vi.fn();
vi.spyOn(history, "length", "get").mockReturnValue(3); vi.spyOn(history, "back").mockImplementation(back);
navigateBack("/library"); navigateUp("/library/music");
expect(back).toHaveBeenCalledOnce(); expect(goto).toHaveBeenCalledWith("/library/music");
expect(goto).not.toHaveBeenCalled(); expect(back).not.toHaveBeenCalled();
});
}); });
it("falls back to the given path on a fresh deep-link (no history)", () => { describe("navigateBack / canGoBack", () => {
const back = vi.fn(); it("falls back to the path when there is no in-app history yet", () => {
vi.spyOn(history, "back").mockImplementation(back); // Fresh session: only the initial load happened (from == null), so depth
vi.spyOn(history, "length", "get").mockReturnValue(1); // 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"); navigateBack("/library");
expect(back).not.toHaveBeenCalled();
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 * App navigation has two distinct affordances (per the Android guidelines):
* back to an explicit path otherwise.
* *
* Hardcoded `goto(backPath)` always sends the user to a fixed screen, which * - **Up** — move to the current screen's *logical parent* in the app
* loses track of where they actually came from (e.g. reaching the genres list * hierarchy (e.g. `/library/music/albums` → `/library/music`). Deterministic,
* from different entry points). Preferring `history.back()` keeps the back * derived from the route, and never depends on how the user got here. This is
* affordance consistent with the platform back gesture and the browser/Android * what the in-app header arrows should do almost everywhere.
* hardware back button.
* *
* We only use history when there is somewhere to go back to *within the app*. * - **Back** — pop the *actual* history stack: return to wherever the user came
* On a fresh deep-link (history length 1, or an external referrer) we fall back * from, which may be a sibling branch (a detail page reached from search vs.
* to `fallbackPath` so the user never gets stranded or bounced out of the app. * 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 { export function navigateBack(fallbackPath: string): void {
if (canGoBack()) { if (canGoBack()) {
@@ -21,13 +93,3 @@ export function navigateBack(fallbackPath: string): void {
goto(fallbackPath); 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;
}
+37 -86
View File
@@ -2,7 +2,6 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import "../app.css"; import "../app.css";
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth"; import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
@@ -13,72 +12,46 @@
import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog"; import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
import { playbackMode } from "$lib/stores/playbackMode"; import { playbackMode } from "$lib/stores/playbackMode";
import { sessions } from "$lib/stores/sessions"; import { sessions } from "$lib/stores/sessions";
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
import ReauthModal from "$lib/components/auth/ReauthModal.svelte"; import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
import Toast from "$lib/components/Toast.svelte"; import Toast from "$lib/components/Toast.svelte";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomNav from "$lib/components/BottomNav.svelte"; import BottomUi from "$lib/components/BottomUi.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal, bottomUiHeight } from "$lib/stores/appState"; import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
import { import {
showBottomNav as computeShowBottomNav, showBottomNav as computeShowBottomNav,
showGlobalMiniPlayer as computeShowGlobalMiniPlayer, showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
routeOwnsLayout as computeRouteOwnsLayout, routeOwnsLayout as computeRouteOwnsLayout,
showBottomUi as computeShowBottomUi,
reservedBottomPadding,
} from "$lib/utils/layoutShell"; } from "$lib/utils/layoutShell";
// Shuffle/repeat/next/previous come from the event-driven queue store, the import { registerNavigationTracking } from "$lib/utils/navigation";
// single source of truth (updated instantly on queue_changed).
import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue";
let { children } = $props(); let { children } = $props();
// The fixed bottom UI (mini player stacked over the bottom nav) is measured in // Track in-app navigation depth so the header "back" affordance knows when a
// real time and its height published to `bottomUiHeight`, so pages can reserve // real in-app Back exists (vs. a stale WebView stack after a background /
// exactly that much space instead of guessing fixed rem values. // restore). Must run during component init — afterNavigate needs a component
let bottomUiEl = $state<HTMLElement | null>(null); // context, not the async onMount callback below.
registerNavigationTracking();
// Route-level visibility for the fixed bottom UI (the mini player itself also // Layout-shell visibility rules live in one pure, unit-tested module
// self-gates on playback state; when it renders nothing the in-flow slot // ($lib/utils/layoutShell) so they can't drift per route/platform.
// collapses to 0 and the ResizeObserver shrinks the reserved padding). //
// All layout-shell visibility/reservation rules live in one pure, unit-tested // The bottom UI (mini player + nav) is rendered IN FLEX FLOW below the
// module ($lib/utils/layoutShell) so they can't drift per route/platform. // scroller — never as a fixed overlay — so the list is physically bounded
// The root owns the single fixed bottom UI (mini player + nav) on every route; // above it and cannot render behind it. There is nothing to measure or
// the library route used to render its own in-flow mini player, which double- // reserve; the old ResizeObserver/`bottomUiHeight`/padding scheme (which
// stacked with this fixed one and hid the last row behind the nav. // started at 0 and kept regressing into "last row hidden behind the nav") is
// gone. See BottomUi.svelte.
const pathname = $derived($page.url.pathname); const pathname = $derived($page.url.pathname);
const showBottomNav = $derived( const showBottomNav = $derived(
computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }) computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated })
); );
const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname })); const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname }));
// The library and settings routes own their own full-height layout (their own // Library/settings/player/login own their own full-height flex column
// scroll container + bottom-space reservation), so the root must leave their // (header + scroller + their own in-flow BottomUi), so the root just clips
// wrapper as a plain non-scrolling box. Every other top-level page (search, // and lets them manage layout. Every other route renders into the root's
// downloads, sessions, home) renders straight into the root, so the root // scroller, with the root's in-flow BottomUi as a flex sibling below it.
// wrapper has to scroll AND reserve the fixed bottom UI's height — otherwise
// the mini player / bottom nav overlay the last rows of content.
const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname })); const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname }));
const showBottomUi = $derived(
computeShowBottomUi({ pathname, isAuthenticated: $isAuthenticated })
);
$effect(() => {
const el = bottomUiEl;
if (!el) {
bottomUiHeight.set(0);
return;
}
const ro = new ResizeObserver((entries) => {
bottomUiHeight.set(entries[0]?.contentRect.height ?? el.offsetHeight);
});
ro.observe(el);
bottomUiHeight.set(el.offsetHeight);
return () => {
ro.disconnect();
bottomUiHeight.set(0);
};
});
onMount(async () => { onMount(async () => {
// Detect platform first (synchronously, before any await) so the global // Detect platform first (synchronously, before any await) so the global
@@ -210,13 +183,18 @@
this wrapper must scroll and reserve the fixed bottom UI's measured this wrapper must scroll and reserve the fixed bottom UI's measured
height so the mini player / bottom nav never overlap the last rows. --> height so the mini player / bottom nav never overlap the last rows. -->
{#if routeOwnsLayout} {#if routeOwnsLayout}
<!-- These routes own their own full-height flex column (header + scroller
+ their own in-flow BottomUi), so the root just clips and steps back. -->
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
{@render children()} {@render children()}
</div> </div>
{:else} {:else}
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
sibling, so the list is physically bounded above it and can never
render behind it. No measurement, no reserved padding. -->
<div <div
class="flex-1 overflow-y-auto min-h-0" class="flex-1 overflow-y-auto min-h-0"
style="padding-bottom: {showBottomUi ? reservedBottomPadding($bottomUiHeight) : '0'}; overscroll-behavior: contain" style="overscroll-behavior: contain"
> >
{@render children()} {@render children()}
</div> </div>
@@ -228,44 +206,17 @@
<!-- Toast notifications (global) --> <!-- Toast notifications (global) -->
<Toast /> <Toast />
<!-- Fixed bottom UI: mini player stacked over the bottom nav, both in normal <!-- Bottom UI (mini player + nav), in normal flex flow below the scroller.
flow inside one measured wrapper. The wrapper's height is observed and Owned routes render their own BottomUi inside their own column instead. -->
published to `bottomUiHeight` so pages reserve exactly this much space. {#if !routeOwnsLayout && (showBottomNav || showGlobalMiniPlayer)}
Mini player is first (visually on top, above the nav). --> <BottomUi showMiniPlayer={showGlobalMiniPlayer} showNav={showBottomNav} />
{#if showBottomNav || showGlobalMiniPlayer}
<div bind:this={bottomUiEl} class="fixed bottom-0 left-0 right-0 z-40 flex flex-col">
{#if showGlobalMiniPlayer}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
shuffle={$shuffle}
repeat={$repeat}
hasNext={$hasNext}
hasPrevious={$hasPrevious}
className="flex-shrink-0"
onExpand={() => {
// Navigate to player page when mini player is expanded
if ($currentMedia) {
goto(`/player/${$currentMedia.id}`);
}
}}
onSleepTimerClick={() => showSleepTimerModal.set(true)}
/>
{/if}
{#if showBottomNav}
<BottomNav className="flex-shrink-0" />
{/if}
</div>
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
{/if} {/if}
<!-- Sleep Timer Modal (global) -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
{:else} {:else}
<div class="flex items-center justify-center h-screen"> <div class="flex items-center justify-center h-screen">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div> <div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
+8 -7
View File
@@ -5,10 +5,9 @@
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
import { bottomUiHeight } from "$lib/stores/appState";
import { reservedBottomPadding } from "$lib/utils/layoutShell";
import { useScrollGuard } from "$lib/composables/useScrollGuard"; import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte"; import Search from "$lib/components/Search.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android) // Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
@@ -190,18 +189,20 @@
</div> </div>
</header> </header>
<!-- Main content. The fixed bottom UI (mini player + nav) is owned entirely <!-- Main content. The BottomUi below is an in-flow flex sibling, so this
by the root layout on every platform, and its live-measured height is scroller is physically bounded above it and its last row can never
published to `bottomUiHeight`. We reserve exactly that here (plus a render behind the nav — no measurement, no reserved padding. -->
little breathing room) so the last row never hides behind the nav. -->
<main <main
class="flex-1 overflow-y-auto p-4 min-h-0" class="flex-1 overflow-y-auto p-4 min-h-0"
style="padding-bottom: {reservedBottomPadding($bottomUiHeight, 1)}; overscroll-behavior: contain" style="overscroll-behavior: contain"
onscroll={scrollGuard.onScroll} onscroll={scrollGuard.onScroll}
> >
{@render children()} {@render children()}
</main> </main>
<!-- Bottom UI (mini player + nav), in-flow below the scroller. -->
<BottomUi />
<!-- Sleep Timer Modal --> <!-- Sleep Timer Modal -->
<SleepTimerModal <SleepTimerModal
isOpen={showSleepTimerModal} isOpen={showSleepTimerModal}
+20 -3
View File
@@ -17,6 +17,23 @@
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music"); 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 // Filter out Playlist libraries - they belong in Music sub-library
const visibleLibraries = $derived.by(() => { const visibleLibraries = $derived.by(() => {
return $libraries.filter(lib => lib.collectionType !== "playlists"); return $libraries.filter(lib => lib.collectionType !== "playlists");
@@ -176,8 +193,8 @@
onItemClick={handleItemClick} onItemClick={handleItemClick}
/> />
</div> </div>
{:else if $currentLibrary} {:else if showInlineLibraryContent}
<!-- Library content --> <!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<button <button
@@ -189,7 +206,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<h1 class="text-2xl font-bold text-white">{$currentLibrary.name}</h1> <h1 class="text-2xl font-bold text-white">{$currentLibrary?.name}</h1>
</div> </div>
{#if isMusicLibrary} {#if isMusicLibrary}
+3 -3
View File
@@ -2,8 +2,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { library, currentLibrary } from "$lib/stores/library";
import { movies } from "$lib/stores/movies"; import { movies } from "$lib/stores/movies";
import { isServerReachable } from "$lib/stores/connectivity"; import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload"; import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
@@ -86,7 +86,7 @@
<div class="flex items-center justify-between px-4"> <div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1> <h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
<button <button
onclick={() => navigateBack("/library")} onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white" class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries" title="Back to libraries"
aria-label="Back to libraries" aria-label="Back to libraries"
+3 -3
View File
@@ -2,9 +2,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateUp } from "$lib/utils/navigation";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { currentLibrary } from "$lib/stores/library"; import { library, currentLibrary } from "$lib/stores/library";
import { music } from "$lib/stores/music"; import { music } from "$lib/stores/music";
import { isServerReachable } from "$lib/stores/connectivity"; import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload"; import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
@@ -108,7 +108,7 @@
<div class="flex items-center justify-between px-4"> <div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">Music</h1> <h1 class="text-3xl font-bold text-white">Music</h1>
<button <button
onclick={() => navigateBack("/library")} onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white" class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries" title="Back to libraries"
aria-label="Back to libraries" aria-label="Back to libraries"
+3 -3
View File
@@ -2,8 +2,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { library, currentLibrary } from "$lib/stores/library";
import { tv } from "$lib/stores/tv"; import { tv } from "$lib/stores/tv";
import { isServerReachable } from "$lib/stores/connectivity"; import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload"; import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
@@ -93,7 +93,7 @@
<div class="flex items-center justify-between px-4"> <div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1> <h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button <button
onclick={() => navigateBack("/library")} onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white" class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries" title="Back to libraries"
aria-label="Back to libraries" aria-label="Back to libraries"
+1 -1
View File
@@ -170,7 +170,7 @@
} }
</script> </script>
<div class="max-w-2xl mx-auto space-y-8 p-6 pb-24 h-full overflow-y-auto"> <div class="max-w-2xl mx-auto space-y-8 p-6">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1> <h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
<p class="text-gray-400">Configure playback and audio processing</p> <p class="text-gray-400">Configure playback and audio processing</p>