Skip to content

Commit 4498c65

Browse files
committed
Add previous/next album controls to the drawer when browsing the feed
The feed now acts as the drawer's album source, so the player can step through its stories. When stepping past the last loaded story, the page is scrolled to the bottom so the feed pages in more, and the drawer waits for them before moving on. The selected story is scrolled into view. Only the story list is walked: the new-releases carousel above it repeats stories in hidden slides. Also ignore album loads that resolve after the drawer moved on, and toggle the preview button against the album the drawer is actually showing.
1 parent dcc426c commit 4498c65

10 files changed

Lines changed: 523 additions & 18 deletions

File tree

‎css/style.css‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,6 +1931,54 @@ body.bes-drawer-expanded #pgBd {
19311931
flex: 0 0 auto;
19321932
}
19331933

1934+
/* Previous/next album controls, shown when browsing a feed */
1935+
.bes-album-toolbar {
1936+
display: flex;
1937+
align-items: center;
1938+
justify-content: space-between;
1939+
gap: 8px;
1940+
margin-bottom: 12px;
1941+
padding: 0 16px 0 20px;
1942+
}
1943+
1944+
.bes-album-toolbar .bes-album-buy {
1945+
margin-bottom: 0;
1946+
padding: 0;
1947+
}
1948+
1949+
.bes-album-nav {
1950+
display: flex;
1951+
gap: 4px;
1952+
}
1953+
1954+
.bes-album-nav button {
1955+
display: flex;
1956+
align-items: center;
1957+
gap: 4px;
1958+
padding: 4px 8px;
1959+
font-size: 13px;
1960+
font-weight: 500;
1961+
color: var(--bes-text-strong);
1962+
background: none;
1963+
border: 1px solid transparent;
1964+
border-radius: 4px;
1965+
cursor: pointer;
1966+
}
1967+
1968+
.bes-album-nav button:hover:not(:disabled) {
1969+
background: var(--bes-surface-1);
1970+
color: var(--bes-text-max);
1971+
}
1972+
1973+
.bes-album-nav button:disabled {
1974+
opacity: 0.3;
1975+
cursor: default;
1976+
}
1977+
1978+
.bes-album-nav.bes-loading {
1979+
cursor: progress;
1980+
}
1981+
19341982
/* Play status icons in track list */
19351983
.bes-player-drawer .play_status {
19361984
background: var(--bes-surface-0);

‎src/components/player/albumNav.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { element } from '../dom';
2+
3+
export interface AlbumNavState {
4+
hasPrevious: boolean;
5+
hasNext: boolean;
6+
loading: boolean;
7+
}
8+
9+
const chevron = (points: string): string =>
10+
`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="${points}"></polyline></svg>`;
11+
12+
export function buildAlbumNav(onPrevious: () => void, onNext: () => void): HTMLElement {
13+
const previous = element('button', {
14+
className: 'bes-album-nav-prev',
15+
html: `${chevron('15 18 9 12 15 6')}<span>Previous album</span>`,
16+
attributes: { type: 'button', title: 'Previous album in feed' }
17+
});
18+
const next = element('button', {
19+
className: 'bes-album-nav-next',
20+
html: `<span>Next album</span>${chevron('9 18 15 12 9 6')}`,
21+
attributes: { type: 'button', title: 'Next album in feed' }
22+
});
23+
24+
previous.addEventListener('click', onPrevious);
25+
next.addEventListener('click', onNext);
26+
27+
return element('div', { className: 'bes-album-nav', children: [previous, next] });
28+
}
29+
30+
export function updateAlbumNav(nav: HTMLElement | null, { hasPrevious, hasNext, loading }: AlbumNavState): void {
31+
if (!nav) return;
32+
33+
const previous = nav.querySelector<HTMLButtonElement>('.bes-album-nav-prev');
34+
const next = nav.querySelector<HTMLButtonElement>('.bes-album-nav-next');
35+
36+
if (previous) previous.disabled = loading || !hasPrevious;
37+
if (next) next.disabled = loading || !hasNext;
38+
nav.classList.toggle('bes-loading', loading);
39+
}

‎src/components/player/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { createPlayerDrawer } from './drawer';
2-
export { loadAlbumIntoDrawer, loadNextAlbum, loadPreviousAlbum } from './loader';
2+
export { loadAlbumIntoDrawer, loadNextAlbum, loadPreviousAlbum, getLoadedAlbumId } from './loader';

‎src/components/player/loader.ts‎

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,22 @@ import { buildDrawerPlayer, buildTrackTable, buildAlbumBuyButton } from './build
77
import { PlayerCommands, registerPlayerShortcuts } from '../../keyboardShortcuts';
88
import { drawOverlay, generateAudioFeatures } from '../../audioFeatures';
99
import { volumeIcon, mutedVolumeIcon } from './icons';
10-
import { replaceChildren } from '../dom';
10+
import { element, replaceChildren } from '../dom';
1111
import { inDrawer, allInDrawer, setText, setStyle } from './query';
1212
import { streamUrlOf, findPlayableTrackAfter, findPlayableTrackBefore } from './trackSelection';
1313
import { applyTrackState, markRowPlayed } from './trackState';
14+
import { buildAlbumNav, updateAlbumNav } from './albumNav';
1415
import {
1516
selectAlbum,
1617
hasNextAlbum,
1718
hasPreviousAlbum,
1819
nextAlbum,
1920
previousAlbum,
20-
albumArtUrlFor
21+
albumArtUrlFor,
22+
canLoadMoreAlbums,
23+
loadMoreAlbums,
24+
revealAlbum,
25+
showAlbumControls
2126
} from '../../discography';
2227

2328
const log = new Logger();
@@ -31,6 +36,9 @@ let drawerPlayerCreated = false;
3136
let previousVolume = 1.0;
3237
let configPort: chrome.runtime.Port | null = null;
3338
let waveformEnabled = true;
39+
let requestedAlbumId: string | null = null;
40+
let fetchCaching = false;
41+
let albumNavLoading = false;
3442

3543
const ALBUM_LOAD_SETTLE_MS = 300;
3644

@@ -220,6 +228,8 @@ export async function loadAlbumIntoDrawer(
220228
port?: chrome.runtime.Port
221229
): Promise<void> {
222230
log.info(`Loading album ${albumId} (${albumType}) into drawer`);
231+
requestedAlbumId = albumId;
232+
fetchCaching = enableFetchCaching;
223233

224234
try {
225235
const tralbumDetails = await getTralbumDetails(
@@ -229,6 +239,11 @@ export async function loadAlbumIntoDrawer(
229239
createFetchFunction(enableFetchCaching)
230240
);
231241

242+
if (requestedAlbumId !== albumId) {
243+
log.debug(`Album ${albumId} arrived after the drawer moved on`);
244+
return;
245+
}
246+
232247
currentAlbumData = tralbumDetails;
233248
selectAlbum(albumId);
234249

@@ -243,14 +258,14 @@ export async function loadAlbumIntoDrawer(
243258
if (drawerPlayerCreated) {
244259
replaceChildren(
245260
elements.tracklistContainer,
246-
buildAlbumBuyButton(tralbumDetails),
261+
albumToolbar(buildAlbumBuyButton(tralbumDetails)),
247262
buildTrackTable(tralbumDetails)
248263
);
249264
} else {
250265
const parts = buildDrawerPlayer(tralbumDetails);
251266

252267
mountDrawerPlayer(elements, parts);
253-
replaceChildren(elements.tracklistContainer, parts.albumBuyButton, parts.tracklistElement);
268+
replaceChildren(elements.tracklistContainer, albumToolbar(parts.albumBuyButton), parts.tracklistElement);
254269
drawerPlayerCreated = true;
255270

256271
startPlayerOnce();
@@ -268,6 +283,52 @@ export async function loadAlbumIntoDrawer(
268283
}
269284
}
270285

286+
export function getLoadedAlbumId(): string | null {
287+
return requestedAlbumId;
288+
}
289+
290+
function hasLaterAlbum(): boolean {
291+
return hasNextAlbum() || canLoadMoreAlbums();
292+
}
293+
294+
function albumToolbar(buyButton: HTMLElement | null): HTMLElement | null {
295+
if (!showAlbumControls()) return buyButton;
296+
297+
const nav = buildAlbumNav(
298+
() => void skipAlbum(loadPreviousAlbum),
299+
() => void skipAlbum(loadNextAlbum)
300+
);
301+
updateAlbumNav(nav, { hasPrevious: hasPreviousAlbum(), hasNext: hasLaterAlbum(), loading: albumNavLoading });
302+
303+
return element('div', { className: 'bes-album-toolbar', children: [nav, buyButton] });
304+
}
305+
306+
function refreshAlbumNav(): void {
307+
updateAlbumNav(inDrawer('.bes-album-nav'), {
308+
hasPrevious: hasPreviousAlbum(),
309+
hasNext: hasLaterAlbum(),
310+
loading: albumNavLoading
311+
});
312+
}
313+
314+
async function skipAlbum(load: () => Promise<boolean>): Promise<void> {
315+
if (albumNavLoading) return;
316+
317+
const keepPlaying = Boolean(audioElement && !audioElement.paused);
318+
albumNavLoading = true;
319+
refreshAlbumNav();
320+
321+
try {
322+
if ((await load()) && keepPlaying) resumePlayback();
323+
} catch (error) {
324+
log.error(`Failed to skip album: ${error}`);
325+
} finally {
326+
albumNavLoading = false;
327+
refreshAlbumNav();
328+
if (currentAlbumData?.tracks) updatePrevNextButtons(currentTrackIndex, currentAlbumData.tracks.length);
329+
}
330+
}
331+
271332
function currentTrack(): TralbumTrack | undefined {
272333
return currentAlbumData?.tracks?.[currentTrackIndex];
273334
}
@@ -334,7 +395,7 @@ function updatePrevNextButtons(index: number, totalTracks: number): void {
334395
const nextButton = inDrawer('.bes-transport-next');
335396

336397
const hasEarlierTrackOrAlbum = index > 0 || hasPreviousAlbum();
337-
const hasLaterTrackOrAlbum = index < totalTracks - 1 || hasNextAlbum();
398+
const hasLaterTrackOrAlbum = index < totalTracks - 1 || hasLaterAlbum();
338399

339400
prevButton?.classList.toggle('bes-hidden', !hasEarlierTrackOrAlbum);
340401
nextButton?.classList.toggle('bes-hidden', !hasLaterTrackOrAlbum);
@@ -366,7 +427,7 @@ interface TrackStep {
366427
const forward: TrackStep = {
367428
name: 'next',
368429
nextWithinAlbum: findPlayableTrackAfter,
369-
hasAdjacentAlbum: hasNextAlbum,
430+
hasAdjacentAlbum: hasLaterAlbum,
370431
loadAdjacentAlbum: loadNextAlbum,
371432
entryTrack: tracks => findPlayableTrackAfter(tracks, -1)
372433
};
@@ -395,7 +456,7 @@ async function step(direction: TrackStep, keepPlaying: boolean): Promise<void> {
395456
}
396457

397458
log.info(`No playable track ${direction.name}: loading ${direction.name} album in discography`);
398-
await direction.loadAdjacentAlbum();
459+
if (!(await direction.loadAdjacentAlbum())) return;
399460

400461
setTimeout(() => {
401462
const entry = direction.entryTrack(currentAlbumData?.tracks);
@@ -600,25 +661,32 @@ function adjustVolumeBy(delta: number): void {
600661
updateMuteButton(audioElement.volume === 0);
601662
}
602663

603-
export async function loadNextAlbum(enableFetchCaching: boolean = false): Promise<boolean> {
664+
export async function loadNextAlbum(enableFetchCaching: boolean = fetchCaching): Promise<boolean> {
665+
if (!hasNextAlbum() && canLoadMoreAlbums()) {
666+
log.info('Reached the end of the loaded albums, loading more');
667+
await loadMoreAlbums();
668+
}
669+
604670
const item = nextAlbum();
605671
if (!item) {
606672
log.info('No next album available');
607673
return false;
608674
}
609675

610676
await loadAlbumIntoDrawer(item.id, item.type, enableFetchCaching);
677+
revealAlbum(item);
611678
return true;
612679
}
613680

614-
export async function loadPreviousAlbum(enableFetchCaching: boolean = false): Promise<boolean> {
681+
export async function loadPreviousAlbum(enableFetchCaching: boolean = fetchCaching): Promise<boolean> {
615682
const item = previousAlbum();
616683
if (!item) {
617684
log.info('No previous album available');
618685
return false;
619686
}
620687

621688
await loadAlbumIntoDrawer(item.id, item.type, enableFetchCaching);
689+
revealAlbum(item);
622690
return true;
623691
}
624692

‎src/discography.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@ export interface DiscographyItem {
88
element: Element;
99
}
1010

11+
export interface AlbumSource {
12+
extract: () => DiscographyItem[];
13+
// Pulls more items into the page (e.g. the next page of a feed); resolves true if any arrived.
14+
loadMore?: () => Promise<boolean>;
15+
reveal?: (item: DiscographyItem) => void;
16+
showAlbumControls?: boolean;
17+
}
18+
1119
let order: DiscographyItem[] = [];
1220
let selectedIndex = -1;
21+
let source: AlbumSource = { extract: extractDiscographyOrder };
22+
let pendingLoadMore: Promise<boolean> | null = null;
23+
let sourceExhausted = false;
1324

1425
export function extractDiscographyOrder(): DiscographyItem[] {
1526
const items: DiscographyItem[] = [];
@@ -33,8 +44,55 @@ export function extractDiscographyOrder(): DiscographyItem[] {
3344
return items;
3445
}
3546

47+
export function setAlbumSource(next: AlbumSource): void {
48+
source = next;
49+
sourceExhausted = false;
50+
updateDiscographyOrder();
51+
}
52+
3653
export function updateDiscographyOrder(): void {
37-
order = extractDiscographyOrder();
54+
const selectedId = order[selectedIndex]?.id;
55+
const previousLength = order.length;
56+
57+
order = source.extract();
58+
if (selectedId) selectedIndex = findAlbumIndexById(selectedId);
59+
if (order.length > previousLength) sourceExhausted = false;
60+
}
61+
62+
export function canLoadMoreAlbums(): boolean {
63+
return selectedIndex !== -1 && Boolean(source.loadMore) && !sourceExhausted;
64+
}
65+
66+
export function loadMoreAlbums(): Promise<boolean> {
67+
if (!source.loadMore || sourceExhausted) return Promise.resolve(false);
68+
69+
pendingLoadMore ??= source
70+
.loadMore()
71+
.catch(error => {
72+
log.warn(`Failed to load more albums: ${error}`);
73+
return false;
74+
})
75+
.then(loaded => {
76+
const previousLength = order.length;
77+
updateDiscographyOrder();
78+
79+
const grew = order.length > previousLength;
80+
if (!loaded && !grew) sourceExhausted = true;
81+
return loaded || grew;
82+
})
83+
.finally(() => {
84+
pendingLoadMore = null;
85+
});
86+
87+
return pendingLoadMore;
88+
}
89+
90+
export function showAlbumControls(): boolean {
91+
return Boolean(source.showAlbumControls) && selectedIndex !== -1 && order.length > 1;
92+
}
93+
94+
export function revealAlbum(item: DiscographyItem): void {
95+
source.reveal?.(item);
3896
}
3997

4098
export function findAlbumIndexById(albumId: string): number {

‎src/label_view.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Logger from './logger';
2-
import { createPlayerDrawer, loadAlbumIntoDrawer } from './components/player';
2+
import { createPlayerDrawer, loadAlbumIntoDrawer, getLoadedAlbumId } from './components/player';
33
import { updateDiscographyOrder } from './discography';
44
import { setFindMusicLinks, FindMusicLink } from './components/player/findMusicLinks';
55
import { extractBandId } from './utilities';
@@ -72,7 +72,7 @@ export function fillFrame(
7272
const player = drawer();
7373
const { isOpen, isMinimized } = player.getState();
7474

75-
if (isOpen && previewState.previewId === target.id) {
75+
if (isOpen && getLoadedAlbumId() === target.id) {
7676
if (isMinimized) player.maximizeDrawer();
7777
else player.minimizeDrawer();
7878
return;

0 commit comments

Comments
 (0)