summaryrefslogtreecommitdiff
path: root/web/js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-02 23:18:16 +0300
committerPaul Buetow <paul@buetow.org>2026-05-02 23:18:16 +0300
commit92a59298ac32b4ee6e4f24e6c93307eaa152cd27 (patch)
tree6788f2603caa45ca3cc67d8ed143f03f4d54cfee /web/js
parent0482a98c538cbe9ca32b4775bd897b9c728cf686 (diff)
refine playback navigation and player modes
Diffstat (limited to 'web/js')
-rw-r--r--web/js/app.js209
-rw-r--r--web/js/detach.js20
-rw-r--r--web/js/keyboard.js35
-rw-r--r--web/js/player.js57
-rw-r--r--web/js/selection.js68
5 files changed, 335 insertions, 54 deletions
diff --git a/web/js/app.js b/web/js/app.js
index 194d9c3..307190f 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -1,7 +1,7 @@
import { API } from './api.js';
import { initKeyboard } from './keyboard.js';
-import { initSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js';
-import { initPlayer, togglePlay, toggleFullscreen, toggleStage, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay } from './player.js';
+import { initSelection, clearSelection, select, selectByElement, next, prev, currentIndex, currentElement, navUp, navDown, navLeft, navRight } from './selection.js';
+import { initPlayer, togglePlay, toggleFullscreen, toggleMinimize, toggleDetach, exitFullscreenIfNeeded, currentMediaId, currentMediaInfo, hasLoadedMedia, isPlaybackActive, seekRelative, selectAndPlay } from './player.js';
import { initSearch, focusSearch, trigger as triggerSearch } from './search.js';
import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuffle.js';
import { initThemes } from './themes.js';
@@ -69,7 +69,10 @@ function initBootstrap() {
// SPA
async function initApp() {
- initPlayer();
+ initPlayer({
+ onNext: (options) => navigatePlayable(1, options),
+ onPrevious: (options) => navigatePlayable(-1, options),
+ });
initSelection();
initSearch({
onChange: (q) => {
@@ -113,7 +116,6 @@ async function initApp() {
prevTrack: () => navigatePlayable(-1),
mediaInfo: () => toggleMediaInfo(),
fullscreen: () => toggleFullscreen(),
- toggleStage: () => toggleStage(),
toggleMinimize: () => toggleMinimize(),
escape: () => { exitFullscreenIfNeeded(); const el = currentElement(); if (el) el.classList.remove('selected'); closeAllModals(); },
shuffle: () => { toggleShuffle(); loadMedia(); },
@@ -302,10 +304,10 @@ async function loadMedia() {
// When viewing a single set with no filters/search, use the browse endpoint
// so that subfolders are presented as folders to navigate into.
- if (singleSetId && !setIds && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) {
+ if (singleSetId && !setIds && !isShuffle() && !state.filters.search && !state.filters.type && !state.filters.favorites && !state.filters.tags && !state.filters.minDuration && !state.filters.maxDuration) {
const data = await API.browse(singleSetId, state.folderPath);
updateBreadcrumb(data.current_path);
- setMedia(data.media || []);
+ setMedia(mediaWithBrowsePath(data.media || [], data.current_path || ''));
renderBrowse(data);
const total = (data.media?.length || 0) + (data.folders?.length || 0);
resultCount.textContent = `${total} items`;
@@ -321,7 +323,7 @@ async function loadMedia() {
min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '',
max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '',
sort: isShuffle() ? 'random' : 'name',
- limit: '200',
+ limit: '1000',
};
const data = await API.media(params);
const list = Array.isArray(data) ? data : data?.media || [];
@@ -361,6 +363,10 @@ function enterFolder(name) {
loadMedia();
}
+function mediaWithBrowsePath(media, path) {
+ return media.map((m) => ({ ...m, browse_path: path || '' }));
+}
+
function navigateBack() {
if (!state.folderPath) return;
const last = state.folderPath.lastIndexOf('/');
@@ -376,14 +382,16 @@ function renderBrowse(data) {
const grid = document.getElementById('media-grid');
if (!grid) return;
const folders = data.folders || [];
- const media = data.media || [];
+ const media = mediaWithBrowsePath(data.media || [], data.current_path || '');
if (!folders.length && !media.length) {
grid.innerHTML = '<p class="text-muted text-sm grid-full">Folder is empty.</p>';
+ clearSelection();
return;
}
const folderHtml = folders.map((f, i) => renderFolder(f, i)).join('');
const mediaHtml = media.map((m, i) => renderItem(m, i)).join('');
grid.innerHTML = folderHtml + mediaHtml;
+ clearSelection();
grid.querySelectorAll('.folder-card').forEach((el) => {
el.addEventListener('click', () => enterFolder(el.dataset.name));
@@ -443,8 +451,9 @@ function renderFolder(folder, index) {
function renderGrid(items) {
const grid = document.getElementById('media-grid');
if (!grid) return;
- if (!items.length) { grid.innerHTML = `<p class="text-muted text-sm grid-full">No results.</p>`; return; }
+ if (!items.length) { grid.innerHTML = `<p class="text-muted text-sm grid-full">No results.</p>`; clearSelection(); return; }
grid.innerHTML = items.map((m, i) => renderItem(m, i)).join('');
+ clearSelection();
grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
el.addEventListener('click', () => { selectByElement(el); });
const playBtn = el.querySelector('[data-action="play"]');
@@ -537,21 +546,187 @@ function selectedPlayableIndex(cards, preferCurrent) {
return idx >= 0 ? idx : -1;
}
-function navigatePlayable(delta) {
+async function navigatePlayable(delta, options = {}) {
const cards = visiblePlayableCards();
- if (!cards.length) return;
- const active = isPlaybackActive();
+ const active = options.forcePlay || isPlaybackActive();
const base = selectedPlayableIndex(cards, active);
- const nextIdx = (base + delta + cards.length) % cards.length;
- const target = cards[nextIdx];
- selectByElement(target);
- if (active) {
- playSelected();
+
+ if (cards.length && !(active && base < 0)) {
+ const atForwardEdge = delta > 0 && base === cards.length - 1;
+ const atBackwardEdge = delta < 0 && base === 0;
+ if (!((atForwardEdge || atBackwardEdge) && canTraverseBrowseFolders())) {
+ const nextIdx = base >= 0
+ ? (base + delta + cards.length) % cards.length
+ : (delta > 0 ? 0 : cards.length - 1);
+ const target = cards[nextIdx];
+ selectByElement(target);
+ if (active) await playSelected();
+ return;
+ }
+ }
+
+ const crossFolderTarget = await findCrossFolderPlayable(delta, active);
+ if (!crossFolderTarget) return;
+ await openPlayableTarget(crossFolderTarget, active);
+}
+
+function canTraverseBrowseFolders() {
+ return !!state.selectedSetId && state.selectedSetIds.length === 1 && !isShuffle() && !hasActiveFilters();
+}
+
+function hasActiveFilters() {
+ return !!(state.filters.search || state.filters.type || state.filters.favorites || state.filters.tags || state.filters.minDuration || state.filters.maxDuration);
+}
+
+async function findCrossFolderPlayable(delta, preferCurrent) {
+ if (!canTraverseBrowseFolders()) return null;
+ const current = currentPlaybackCandidate(preferCurrent);
+ if (!current) return null;
+ const currentPath = current.browse_path ?? state.folderPath ?? '';
+ const currentData = await browsePath(currentPath);
+ const media = mediaWithBrowsePath(currentData.media || [], currentPath);
+ const idx = media.findIndex((m) => String(m.id) === String(current.id));
+
+ if (idx >= 0) {
+ const nextInFolder = idx + delta;
+ if (nextInFolder >= 0 && nextInFolder < media.length) {
+ return { path: currentPath, media: media[nextInFolder] };
+ }
+ }
+
+ return delta > 0
+ ? findAfterFolder(currentPath)
+ : findBeforeFolder(currentPath);
+}
+
+function currentPlaybackCandidate(preferCurrent) {
+ const playing = currentMediaInfo();
+ if (preferCurrent && playing) return playing;
+ const selected = currentElement();
+ if (selected?.dataset?.id) {
+ const selectedMedia = state.media.find((m) => String(m.id) === selected.dataset.id);
+ if (selectedMedia) return selectedMedia;
+ }
+ return playing;
+}
+
+async function findAfterFolder(path) {
+ if (!path) return firstPlayableInFolder('');
+ let childPath = normalizePath(path);
+ while (childPath) {
+ const parent = parentPath(childPath);
+ const name = baseName(childPath);
+ const data = await browsePath(parent);
+ const items = browseItems(data, parent);
+ const idx = items.findIndex((item) => item.type === 'folder' && item.name === name);
+ for (const item of items.slice(idx + 1)) {
+ const target = item.type === 'media'
+ ? { path: parent, media: item.media }
+ : await firstPlayableInFolder(joinPath(parent, item.name));
+ if (target) return target;
+ }
+ childPath = parent;
+ }
+ return firstPlayableInFolder('');
+}
+
+async function findBeforeFolder(path) {
+ if (!path) return lastPlayableInFolder('');
+ let childPath = normalizePath(path);
+ while (childPath) {
+ const parent = parentPath(childPath);
+ const name = baseName(childPath);
+ const data = await browsePath(parent);
+ const items = browseItems(data, parent);
+ const idx = items.findIndex((item) => item.type === 'folder' && item.name === name);
+ for (const item of items.slice(0, Math.max(0, idx)).reverse()) {
+ const target = item.type === 'media'
+ ? { path: parent, media: item.media }
+ : await lastPlayableInFolder(joinPath(parent, item.name));
+ if (target) return target;
+ }
+ childPath = parent;
+ }
+ return lastPlayableInFolder('');
+}
+
+async function firstPlayableInFolder(path) {
+ const data = await browsePath(path);
+ for (const folder of data.folders || []) {
+ const target = await firstPlayableInFolder(joinPath(path, folder.name));
+ if (target) return target;
+ }
+ const media = mediaWithBrowsePath(data.media || [], data.current_path || path);
+ if (media.length) return { path: data.current_path || path, media: media[0] };
+ return null;
+}
+
+async function lastPlayableInFolder(path) {
+ const data = await browsePath(path);
+ const media = mediaWithBrowsePath(data.media || [], data.current_path || path);
+ if (media.length) return { path: data.current_path || path, media: media[media.length - 1] };
+ const folders = data.folders || [];
+ for (const folder of folders.slice().reverse()) {
+ const target = await lastPlayableInFolder(joinPath(path, folder.name));
+ if (target) return target;
+ }
+ return null;
+}
+
+function browseItems(data, path) {
+ const folders = (data.folders || []).map((folder) => ({ type: 'folder', name: folder.name }));
+ const media = mediaWithBrowsePath(data.media || [], data.current_path || path)
+ .map((m) => ({ type: 'media', media: m }));
+ return folders.concat(media);
+}
+
+async function browsePath(path) {
+ return API.browse(state.selectedSetId, path || '');
+}
+
+function normalizePath(path) {
+ return (path || '').split('/').filter(Boolean).join('/');
+}
+
+function parentPath(path) {
+ const normalized = normalizePath(path);
+ const idx = normalized.lastIndexOf('/');
+ return idx >= 0 ? normalized.slice(0, idx) : '';
+}
+
+function baseName(path) {
+ const normalized = normalizePath(path);
+ const idx = normalized.lastIndexOf('/');
+ return idx >= 0 ? normalized.slice(idx + 1) : normalized;
+}
+
+function joinPath(parent, child) {
+ const cleanParent = normalizePath(parent);
+ const cleanChild = normalizePath(child);
+ return cleanParent ? `${cleanParent}/${cleanChild}` : cleanChild;
+}
+
+async function openPlayableTarget(target, play) {
+ state.folderPath = target.path || '';
+ await loadMedia();
+ const card = document.querySelector(`#media-grid [data-id="${target.media.id}"]`);
+ if (card) selectByElement(card);
+ const idx = state.media.findIndex((m) => String(m.id) === String(target.media.id));
+ const media = idx >= 0 ? state.media[idx] : { ...target.media, browse_path: target.path || '' };
+ if (play) {
+ try {
+ const detail = await API.mediaDetail(media.id);
+ const resumeFrom = detail?.progress?.position_seconds ?? 0;
+ selectAndPlay(media, idx >= 0 ? idx : 0, resumeFrom);
+ } catch {
+ selectAndPlay(media, idx >= 0 ? idx : 0, 0);
+ }
}
}
function seekByKeyboard(direction, repeated) {
if (!hasLoadedMedia()) return false;
+ if (!document.fullscreenElement) return false;
const step = repeated ? 15 : 5;
return seekRelative(direction * step);
}
diff --git a/web/js/detach.js b/web/js/detach.js
index d255b2e..0c2d2aa 100644
--- a/web/js/detach.js
+++ b/web/js/detach.js
@@ -43,6 +43,17 @@ document.addEventListener('keydown', (ev) => {
const editing = tag === 'INPUT' || tag === 'TEXTAREA' || ev.target?.isContentEditable;
if (editing || ev.ctrlKey || ev.metaKey || ev.altKey) return;
+ if (ev.shiftKey && ev.code === 'KeyN') {
+ ev.preventDefault();
+ post({ type: 'detach-next', play: currentState().playing });
+ return;
+ }
+ if (ev.shiftKey && ev.code === 'KeyP') {
+ ev.preventDefault();
+ post({ type: 'detach-prev', play: currentState().playing });
+ return;
+ }
+
if (ev.key === ' ' || ev.code === 'Space') {
ev.preventDefault();
togglePlayback();
@@ -55,6 +66,15 @@ document.addEventListener('keydown', (ev) => {
} else if (ev.key === 'ArrowRight' || ev.key === 'l') {
ev.preventDefault();
seekRelative(ev.repeat ? 15 : 5);
+ } else if (ev.key === 'p') {
+ ev.preventDefault();
+ togglePlayback();
+ } else if (ev.key === 'N') {
+ ev.preventDefault();
+ post({ type: 'detach-next', play: currentState().playing });
+ } else if (ev.key === 'P') {
+ ev.preventDefault();
+ post({ type: 'detach-prev', play: currentState().playing });
}
});
diff --git a/web/js/keyboard.js b/web/js/keyboard.js
index af815f4..3a1ee25 100644
--- a/web/js/keyboard.js
+++ b/web/js/keyboard.js
@@ -68,6 +68,23 @@ export function initKeyboard(handlers) {
if (e.ctrlKey || e.metaKey || e.altKey) return; // don't intercept browser shortcuts
+ if (e.shiftKey && e.code === 'KeyN') {
+ e.preventDefault();
+ handlers.nextTrack?.(e);
+ return;
+ }
+ if (e.shiftKey && e.code === 'KeyP') {
+ e.preventDefault();
+ handlers.prevTrack?.(e);
+ return;
+ }
+ if (e.code === 'KeyS') {
+ e.preventDefault();
+ if (e.shiftKey) handlers.share?.(e);
+ else handlers.sidebar?.(e);
+ return;
+ }
+
switch (e.key) {
// Navigation
case 'ArrowUp':
@@ -116,9 +133,21 @@ export function initKeyboard(handlers) {
handlers.navRight?.(e);
break;
case 'Enter': handlers.enter?.(e); break;
- case 'n': handlers.nextTrack?.(e); break;
- case 'p': handlers.prevTrack?.(e); break;
- case 'c': handlers.toggleStage?.(e); break;
+ case 'n':
+ handlers.notes?.(e);
+ break;
+ case 'p':
+ e.preventDefault();
+ handlers.playPause?.(e);
+ break;
+ case 'N':
+ e.preventDefault();
+ handlers.nextTrack?.(e);
+ break;
+ case 'P':
+ e.preventDefault();
+ handlers.prevTrack?.(e);
+ break;
case 'C': handlers.toggleMinimize?.(e); break;
case 'f': handlers.fullscreen?.(e); break;
case 'Escape': handlers.escape?.(e); break;
diff --git a/web/js/player.js b/web/js/player.js
index 8798550..a9a69dd 100644
--- a/web/js/player.js
+++ b/web/js/player.js
@@ -10,6 +10,8 @@ let detachWindow = null;
let detachReady = false;
let pendingDetachMessage = null;
let detachedPlaybackState = null;
+let nextHandler = null;
+let previousHandler = null;
const els = () => ({
video: document.getElementById('media-video'),
@@ -20,7 +22,6 @@ const els = () => ({
btnNext: document.getElementById('btn-next'),
btnMute: document.getElementById('btn-mute'),
btnFs: document.getElementById('btn-fullscreen'),
- btnToggleStage: document.getElementById('btn-toggle-stage'),
btnMinimize: document.getElementById('btn-minimize'),
btnRestore: document.getElementById('btn-restore-player'),
restoreTitle: document.getElementById('player-restore-title'),
@@ -35,16 +36,17 @@ const els = () => ({
timeTotal: document.getElementById('time-total'),
});
-export function initPlayer() {
+export function initPlayer(options = {}) {
+ nextHandler = typeof options.onNext === 'function' ? options.onNext : null;
+ previousHandler = typeof options.onPrevious === 'function' ? options.onPrevious : null;
const e = els();
if (!e.video || !e.audio) return;
e.btnPlay?.addEventListener('click', togglePlay);
- e.btnPrev?.addEventListener('click', playPrevious);
- e.btnNext?.addEventListener('click', playNext);
+ e.btnPrev?.addEventListener('click', () => triggerPrevious({ forcePlay: true }));
+ e.btnNext?.addEventListener('click', () => triggerNext({ forcePlay: true }));
e.btnMute?.addEventListener('click', toggleMute);
e.btnFs?.addEventListener('click', toggleFullscreen);
- e.btnToggleStage?.addEventListener('click', toggleStage);
e.btnMinimize?.addEventListener('click', minimizePlayer);
e.btnRestore?.addEventListener('click', toggleMinimize);
e.bigPlay?.addEventListener('click', togglePlay);
@@ -79,13 +81,13 @@ export function initPlayer() {
updateUI(false);
isPlaying = false;
stopProgressTimer();
- playNext();
+ triggerNext({ forcePlay: true });
});
m.addEventListener('play', () => { updateUI(true); isPlaying = true; startProgressTimer(); });
m.addEventListener('pause', () => { updateUI(false); isPlaying = false; stopProgressTimer(); });
});
- e.video.addEventListener('loadedmetadata', updateCollapsedSize);
+ e.video.addEventListener('loadedmetadata', updateFloatingSize);
function setupVideoDebug(m) {
const events = ['loadstart','loadeddata','loadedmetadata','canplay','canplaythrough','playing','waiting','stalled','suspend','error','abort','emptied','ended'];
@@ -135,7 +137,7 @@ export function initPlayer() {
});
}
-function updateCollapsedSize() {
+function updateFloatingSize() {
const e = els();
if (!e.player || !e.video) return;
const vw = e.video.videoWidth || 0;
@@ -143,15 +145,15 @@ function updateCollapsedSize() {
let w, h;
if (vw > 0 && vh > 0) {
const aspect = vw / vh;
- const base = 240; // px reference height for collapsed card
+ const base = 240; // px reference height for the floating player
h = Math.max(180, Math.min(340, base));
w = Math.max(240, Math.min(480, Math.round(h * aspect)));
} else {
w = 320;
h = 240;
}
- e.player.style.setProperty('--collapsed-w', w + 'px');
- e.player.style.setProperty('--collapsed-h', h + 'px');
+ e.player.style.setProperty('--floating-w', w + 'px');
+ e.player.style.setProperty('--floating-h', h + 'px');
}
function updateBufferedRanges(m) {
@@ -275,7 +277,7 @@ export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0)
e.fill.style.width = '0%';
e.thumb.style.left = '0%';
updateMinimizedTitle();
- updateCollapsedSize();
+ updateFloatingSize();
}
function loadMedia(media, resumeFrom = 0) {
@@ -317,7 +319,7 @@ function loadMedia(media, resumeFrom = 0) {
e.fill.style.width = '0%';
e.thumb.style.left = '0%';
updateMinimizedTitle();
- updateCollapsedSize();
+ updateFloatingSize();
}
function currentMediaElement() {
@@ -372,13 +374,6 @@ export function toggleFullscreen() {
}
}
-export function toggleStage() {
- const e = els();
- e.player?.classList.remove('minimized');
- e.player?.classList.toggle('collapsed');
- console.log('toggleStage: collapsed =', e.player?.classList.contains('collapsed'));
-}
-
export function toggleMinimize() {
const e = els();
if (!e.player || !e.player.classList.contains('open')) return;
@@ -432,6 +427,22 @@ function highlightPlayingCard() {
}
}
+function triggerPrevious(options = {}) {
+ if (previousHandler) {
+ previousHandler(options);
+ return;
+ }
+ playPrevious();
+}
+
+function triggerNext(options = {}) {
+ if (nextHandler) {
+ nextHandler(options);
+ return;
+ }
+ playNext();
+}
+
export function playPrevious() {
const list = state.media;
if (!list.length) return;
@@ -458,6 +469,8 @@ function currentMediaListIndex() {
export function currentMediaId() { return currentMedia?.id; }
+export function currentMediaInfo() { return currentMedia; }
+
export function isPlaybackActive() {
if (isDetached()) return !!detachedPlaybackState?.playing;
return isPlaying;
@@ -595,9 +608,9 @@ window.addEventListener('message', (ev) => {
} else if (ev.data.type === 'detach-state') {
detachedPlaybackState = ev.data.state;
} else if (ev.data.type === 'detach-prev') {
- playPrevious();
+ triggerPrevious({ forcePlay: ev.data.play ?? true });
} else if (ev.data.type === 'detach-next') {
- playNext();
+ triggerNext({ forcePlay: ev.data.play ?? true });
}
});
diff --git a/web/js/selection.js b/web/js/selection.js
index 6ee4a58..828981a 100644
--- a/web/js/selection.js
+++ b/web/js/selection.js
@@ -10,10 +10,25 @@ export function initSelection() {
});
}
+export function clearSelection() {
+ selectedIndex = -1;
+ refreshCards().forEach((c) => c.classList.remove('selected'));
+}
+
function refreshCards() {
return Array.from(document.querySelectorAll('.media-card, .media-row, .folder-card'));
}
+function syncedIndex(cards) {
+ const domIndex = cards.findIndex((c) => c.classList.contains('selected'));
+ if (domIndex >= 0) {
+ selectedIndex = domIndex;
+ return domIndex;
+ }
+ selectedIndex = -1;
+ return -1;
+}
+
// Compute grid geometry from the rendered cards.
function gridGeometry(cards) {
if (!cards.length) return { cols: 0, rowMap: [] };
@@ -50,7 +65,12 @@ export function selectByElement(el) {
export function next(step = 1) {
const cards = refreshCards();
if (!cards.length) return;
- select((selectedIndex + step + cards.length) % cards.length);
+ const idx = syncedIndex(cards);
+ if (idx < 0) {
+ select(step >= 0 ? 0 : cards.length - 1);
+ return;
+ }
+ select((idx + step + cards.length) % cards.length);
}
export function prev(step = 1) { next(-step); }
@@ -59,12 +79,17 @@ export function prev(step = 1) { next(-step); }
export function navLeft() {
const cards = refreshCards();
if (!cards.length) return;
+ const idx = syncedIndex(cards);
+ if (idx < 0) {
+ select(cards.length - 1);
+ return;
+ }
const { cols } = gridGeometry(cards);
if (cols === 0) return;
- const row = Math.floor(selectedIndex / cols);
- const col = selectedIndex % cols;
+ const row = Math.floor(idx / cols);
+ const col = idx % cols;
if (col > 0) {
- select(selectedIndex - 1);
+ select(idx - 1);
} else if (row > 0) {
// wrap to end of previous row
select(row * cols - 1);
@@ -74,14 +99,19 @@ export function navLeft() {
export function navRight() {
const cards = refreshCards();
if (!cards.length) return;
+ const idx = syncedIndex(cards);
+ if (idx < 0) {
+ select(0);
+ return;
+ }
const { cols } = gridGeometry(cards);
if (cols === 0) return;
- const row = Math.floor(selectedIndex / cols);
- const col = selectedIndex % cols;
+ const row = Math.floor(idx / cols);
+ const col = idx % cols;
const lastInRow = Math.min((row + 1) * cols, cards.length) - 1;
if (col < (lastInRow - row * cols)) {
- select(selectedIndex + 1);
- } else if (selectedIndex < cards.length - 1) {
+ select(idx + 1);
+ } else if (idx < cards.length - 1) {
// wrap to start of next row
select(Math.min((row + 1) * cols, cards.length - 1));
}
@@ -90,9 +120,14 @@ export function navRight() {
export function navUp() {
const cards = refreshCards();
if (!cards.length) return;
+ const idx = syncedIndex(cards);
+ if (idx < 0) {
+ select(cards.length - 1);
+ return;
+ }
const { cols } = gridGeometry(cards);
if (cols === 0) return;
- const idxAbove = selectedIndex - cols;
+ const idxAbove = idx - cols;
if (idxAbove >= 0) {
select(idxAbove);
}
@@ -101,13 +136,22 @@ export function navUp() {
export function navDown() {
const cards = refreshCards();
if (!cards.length) return;
+ const idx = syncedIndex(cards);
+ if (idx < 0) {
+ select(0);
+ return;
+ }
const { cols } = gridGeometry(cards);
if (cols === 0) return;
- const idxBelow = selectedIndex + cols;
+ const idxBelow = idx + cols;
if (idxBelow < cards.length) {
select(idxBelow);
}
}
-export function currentIndex() { return selectedIndex; }
-export function currentElement() { return refreshCards()[selectedIndex] ?? null; }
+export function currentIndex() { return syncedIndex(refreshCards()); }
+export function currentElement() {
+ const cards = refreshCards();
+ const idx = syncedIndex(cards);
+ return idx >= 0 ? cards[idx] : null;
+}