Repository navigation
Add previous/next album controls to the drawer #306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
4498c65
f5d5d20
42b0a0b
1249d37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { element } from '../dom'; | ||
|
|
||
| export interface AlbumNavState { | ||
| hasPrevious: boolean; | ||
| hasNext: boolean; | ||
| loading: boolean; | ||
| } | ||
|
|
||
| const chevron = (points: string): string => | ||
| `<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>`; | ||
|
|
||
| export function buildAlbumNav(onPrevious: () => void, onNext: () => void): HTMLElement { | ||
| const previous = element('button', { | ||
| className: 'bes-album-nav-prev', | ||
| html: `${chevron('15 18 9 12 15 6')}<span>Previous album</span>`, | ||
| attributes: { type: 'button', title: 'Previous album' } | ||
| }); | ||
| const next = element('button', { | ||
| className: 'bes-album-nav-next', | ||
| html: `<span>Next album</span>${chevron('9 18 15 12 9 6')}`, | ||
| attributes: { type: 'button', title: 'Next album' } | ||
| }); | ||
|
|
||
| previous.addEventListener('click', onPrevious); | ||
| next.addEventListener('click', onNext); | ||
|
|
||
| return element('div', { className: 'bes-album-nav', children: [previous, next] }); | ||
| } | ||
|
|
||
| export function updateAlbumNav(nav: HTMLElement | null, { hasPrevious, hasNext, loading }: AlbumNavState): void { | ||
| if (!nav) return; | ||
|
|
||
| const previous = nav.querySelector<HTMLButtonElement>('.bes-album-nav-prev'); | ||
| const next = nav.querySelector<HTMLButtonElement>('.bes-album-nav-next'); | ||
|
|
||
| if (previous) previous.disabled = loading || !hasPrevious; | ||
| if (next) next.disabled = loading || !hasNext; | ||
| nav.classList.toggle('bes-loading', loading); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,2 @@ | ||
| export { createPlayerDrawer } from './drawer'; | ||
| export { loadAlbumIntoDrawer, loadNextAlbum, loadPreviousAlbum } from './loader'; | ||
| export { loadAlbumIntoDrawer, loadNextAlbum, loadPreviousAlbum, getLoadedAlbumId } from './loader'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,17 +7,22 @@ import { buildDrawerPlayer, buildTrackTable, buildAlbumBuyButton } from './build | |
| import { PlayerCommands, registerPlayerShortcuts } from '../../keyboardShortcuts'; | ||
| import { drawOverlay, generateAudioFeatures } from '../../audioFeatures'; | ||
| import { volumeIcon, mutedVolumeIcon } from './icons'; | ||
| import { replaceChildren } from '../dom'; | ||
| import { element, replaceChildren } from '../dom'; | ||
| import { inDrawer, allInDrawer, setText, setStyle } from './query'; | ||
| import { streamUrlOf, findPlayableTrackAfter, findPlayableTrackBefore } from './trackSelection'; | ||
| import { applyTrackState, markRowPlayed } from './trackState'; | ||
| import { buildAlbumNav, updateAlbumNav } from './albumNav'; | ||
| import { | ||
| selectAlbum, | ||
| hasNextAlbum, | ||
| hasPreviousAlbum, | ||
| nextAlbum, | ||
| previousAlbum, | ||
| albumArtUrlFor | ||
| albumArtUrlFor, | ||
| canLoadMoreAlbums, | ||
| loadMoreAlbums, | ||
| revealAlbum, | ||
| showAlbumControls | ||
| } from '../../discography'; | ||
|
|
||
| const log = new Logger(); | ||
|
|
@@ -31,6 +36,9 @@ let drawerPlayerCreated = false; | |
| let previousVolume = 1.0; | ||
| let configPort: chrome.runtime.Port | null = null; | ||
| let waveformEnabled = true; | ||
| let requestedAlbumId: string | null = null; | ||
| let fetchCaching = false; | ||
| let albumNavLoading = false; | ||
|
|
||
| const ALBUM_LOAD_SETTLE_MS = 300; | ||
|
|
||
|
|
@@ -220,6 +228,8 @@ export async function loadAlbumIntoDrawer( | |
| port?: chrome.runtime.Port | ||
| ): Promise<void> { | ||
| log.info(`Loading album ${albumId} (${albumType}) into drawer`); | ||
| requestedAlbumId = albumId; | ||
| fetchCaching = enableFetchCaching; | ||
|
|
||
| try { | ||
| const tralbumDetails = await getTralbumDetails( | ||
|
|
@@ -229,6 +239,11 @@ export async function loadAlbumIntoDrawer( | |
| createFetchFunction(enableFetchCaching) | ||
| ); | ||
|
|
||
| if (requestedAlbumId !== albumId) { | ||
| log.debug(`Album ${albumId} arrived after the drawer moved on`); | ||
| return; | ||
| } | ||
|
|
||
| currentAlbumData = tralbumDetails; | ||
| selectAlbum(albumId); | ||
|
|
||
|
|
@@ -243,14 +258,14 @@ export async function loadAlbumIntoDrawer( | |
| if (drawerPlayerCreated) { | ||
| replaceChildren( | ||
| elements.tracklistContainer, | ||
| buildAlbumBuyButton(tralbumDetails), | ||
| albumToolbar(buildAlbumBuyButton(tralbumDetails)), | ||
| buildTrackTable(tralbumDetails) | ||
| ); | ||
| } else { | ||
| const parts = buildDrawerPlayer(tralbumDetails); | ||
|
|
||
| mountDrawerPlayer(elements, parts); | ||
| replaceChildren(elements.tracklistContainer, parts.albumBuyButton, parts.tracklistElement); | ||
| replaceChildren(elements.tracklistContainer, albumToolbar(parts.albumBuyButton), parts.tracklistElement); | ||
| drawerPlayerCreated = true; | ||
|
|
||
| startPlayerOnce(); | ||
|
|
@@ -268,6 +283,52 @@ export async function loadAlbumIntoDrawer( | |
| } | ||
| } | ||
|
|
||
| export function getLoadedAlbumId(): string | null { | ||
| return requestedAlbumId; | ||
| } | ||
|
|
||
| function hasLaterAlbum(): boolean { | ||
| return hasNextAlbum() || canLoadMoreAlbums(); | ||
| } | ||
|
|
||
| function albumToolbar(buyButton: HTMLElement | null): HTMLElement | null { | ||
| if (!showAlbumControls()) return buyButton; | ||
|
|
||
| const nav = buildAlbumNav( | ||
| () => void skipAlbum(loadPreviousAlbum), | ||
| () => void skipAlbum(loadNextAlbum) | ||
| ); | ||
| updateAlbumNav(nav, { hasPrevious: hasPreviousAlbum(), hasNext: hasLaterAlbum(), loading: albumNavLoading }); | ||
|
|
||
| return element('div', { className: 'bes-album-toolbar', children: [nav, buyButton] }); | ||
| } | ||
|
|
||
| function refreshAlbumNav(): void { | ||
| updateAlbumNav(inDrawer('.bes-album-nav'), { | ||
| hasPrevious: hasPreviousAlbum(), | ||
| hasNext: hasLaterAlbum(), | ||
| loading: albumNavLoading | ||
| }); | ||
| } | ||
|
|
||
| async function skipAlbum(load: () => Promise<boolean>): Promise<void> { | ||
| if (albumNavLoading) return; | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test for this please |
||
|
|
||
| const keepPlaying = Boolean(audioElement && !audioElement.paused); | ||
| albumNavLoading = true; | ||
| refreshAlbumNav(); | ||
|
|
||
| try { | ||
| if ((await load()) && keepPlaying) resumePlayback(); | ||
| } catch (error) { | ||
| log.error(`Failed to skip album: ${error}`); | ||
| } finally { | ||
| albumNavLoading = false; | ||
| refreshAlbumNav(); | ||
| if (currentAlbumData?.tracks) updatePrevNextButtons(currentTrackIndex, currentAlbumData.tracks.length); | ||
| } | ||
| } | ||
|
|
||
| function currentTrack(): TralbumTrack | undefined { | ||
| return currentAlbumData?.tracks?.[currentTrackIndex]; | ||
| } | ||
|
|
@@ -334,7 +395,7 @@ function updatePrevNextButtons(index: number, totalTracks: number): void { | |
| const nextButton = inDrawer('.bes-transport-next'); | ||
|
|
||
| const hasEarlierTrackOrAlbum = index > 0 || hasPreviousAlbum(); | ||
| const hasLaterTrackOrAlbum = index < totalTracks - 1 || hasNextAlbum(); | ||
| const hasLaterTrackOrAlbum = index < totalTracks - 1 || hasLaterAlbum(); | ||
|
|
||
| prevButton?.classList.toggle('bes-hidden', !hasEarlierTrackOrAlbum); | ||
| nextButton?.classList.toggle('bes-hidden', !hasLaterTrackOrAlbum); | ||
|
|
@@ -366,7 +427,7 @@ interface TrackStep { | |
| const forward: TrackStep = { | ||
| name: 'next', | ||
| nextWithinAlbum: findPlayableTrackAfter, | ||
| hasAdjacentAlbum: hasNextAlbum, | ||
| hasAdjacentAlbum: hasLaterAlbum, | ||
| loadAdjacentAlbum: loadNextAlbum, | ||
| entryTrack: tracks => findPlayableTrackAfter(tracks, -1) | ||
| }; | ||
|
|
@@ -395,7 +456,7 @@ async function step(direction: TrackStep, keepPlaying: boolean): Promise<void> { | |
| } | ||
|
|
||
| log.info(`No playable track ${direction.name}: loading ${direction.name} album in discography`); | ||
| await direction.loadAdjacentAlbum(); | ||
| if (!(await direction.loadAdjacentAlbum())) return; | ||
|
|
||
| setTimeout(() => { | ||
| const entry = direction.entryTrack(currentAlbumData?.tracks); | ||
|
|
@@ -600,25 +661,32 @@ function adjustVolumeBy(delta: number): void { | |
| updateMuteButton(audioElement.volume === 0); | ||
| } | ||
|
|
||
| export async function loadNextAlbum(enableFetchCaching: boolean = false): Promise<boolean> { | ||
| export async function loadNextAlbum(enableFetchCaching: boolean = fetchCaching): Promise<boolean> { | ||
| if (!hasNextAlbum() && canLoadMoreAlbums()) { | ||
| log.info('Reached the end of the loaded albums, loading more'); | ||
| await loadMoreAlbums(); | ||
| } | ||
|
|
||
| const item = nextAlbum(); | ||
| if (!item) { | ||
| log.info('No next album available'); | ||
| return false; | ||
| } | ||
|
|
||
| await loadAlbumIntoDrawer(item.id, item.type, enableFetchCaching); | ||
| revealAlbum(item); | ||
| return true; | ||
| } | ||
|
|
||
| export async function loadPreviousAlbum(enableFetchCaching: boolean = false): Promise<boolean> { | ||
| export async function loadPreviousAlbum(enableFetchCaching: boolean = fetchCaching): Promise<boolean> { | ||
| const item = previousAlbum(); | ||
| if (!item) { | ||
| log.info('No previous album available'); | ||
| return false; | ||
| } | ||
|
|
||
| await loadAlbumIntoDrawer(item.id, item.type, enableFetchCaching); | ||
| revealAlbum(item); | ||
| return true; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,8 +8,25 @@ export interface DiscographyItem { | |||||||||||||||||||||||||
| element: Element; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface AlbumSource { | ||||||||||||||||||||||||||
| extract: () => DiscographyItem[]; | ||||||||||||||||||||||||||
| // Pulls more items into the page (e.g. the next page of a feed); resolves true if any arrived. | ||||||||||||||||||||||||||
| loadMore?: () => Promise<boolean>; | ||||||||||||||||||||||||||
| reveal?: (item: DiscographyItem) => void; | ||||||||||||||||||||||||||
| showAlbumControls?: boolean; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let order: DiscographyItem[] = []; | ||||||||||||||||||||||||||
| let selectedIndex = -1; | ||||||||||||||||||||||||||
| const discographySource: AlbumSource = { | ||||||||||||||||||||||||||
| extract: extractDiscographyOrder, | ||||||||||||||||||||||||||
| reveal: item => item.element.scrollIntoView?.({ behavior: 'smooth', block: 'center' }), | ||||||||||||||||||||||||||
| showAlbumControls: true | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let source: AlbumSource = discographySource; | ||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why create just to copy over?
Suggested change
|
||||||||||||||||||||||||||
| let pendingLoadMore: Promise<boolean> | null = null; | ||||||||||||||||||||||||||
| let sourceExhausted = false; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function extractDiscographyOrder(): DiscographyItem[] { | ||||||||||||||||||||||||||
| const items: DiscographyItem[] = []; | ||||||||||||||||||||||||||
|
|
@@ -33,8 +50,55 @@ export function extractDiscographyOrder(): DiscographyItem[] { | |||||||||||||||||||||||||
| return items; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function setAlbumSource(next: AlbumSource): void { | ||||||||||||||||||||||||||
| source = next; | ||||||||||||||||||||||||||
| sourceExhausted = false; | ||||||||||||||||||||||||||
| updateDiscographyOrder(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function updateDiscographyOrder(): void { | ||||||||||||||||||||||||||
| order = extractDiscographyOrder(); | ||||||||||||||||||||||||||
| const selectedId = order[selectedIndex]?.id; | ||||||||||||||||||||||||||
| const previousLength = order.length; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| order = source.extract(); | ||||||||||||||||||||||||||
| if (selectedId) selectedIndex = findAlbumIndexById(selectedId); | ||||||||||||||||||||||||||
| if (order.length > previousLength) sourceExhausted = false; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function canLoadMoreAlbums(): boolean { | ||||||||||||||||||||||||||
| return selectedIndex !== -1 && Boolean(source.loadMore) && !sourceExhausted; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function loadMoreAlbums(): Promise<boolean> { | ||||||||||||||||||||||||||
| if (!source.loadMore || sourceExhausted) return Promise.resolve(false); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| pendingLoadMore ??= source | ||||||||||||||||||||||||||
| .loadMore() | ||||||||||||||||||||||||||
| .catch(error => { | ||||||||||||||||||||||||||
| log.warn(`Failed to load more albums: ${error}`); | ||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| .then(loaded => { | ||||||||||||||||||||||||||
| const previousLength = order.length; | ||||||||||||||||||||||||||
| updateDiscographyOrder(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const grew = order.length > previousLength; | ||||||||||||||||||||||||||
| if (!loaded && !grew) sourceExhausted = true; | ||||||||||||||||||||||||||
| return loaded || grew; | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| .finally(() => { | ||||||||||||||||||||||||||
| pendingLoadMore = null; | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return pendingLoadMore; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function showAlbumControls(): boolean { | ||||||||||||||||||||||||||
| return Boolean(source.showAlbumControls) && selectedIndex !== -1 && order.length > 1; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function revealAlbum(item: DiscographyItem): void { | ||||||||||||||||||||||||||
| source.reveal?.(item); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function findAlbumIndexById(albumId: string): number { | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||||||
| import Logger from './logger'; | ||||||||||
| import { createPlayerDrawer, loadAlbumIntoDrawer } from './components/player'; | ||||||||||
| import { createPlayerDrawer, loadAlbumIntoDrawer, getLoadedAlbumId } from './components/player'; | ||||||||||
| import { updateDiscographyOrder } from './discography'; | ||||||||||
| import { setFindMusicLinks, FindMusicLink } from './components/player/findMusicLinks'; | ||||||||||
| import { extractBandId } from './utilities'; | ||||||||||
|
|
@@ -72,7 +72,7 @@ export function fillFrame( | |||||||||
| const player = drawer(); | ||||||||||
| const { isOpen, isMinimized } = player.getState(); | ||||||||||
|
|
||||||||||
| if (isOpen && previewState.previewId === target.id) { | ||||||||||
| if (isOpen && getLoadedAlbumId() === target.id) { | ||||||||||
| if (isMinimized) player.maximizeDrawer(); | ||||||||||
| else player.minimizeDrawer(); | ||||||||||
| return; | ||||||||||
|
|
@@ -102,7 +102,11 @@ export function attachPreviewListeners( | |||||||||
| previewState: { previewOpen: boolean; previewId?: string }, | ||||||||||
| enableFetchCaching: boolean = false | ||||||||||
| ): void { | ||||||||||
| root.querySelectorAll('.open-iframe').forEach(button => { | ||||||||||
| root.querySelectorAll<HTMLElement>('.open-iframe').forEach(button => { | ||||||||||
| // Feed previews are bound by the feed and again by the label view's page-wide pass. | ||||||||||
| if (button.dataset.besBound === 'true') return; | ||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
|
||||||||||
| button.dataset.besBound = 'true'; | ||||||||||
| button.addEventListener('click', event => { | ||||||||||
| fillFrame(event, previewState, enableFetchCaching, port); | ||||||||||
| }); | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test for this guard please