Skip to content

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() calls track() 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.canGoBack and canGoForward compare the index with the count. router.back() and forward() call history.back() and forward(). Back stops at the app's first page and never leaves the app.
  • Replacing an entry (setQuery(), navigate(…, { replace: true })) keeps its history.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:

  1. showLoading(root), then await an api.* call.
  2. if (!ctx.isCurrent()) return; If you've already clicked somewhere else, this slow answer must not draw over the new page.
  3. render(root, html`…`) draws the page from components: <nessie-view-header>, <nessie-track-list>, <nessie-pager>.
  4. Data is handed to components as properties: list.tracks = page.items.
  5. Helpers from views/common.js wire the buttons. bindListControls makes sort and paging change the address. wirePlayActions makes 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 own order.
  • sortSelect() draws the menu and a button beside it. The button flips the direction, with an icon and tooltip by kind: 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.