diff --git a/backend/shortcuts/config.go b/backend/shortcuts/config.go
index 6ce12c1..df45fd1 100644
--- a/backend/shortcuts/config.go
+++ b/backend/shortcuts/config.go
@@ -24,10 +24,19 @@ func DefaultBindings() map[string]string {
"player.repeat": "R",
"player.mute": "M",
- // Navigation (Global scope)
+ // Navigation (Global scope). Back and forward are the browser's
+ // own combination on every platform, which is the whole design
+ // brief for them: the app has one global history and this is the
+ // gesture people already have for it. The modifier is what keeps
+ // them clear of `player.seekBack`/`seekForward`, which are the
+ // bare arrows -- a binding is matched on its full canonical
+ // string, so "Alt+Left" and "Left" are different keys and not a
+ // conflict.
"nav.search": "/",
"nav.searchAlt": "Ctrl+F",
"nav.queue": "Q",
+ "nav.back": "Alt+Left",
+ "nav.forward": "Alt+Right",
// App actions
"app.selectAll": "Ctrl+A",
diff --git a/frontend/index.css b/frontend/index.css
index 71b65db..44e6a47 100644
--- a/frontend/index.css
+++ b/frontend/index.css
@@ -104,6 +104,15 @@ p {
flex: 0 1 320px;
}
+/* The bar is `justify-content: space-between`, which with four children
+ spreads them evenly and left back/forward floating in the middle of
+ nothing. Collecting the free space *after* this one puts the pair
+ beside the brand, where a browser keeps them, and leaves the
+ right-hand group exactly as it was. */
+.top-bar nav-history {
+ margin-right: auto;
+}
+
ul {
list-style-type: none;
}
@@ -133,6 +142,23 @@ ul {
.subtitle {
display: none;
}
+
+ /* Back/forward is Desktop-band chrome (#6), and 900 is the same
+ line the sidebar's labels and the subtitle are already given up
+ at -- below it the shell is narrow enough that the header is
+ what runs out of room first. Measured at 600, the bottom of the
+ Compact band: the bar is 611px inside a 600px viewport *before*
+ this component exists (filed separately), and 695px with it, so
+ keeping it here would be widening a violation of the promise
+ that nothing scrolls sideways at a supported size.
+
+ Nothing is unreachable as a result, which is the rule that
+ decides it: Alt+Left / Alt+Right are global and every width has
+ them, the detail views keep their own back buttons, and the
+ phone additionally has the platform's gesture. */
+ .top-bar nav-history {
+ display: none;
+ }
}
body div.sidebar {
@@ -346,7 +372,13 @@ body div.sidebar {
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
- the drawer's Settings. */
+ the drawer's Settings.
+
+ `nav-history` is already gone from 899 down. It would belong
+ here anyway and for a stronger reason than width: the phone has
+ Back as a gesture or a button the OS owns, and this app hooks it
+ (`popstate`), so a second Back in the chrome duplicates a
+ control the platform provides. */
.top-bar library-filter {
display: none;
}
diff --git a/frontend/index.html b/frontend/index.html
index cb14257..dbfa05c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -20,6 +20,12 @@
Music how it was meant to bee.
+
+
diff --git a/frontend/index.ts b/frontend/index.ts
index cb7396a..bfe19f7 100644
--- a/frontend/index.ts
+++ b/frontend/index.ts
@@ -23,6 +23,7 @@ import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
+import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
@@ -41,6 +42,7 @@ import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store';
import { activeViewStore } from '@store/active-view-store';
+import { historyStore } from '@store/history-store';
import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js';
@@ -215,45 +217,80 @@ document.addEventListener('navigate', (e: Event) => {
// go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries.
-/** The navigation an entry stands for. `undefined` on the entry that
- * predates the app's own routing, which is the one back exits from. */
-type NavState = { yjNav?: { view: string; [key: string]: any } };
+/** The navigation an entry stands for, and where it sits in this
+ * session's list. `undefined` on the entry that predates the app's own
+ * routing, which is the one back exits from. */
+type NavState = { yjNav?: { view: string; [key: string]: any }; yjIdx?: number };
/** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */
let historyStarted = false;
-/** How many entries this session has pushed beyond that first one --
- * i.e. how deep back can go while staying inside the app. */
-let pushedEntries = 0;
+// Back and forward are the *same* `popstate` event -- it carries no
+// direction, and the History API exposes neither the current position
+// nor a reachable depth. So the shell numbers its own entries: the
+// index of the one showing, and the highest index reachable from here.
+//
+// The counter this replaced (`pushedEntries`, one number decremented on
+// every pop) could not express forward at all: going forward looked
+// exactly like going back again, so two presses of a Forward button
+// would have claimed the app was at its root.
+
+/** Index of the entry now showing. 0 is the launch entry, which is
+ * replaced rather than pushed -- so this is also how deep back can go
+ * while staying inside the app. */
+let currentIndex = 0;
+
+/** The highest index reachable from here: how far forward is left.
+ * A new navigation truncates the forward list, exactly as a browser
+ * does, so this is reset to the entry being pushed. */
+let maxIndex = 0;
+
+function publishDepth(): void {
+ historyStore.setDepth(currentIndex > 0, currentIndex < maxIndex);
+}
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` is bookkeeping, not destination: keeping it in the entry
// would make a replayed navigation claim to be a back-navigation.
const { _isBack: _ignored, ...nav } = detail;
- const state: NavState = { yjNav: nav };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
- history.pushState(state, '');
- pushedEntries += 1;
+ currentIndex += 1;
+ // Navigating from the middle of the list drops what was ahead
+ // of it -- there is no longer a forward to go to.
+ maxIndex = currentIndex;
+ history.pushState({ yjNav: nav, yjIdx: currentIndex }, '');
} else {
- history.replaceState(state, '');
+ currentIndex = 0;
+ maxIndex = 0;
+ history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
historyStarted = true;
}
+
+ publishDepth();
}
window.addEventListener('popstate', (e: PopStateEvent) => {
- const nav = (e.state as NavState | null)?.yjNav;
+ const state = e.state as NavState | null;
+ const nav = state?.yjNav;
// Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to
// finish.
if (!nav) return;
- pushedEntries = Math.max(0, pushedEntries - 1);
+ // The entry says where it is, so this works in both directions and
+ // across a jump of more than one -- which a long-press on a
+ // browser's back button, and `history.go(-n)`, both produce.
+ // The fallback is for an entry pushed before this numbering
+ // existed; it can only be wrong about a control's disabled state,
+ // never about which view is restored.
+ currentIndex = state?.yjIdx ?? Math.max(0, currentIndex - 1);
+ publishDepth();
void handleNavigate({ ...nav, _isBack: true });
});
@@ -512,7 +549,18 @@ function schedule(fn: () => void): void {
// anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it.
document.addEventListener('navigate-back', () => {
- if (pushedEntries > 0) history.back();
+ if (currentIndex > 0) history.back();
+});
+
+// Forward: the other half of #6. The stack was always global -- every
+// navigation is an entry and `popstate` restores any of them -- so what
+// was missing is a way to ask for one, and a truthful answer to whether
+// there is one to ask for. It is guarded for the same reason back is:
+// `history.forward()` at the end of the list is silent, so a button
+// that offers it when there is nothing there is a button that does
+// nothing.
+document.addEventListener('navigate-forward', () => {
+ if (currentIndex < maxIndex) history.forward();
});
// Navigate to the user's configured launch page. Falls back to 'home'
diff --git a/frontend/src/assets/icons/fa/solid/arrow-right.svg b/frontend/src/assets/icons/fa/solid/arrow-right.svg
new file mode 100644
index 0000000..f37a77c
--- /dev/null
+++ b/frontend/src/assets/icons/fa/solid/arrow-right.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/nav-history/nav-history.ts b/frontend/src/components/nav-history/nav-history.ts
new file mode 100644
index 0000000..81efc6c
--- /dev/null
+++ b/frontend/src/components/nav-history/nav-history.ts
@@ -0,0 +1,133 @@
+import { LitElement, html, css } from 'lit';
+import { customElement } from 'lit/decorators.js';
+import '@awesome.me/webawesome/dist/components/icon/icon.js';
+import { designTokens } from '../../styles/tokens.css';
+import { HistoryController } from '@store/controllers/history-controller';
+
+/**
+ * Global back and forward, in the top bar (#6).
+ *
+ * **The stack was already global; the affordance was not.** Every
+ * navigation has been a history entry since the Android back gesture
+ * landed, and `popstate` restores any of them in either direction --
+ * `back-navigation.spec.ts` has asserted `goForward()` since it was
+ * written. What the report describes as "back is tab-scoped" is that
+ * the *only* way back was a detail view's own button, which vanishes
+ * the moment you leave for another tab: the album you were reading is
+ * still one entry away, and nothing on screen says so or offers it.
+ *
+ * Four things about this are load-bearing.
+ *
+ * **It asks the shell rather than the History API.** `history.length`
+ * counts entries this app did not push and never shrinks, and there is
+ * no way to ask where in the list you are -- so a control derived from
+ * it is confidently wrong at both ends. `historyStore` is the shell's
+ * own numbering.
+ *
+ * **A control that cannot act is `disabled`, not hidden.** This is the
+ * one place in the app where that is right rather than the fault
+ * `library-status-indicator` was: back and forward are a *pair* whose
+ * positions the user learns, and a button that disappears at the end
+ * of the list moves the other one under the cursor. It is also what
+ * every browser does, which is the whole design brief here.
+ *
+ * **The buttons dispatch the events the rest of the app already
+ * dispatches**, `navigate-back` and `navigate-forward`, rather than
+ * calling `history.back()` themselves. The shell owns the guard -- one
+ * press is one entry, and at the root there is nothing of ours to go
+ * back to -- and a second caller reaching for `history` directly is
+ * how the old `navStack` came to disagree with the platform.
+ *
+ * **It is desktop chrome.** Below 600px the phone has a system back
+ * gesture (and, on Android, a hardware/gesture Back that this app
+ * hooks), the top bar is 3.25em with three other things in it, and two
+ * more 32px targets there would be the first thing to overflow. Hidden
+ * by `index.css` at that width, next to the rest of the phone header's
+ * concessions.
+ */
+@customElement('nav-history')
+export class NavHistory extends LitElement {
+ private historyCtrl = new HistoryController(this);
+
+ static override styles = [designTokens, css`
+ :host {
+ display: flex;
+ align-items: center;
+ gap: 0.25em;
+ /* A grid item's implicit minimum is its content; this one
+ genuinely cannot shrink, so it says so rather than
+ letting the header widen the body. */
+ flex: 0 0 auto;
+ }
+
+ button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2em;
+ height: 2em;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--yj-text-primary, #f8f9fa);
+ cursor: pointer;
+ font-size: 1em;
+ }
+
+ button:hover:not(:disabled) {
+ background-color: var(--yj-bg-overlay, #495057);
+ }
+
+ button:focus-visible {
+ outline: 2px solid var(--yj-accent, #ffd43b);
+ outline-offset: 2px;
+ }
+
+ button:disabled {
+ /* Not a contrast failure: a disabled control is exempt from
+ 1.4.3, and the pair has to read as unavailable rather
+ than merely quiet. */
+ color: var(--yj-text-tertiary, #868e96);
+ cursor: default;
+ }
+ `];
+
+ private go(direction: 'back' | 'forward') {
+ this.dispatchEvent(new CustomEvent(`navigate-${direction}`, {
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ override render() {
+ const { canBack, canForward } = this.historyCtrl.depth;
+
+ return html`
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nav-history': NavHistory;
+ }
+}
diff --git a/frontend/src/icons/names.txt b/frontend/src/icons/names.txt
index f0eba3f..6154848 100644
--- a/frontend/src/icons/names.txt
+++ b/frontend/src/icons/names.txt
@@ -19,6 +19,7 @@ regular/heart
regular/star
solid/arrow-down-wide-short
solid/arrow-left
+solid/arrow-right
solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
diff --git a/frontend/src/services/keyboard-shortcut-service.ts b/frontend/src/services/keyboard-shortcut-service.ts
index ed8d049..12689cf 100644
--- a/frontend/src/services/keyboard-shortcut-service.ts
+++ b/frontend/src/services/keyboard-shortcut-service.ts
@@ -400,6 +400,20 @@ async function dispatch(action: string): Promise {
break;
}
+ // The keyboard half of #6. It dispatches the same events the
+ // header's buttons and the detail views' own back buttons do,
+ // rather than calling `history.back()` here: the shell owns the
+ // guard that stops a press at the root leaving the app, and a
+ // second caller reaching for `history` directly is how the old
+ // `navStack` came to disagree with the platform.
+ case 'nav.back':
+ document.dispatchEvent(new CustomEvent('navigate-back'));
+ break;
+
+ case 'nav.forward':
+ document.dispatchEvent(new CustomEvent('navigate-forward'));
+ break;
+
case 'nav.queue': {
const queuePanel = document.getElementById(
'queue-panel',
diff --git a/frontend/src/services/shortcut-meta.ts b/frontend/src/services/shortcut-meta.ts
index eb1d0f9..1458b84 100644
--- a/frontend/src/services/shortcut-meta.ts
+++ b/frontend/src/services/shortcut-meta.ts
@@ -103,6 +103,18 @@ export const SHORTCUT_META: Record = {
scope: 'global',
defaultKey: 'Q',
},
+ 'nav.back': {
+ label: 'Back',
+ category: 'Navigation',
+ scope: 'global',
+ defaultKey: 'Alt+Left',
+ },
+ 'nav.forward': {
+ label: 'Forward',
+ category: 'Navigation',
+ scope: 'global',
+ defaultKey: 'Alt+Right',
+ },
'app.shortcuts': {
label: 'Keyboard Shortcuts',
category: 'App',
diff --git a/frontend/src/store/controllers/history-controller.ts b/frontend/src/store/controllers/history-controller.ts
new file mode 100644
index 0000000..3fe77d8
--- /dev/null
+++ b/frontend/src/store/controllers/history-controller.ts
@@ -0,0 +1,40 @@
+import type {
+ ReactiveController,
+ ReactiveControllerHost,
+} from 'lit';
+import { historyStore, type HistoryDepth } from '../history-store';
+
+/**
+ * HistoryController connects a Lit component to the HistoryStore.
+ *
+ * Usage in a component:
+ *
+ * private historyCtrl = new HistoryController(this);
+ *
+ * render() {
+ * const { canBack } = this.historyCtrl.depth;
+ * }
+ */
+export class HistoryController implements ReactiveController {
+ private host: ReactiveControllerHost;
+ private unsubscribe?: () => void;
+
+ constructor(host: ReactiveControllerHost) {
+ this.host = host;
+ host.addController(this);
+ }
+
+ hostConnected(): void {
+ this.unsubscribe = historyStore.subscribe(() => {
+ this.host.requestUpdate();
+ });
+ }
+
+ hostDisconnected(): void {
+ this.unsubscribe?.();
+ }
+
+ get depth(): HistoryDepth {
+ return historyStore.get();
+ }
+}
diff --git a/frontend/src/store/history-store.ts b/frontend/src/store/history-store.ts
new file mode 100644
index 0000000..ba30064
--- /dev/null
+++ b/frontend/src/store/history-store.ts
@@ -0,0 +1,66 @@
+/**
+ * How far the session can go back and forward.
+ *
+ * The History API exposes `length` and nothing useful: it counts
+ * entries the app did not push, does not say where in the list the
+ * current entry is, and `popstate` fires *identically* whether the
+ * user went back or forward. So a control that wants to grey itself
+ * out has to be told, and the shell is the only thing in a position to
+ * know (#6).
+ *
+ * Two rules follow from how the shell counts, and both are the reason
+ * this is a pair of booleans rather than one depth:
+ *
+ * **Forward is not "back, negated".** `pushedEntries` -- the counter
+ * this replaces -- decremented on every `popstate`, which made a
+ * forward navigation look like a second back. The shell keeps an index
+ * per entry and a high-water mark instead, and publishes the two
+ * answers rather than the arithmetic.
+ *
+ * **Back stops at the app's own floor.** The launch entry is
+ * *replaced*, not pushed, so that one back press from the root exits
+ * the app on Android; `canBack` is false there, which is what stops
+ * the header's own button being the thing that quits.
+ */
+
+type Subscriber = () => void;
+
+export interface HistoryDepth {
+ canBack: boolean;
+ canForward: boolean;
+}
+
+class HistoryStore {
+ private depth: HistoryDepth = { canBack: false, canForward: false };
+
+ private subscribers = new Set();
+
+ get(): HistoryDepth {
+ return this.depth;
+ }
+
+ /** Called by the shell whenever an entry is pushed or restored. */
+ setDepth(canBack: boolean, canForward: boolean): void {
+ if (
+ canBack === this.depth.canBack &&
+ canForward === this.depth.canForward
+ ) {
+ return;
+ }
+
+ this.depth = { canBack, canForward };
+ this.notify();
+ }
+
+ subscribe(fn: Subscriber): () => void {
+ this.subscribers.add(fn);
+
+ return () => this.subscribers.delete(fn);
+ }
+
+ private notify(): void {
+ this.subscribers.forEach((fn) => fn());
+ }
+}
+
+export const historyStore = new HistoryStore();
diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts
index d957fcc..b0f9f92 100644
--- a/frontend/src/store/index.ts
+++ b/frontend/src/store/index.ts
@@ -11,6 +11,9 @@ export { searchStore } from './search-store';
export { SearchController } from './controllers/search-controller';
export { activeViewStore } from './active-view-store';
export { ActiveViewController } from './controllers/active-view-controller';
+export { historyStore } from './history-store';
+export type { HistoryDepth } from './history-store';
+export { HistoryController } from './controllers/history-controller';
export { shortcutsStore } from './shortcuts-store';
export type { ShortcutsState } from './shortcuts-store';
export { ShortcutsController } from './controllers/shortcuts-controller';
diff --git a/frontend/test/components/nav-history.test.ts b/frontend/test/components/nav-history.test.ts
new file mode 100644
index 0000000..c46135f
--- /dev/null
+++ b/frontend/test/components/nav-history.test.ts
@@ -0,0 +1,91 @@
+/**
+ * The global back/forward control (#6).
+ *
+ * The interesting half of this component is what it does when it
+ * *cannot* act. The app's rule is that a control which cannot do
+ * anything should not be a button at all — `library-status-indicator`
+ * spent a release as a `