diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-02 23:18:16 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-02 23:18:16 +0300 |
| commit | 92a59298ac32b4ee6e4f24e6c93307eaa152cd27 (patch) | |
| tree | 6788f2603caa45ca3cc67d8ed143f03f4d54cfee /web/js | |
| parent | 0482a98c538cbe9ca32b4775bd897b9c728cf686 (diff) | |
refine playback navigation and player modes
Diffstat (limited to 'web/js')
| -rw-r--r-- | web/js/app.js | 209 | ||||
| -rw-r--r-- | web/js/detach.js | 20 | ||||
| -rw-r--r-- | web/js/keyboard.js | 35 | ||||
| -rw-r--r-- | web/js/player.js | 57 | ||||
| -rw-r--r-- | web/js/selection.js | 68 |
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; +} |
