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

This commit is contained in:
2026-07-16 22:53:03 +02:00
parent 532ffa661a
commit 1992a8187d
10 changed files with 272 additions and 192 deletions
-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
// ($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("");
+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
const mockGetHandle = vi.fn(() => "repo-handle-1");
vi.mock("./auth", () => ({
@@ -42,6 +54,7 @@ describe("playbackMode store", () => {
beforeEach(() => {
vi.clearAllMocks();
currentSelectedSession = null;
capturedStatusListener = null;
});
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", () => {
it("refreshes from Rust after a successful transferToRemote", async () => {
const { playbackMode } = await import("./playbackMode");
+23 -2
View File
@@ -316,10 +316,31 @@ function createPlaybackModeStore() {
const mode = event.payload.mode as PlaybackMode;
const remoteSessionId =
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);
update((s) => ({ ...s, mode, remoteSessionId }));
// Keep the selected session in step so the merged UI stores follow.
sessions.selectSession(remoteSessionId);
// Keep the selected session in step so the merged UI stores follow, but
// 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);
}
}
});