a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: search

+259 -23
+7 -1
src/css/components.css
··· 135 135 height: var(--art-album); 136 136 } 137 137 138 - #sidebar #library .nested { 138 + #sidebar #library .nested, 139 + #sidebar #library .nested-songs { 139 140 list-style: none; 140 141 margin-inline-start: 1rem; 141 142 } 142 143 143 144 #sidebar #library .nested li { 144 145 margin-block-start: 0.25rem; 146 + } 147 + 148 + #sidebar #library #library-search { 149 + width: 100%; 150 + margin-block-start: 0.5rem; 145 151 } 146 152 147 153 #sidebar #library .tree-toggle.focused,
+10
src/index.html
··· 162 162 <div id="keyboard-help-modal" class="modal hidden"> 163 163 <div class="modal-content"> 164 164 <h2>keyboard help</h2> 165 + <p> 166 + use tab/shift+tab to switch between queue, library, and library search 167 + </p> 165 168 <div class="shortcuts-grid"> 166 169 <div class="shortcut-section-group"> 167 170 <h3>general</h3> ··· 231 234 232 235 <aside id="sidebar"> 233 236 <div id="library"> 237 + <input 238 + type="text" 239 + id="library-search" 240 + placeholder="search..." 241 + autocomplete="off" 242 + /> 234 243 <h2> 235 244 <a class="section-toggle" data-section="artists">▸ artists</a> 236 245 </h2> ··· 303 312 <script src="js/queue.js"></script> 304 313 <script src="js/library-selection.js"></script> 305 314 <script src="js/library.js"></script> 315 + <script src="js/library-search.js"></script> 306 316 <script src="js/modal.js"></script> 307 317 <script src="js/settings.js"></script> 308 318 <script src="js/player.js"></script>
+7
src/js/api.js
··· 143 143 return this.request("getStarred2.view"); 144 144 } 145 145 146 + search(query) { 147 + if (!query?.trim()) return Promise.resolve({}); 148 + return this.request("search3.view", { 149 + query: query.trim(), 150 + }); 151 + } 152 + 146 153 star(id) { 147 154 return this._validateAndRequest(id, "star.view", {}, "Song ID"); 148 155 }
+1
src/js/constants.js
··· 36 36 CONTEXT_MENU: "context-menu", 37 37 COVER_ART: "cover-art", 38 38 KEYBOARD_HELP_MODAL: "keyboard-help-modal", 39 + LIBRARY_SEARCH: "library-search", 39 40 LIBRARY_TREE: "artists-tree", 40 41 LIBRARY: "library", 41 42 LOGIN_FORM: "login-form",
+8 -4
src/js/events.js
··· 40 40 41 41 if (libraryEl) { 42 42 libraryEl.tabIndex = 0; 43 - libraryEl.addEventListener("click", () => libraryEl.focus()); 43 + libraryEl.addEventListener("click", (e) => { 44 + if (e.target.closest(`#${DOM_IDS.LIBRARY_SEARCH}`)) return; 45 + libraryEl.focus(); 46 + }); 44 47 } 45 48 46 49 // setup settings modal ··· 123 126 // handle row selection on click 124 127 const row = e.target.closest("tr"); 125 128 if (row) { 126 - const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 129 + const idx = parseInt(row.getAttribute("data-index")); 127 130 queueSelection.select(idx, { 128 131 multi: e.ctrlKey || e.metaKey, 129 132 shift: e.shiftKey, ··· 132 135 return; 133 136 } 134 137 135 - const idx = parseInt(e.target.closest("tr").getAttribute(DATA_ATTRS.INDEX)); 138 + const idx = parseInt(e.target.closest("tr").getAttribute("data-index")); 136 139 for (const [className, handler] of Object.entries(QUEUE_BUTTON_HANDLERS)) { 137 140 if (btn.classList.contains(className)) { 138 141 handler(idx); ··· 145 148 ui.queueList.addEventListener("dblclick", (e) => { 146 149 const row = e.target.closest("tr"); 147 150 if (row) { 148 - const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 151 + const idx = parseInt(row.getAttribute("data-index")); 149 152 state.queueIndex = idx; 150 153 saveQueue(); 151 154 playTrack(state.queue[idx]); ··· 193 196 setupQueueContextMenu(); 194 197 initVirtualScroller(); 195 198 setupKeyboardShortcuts(); 199 + setupSearch(); 196 200 setupMediaSessionHandlers(); 197 201 });
+12 -3
src/js/input.js
··· 7 7 const INTERACTIVE_SELECTOR = 8 8 "button, a, input, select, textarea, [role='button'], tr, li, ul, .section-toggle"; 9 9 10 - // apply `tabIndex=-1` to ALL interactive elements, only queue and library get tabIndex=0 10 + // things that should always be tabbable 11 + const ALWAYS_TABBABLE = new Set([ 12 + DOM_IDS.QUEUE, 13 + DOM_IDS.LIBRARY, 14 + DOM_IDS.LIBRARY_SEARCH, 15 + ]); 16 + 17 + // apply `tabIndex=-1` to interactive elements, except for queue, library, search 18 + // why? we have custom navigation (*-selection.js), keyboard shortcuts, and context menus that's much more intuitive than tabbing through every single button 19 + // if there are accessibility concerns, please let me know!! 11 20 function lockTabOrder() { 12 21 document.querySelectorAll(INTERACTIVE_SELECTOR).forEach((el) => { 13 - // skip queue and library which are always tabbable 14 - if (el.id === DOM_IDS.QUEUE || el.id === DOM_IDS.LIBRARY) return; 22 + // skip elements that are always tabbable 23 + if (ALWAYS_TABBABLE.has(el.id)) return; 15 24 16 25 // skip if element is inside a modal 17 26 const modal = el.closest(".modal:not(.hidden)");
+192
src/js/library-search.js
··· 1 + // search functionality 2 + 3 + let searchTimeout; 4 + 5 + // highlight query matches 6 + function highlightText(element, query) { 7 + if (!query.trim()) return; 8 + 9 + const regex = new RegExp( 10 + `(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, 11 + "gi", 12 + ); 13 + 14 + const walk = (node) => { 15 + if (node.nodeType === Node.TEXT_NODE) { 16 + const span = document.createElement("span"); 17 + span.innerHTML = node.textContent.replace(regex, "<mark>$1</mark>"); 18 + if (span.innerHTML !== node.textContent) 19 + node.parentNode.replaceChild(span, node); 20 + } else if (node.nodeType === Node.ELEMENT_NODE && node.tagName !== "MARK") { 21 + for (const child of Array.from(node.childNodes)) { 22 + walk(child); 23 + } 24 + } 25 + }; 26 + 27 + walk(element); 28 + } 29 + 30 + // get or create nested item in container 31 + function getOrCreateItem(container, id, info, factory) { 32 + if (!container[id]) container[id] = factory(info); 33 + return container[id]; 34 + } 35 + 36 + async function renderSearchResults(query) { 37 + query = query.trim(); 38 + const artistsTree = document.getElementById("artists-tree"); 39 + 40 + if (!query) { 41 + renderLibraryTree(); 42 + renderPlaylistsTree(); 43 + return; 44 + } 45 + 46 + try { 47 + const data = await state.api.search(query); 48 + const searchResult = data.searchResult3 || {}; 49 + const treeData = {}; 50 + 51 + // collect songs 52 + toArray(searchResult.song).forEach((song) => { 53 + const artist = getOrCreateItem( 54 + treeData, 55 + song.artistId, 56 + { id: song.artistId, name: song.artist }, 57 + (info) => ({ info, albums: {} }), 58 + ); 59 + const album = getOrCreateItem( 60 + artist.albums, 61 + song.parent, 62 + { id: song.parent, name: song.album, coverArt: song.coverArt }, 63 + (info) => ({ info, songs: [] }), 64 + ); 65 + album.songs.push(song); 66 + }); 67 + 68 + // collect albums 69 + toArray(searchResult.album).forEach((album) => { 70 + const artist = getOrCreateItem( 71 + treeData, 72 + album.artistId, 73 + { id: album.artistId, name: album.artist }, 74 + (info) => ({ info, albums: {} }), 75 + ); 76 + getOrCreateItem(artist.albums, album.id, album, (info) => ({ 77 + info, 78 + songs: [], 79 + })); 80 + }); 81 + 82 + // skip artists without albums 83 + toArray(searchResult.artist) 84 + .filter((artist) => artist.albumCount > 0) 85 + .forEach((artist) => { 86 + getOrCreateItem(treeData, artist.id, artist, (info) => ({ 87 + info, 88 + albums: {}, 89 + })); 90 + }); 91 + 92 + // use artist images from library 93 + Object.values(treeData).forEach((artistData) => { 94 + const libraryArtist = state.library.find( 95 + (a) => a.id === artistData.info.id, 96 + ); 97 + if (libraryArtist?.coverArt) { 98 + artistData.info.coverArt = libraryArtist.coverArt; 99 + } 100 + }); 101 + 102 + if (!Object.keys(treeData).length) { 103 + artistsTree.innerHTML = `<div>${STRINGS.SEARCH_NO_RESULTS}</div>`; 104 + return; 105 + } 106 + 107 + // render artist tree 108 + const ul = createElement("ul"); 109 + Object.values(treeData).forEach((artistData) => { 110 + const li = buildTreeItem( 111 + artistData.info, 112 + { 113 + label: artistData.info.name, 114 + cover: state.settings.artArtist > 0 ? artistData.info.coverArt : null, 115 + }, 116 + (_, liNode) => expandSearchArtist(liNode, artistData, query), 117 + (artist) => addArtistToQueue(artist.id), 118 + (artist) => addNextByType.artist(artist.id), 119 + "artArtist", 120 + artistData.info.id, 121 + ); 122 + ul.appendChild(li); 123 + if (Object.keys(artistData.albums).length) 124 + expandSearchArtist(li, artistData, query); 125 + }); 126 + 127 + artistsTree.innerHTML = ""; 128 + artistsTree.appendChild(ul); 129 + highlightText(artistsTree, query); 130 + } catch (error) { 131 + console.error("[Search] Failed:", error); 132 + artistsTree.innerHTML = `<div>${error.message}</div>`; 133 + } 134 + } 135 + 136 + function expandSearchArtist(parentLi, artistData, searchQuery) { 137 + clearNestedList(parentLi, CLASSES.NESTED); 138 + const ul = createElement("ul", { className: CLASSES.NESTED }); 139 + 140 + Object.values(artistData.albums).forEach((albumData) => { 141 + const li = buildTreeItem( 142 + albumData.info, 143 + { 144 + label: albumData.info.name, 145 + cover: state.settings.artAlbum > 0 ? albumData.info.coverArt : null, 146 + }, 147 + albumData.songs.length 148 + ? (_, node) => expandSearchAlbum(node, albumData, searchQuery) 149 + : null, 150 + (album) => addAlbumToQueue(album.id), 151 + (album) => addNextByType.album(album.id), 152 + "artAlbum", 153 + albumData.info.id, 154 + ); 155 + ul.appendChild(li); 156 + if (albumData.songs.length) expandSearchAlbum(li, albumData, searchQuery); 157 + }); 158 + 159 + parentLi.appendChild(ul); 160 + if (searchQuery) highlightText(ul, searchQuery); 161 + } 162 + 163 + function expandSearchAlbum(parentLi, albumData, searchQuery) { 164 + clearNestedList(parentLi, CLASSES.NESTED_SONGS); 165 + const ul = createElement("ul", { className: CLASSES.NESTED_SONGS }); 166 + 167 + albumData.songs.forEach((song) => { 168 + const li = buildTreeItem( 169 + song, 170 + { label: song.title }, 171 + null, 172 + (song) => addSongToQueue(song), 173 + (song) => addNextByType.song(song), 174 + "artSong", 175 + song.id, 176 + ); 177 + ul.appendChild(li); 178 + }); 179 + 180 + parentLi.appendChild(ul); 181 + if (searchQuery) highlightText(ul, searchQuery); 182 + } 183 + 184 + function setupSearch() { 185 + const searchInput = document.getElementById(DOM_IDS.LIBRARY_SEARCH); 186 + if (!searchInput) return; 187 + 188 + searchInput.addEventListener("input", (e) => { 189 + clearTimeout(searchTimeout); 190 + searchTimeout = setTimeout(() => renderSearchResults(e.target.value), 300); 191 + }); 192 + }
+6
src/js/library.js
··· 184 184 updateQueueDisplay(); 185 185 } 186 186 187 + function clearNestedList(parentLi, className) { 188 + const existingUl = parentLi.querySelector(`ul.${className}`); 189 + if (existingUl) existingUl.remove(); 190 + } 191 + 187 192 function buildTreeItem(item, mapped, onToggle, onAction, onAddNext, artType) { 188 193 libraryItemsById.set(item.id, item); 189 194 ··· 216 221 const items = itemExtractor(data); 217 222 if (!items.length) return; 218 223 224 + clearNestedList(parentLi, ulClassName); 219 225 const ul = createElement("ul", { className: ulClassName }); 220 226 items.forEach((item) => { 221 227 const mapped = itemMapper(item);
+16 -15
src/js/strings/en.js
··· 1 1 const STRINGS = { 2 - NO_TRACK_PLAYING: "no track playing", 2 + CONNECTION_ERROR: "connection failed:", 3 3 NO_ARTISTS: "no artists", 4 4 NO_PLAYLISTS: "no playlists", 5 - CONNECTION_ERROR: "connection failed:", 6 - UNKNOWN_TRACK: "unknown", 7 - UNKNOWN_ARTIST: "unknown artist", 5 + NO_TRACK_PLAYING: "no track playing", 6 + SEARCH_NO_RESULTS: "no results", 8 7 SERVER_URL_EMPTY: "server URL cannot be empty", 9 8 SERVER_URL_REQUIRED: "server URL is required", 9 + UNKNOWN_ARTIST: "unknown artist", 10 + UNKNOWN_TRACK: "unknown", 10 11 // context menu 11 - CONTEXT_PLAY: "play", 12 - CONTEXT_PLAY_NEXT: "play next", 12 + CONTEXT_CLEAR: "clear", 13 13 CONTEXT_FAVORITE: "favorite", 14 - CONTEXT_UNFAVORITE: "unfavorite", 14 + CONTEXT_MOVE_DOWN: "move down", 15 15 CONTEXT_MOVE_UP: "move up", 16 - CONTEXT_MOVE_DOWN: "move down", 17 - CONTEXT_CLEAR: "clear", 16 + CONTEXT_PLAY_NEXT: "play next", 17 + CONTEXT_PLAY: "play", 18 18 CONTEXT_SORT: "sort", 19 + CONTEXT_UNFAVORITE: "unfavorite", 19 20 // context menu - sort 20 - CONTEXT_SORT_SHUFFLE: "shuffle", 21 - CONTEXT_SORT_SONG_AZ: "song (a-z)", 22 - CONTEXT_SORT_SONG_ZA: "song (z-a)", 21 + CONTEXT_SORT_ALBUM_AZ: "album (a-z)", 22 + CONTEXT_SORT_ALBUM_ZA: "album (z-a)", 23 23 CONTEXT_SORT_ARTIST_AZ: "artist (a-z)", 24 24 CONTEXT_SORT_ARTIST_ZA: "artist (z-a)", 25 - CONTEXT_SORT_ALBUM_AZ: "album (a-z)", 26 - CONTEXT_SORT_ALBUM_ZA: "album (z-a)", 27 - CONTEXT_SORT_DURATION_SHORT_LONG: "duration (short to long)", 28 25 CONTEXT_SORT_DURATION_LONG_SHORT: "duration (long to short)", 26 + CONTEXT_SORT_DURATION_SHORT_LONG: "duration (short to long)", 29 27 CONTEXT_SORT_FAVORITED_FIRST: "favorited first", 30 28 CONTEXT_SORT_FAVORITED_LAST: "favorited last", 29 + CONTEXT_SORT_SHUFFLE: "shuffle", 30 + CONTEXT_SORT_SONG_AZ: "song (a-z)", 31 + CONTEXT_SORT_SONG_ZA: "song (z-a)", 31 32 };