koan-server 0.66.2

GraphQL, Subsonic REST, and MCP server for koan music player.
Documentation
{#- The whole page around a page's content: the sidebar, which becomes the tab bar on a phone, and the transport bar. -#}
{#-
  Six tabs, each as wide as its label, fit a 360 px screen at the meta size. A phone has no room for a seventh, so
  there Playlists becomes Library, a page of the library's own lists; the sidebar lists them directly.

  The theme's navigation row: lowercase in `muted`, the page shown in the accent with a rule on its leading edge, and
  no fill. On a phone, the tab bar's: the page shown in the accent and underlined.
-#}
{%- let nav = "border-l-2 border-transparent px-2.5 py-1.5 text-muted lowercase hover:text-ink hover:no-underline aria-[current=page]:border-brand aria-[current=page]:text-brand max-wide:flex max-wide:flex-auto max-wide:items-center max-wide:justify-center max-wide:border-l-0 max-wide:px-0 max-wide:text-meta max-wide:underline-offset-4 max-wide:aria-[current=page]:underline" -%}
{%- let account = "px-1.5 py-1 text-meta whitespace-nowrap text-muted lowercase hover:text-ink hover:no-underline aria-[current=page]:text-brand" -%}
{%- let version = env!("CARGO_PKG_VERSION") -%}
{% include "head.html" %}
<script type="module" src="{{ crate::ui::ASSETS.datastar_js }}"></script>
<script src="{{ crate::ui::ASSETS.player_js }}" defer></script><script src="{{ crate::ui::ASSETS.ui_js }}" defer></script>
</head><body{% if proxied %} data-proxied{% endif %}>
<nav class="fixed top-0 bottom-(--bar-h) left-0 z-4 flex w-(--side-w) flex-col gap-0.5 border-r border-rule bg-bg px-2.5 py-4 wide:overflow-y-auto pt-[max(16px,env(safe-area-inset-top))] max-wide:top-auto max-wide:right-0 max-wide:bottom-0 max-wide:h-[calc(var(--tabs-h)+env(safe-area-inset-bottom))] max-wide:w-auto max-wide:flex-row max-wide:gap-0 max-wide:border-t max-wide:border-r-0 max-wide:p-0 max-wide:pb-[env(safe-area-inset-bottom)]" aria-label="Library">
<a class="mb-3 px-3 py-2 text-[22px] font-extralight text-brand hover:text-ink hover:no-underline max-wide:hidden" href="/">kōan</a>
<a class="{{ nav }}" href="/albums" data-nav="albums">Albums</a>
<a class="{{ nav }}" href="/artists" data-nav="artists">Artists</a>
<a class="{{ nav }} max-wide:hidden" href="/tracks" data-nav="tracks">Tracks</a>
<a class="{{ nav }} max-wide:hidden" href="/playlists" data-nav="playlists">Playlists</a>
<a class="{{ nav }} wide:hidden" href="/library" data-nav="library playlists recent favourites history tracks">Library</a>
<a class="{{ nav }}" href="/search" data-nav="search">Search</a>
<a class="{{ nav }}" href="/queue" data-nav="queue">Queue</a>
<a class="{{ nav }} mt-3 max-wide:hidden" href="/recent" data-nav="recent">Recently played</a>
<a class="{{ nav }} max-wide:hidden" href="/favourites" data-nav="favourites">Favourites</a>
<a class="{{ nav }} max-wide:hidden" href="/history" data-nav="history">History</a>
{%- if admin %}
{#- The Library page holds the rescan and every scan's progress. A phone reaches it through its Library tab; a wide screen lists the page's other links itself, so only an admin needs it here. #}
<a class="{{ nav }} max-wide:hidden" href="/library" data-nav="library">Library</a>
{%- endif %}
<a class="{{ nav }} wide:hidden" href="/account" data-nav="account">Account</a>
{#- On a wide screen the sidebar ends with who is signed in and the version; the account page has everything else. A phone has an Account tab instead. #}
<div class="mt-auto flex min-w-0 flex-col gap-1 max-wide:hidden">
{%- if auth_enabled %}
<form class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 px-1 text-meta text-muted" method="post" action="/auth/signout"><span class="min-w-0 flex-[1_0_100%] truncate px-1.5 pb-0.5 text-ink">{{ username }}</span>
<a class="{{ account }}" href="/account" data-nav="account">Account</a>
<button class="quiet px-1.5 py-1 text-meta">Sign out</button></form>
{%- else %}
<a class="{{ account }} self-start" href="/account" data-nav="account">Account</a>
{%- endif %}
<a class="px-1.5 py-0.5 text-fine text-muted tabular-nums hover:text-ink hover:no-underline" href="https://github.com/radiosilence/koan/releases/tag/v{{ version }}">kōan {{ version }}</a></div></nav>
<main id="content" class="ml-(--side-w) min-w-0 px-7 pt-[max(24px,env(safe-area-inset-top))] pb-10 max-wide:ml-0 max-wide:p-4 max-wide:pt-[max(16px,env(safe-area-inset-top))]">{{ content }}</main>
<footer class="fixed inset-x-0 bottom-(--tabs-h) z-5 grid h-[calc(var(--bar-h)+env(safe-area-inset-bottom))] grid-cols-[minmax(0,1fr)_minmax(0,2fr)_minmax(0,1fr)] items-center gap-4 border-t border-rule bg-bg px-4 pb-[env(safe-area-inset-bottom)] max-wide:bottom-[calc(var(--tabs-h)+env(safe-area-inset-bottom))] max-wide:h-(--bar-h) max-wide:grid-cols-[minmax(0,1fr)_auto] max-wide:gap-2 max-wide:pr-2 max-wide:pb-0 max-wide:pl-3">
<progress class="absolute inset-x-0 -top-px hidden h-0.5 w-full appearance-none border-0 bg-rule max-wide:block [&::-moz-progress-bar]:bg-brand [&::-webkit-progress-bar]:bg-rule [&::-webkit-progress-value]:bg-brand" data-np="progress" max="1" value="0"></progress>
<div class="flex min-w-0 items-center gap-2.5">
<a class="flex-none hover:no-underline" href="/queue" tabindex="-1" aria-hidden="true"><img class="size-10 bg-surface object-cover" data-np="cover" alt="" hidden></a>
<span class="flex min-w-0 flex-col items-start">
<a class="max-w-full truncate text-meta text-strong hover:no-underline" href="/queue" data-np="title">Nothing playing</a>
<span class="credit max-w-full truncate text-fine text-muted" data-np="artist"></span></span></div>
<div class="flex min-w-0 flex-col items-center gap-1">{{ crate::ui::pages::buttons(false) }}{{ crate::ui::pages::scrub("max-wide:hidden", "h-4") }}</div></footer>
{#- The menu every track row opens, filled for the row by the UI's script. Beside the pointer on a wide screen, a sheet above the bar on a phone. #}
<div id="track-menu" popover="manual" role="menu" class="fixed inset-auto m-0 hidden min-w-52 flex-col open:flex border border-rule bg-bg p-1 text-ink max-wide:inset-x-3 max-wide:w-auto max-wide:bottom-[calc(var(--bar-h)+var(--tabs-h)+env(safe-area-inset-bottom)+8px)]" aria-label="Track"></div>
{#- A track's properties, filled from `/track/{id}/properties` by the UI's script. #}
<dialog id="track-properties" class="m-auto max-h-[85dvh] w-[calc(100%-24px)] max-w-panel overflow-y-auto border border-rule bg-bg px-5 pt-4 pb-5 text-ink backdrop:bg-bg/70" aria-label="Properties"><div id="track-properties-body"></div>
<form method="dialog" class="mt-6 flex justify-end"><button>Close</button></form></dialog></body></html>