layout improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 9m2s
Traceability Validation / Check Requirement Traces (push) Successful in 2m30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled

This commit is contained in:
2026-06-28 20:14:17 +02:00
parent ef7be645b3
commit 8eae4ae253
12 changed files with 403 additions and 59 deletions
+4
View File
@@ -9,6 +9,10 @@ export const isAndroid = writable(false);
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
// ($lib/stores/queue), the single source of truth.
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
export const librarySearchQuery = writable("");
+15 -11
View File
@@ -254,8 +254,9 @@ export const mergedVolume = derived(
* Should show audio miniplayer - state machine gated
* Only true when:
* 1. In remote mode with an active session playing media, OR
* 2. Player is in playing or paused state (not idle, loading, error)
* AND current media is audio (not video: Movie or Episode)
* 2. There is an audio item loaded/queued and we are not in a genuine
* stopped state. The bar stays visible through transient idle/loading/
* seeking blips so it never flickers while advancing between tracks.
*/
export const shouldShowAudioMiniPlayer = derived(
[player, currentMedia, currentQueueItem, isRemoteMode, selectedSession],
@@ -265,14 +266,6 @@ export const shouldShowAudioMiniPlayer = derived(
return true;
}
// Local mode: hide only when there is genuinely nothing loaded
// (idle/stopped/error). Keep showing through loading/seeking transitions
// so the mini player doesn't blink out when advancing between tracks.
const state = $player.state;
if (state.kind === "idle" || state.kind === "error") {
return false;
}
// Determine media type from the player state, falling back to the queue
// item (the player store can momentarily lack media during transitions).
const mediaType = $media?.type ?? $queueItem?.type;
@@ -280,7 +273,18 @@ export const shouldShowAudioMiniPlayer = derived(
return false;
}
// Show for audio content
const state = $player.state;
// A genuine stop clears the queue too, so when the player reports
// idle/error we only hide if there is also no queue item to fall back to.
// This keeps the bar visible through a transient idle/stopped blip emitted
// mid-transition (e.g. sleep-timer churn or a stop-then-load track change),
// while still hiding once playback has truly ended and the queue is empty.
if (state.kind === "idle" || state.kind === "error") {
return $queueItem != null;
}
// playing / paused / loading / seeking — audio is active, show the bar.
return true;
}
);
+84
View File
@@ -0,0 +1,84 @@
/**
* Tests for `shouldShowAudioMiniPlayer` visibility invariant.
*
* The mini player must NOT flicker out while advancing between tracks. The
* backend can momentarily report an idle/stopped state mid-transition, so the
* bar stays visible as long as a queue item still exists, and only hides once
* playback has genuinely ended and the queue has been cleared.
*
* TRACES: UR-005 | DR-009
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { writable } from "svelte/store";
import { get } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
// Mock the dependency stores so we can drive remote mode and the current queue
// item independently of the real backend listeners.
const isRemoteMode = writable(false);
const selectedSession = writable<any>(null);
const currentQueueItem = writable<MediaItem | null>(null);
vi.mock("./playbackMode", () => ({ isRemoteMode }));
vi.mock("./sessions", () => ({ selectedSession }));
vi.mock("./queue", () => ({ currentQueueItem }));
// Imported after the mocks so player.ts picks up the mocked stores.
const { player, shouldShowAudioMiniPlayer } = await import("./player");
const audioItem = { id: "a1", type: "Audio" } as unknown as MediaItem;
const videoItem = { id: "v1", type: "Movie" } as unknown as MediaItem;
describe("shouldShowAudioMiniPlayer", () => {
beforeEach(() => {
isRemoteMode.set(false);
selectedSession.set(null);
currentQueueItem.set(null);
player.setIdle();
});
it("shows while an audio track is playing", () => {
player.setPlaying(audioItem, 0, 100);
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
it("stays visible through a transient idle blip while still queued", () => {
// A track is queued (e.g. mid-transition the backend emits idle but the
// next track is already in the queue).
currentQueueItem.set(audioItem);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
it("stays visible through loading/seeking transitions", () => {
currentQueueItem.set(audioItem);
player.setLoading(audioItem);
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
player.setSeeking(audioItem, 10);
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
it("hides once playback ends and the queue is cleared", () => {
currentQueueItem.set(null);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides for video content even while playing", () => {
player.setPlaying(videoItem, 0, 100);
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides for video content reported only via the queue item", () => {
currentQueueItem.set(videoItem);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("shows in remote mode when the session has a now-playing item", () => {
isRemoteMode.set(true);
selectedSession.set({ nowPlayingItem: { id: "r1" } });
expect(get(shouldShowAudioMiniPlayer)).toBe(true);
});
});