Add browser search/filter for media items

- Search bar appears when browsing inside a folder
- Client-side filtering with 200ms debounce
- Clear button and search icon
- Hides at root level, resets on navigation
- Localized placeholder (en/ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 02:44:31 +03:00
parent 98a33bca54
commit 5f474d6c9f
5 changed files with 136 additions and 1 deletions

View File

@@ -1261,6 +1261,68 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* Browser Search */
.browser-search-wrapper {
flex: 1;
position: relative;
min-width: 0;
max-width: 300px;
}
.browser-search-icon {
position: absolute;
left: 0.6rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.browser-search-input {
width: 100%;
padding: 0.4rem 2rem 0.4rem 2rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.813rem;
outline: none;
transition: border-color 0.2s;
}
.browser-search-input:focus {
border-color: var(--accent);
}
.browser-search-input::placeholder {
color: var(--text-muted);
}
.browser-search-clear {
position: absolute;
right: 0.4rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: auto;
height: auto;
transition: color 0.15s;
}
.browser-search-clear:hover {
color: var(--text-primary);
background: transparent !important;
transform: translateY(-50%) !important;
}
.view-toggle { .view-toggle {
display: flex; display: flex;
background: var(--bg-tertiary); background: var(--bg-tertiary);
@@ -1853,6 +1915,10 @@
align-items: stretch; align-items: stretch;
} }
.browser-search-wrapper {
max-width: none;
}
.browser-toolbar-right { .browser-toolbar-right {
justify-content: flex-end; justify-content: flex-end;
} }

View File

@@ -196,6 +196,13 @@
<span data-i18n="browser.play_all">Play All</span> <span data-i18n="browser.play_all">Play All</span>
</button> </button>
</div> </div>
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()">
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;">
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div class="browser-toolbar-right"> <div class="browser-toolbar-right">
<label class="items-per-page-label"> <label class="items-per-page-label">
<span data-i18n="browser.items_per_page">Items per page:</span> <span data-i18n="browser.items_per_page">Items per page:</span>

View File

@@ -1460,6 +1460,8 @@ let totalItems = 0;
let mediaFolders = {}; let mediaFolders = {};
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null; let cachedItems = null;
let browserSearchTerm = '';
let browserSearchTimer = null;
// Load media folders on page load // Load media folders on page load
async function loadMediaFolders() { async function loadMediaFolders() {
@@ -1490,6 +1492,10 @@ function showRootFolders() {
currentFolderId = ''; currentFolderId = '';
currentPath = ''; currentPath = '';
currentOffset = 0; currentOffset = 0;
cachedItems = null;
// Hide search at root level
showBrowserSearch(false);
// Render breadcrumb with just "Home" (not clickable at root) // Render breadcrumb with just "Home" (not clickable at root)
const breadcrumb = document.getElementById('breadcrumb'); const breadcrumb = document.getElementById('breadcrumb');
@@ -1550,6 +1556,9 @@ function showRootFolders() {
} }
async function browsePath(folderId, path, offset = 0, nocache = false) { async function browsePath(folderId, path, offset = 0, nocache = false) {
// Clear search when navigating
showBrowserSearch(false);
try { try {
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
if (!token) { if (!token) {
@@ -1582,6 +1591,9 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
renderBrowserItems(cachedItems); renderBrowserItems(cachedItems);
renderPagination(); renderPagination();
// Show search bar when inside a folder
showBrowserSearch(true);
// Show/hide Play All button based on whether media items exist // Show/hide Play All button based on whether media items exist
const hasMedia = data.items.some(item => item.is_media); const hasMedia = data.items.some(item => item.is_media);
document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none'; document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none';
@@ -2072,6 +2084,54 @@ function refreshBrowser() {
} }
} }
// Browser search
function onBrowserSearch() {
const input = document.getElementById('browserSearchInput');
const clearBtn = document.getElementById('browserSearchClear');
const term = input.value.trim();
clearBtn.style.display = term ? 'flex' : 'none';
// Debounce: wait 200ms after typing stops
if (browserSearchTimer) clearTimeout(browserSearchTimer);
browserSearchTimer = setTimeout(() => {
browserSearchTerm = term.toLowerCase();
applyBrowserSearch();
}, 200);
}
function clearBrowserSearch() {
const input = document.getElementById('browserSearchInput');
input.value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
applyBrowserSearch();
input.focus();
}
function applyBrowserSearch() {
if (!cachedItems) return;
if (!browserSearchTerm) {
renderBrowserItems(cachedItems);
return;
}
const filtered = cachedItems.filter(item =>
item.name.toLowerCase().includes(browserSearchTerm)
);
renderBrowserItems(filtered);
}
function showBrowserSearch(visible) {
document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none';
if (!visible) {
document.getElementById('browserSearchInput').value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
}
}
function setViewMode(mode) { function setViewMode(mode) {
if (mode === viewMode) return; if (mode === viewMode) return;
viewMode = mode; viewMode = mode;
@@ -2084,7 +2144,7 @@ function setViewMode(mode) {
// Re-render current view from cache (no network request) // Re-render current view from cache (no network request)
if (currentFolderId && cachedItems) { if (currentFolderId && cachedItems) {
renderBrowserItems(cachedItems); applyBrowserSearch();
} else { } else {
showRootFolders(); showRootFolders();
} }

View File

@@ -124,6 +124,7 @@
"browser.view_grid": "Grid view", "browser.view_grid": "Grid view",
"browser.view_compact": "Compact view", "browser.view_compact": "Compact view",
"browser.view_list": "List view", "browser.view_list": "List view",
"browser.search": "Search...",
"browser.items_per_page": "Items per page:", "browser.items_per_page": "Items per page:",
"browser.page": "Page", "browser.page": "Page",
"browser.previous": "Previous", "browser.previous": "Previous",

View File

@@ -124,6 +124,7 @@
"browser.view_grid": "Сетка", "browser.view_grid": "Сетка",
"browser.view_compact": "Компактный вид", "browser.view_compact": "Компактный вид",
"browser.view_list": "Список", "browser.view_list": "Список",
"browser.search": "Поиск...",
"browser.items_per_page": "Элементов на странице:", "browser.items_per_page": "Элементов на странице:",
"browser.page": "Страница", "browser.page": "Страница",
"browser.previous": "Предыдущая", "browser.previous": "Предыдущая",