Routing and views¶
Every page is a hash address: everything after # in the URL. The server never sees it, so Back, Forward and bookmarks work without any server routes.
#/library/3/albums?q=blue&page=2
└── route "/library/:libraryId/albums" params { libraryId: "3" } query { q: "blue", page: "2" }
router.js turns each pattern in views/routes.js into a regular expression. On every hashchange it tries the patterns in order and fires a route event. An empty or unknown address goes to the library you used last. If there are no libraries yet, it goes to Settings → Libraries.
A route may also say top: true: it's one of the sidebar's own pages (Home, Songs, Albums, Playlists, Settings…). On a phone, those pages show no Back button.
| Address | View module |
|---|---|
/library/:libraryId |
home.js: the library's home |
/library/:libraryId/tracks · /artists · /albums · /genres |
tracks.js, artists.js, albums.js, genres.js |
/library/:libraryId/genre/:genreId, /all/genre/:genreId |
genre.js: one library's songs in the genre, or every library's (from search's All libraries). The /all page's Mix opens /all/mixes/genre-mix/:id, drawn by smart.js over your libraries like /all/smart/ |
/library/:libraryId/smart/:key, /all/smart/:key |
smart.js: Most played and similar lists, for one library or for all of them |
/library/:libraryId/mixes, …/mixes/:key/:param |
mixes.js; a single mix is drawn by smart.js |
…/mixes/new, …/mixes/custom-mix/:param/edit |
mix-editor.js: a custom mix's rules, with a live preview |
/library/:libraryId/ai |
ai.js: the AI playlist preview |
/library/:libraryId/get |
get.js: Request music (Lidarr) |
/library/:libraryId/search |
search.js: universal search results. ?q=, ?kind= (a chip), and ?library=all|<id> from the library menu (left out for the library on screen); linker() builds every link on the page so chips and See all keep it. With no music found, elsewhere becomes a "Found in Alex's music (3 results)" line |
/album/:albumId, /artist/:artistId |
album.js, artist.js |
/playlists, /playlist/:playlistId |
playlists.js (Liked Songs' card first, when you have any), playlist.js |
/liked |
smart.js with no :key: Liked Songs, the smart playlist liked, from every library, with a Download button (source { kind: 'liked' }) |
/downloads, /downloads/:id |
downloads.js: what's downloaded to this device, and one download from its snapshot (works offline) |
/recap/:year |
recap.js: a year's recap, the full-screen story <nessie-recap> over the page (?profile= for an admin) |
/settings, /settings/:section |
settings/index.js, which draws one tab module |
Back and Forward¶
The header has Back and Forward buttons beside the logo. They're dimmed when there's nowhere to go. The browser won't say whether Back or Forward has anywhere to go, so the router counts the app's history entries itself:
- Every entry carries its place in
history.state.nessieIndex.resolve()callstrack()first. An entry without an index is new (a link,navigate(), an address typed in): it gets the next number, and history now ends there. - The number of entries is kept in
sessionStorage['nessie-history-length'], so Forward still knows where it leads after a reload. router.canGoBackandcanGoForwardcompare the index with the count.router.back()andforward()callhistory.back()andforward(). Back stops at the app's first page and never leaves the app.- Replacing an entry (
setQuery(),navigate(…, { replace: true })) keeps itshistory.state, so a search or a sort doesn't add a step.
setupHistoryButtons() in main.js updates the buttons on every route event. On phones (below md) there's no Forward button. Back takes the logo's place, and only on pages that aren't top and have somewhere to go back to. To show it, the header gets the class has-back.
What a view is¶
A view is a plain module whose default export is an object:
export default {
search: null, // header search placeholder for a view that searches itself; null = the library search
searchKind: 'songs', // which results tab a search from here opens on
async render(root, ctx) { … }
};
ctx holds params, query, router, libraries, currentLibrary, player (the shared Player, for a view that drives it itself, like the recap), and isCurrent().
Almost every view follows the same pattern. views/tracks.js is a short, typical example:
showLoading(root), thenawaitanapi.*call.if (!ctx.isCurrent()) return;If you've already clicked somewhere else, this slow answer must not draw over the new page.render(root, html`…`)draws the page from components:<nessie-view-header>,<nessie-track-list>,<nessie-pager>.- Data is handed to components as properties:
list.tracks = page.items. - Helpers from
views/common.jswire the buttons.bindListControlsmakes sort and paging change the address.wirePlayActionsmakes Play, Shuffle and Queue fire events.
Sorting¶
Songs, Artists, Albums and Playlists each list their sorts as objects: { value, label, sort, kind, natural, order }.
chosenSort(SORTS, ctx.query)picks the sort from?sort=and the direction from?order=asc|desc, falling back to the sort's ownorder.sortSelect()draws the menu and a button beside it. The button flips the direction, with an icon and tooltip bykind: A to Z for text, Most first for counts, Newest first for dates.- Choosing another sort clears
?order, so the new sort starts in its usual direction.
Watch for this
The API's order doesn't mean up or down. It means the sort's natural direction (asc) or reversed (desc), and some sorts are naturally most-first or newest-first (plays, added, recent, updated, tracks, albums). Each sort's natural says which way it runs, and chosenSort() converts the direction you see into the API's order. Sending desc for "Most played" used to list unplayed songs first.
How main.js swaps views¶
showRoute() in main.js gives each visit a fresh <div class="view">, so listeners a view attached go away with it. The new view is drawn off screen while the old one stays visible. The spinner only appears if loading takes longer than 350 ms. When the view is ready:
- it swaps in;
- the tab title becomes the view's
<h1>; - focus moves to the heading, so screen readers announce the new page.
Some changes only alter the query: a new sort, a search, a new mix. Those keep the scroll position, because router.setQuery() replaces the history entry instead of adding one.