Add media browser with grid/compact/list views and single-click playback

- Add browser UI with three view modes (grid, compact, list) and pagination
- Add file browsing, thumbnail loading, download, and play endpoints
- Add duration extraction via mutagen for media files
- Single-click plays media or navigates folders, with play overlay on hover
- Add type badges, file size display, and duration metadata
- Add localization keys for browser UI (en/ru)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 23:34:38 +03:00
parent 32b058c5fb
commit e16674c658
7 changed files with 784 additions and 66 deletions

View File

@@ -1406,10 +1406,10 @@
let currentFolderId = null;
let currentPath = '';
let currentOffset = 0;
const ITEMS_PER_PAGE = 100;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let selectedItem = null;
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
// Load media folders on page load
async function loadMediaFolders() {
@@ -1474,7 +1474,7 @@ async function browsePath(folderId, path, offset = 0) {
const encodedPath = encodeURIComponent(path);
const response = await fetch(
`/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${ITEMS_PER_PAGE}`,
`/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
@@ -1486,7 +1486,7 @@ async function browsePath(folderId, path, offset = 0) {
totalItems = data.total;
renderBreadcrumbs(data.current_path, data.parent_path);
renderBrowserGrid(data.items);
renderBrowserItems(data.items);
renderPagination();
// Save last path
@@ -1533,12 +1533,123 @@ function renderBreadcrumbs(currentPath, parentPath) {
});
}
function renderBrowserGrid(items) {
const grid = document.getElementById('browserGrid');
grid.innerHTML = '';
function renderBrowserItems(items) {
const container = document.getElementById('browserGrid');
// Switch container class based on view mode
if (viewMode === 'list') {
container.className = 'browser-list';
renderBrowserList(items, container);
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
renderBrowserGrid(items, container);
} else {
container.className = 'browser-grid';
renderBrowserGrid(items, container);
}
}
function renderBrowserList(items, container) {
container.innerHTML = '';
if (!items || items.length === 0) {
grid.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
return;
}
items.forEach(item => {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.dataset.name = item.name;
row.dataset.type = item.type;
// Icon (small) with play overlay
const icon = document.createElement('div');
icon.className = 'browser-list-icon';
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-list-thumbnail loading';
thumbnail.alt = item.name;
icon.appendChild(thumbnail);
loadThumbnail(thumbnail, item.name);
} else {
icon.textContent = getFileIcon(item.type);
}
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-list-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
icon.appendChild(overlay);
}
row.appendChild(icon);
// Name
const name = document.createElement('div');
name.className = 'browser-list-name';
name.textContent = item.name;
row.appendChild(name);
// Type badge
if (item.type !== 'folder') {
const typeBadge = document.createElement('div');
typeBadge.className = `browser-list-type ${item.type}`;
typeBadge.textContent = item.type;
row.appendChild(typeBadge);
} else {
row.appendChild(document.createElement('div'));
}
// Duration
const dur = document.createElement('div');
dur.className = 'browser-list-duration';
dur.textContent = formatDuration(item.duration) || '';
row.appendChild(dur);
// Size
const size = document.createElement('div');
size.className = 'browser-list-size';
size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : '';
row.appendChild(size);
// Download button
if (item.is_media) {
row.appendChild(createDownloadBtn(item.name, 'browser-list-download'));
} else {
row.appendChild(document.createElement('div'));
}
// Tooltip on row when name is ellipsed
row.addEventListener('mouseenter', () => {
if (name.scrollWidth > name.clientWidth) {
row.title = item.name;
} else {
row.title = '';
}
});
// Single click: play media or navigate folder
row.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(row);
});
}
function renderBrowserGrid(items, container) {
container = container || document.getElementById('browserGrid');
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
return;
}
@@ -1552,16 +1663,20 @@ function renderBrowserGrid(items) {
if (item.type !== 'folder') {
const typeBadge = document.createElement('div');
typeBadge.className = `browser-item-type ${item.type}`;
typeBadge.textContent = item.type;
typeBadge.innerHTML = getTypeBadgeIcon(item.type);
div.appendChild(typeBadge);
}
// Thumbnail wrapper (for play overlay)
const thumbWrapper = document.createElement('div');
thumbWrapper.className = 'browser-thumb-wrapper';
// Thumbnail or icon
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-thumbnail loading';
thumbnail.alt = item.name;
div.appendChild(thumbnail);
thumbWrapper.appendChild(thumbnail);
// Lazy load thumbnail
loadThumbnail(thumbnail, item.name);
@@ -1569,9 +1684,19 @@ function renderBrowserGrid(items) {
const icon = document.createElement('div');
icon.className = 'browser-icon';
icon.textContent = getFileIcon(item.type);
div.appendChild(icon);
thumbWrapper.appendChild(icon);
}
// Play overlay for media files
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
thumbWrapper.appendChild(overlay);
}
div.appendChild(thumbWrapper);
// Info
const info = document.createElement('div');
info.className = 'browser-item-info';
@@ -1581,23 +1706,52 @@ function renderBrowserGrid(items) {
name.textContent = item.name;
info.appendChild(name);
if (item.size !== null && item.type !== 'folder') {
if (item.type !== 'folder') {
const meta = document.createElement('div');
meta.className = 'browser-item-meta';
meta.textContent = formatFileSize(item.size);
info.appendChild(meta);
const parts = [];
const duration = formatDuration(item.duration);
if (duration) parts.push(duration);
if (item.size !== null) parts.push(formatFileSize(item.size));
meta.textContent = parts.join(' \u00B7 ');
if (parts.length) info.appendChild(meta);
}
div.appendChild(info);
// Events
div.onclick = () => handleItemClick(item, div);
div.ondblclick = () => handleItemDoubleClick(item);
// Tooltip on card when name is ellipsed
div.addEventListener('mouseenter', () => {
if (name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) {
div.title = item.name;
} else {
div.title = '';
}
});
grid.appendChild(div);
// Single click: play media or navigate folder
div.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(div);
});
}
function getTypeBadgeIcon(type) {
const svgs = {
'audio': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
'video': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
};
return svgs[type] || '';
}
function getFileIcon(type) {
const icons = {
'folder': '📁',
@@ -1616,6 +1770,17 @@ function formatFileSize(bytes) {
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (seconds == null || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
async function loadThumbnail(imgElement, fileName) {
try {
const token = localStorage.getItem('media_server_token');
@@ -1662,30 +1827,6 @@ async function loadThumbnail(imgElement, fileName) {
}
}
function handleItemClick(item, element) {
// Clear previous selection
document.querySelectorAll('.browser-item.selected').forEach(el => {
el.classList.remove('selected');
});
// Select current item
element.classList.add('selected');
selectedItem = item;
}
function handleItemDoubleClick(item) {
if (item.type === 'folder') {
// Navigate into folder
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
// Play media file
playMediaFile(item.name);
}
}
async function playMediaFile(fileName) {
try {
const token = localStorage.getItem('media_server_token');
@@ -1718,14 +1859,43 @@ async function playMediaFile(fileName) {
}
}
function downloadFile(fileName, event) {
if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token');
if (!token) return;
const fullPath = currentPath === '/'
? '/' + fileName
: currentPath + '/' + fileName;
const encodedPath = encodeURIComponent(fullPath);
const url = `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}&token=${token}`;
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function createDownloadBtn(fileName, cssClass) {
const btn = document.createElement('button');
btn.className = cssClass;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
btn.title = t('browser.download');
btn.onclick = (e) => downloadFile(fileName, e);
return btn;
}
function renderPagination() {
const pagination = document.getElementById('browserPagination');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const pageInfo = document.getElementById('pageInfo');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
const currentPage = Math.floor(currentOffset / ITEMS_PER_PAGE) + 1;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
if (totalPages <= 1) {
pagination.style.display = 'none';
@@ -1733,21 +1903,81 @@ function renderPagination() {
}
pagination.style.display = 'flex';
pageInfo.textContent = `${currentPage} / ${totalPages}`;
pageInput.value = currentPage;
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
function previousPage() {
if (currentOffset >= ITEMS_PER_PAGE) {
browsePath(currentFolderId, currentPath, currentOffset - ITEMS_PER_PAGE);
if (currentOffset >= itemsPerPage) {
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
}
}
function nextPage() {
if (currentOffset + ITEMS_PER_PAGE < totalItems) {
browsePath(currentFolderId, currentPath, currentOffset + ITEMS_PER_PAGE);
if (currentOffset + itemsPerPage < totalItems) {
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
}
}
function setViewMode(mode) {
viewMode = mode;
localStorage.setItem('mediaBrowser.viewMode', mode);
// Update toggle buttons
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Re-render current items if we have a folder selected
if (currentFolderId) {
browsePath(currentFolderId, currentPath, currentOffset);
}
}
function onItemsPerPageChanged() {
const select = document.getElementById('itemsPerPageSelect');
itemsPerPage = parseInt(select.value);
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
// Reset to first page and reload
if (currentFolderId) {
currentOffset = 0;
browsePath(currentFolderId, currentPath, 0);
}
}
function goToPage() {
const pageInput = document.getElementById('pageInput');
const totalPages = Math.ceil(totalItems / itemsPerPage);
let page = parseInt(pageInput.value);
if (isNaN(page) || page < 1) page = 1;
if (page > totalPages) page = totalPages;
pageInput.value = page;
const newOffset = (page - 1) * itemsPerPage;
if (newOffset !== currentOffset) {
browsePath(currentFolderId, currentPath, newOffset);
}
}
function initBrowserToolbar() {
// Restore view mode
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
viewMode = savedViewMode;
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Restore items per page
const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage');
if (savedItemsPerPage) {
itemsPerPage = parseInt(savedItemsPerPage);
document.getElementById('itemsPerPageSelect').value = savedItemsPerPage;
}
}
@@ -1802,6 +2032,8 @@ async function saveFolder(event) {
// Initialize browser on page load
window.addEventListener('DOMContentLoaded', () => {
initBrowserToolbar();
// Load media folders after authentication
const token = localStorage.getItem('media_server_token');
if (token) {