fix(home): restart the hero banner timer on a manual change
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m31s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Failing after 14m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m31s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Failing after 14m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The rotation interval was installed once when the banner mounted and never touched again, so a swipe, arrow or dot tap inherited whatever was left of the running countdown — swiping 5.5s into a 6s interval moved the banner on half a second later. The timer moves into heroRotation.ts as a small restartable object so it can be unit-tested, and every manual navigation path restarts it from that moment. Verified red-first: with restart() reverted to leave a running timer alone, the regression test fails. Release 0.9.1.
This commit is contained in:
@@ -9,6 +9,20 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
For how long each fixed defect had been shipping before it was found, see
|
||||
[docs/defect-windows.md](docs/defect-windows.md).
|
||||
|
||||
## v0.9.1
|
||||
|
||||
A one-line fix to the home screen, released on its own because it is the kind of
|
||||
small wrongness you notice every time.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Swiping the hero banner now buys you a full six seconds.** The rotation
|
||||
timer was started once when the banner appeared and then left alone, so a
|
||||
swipe, arrow or dot tap inherited whatever was left of the running countdown
|
||||
— swipe five and a half seconds in and the banner moved on half a second
|
||||
later, before you had read the title. Any manual change now restarts the
|
||||
countdown from that moment. (UR-034 → DR-038)
|
||||
|
||||
## v0.9.0
|
||||
|
||||
An audit release. One new setting you asked for, two naming bugs that only ever
|
||||
|
||||
@@ -36,6 +36,7 @@ went unexercised until a later feature leaned on them.
|
||||
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
||||
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
||||
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
||||
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
|
||||
|
||||
### Why they took so long to surface
|
||||
|
||||
|
||||
@@ -698,6 +698,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done |
|
||||
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
|
||||
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
||||
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.9.0
|
||||
pkgver=0.9.1
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { createRotationTimer, type RotationTimer } from "./heroRotation";
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
@@ -13,7 +14,7 @@
|
||||
let { items, autoRotate = true, interval = 6000 }: Props = $props();
|
||||
|
||||
let currentIndex = $state(0);
|
||||
let intervalId: number | null = null;
|
||||
let rotation: RotationTimer | null = null;
|
||||
|
||||
// Touch/swipe state
|
||||
let touchStartX = $state(0);
|
||||
@@ -70,8 +71,22 @@
|
||||
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
||||
}
|
||||
|
||||
// Manual navigation (swipe, arrows, dots) restarts the countdown, so the
|
||||
// banner always waits a full interval after the last change instead of
|
||||
// firing whatever was left of the previous one.
|
||||
function showNext() {
|
||||
next();
|
||||
rotation?.restart();
|
||||
}
|
||||
|
||||
function showPrev() {
|
||||
prev();
|
||||
rotation?.restart();
|
||||
}
|
||||
|
||||
function goToIndex(idx: number) {
|
||||
currentIndex = idx;
|
||||
rotation?.restart();
|
||||
}
|
||||
|
||||
// Touch/swipe handlers
|
||||
@@ -96,10 +111,10 @@
|
||||
if (Math.abs(diff) > swipeThreshold) {
|
||||
if (diff > 0) {
|
||||
// Swiped left - go to next
|
||||
next();
|
||||
showNext();
|
||||
} else {
|
||||
// Swiped right - go to previous
|
||||
prev();
|
||||
showPrev();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +125,12 @@
|
||||
// Auto-rotate logic
|
||||
$effect(() => {
|
||||
if (autoRotate && items.length > 1) {
|
||||
intervalId = window.setInterval(next, interval);
|
||||
const timer = createRotationTimer(interval, next);
|
||||
rotation = timer;
|
||||
timer.restart();
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
timer.stop();
|
||||
rotation = null;
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -219,7 +237,7 @@
|
||||
|
||||
<!-- Swipe Indicators (Desktop hover) -->
|
||||
<button
|
||||
onclick={prev}
|
||||
onclick={showPrev}
|
||||
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
||||
aria-label="Previous item"
|
||||
>
|
||||
@@ -229,7 +247,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={next}
|
||||
onclick={showNext}
|
||||
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
||||
aria-label="Next item"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// TRACES: UR-034 | DR-038 | UT-207
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRotationTimer } from "./heroRotation";
|
||||
|
||||
describe("hero banner rotation timer", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("advances once per interval while running", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(6000, onElapse);
|
||||
timer.restart();
|
||||
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(onElapse).toHaveBeenCalledTimes(2);
|
||||
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
it("does nothing until started", () => {
|
||||
const onElapse = vi.fn();
|
||||
createRotationTimer(6000, onElapse);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(onElapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The bug: a manual swipe/click left the interval running, so the banner
|
||||
// rotated again almost immediately instead of waiting a full interval.
|
||||
it("restarts the countdown from now, not from the last auto-advance", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(6000, onElapse);
|
||||
timer.restart();
|
||||
|
||||
// 5.5s in the user swipes — the timer must restart from that moment.
|
||||
vi.advanceTimersByTime(5500);
|
||||
timer.restart();
|
||||
|
||||
// The remaining 500ms of the old countdown must NOT fire.
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(onElapse).not.toHaveBeenCalled();
|
||||
|
||||
// A full interval after the swipe, it advances.
|
||||
vi.advanceTimersByTime(5500);
|
||||
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
it("does not stack timers when restarted repeatedly", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(1000, onElapse);
|
||||
timer.restart();
|
||||
timer.restart();
|
||||
timer.restart();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onElapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
timer.stop();
|
||||
});
|
||||
|
||||
it("stops firing after stop()", () => {
|
||||
const onElapse = vi.fn();
|
||||
const timer = createRotationTimer(1000, onElapse);
|
||||
timer.restart();
|
||||
timer.stop();
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(onElapse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports whether it is running", () => {
|
||||
const timer = createRotationTimer(1000, () => {});
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
timer.restart();
|
||||
expect(timer.isRunning()).toBe(true);
|
||||
timer.stop();
|
||||
expect(timer.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Auto-rotation timer for the home-screen hero banner.
|
||||
//
|
||||
// Extracted from HeroBanner.svelte so it can be unit-tested: a manual swipe or
|
||||
// dot/arrow click must restart the countdown from that moment. The old code
|
||||
// installed one bare setInterval and left it running, so swiping late in an
|
||||
// interval made the banner jump to the next item almost immediately.
|
||||
//
|
||||
// TRACES: UR-034 | DR-038 | UT-207
|
||||
|
||||
export interface RotationTimer {
|
||||
/** (Re)start the countdown from now, replacing any pending tick. */
|
||||
restart(): void;
|
||||
/** Cancel the countdown. */
|
||||
stop(): void;
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a repeating timer that calls `onElapse` every `interval` ms once
|
||||
* started. Restarting is idempotent — there is never more than one live timer.
|
||||
*/
|
||||
export function createRotationTimer(interval: number, onElapse: () => void): RotationTimer {
|
||||
let handle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stop() {
|
||||
if (handle !== null) {
|
||||
clearInterval(handle);
|
||||
handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
restart() {
|
||||
stop();
|
||||
handle = setInterval(onElapse, interval);
|
||||
},
|
||||
stop,
|
||||
isRunning: () => handle !== null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user