diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:49 +0300 |
| commit | 8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch) | |
| tree | 5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /web/js/views | |
| parent | 59c8870061465142306e7a6e5d3fcb344686cfe9 (diff) | |
Refine media browsing and set covers
Diffstat (limited to 'web/js/views')
| -rw-r--r-- | web/js/views/help.js | 2 | ||||
| -rw-r--r-- | web/js/views/media-grid.js | 243 | ||||
| -rw-r--r-- | web/js/views/playback-nav.js | 6 | ||||
| -rw-r--r-- | web/js/views/sets.js | 83 | ||||
| -rw-r--r-- | web/js/views/tags.js | 52 |
5 files changed, 315 insertions, 71 deletions
diff --git a/web/js/views/help.js b/web/js/views/help.js index 05d6949..a85c283 100644 --- a/web/js/views/help.js +++ b/web/js/views/help.js @@ -27,7 +27,5 @@ export function toggleHelp() { } export function showSearch() { - const bar = document.getElementById('search-bar'); - bar?.classList.remove('hidden'); focusSearch(); } diff --git a/web/js/views/media-grid.js b/web/js/views/media-grid.js index 08435b8..4625eba 100644 --- a/web/js/views/media-grid.js +++ b/web/js/views/media-grid.js @@ -5,6 +5,33 @@ import { fmtSize } from '../dom.js'; import { escapeHtml, fmtDur, toast } from '../utils.js'; let callbacks = {}; +let mediaPageSize = 100; +let lastMediaPageKey = ''; + +export function setMediaPageSize(value) { + const parsed = Number.parseInt(value, 10); + mediaPageSize = Number.isFinite(parsed) && parsed > 0 ? parsed : 100; + state.mediaPage = 0; +} + +export function paginateItems(items, page, pageSize = mediaPageSize) { + const size = Math.max(1, Number.parseInt(pageSize, 10) || 100); + const total = items.length; + const maxPage = Math.max(0, Math.ceil(total / size) - 1); + const current = Math.max(0, Math.min(Number.parseInt(page, 10) || 0, maxPage)); + const start = current * size; + const end = Math.min(start + size, total); + return { + items: items.slice(start, end), + total, + page: current, + pageSize: size, + start, + end, + hasPrev: current > 0, + hasNext: current < maxPage, + }; +} export function initMediaGrid(options = {}) { callbacks = options; @@ -14,13 +41,11 @@ export function initMediaGrid(options = {}) { const el = e.target.closest('.media-card, .media-row'); if (!el) return; selectByElement(el); - const idx = parseInt(el.dataset.index, 10); - const media = state.media[idx]; - if (media?.type === 'image') { - callbacks.openLightbox?.(state.media, media.id); - } else { - callbacks.playSelected?.(); + if (el.classList.contains('set-card')) { + callbacks.openSet?.(parseInt(el.dataset.setId, 10)); + return; } + callbacks.playSelected?.(); }); grid?.addEventListener('click', (e) => { @@ -51,13 +76,20 @@ export async function loadMedia() { const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : ''; const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null; - if (singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) { + if (!singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) { + breadcrumb?.classList.add('hidden'); + setMedia([]); + syncMediaPage('sets'); + const page = renderSetGrid(state.sets || []); + resultCount.textContent = resultText(state.sets.length, page); + } else if (singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) { + syncMediaPage(`browse:${singleSetId}:${state.folderPath || ''}`); const data = await API.browse(singleSetId, state.folderPath); updateBreadcrumb(data.current_path); setMedia(mediaWithBrowsePath(data.media || [], data.current_path || '')); - renderBrowse(data); + const page = renderBrowse(data); const total = (data.media?.length || 0) + (data.folders?.length || 0) + (data.episodes?.length || 0); - resultCount.textContent = `${total} items`; + resultCount.textContent = resultText(total, page); } else { breadcrumb?.classList.add('hidden'); const sort = callbacks.isShuffle?.() ? 'random' : (state.filters.sort || 'name'); @@ -75,17 +107,29 @@ export async function loadMedia() { sort, limit: '1000', }; + syncMediaPage(`grid:${JSON.stringify(params)}`); const data = await API.media(params); const list = Array.isArray(data) ? data : data?.media || []; setMedia(list); - renderGrid(list); - resultCount.textContent = `${list.length} items`; + const page = renderGrid(list); + resultCount.textContent = resultText(list.length, page); } } catch (err) { grid.innerHTML = `<p class="error-message grid-full">${escapeHtml(err.message)}</p>`; } } +function syncMediaPage(key) { + if (key === lastMediaPageKey) return; + lastMediaPageKey = key; + state.mediaPage = 0; +} + +function resultText(total, page) { + if (!page || total <= page.pageSize) return `${total} items`; + return `${page.start + 1}-${page.end} of ${total} items`; +} + function updateBreadcrumb(currentPath) { const el = document.getElementById('breadcrumb-bar'); if (!el) return; @@ -114,11 +158,31 @@ export function enterFolder(name) { } export function mediaWithBrowsePath(media, path) { - return media.map((m) => ({ ...m, browse_path: path || '' })); + const prefix = path ? `${path}/` : ''; + return media.map((m) => { + const relPath = m.rel_path || ''; + const relativeToCurrent = prefix && relPath.startsWith(prefix) + ? relPath.slice(prefix.length) + : relPath; + return { + ...m, + browse_path: path || '', + flattened_folder: relativeToCurrent.includes('/'), + }; + }); } export function navigateBack() { - if (!state.folderPath) return; + if (!state.folderPath) { + if (state.selectedSetId || state.selectedSetIds.length) { + state.selectedSetId = null; + state.selectedSetIds = []; + state.mediaPage = 0; + callbacks.onSetCleared?.(); + loadMedia(); + } + return; + } const last = state.folderPath.lastIndexOf('/'); if (last < 0) { state.folderPath = ''; @@ -128,6 +192,69 @@ export function navigateBack() { loadMedia(); } +function renderSetGrid(sets) { + const grid = document.getElementById('media-grid'); + if (!grid) return; + if (!sets.length) { + const page = paginateItems([], state.mediaPage); + state.mediaPage = page.page; + grid.innerHTML = '<p class="text-muted text-sm grid-full">No sets.</p>'; + clearSelection(); + return page; + } + const entries = sets.map((item, index) => ({ item, index })); + const page = paginateItems(entries, state.mediaPage); + state.mediaPage = page.page; + grid.innerHTML = renderPager(page, 'top') + + page.items.map((entry) => renderSetCard(entry.item, entry.index)).join('') + + renderPager(page, 'bottom'); + clearSelection(); + bindSetCards(grid); + bindPager(grid); + return page; +} + +function renderSetCard(set, index) { + return ` + <div class="media-card set-card" data-set-id="${set.id}" data-index="${index}" tabindex="0" role="button" aria-label="Set ${escapeHtml(set.name)}"> + <div class="thumb-wrap"> + <img src="/api/sets/${set.id}/cover" alt="" loading="lazy" onerror="this.remove();"> + <span class="placeholder">▦</span> + <div class="card-actions"> + <button class="icon-btn btn-sm" data-action="regen-set-cover" title="Regenerate cover">🔄</button> + </div> + </div> + <div class="meta"> + <div class="title">${escapeHtml(set.name)}</div> + <div class="subtitle">${set.is_podcast ? 'Podcast set' : 'Set'}</div> + </div> + </div> + `; +} + +function bindSetCards(grid) { + grid.querySelectorAll('.set-card').forEach((el) => { + el.addEventListener('click', (e) => { + selectByElement(el); + if (e.target.closest('.card-actions, button')) return; + e.stopPropagation(); + callbacks.openSet?.(parseInt(el.dataset.setId, 10)); + }); + el.querySelector('[data-action="regen-set-cover"]')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = parseInt(el.dataset.setId, 10); + if (!Number.isFinite(id)) return; + try { + await API.regenCover(id); + toast('Set cover regenerated'); + await loadMedia(); + } catch (err) { + toast(err.message || 'Cover failed', 'error'); + } + }); + }); +} + function renderBrowse(data) { const grid = document.getElementById('media-grid'); if (!grid) return; @@ -135,13 +262,31 @@ function renderBrowse(data) { const media = mediaWithBrowsePath(data.media || [], data.current_path || ''); const episodes = data.episodes || []; if (!folders.length && !media.length && !episodes.length) { + const page = paginateItems([], state.mediaPage); + state.mediaPage = page.page; grid.innerHTML = '<p class="text-muted text-sm grid-full">Folder is empty.</p>'; clearSelection(); - return; + return page; } - const folderHtml = folders.map((f, i) => renderFolder(f, i)).join(''); - const mediaHtml = media.map((m, i) => renderItem(m, i)).join(''); - grid.innerHTML = folderHtml + mediaHtml; + const entries = [ + ...folders.map((folder, index) => ({ type: 'folder', item: folder, index })), + ...media.map((item, index) => ({ type: 'media', item, index })), + ...episodes.map((item, index) => ({ type: 'episode', item, index })), + ]; + const page = paginateItems(entries, state.mediaPage); + state.mediaPage = page.page; + const folderHtml = page.items + .filter((entry) => entry.type === 'folder') + .map((entry) => renderFolder(entry.item, entry.index)) + .join(''); + const mediaHtml = page.items + .filter((entry) => entry.type === 'media') + .map((entry) => renderItem(entry.item, entry.index)) + .join(''); + const pageEpisodes = page.items + .filter((entry) => entry.type === 'episode') + .map((entry) => entry.item); + grid.innerHTML = renderPager(page, 'top') + folderHtml + mediaHtml + renderPager(page, 'bottom'); clearSelection(); grid.querySelectorAll('.folder-card [data-action="regen-folder-cover"]').forEach((b) => { @@ -168,12 +313,16 @@ function renderBrowse(data) { }); bindMediaItems(grid); + bindPager(grid); - if (episodes.length) { + if (pageEpisodes.length) { import('../podcasts.js').then(m => { - m.renderPodcastEpisodes(grid, episodes); + m.renderPodcastEpisodes(grid, pageEpisodes, { + before: grid.querySelector('[data-page-pager="bottom"]'), + }); }).catch(() => {}); } + return page; } function renderFolder(folder, index) { @@ -202,18 +351,55 @@ function renderGrid(items) { const grid = document.getElementById('media-grid'); if (!grid) return; if (!items.length) { + const page = paginateItems([], state.mediaPage); + state.mediaPage = page.page; grid.innerHTML = `<p class="text-muted text-sm grid-full">No results.</p>`; clearSelection(); - return; + return page; } - grid.innerHTML = items.map((m, i) => renderItem(m, i)).join(''); + const entries = items.map((item, index) => ({ item, index })); + const page = paginateItems(entries, state.mediaPage); + state.mediaPage = page.page; + grid.innerHTML = renderPager(page, 'top') + + page.items.map((entry) => renderItem(entry.item, entry.index)).join('') + + renderPager(page, 'bottom'); clearSelection(); bindMediaItems(grid); + bindPager(grid); + return page; +} + +function renderPager(page, position) { + if (!page || page.total <= page.pageSize) return ''; + const showPrev = position === 'top' && page.hasPrev; + const showNext = position === 'bottom' && page.hasNext; + if (!showPrev && !showNext) return ''; + const action = showPrev ? 'prev' : 'next'; + const label = showPrev ? 'Prev' : 'Next'; + return ` + <div class="media-pager media-pager-${position} grid-full" data-page-pager="${position}"> + <span class="media-page-summary">${page.start + 1}-${page.end} of ${page.total}</span> + <button class="btn btn-ghost btn-sm" type="button" data-page-action="${action}">${label}</button> + </div> + `; +} + +function bindPager(grid) { + grid.querySelectorAll('[data-page-action]').forEach((button) => { + button.addEventListener('click', () => { + state.mediaPage += button.dataset.pageAction === 'next' ? 1 : -1; + loadMedia(); + }); + }); } function bindMediaItems(grid) { grid.querySelectorAll('.media-card, .media-row').forEach((el) => { - el.addEventListener('click', () => { selectByElement(el); }); + el.addEventListener('click', (e) => { + selectByElement(el); + if (el.dataset.flattenedFolder !== 'true' || e.target.closest('.card-actions, button')) return; + callbacks.playSelected?.(); + }); const playBtn = el.querySelector('[data-action="play"]'); const viewBtn = el.querySelector('[data-action="view"]'); const favBtn = el.querySelector('[data-action="favorite"]'); @@ -229,9 +415,7 @@ function bindMediaItems(grid) { viewBtn?.addEventListener('click', (e) => { e.stopPropagation(); selectByElement(el); - const idx = parseInt(el.dataset.index, 10); - const media = state.media[idx]; - if (media) callbacks.openLightbox?.(state.media, media.id); + callbacks.playSelected?.(); }); favBtn?.addEventListener('click', (e) => { e.stopPropagation(); @@ -258,9 +442,10 @@ function bindMediaItems(grid) { function renderItem(m, index) { const sizeText = fmtSize(m.file_size_bytes); + const flattenedFolder = m.flattened_folder ? 'true' : 'false'; if (m.type === 'video') { return ` - <div class="media-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}"> + <div class="media-card" data-id="${m.id}" data-index="${index}" data-flattened-folder="${flattenedFolder}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}"> <div class="thumb-wrap"> ${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`} <span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span> @@ -284,12 +469,12 @@ function renderItem(m, index) { const resText = escapeHtml(m.resolution || ''); const safeSizeText = escapeHtml(sizeText || ''); return ` - <div class="media-card image-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}"> + <div class="media-card image-card" data-id="${m.id}" data-index="${index}" data-flattened-folder="${flattenedFolder}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}"> <div class="thumb-wrap"> ${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`} <span class="badge">${resText}${safeSizeText ? ' • ' + safeSizeText : ''}</span> <div class="card-actions"> - <button class="icon-btn btn-sm" data-action="view" title="View">👁</button> + <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button> <button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button> <button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button> <button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button> @@ -305,7 +490,7 @@ function renderItem(m, index) { `; } return ` - <div class="media-card audio-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}"> + <div class="media-card audio-card" data-id="${m.id}" data-index="${index}" data-flattened-folder="${flattenedFolder}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}"> <div class="thumb-wrap"> ${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No cover</span>`} <span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span> diff --git a/web/js/views/playback-nav.js b/web/js/views/playback-nav.js index dfb959d..eb581db 100644 --- a/web/js/views/playback-nav.js +++ b/web/js/views/playback-nav.js @@ -22,7 +22,11 @@ export function initPlaybackNav({ isShuffle } = {}) { } export async function playSelected() { - const el = currentElement(); + let el = currentElement(); + if (!el) { + el = document.querySelector('#media-grid .media-card[data-id], #media-grid .media-row[data-id]'); + if (el) selectByElement(el); + } if (!el) return; const idx = parseInt(el.dataset.index, 10); const media = state.media[idx]; diff --git a/web/js/views/sets.js b/web/js/views/sets.js index 6badcb3..1287fe9 100644 --- a/web/js/views/sets.js +++ b/web/js/views/sets.js @@ -10,38 +10,53 @@ export function initSets({ onLoadMedia } = {}) { export function renderSets() { const el = document.getElementById('set-list'); - if (!el) return; - el.innerHTML = state.sets.map((s) => - `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}"> - <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span> - <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button> - </div>` - ).join(''); - el.querySelectorAll('.set-row').forEach((row) => { - row.addEventListener('click', (e) => { - if (e.target.closest('[data-cover-set]')) return; - e.preventDefault(); - const id = parseInt(row.dataset.id, 10); - state.selectedSetIds = [id]; - state.selectedSetId = id; - state.folderPath = ''; - updateSetRowsUI(); - loadMediaCallback(); + if (el) { + el.innerHTML = state.sets.map((s) => + `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}"> + <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span> + <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button> + </div>` + ).join(''); + el.querySelectorAll('.set-row').forEach((row) => { + row.addEventListener('click', (e) => { + if (e.target.closest('[data-cover-set]')) return; + e.preventDefault(); + selectSet(parseInt(row.dataset.id, 10)); + }); }); - }); - el.querySelectorAll('[data-cover-set]').forEach((b) => { - b.addEventListener('click', async (e) => { - e.stopPropagation(); - const id = b.dataset.coverSet; - try { - await API.regenCover(id); - toast('Cover regenerated'); - } catch (err) { - toast(err.message || 'Cover failed', 'error'); - } + el.querySelectorAll('[data-cover-set]').forEach((b) => { + b.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = b.dataset.coverSet; + try { + await API.regenCover(id); + toast('Cover regenerated'); + } catch (err) { + toast(err.message || 'Cover failed', 'error'); + } + }); }); - }); + } + updateSetRowsUI(); +} + +export function selectSet(id) { + if (!Number.isFinite(id)) return false; + if (!state.sets.some((set) => set.id === id)) return false; + state.selectedSetIds = [id]; + state.selectedSetId = id; + state.folderPath = ''; updateSetRowsUI(); + loadMediaCallback(); + return true; +} + +export function selectSetByHotkey(key) { + const index = parseInt(key, 10) - 1; + if (!Number.isFinite(index) || index < 0 || index >= 9 || index >= state.sets.length) { + return false; + } + return selectSet(state.sets[index].id); } export function updateSetRowsUI() { @@ -76,18 +91,10 @@ export function toggleSetSelection() { loadMediaCallback(); } -function setActiveSet(id) { - document.querySelectorAll('.set-item').forEach((a) => a.classList.toggle('active', parseInt(a.dataset.id, 10) === id)); -} - export function setSetByDelta(dx) { if (!state.sets.length) return; let idx = state.sets.findIndex((s) => s.id === state.selectedSetId); if (idx < 0) idx = 0; const nextIdx = (idx + dx + state.sets.length) % state.sets.length; - state.selectedSetId = state.sets[nextIdx].id; - state.selectedSetIds = [state.selectedSetId]; - state.folderPath = ''; - setActiveSet(state.selectedSetId); - loadMediaCallback(); + selectSet(state.sets[nextIdx].id); } diff --git a/web/js/views/tags.js b/web/js/views/tags.js index 7d3ceba..1d7d29f 100644 --- a/web/js/views/tags.js +++ b/web/js/views/tags.js @@ -1,10 +1,14 @@ import { API } from '../api.js'; import { currentElement } from '../selection.js'; +import { state } from '../state.js'; import { escapeHtml, toast } from '../utils.js'; let tagsCurrentMediaId = null; +let tagFilterCallback = () => {}; +let cachedTags = []; -export function initTags() { +export function initTags(options = {}) { + tagFilterCallback = typeof options.onFilterChange === 'function' ? options.onFilterChange : (() => {}); document.getElementById('tags-close')?.addEventListener('click', closeTagsModal); document.getElementById('tags-modal')?.addEventListener('click', (e) => { if (e.target === document.getElementById('tags-modal')) closeTagsModal(); @@ -16,6 +20,8 @@ export function initTags() { addTagForSelected(); } }); + document.addEventListener('filters:changed', renderTagFilter); + refreshTagFilter(); } export async function openTagsForSelected() { @@ -56,6 +62,7 @@ function renderTagsList(tags) { await API.removeTag(tagsCurrentMediaId, b.dataset.tag); const detail = await API.mediaDetail(tagsCurrentMediaId); renderTagsList(detail?.tags || []); + refreshTagFilter(); } catch (err) { toast(err.message || 'Remove tag failed', 'error'); } @@ -73,7 +80,50 @@ async function addTagForSelected() { input.value = ''; const detail = await API.mediaDetail(tagsCurrentMediaId); renderTagsList(detail?.tags || []); + refreshTagFilter(); } catch (err) { toast(err.message || 'Add tag failed', 'error'); } } + +async function refreshTagFilter() { + const el = document.getElementById('tag-filter-list'); + if (!el) return; + try { + cachedTags = await API.tags(); + renderTagFilter(); + } catch { + cachedTags = []; + renderTagFilter(); + } +} + +function renderTagFilter() { + const el = document.getElementById('tag-filter-list'); + if (!el) return; + const active = selectedTags(); + el.innerHTML = (cachedTags || []).map((tag) => { + const name = tag.name || ''; + const isActive = active.includes(name); + return `<button type="button" class="tag-filter-chip${isActive ? ' active' : ''}" data-tag="${escapeHtml(name)}" title="Filter tag ${escapeHtml(name)}"><span>#${escapeHtml(name)}</span></button>`; + }).join(''); + el.querySelectorAll('.tag-filter-chip').forEach((button) => { + button.addEventListener('click', () => toggleTagFilter(button.dataset.tag)); + }); +} + +function toggleTagFilter(name) { + if (!name) return; + const tags = selectedTags(); + const next = tags.includes(name) + ? tags.filter((tag) => tag !== name) + : [...tags, name]; + state.filters.tags = next.join(','); + state.folderPath = ''; + renderTagFilter(); + tagFilterCallback(); +} + +function selectedTags() { + return (state.filters.tags || '').split(',').map((tag) => tag.trim()).filter(Boolean); +} |
