1702 lines
101 KiB
HTML
1702 lines
101 KiB
HTML
<!DOCTYPE HTML>
|
||
<html lang="en" class="navy" dir="ltr">
|
||
<head>
|
||
<!-- Book generated using mdBook -->
|
||
<meta charset="UTF-8">
|
||
<title>UX Flows - JellyTau Documentation</title>
|
||
|
||
|
||
<!-- Custom HTML head -->
|
||
|
||
<meta name="description" content="Requirements, traceability, and architecture for the JellyTau Jellyfin client.">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="theme-color" content="#ffffff">
|
||
|
||
<link rel="icon" href="favicon.svg">
|
||
<link rel="shortcut icon" href="favicon.png">
|
||
<link rel="stylesheet" href="css/variables.css">
|
||
<link rel="stylesheet" href="css/general.css">
|
||
<link rel="stylesheet" href="css/chrome.css">
|
||
<link rel="stylesheet" href="css/print.css" media="print">
|
||
|
||
<!-- Fonts -->
|
||
<link rel="stylesheet" href="FontAwesome/css/font-awesome.css">
|
||
<link rel="stylesheet" href="fonts/fonts.css">
|
||
|
||
<!-- Highlight.js Stylesheets -->
|
||
<link rel="stylesheet" href="highlight.css">
|
||
<link rel="stylesheet" href="tomorrow-night.css">
|
||
<link rel="stylesheet" href="ayu-highlight.css">
|
||
|
||
<!-- Custom theme stylesheets -->
|
||
|
||
</head>
|
||
<body class="sidebar-visible no-js">
|
||
<div id="body-container">
|
||
<!-- Provide site root to javascript -->
|
||
<script>
|
||
var path_to_root = "";
|
||
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "navy" : "navy";
|
||
</script>
|
||
|
||
<!-- Work around some values being stored in localStorage wrapped in quotes -->
|
||
<script>
|
||
try {
|
||
var theme = localStorage.getItem('mdbook-theme');
|
||
var sidebar = localStorage.getItem('mdbook-sidebar');
|
||
|
||
if (theme.startsWith('"') && theme.endsWith('"')) {
|
||
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
|
||
}
|
||
|
||
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
|
||
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
|
||
}
|
||
} catch (e) { }
|
||
</script>
|
||
|
||
<!-- Set the theme before any content is loaded, prevents flash -->
|
||
<script>
|
||
var theme;
|
||
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
|
||
if (theme === null || theme === undefined) { theme = default_theme; }
|
||
var html = document.querySelector('html');
|
||
html.classList.remove('navy')
|
||
html.classList.add(theme);
|
||
var body = document.querySelector('body');
|
||
body.classList.remove('no-js')
|
||
body.classList.add('js');
|
||
</script>
|
||
|
||
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
|
||
|
||
<!-- Hide / unhide sidebar before it is displayed -->
|
||
<script>
|
||
var body = document.querySelector('body');
|
||
var sidebar = null;
|
||
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
|
||
if (document.body.clientWidth >= 1080) {
|
||
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
|
||
sidebar = sidebar || 'visible';
|
||
} else {
|
||
sidebar = 'hidden';
|
||
}
|
||
sidebar_toggle.checked = sidebar === 'visible';
|
||
body.classList.remove('sidebar-visible');
|
||
body.classList.add("sidebar-" + sidebar);
|
||
</script>
|
||
|
||
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
|
||
<div class="sidebar-scrollbox">
|
||
<ol class="chapter"><li class="chapter-item expanded affix "><a href="index.html">Introduction</a></li><li class="chapter-item expanded affix "><li class="part-title">Requirements & Traceability</li><li class="chapter-item expanded "><a href="requirements.html"><strong aria-hidden="true">1.</strong> Requirements Specification</a></li><li class="chapter-item expanded "><a href="traceability.html"><strong aria-hidden="true">2.</strong> Traceability Matrix</a></li><li class="chapter-item expanded "><a href="traceability-ci.html"><strong aria-hidden="true">3.</strong> Traceability CI</a></li><li class="chapter-item expanded "><a href="traces-quick-ref.html"><strong aria-hidden="true">4.</strong> Traces Quick Reference</a></li><li class="chapter-item expanded affix "><li class="part-title">Architecture</li><li class="chapter-item expanded "><a href="architecture/index.html"><strong aria-hidden="true">5.</strong> Overview</a><a class="toggle"><div>❱</div></a></li><li><ol class="section"><li class="chapter-item "><a href="architecture/01-rust-backend.html"><strong aria-hidden="true">5.1.</strong> Rust Backend</a></li><li class="chapter-item "><a href="architecture/02-svelte-frontend.html"><strong aria-hidden="true">5.2.</strong> Svelte Frontend</a></li><li class="chapter-item "><a href="architecture/03-data-flow.html"><strong aria-hidden="true">5.3.</strong> Data Flow</a></li><li class="chapter-item "><a href="architecture/04-type-sync-and-threading.html"><strong aria-hidden="true">5.4.</strong> Type Sync & Threading</a></li><li class="chapter-item "><a href="architecture/05-platform-backends.html"><strong aria-hidden="true">5.5.</strong> Platform Backends</a></li><li class="chapter-item "><a href="architecture/06-downloads-and-offline.html"><strong aria-hidden="true">5.6.</strong> Downloads & Offline</a></li><li class="chapter-item "><a href="architecture/07-connectivity.html"><strong aria-hidden="true">5.7.</strong> Connectivity</a></li><li class="chapter-item "><a href="architecture/08-database-design.html"><strong aria-hidden="true">5.8.</strong> Database Design</a></li><li class="chapter-item "><a href="architecture/09-security.html"><strong aria-hidden="true">5.9.</strong> Security</a></li></ol></li><li class="chapter-item expanded "><li class="part-title">UX</li><li class="chapter-item expanded "><a href="ux-flows.html" class="active"><strong aria-hidden="true">6.</strong> UX Flows</a></li><li class="chapter-item expanded affix "><li class="part-title">Specs — Pending Work</li><li class="chapter-item expanded "><a href="specs/index.html"><strong aria-hidden="true">7.</strong> Specs Index</a></li><li class="chapter-item expanded "><a href="specs/SPEC-TEMPLATE.html"><strong aria-hidden="true">8.</strong> Spec Template</a></li><li class="chapter-item expanded "><a href="specs/SPEC-REVIEW-CHECKLIST.html"><strong aria-hidden="true">9.</strong> Spec Review Checklist</a></li><li class="chapter-item expanded "><a href="specs/playback-backend-unification.html"><strong aria-hidden="true">10.</strong> Playback Backend Unification</a></li><li class="chapter-item expanded "><a href="specs/linux-native-video-spike.html"><strong aria-hidden="true">11.</strong> Linux Native Video Spike</a></li><li class="chapter-item expanded "><a href="specs/backend-owned-stream-selection.html"><strong aria-hidden="true">12.</strong> Backend-Owned Stream Selection</a></li><li class="chapter-item expanded "><a href="specs/player-facade-enforcement.html"><strong aria-hidden="true">13.</strong> Player Facade Enforcement</a></li><li class="chapter-item expanded "><a href="specs/windows-native-audio-backend.html"><strong aria-hidden="true">14.</strong> Windows Native Audio Backend</a></li><li class="chapter-item expanded "><a href="specs/libmpv2-migration.html"><strong aria-hidden="true">15.</strong> libmpv2 Migration</a></li><li class="chapter-item expanded "><a href="specs/read-through-media-cache.html"><strong aria-hidden="true">16.</strong> Read-Through Media Cache</a></li><li class="chapter-item expanded "><a href="specs/scoped-search.html"><strong aria-hidden="true">17.</strong> Scoped Search</a></li><li class="chapter-item expanded "><a href="specs/scoped-search-boundary.html"><strong aria-hidden="true">18.</strong> Scoped Search Boundary</a></li><li class="chapter-item expanded "><a href="specs/scoped-search-boundary-implementation.html"><strong aria-hidden="true">19.</strong> Scoped Search Boundary — Implementation</a></li><li class="chapter-item expanded "><a href="specs/frontend-domain-model.html"><strong aria-hidden="true">20.</strong> Frontend Domain Model</a></li><li class="chapter-item expanded "><a href="specs/desktop-native-video.html"><strong aria-hidden="true">21.</strong> Desktop Native Video</a></li><li class="chapter-item expanded "><a href="specs/build-provenance.html"><strong aria-hidden="true">22.</strong> Build Provenance</a></li><li class="chapter-item expanded affix "><li class="part-title">Build & Release</li><li class="chapter-item expanded "><a href="build/build-release.html"><strong aria-hidden="true">23.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="release-checklist.html"><strong aria-hidden="true">24.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="native-player-verification.html"><strong aria-hidden="true">25.</strong> Native Player Verification</a></li><li class="chapter-item expanded "><a href="build/build-desktop-packages.html"><strong aria-hidden="true">26.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="build/build-windows.html"><strong aria-hidden="true">27.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="defect-windows.html"><strong aria-hidden="true">28.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="build/docker.html"><strong aria-hidden="true">29.</strong> Docker</a></li><li class="chapter-item expanded "><a href="build/build-builder-image.html"><strong aria-hidden="true">30.</strong> Builder Image</a></li><li class="chapter-item expanded affix "><li class="spacer"></li><li class="chapter-item expanded affix "><a href="api-redirect.html">Rust API Reference (rustdoc)</a></li></ol>
|
||
</div>
|
||
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
|
||
<div class="sidebar-resize-indicator"></div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Track and set sidebar scroll position -->
|
||
<script>
|
||
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
|
||
sidebarScrollbox.addEventListener('click', function(e) {
|
||
if (e.target.tagName === 'A') {
|
||
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
|
||
}
|
||
}, { passive: true });
|
||
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
|
||
sessionStorage.removeItem('sidebar-scroll');
|
||
if (sidebarScrollTop) {
|
||
// preserve sidebar scroll position when navigating via links within sidebar
|
||
sidebarScrollbox.scrollTop = sidebarScrollTop;
|
||
} else {
|
||
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
|
||
var activeSection = document.querySelector('#sidebar .active');
|
||
if (activeSection) {
|
||
activeSection.scrollIntoView({ block: 'center' });
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div id="page-wrapper" class="page-wrapper">
|
||
|
||
<div class="page">
|
||
<div id="menu-bar-hover-placeholder"></div>
|
||
<div id="menu-bar" class="menu-bar sticky">
|
||
<div class="left-buttons">
|
||
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
|
||
<i class="fa fa-bars"></i>
|
||
</label>
|
||
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
|
||
<i class="fa fa-paint-brush"></i>
|
||
</button>
|
||
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
||
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
|
||
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
|
||
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
|
||
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
|
||
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
|
||
</ul>
|
||
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
|
||
<i class="fa fa-search"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<h1 class="menu-title">JellyTau Documentation</h1>
|
||
|
||
<div class="right-buttons">
|
||
<a href="print.html" title="Print this book" aria-label="Print this book">
|
||
<i id="print-button" class="fa fa-print"></i>
|
||
</a>
|
||
<a href="https://gitea.tourolle.paris/dtourolle/jellytau" title="Git repository" aria-label="Git repository">
|
||
<i id="git-repository-button" class="fa fa-github"></i>
|
||
</a>
|
||
<a href="https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/../docs/ux-flows.md" title="Suggest an edit" aria-label="Suggest an edit">
|
||
<i id="git-edit-button" class="fa fa-edit"></i>
|
||
</a>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div id="search-wrapper" class="hidden">
|
||
<form id="searchbar-outer" class="searchbar-outer">
|
||
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
|
||
</form>
|
||
<div id="searchresults-outer" class="searchresults-outer hidden">
|
||
<div id="searchresults-header" class="searchresults-header"></div>
|
||
<ul id="searchresults">
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
|
||
<script>
|
||
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
|
||
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
|
||
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
|
||
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
|
||
});
|
||
</script>
|
||
|
||
<div id="content" class="content">
|
||
<main>
|
||
<h1 id="jellytau-ux-flows--screen-transitions"><a class="header" href="#jellytau-ux-flows--screen-transitions">JellyTau UX Flows & Screen Transitions</a></h1>
|
||
<p>This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.</p>
|
||
<hr />
|
||
<h2 id="1-core-navigation-structure"><a class="header" href="#1-core-navigation-structure">1. Core Navigation Structure</a></h2>
|
||
<h3 id="11-navigation-system"><a class="header" href="#11-navigation-system">1.1 Navigation System</a></h3>
|
||
<p>JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.</p>
|
||
<p><strong>Bottom Navigation Bar (All Platforms - DR-045, UR-039):</strong></p>
|
||
<p>The bottom navigation bar is the primary navigation and is <strong>always visible</strong> on all platforms (mobile and desktop) except when:</p>
|
||
<ul>
|
||
<li>Full-screen video player is active</li>
|
||
<li>User is on the login screen</li>
|
||
</ul>
|
||
<p><strong>Bottom Nav Structure:</strong></p>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ [Home] [Library] [Search] │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<p><strong>Routes:</strong></p>
|
||
<ul>
|
||
<li><strong>Home</strong> → <code>/</code> (home page with carousels and featured content)</li>
|
||
<li><strong>Library</strong> → <code>/library</code> (library selector showing all libraries)</li>
|
||
<li><strong>Search</strong> → <code>/search</code> (dedicated search page)</li>
|
||
</ul>
|
||
<p><strong>Note:</strong> Available on both mobile and desktop for consistent navigation access.</p>
|
||
<p><strong>Header Navigation (Desktop):</strong></p>
|
||
<p>On desktop (md breakpoint and above), the header contains:</p>
|
||
<ul>
|
||
<li>Logo (links to <code>/library</code>)</li>
|
||
<li>Navigation links: Home, Library, Downloads, Settings</li>
|
||
<li>Search bar (inline)</li>
|
||
<li>Account menu (see §1.2)</li>
|
||
</ul>
|
||
<p><strong>Mobile Navigation:</strong></p>
|
||
<p>On mobile, the header contains:</p>
|
||
<ul>
|
||
<li>Logo</li>
|
||
<li>Account menu button (see §1.2)</li>
|
||
</ul>
|
||
<h3 id="12-account-menu"><a class="header" href="#12-account-menu">1.2 Account Menu</a></h3>
|
||
<p>Account-level destinations — the ones that are <em>about the user</em> rather than
|
||
about media — live behind a single <strong>account menu</strong>, anchored to the user's
|
||
name/avatar at the right of the header.</p>
|
||
<p><strong>Contents, in order:</strong></p>
|
||
<pre><code>┌──────────────────────────┐
|
||
│ Signed in as <name> │ ← identity, not a menu item
|
||
│ <server host> │
|
||
├──────────────────────────┤
|
||
│ ⬇ Downloads │
|
||
│ ⚙ Settings │
|
||
│ ▦ Display │ ← grid/list preference (§5A.2)
|
||
├──────────────────────────┤
|
||
│ ⇥ Sign out │
|
||
└──────────────────────────┘
|
||
</code></pre>
|
||
<p><strong>Rules:</strong></p>
|
||
<ul>
|
||
<li><strong>One menu, both platforms.</strong> Desktop and mobile show the same items in the
|
||
same order. A user who learns where Settings lives on one form factor finds
|
||
it in the same place on the other.</li>
|
||
<li><strong>Anchored to identity.</strong> The trigger is the username/avatar, because that is
|
||
where users look for account actions. A bare three-dot icon does not signal
|
||
"your account".</li>
|
||
<li><strong>Sign out is separated</strong> by a divider and placed last — it is destructive and
|
||
must not sit adjacent to routine navigation.</li>
|
||
<li><strong>The menu is reachable from every authenticated screen</strong>, not only from
|
||
library routes. See §1.3.</li>
|
||
</ul>
|
||
<p><strong>Access Points Summary:</strong></p>
|
||
<ul>
|
||
<li><strong>Downloads</strong> → header icon (desktop) + account menu (both)</li>
|
||
<li><strong>Settings</strong> → header nav link (desktop) + account menu (both)</li>
|
||
<li><strong>Sign out</strong> → account menu only</li>
|
||
</ul>
|
||
<h3 id="13-chrome-availability"><a class="header" href="#13-chrome-availability">1.3 Chrome availability</a></h3>
|
||
<p>The header is shared across chrome-bearing routes. Routes fall into three groups:</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Route group</th><th>Header</th><th>Bottom nav</th><th>Account menu reachable?</th></tr></thead><tbody>
|
||
<tr><td><code>/library/*</code></td><td>Yes (own layout, shared <code>AppHeader</code>)</td><td>Yes</td><td>Yes</td></tr>
|
||
<tr><td><code>/</code>, <code>/search</code>, <code>/downloads</code></td><td>Yes (root-owned <code>AppHeader</code>)</td><td>Yes</td><td>Yes</td></tr>
|
||
<tr><td><code>/settings</code></td><td>Own layout</td><td>No</td><td>n/a — already there</td></tr>
|
||
<tr><td><code>/player/*</code>, <code>/login</code></td><td>No</td><td>No</td><td>No (by design)</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p>The rule the app honours: every authenticated, non-immersive screen exposes the
|
||
account menu. Only the full-screen player and the login screen are chrome-free.</p>
|
||
<h3 id="14-known-deviations"><a class="header" href="#14-known-deviations">1.4 Known deviations</a></h3>
|
||
<p><em>(None — the account-menu and chrome-availability defects tracked here under
|
||
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
|
||
from every authenticated non-immersive screen via the shared <code>AccountMenu</code>, the
|
||
username/avatar is the menu trigger, desktop and mobile share one menu, and the
|
||
Display preference has a Settings entry — UR-029, §5A.4.)</em></p>
|
||
<hr />
|
||
<h2 id="2-initial-app-launch-flow"><a class="header" href="#2-initial-app-launch-flow">2. Initial App Launch Flow</a></h2>
|
||
<h3 id="21-first-time-launch"><a class="header" href="#21-first-time-launch">2.1 First-Time Launch</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
|
||
CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
|
||
CheckAuth -->|Yes| AutoLogin[Auto-login]
|
||
|
||
LoginScreen --> EnterURL[Enter Server URL]
|
||
EnterURL --> EnterCreds[Enter Username/Password]
|
||
EnterCreds --> LoginSuccess{Success?}
|
||
LoginSuccess -->|No| LoginError[Show Error]
|
||
LoginError --> EnterCreds
|
||
LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]
|
||
|
||
AutoLogin --> TokenValid{Token Valid?}
|
||
TokenValid -->|No| LoginScreen
|
||
TokenValid -->|Yes| HomePage
|
||
|
||
StoreToken --> HomePage[Home Page<br/>/]
|
||
</code></pre>
|
||
<p><strong>Screens:</strong></p>
|
||
<ol>
|
||
<li>
|
||
<p><strong>Login Screen</strong> (<code>/login</code>)</p>
|
||
<ul>
|
||
<li>Server URL input</li>
|
||
<li>Username input</li>
|
||
<li>Password input</li>
|
||
<li>"Remember me" checkbox (default: on)</li>
|
||
<li>Login button</li>
|
||
<li>No header, no bottom nav</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Home Page</strong> (<code>/</code>)</p>
|
||
<ul>
|
||
<li>Default landing page after successful login</li>
|
||
<li>Shows featured content, carousels, continue watching</li>
|
||
<li>No MiniPlayer visible (nothing playing yet)</li>
|
||
<li>Bottom nav: Home tab active</li>
|
||
<li>Header with navigation links</li>
|
||
</ul>
|
||
</li>
|
||
</ol>
|
||
<h3 id="22-subsequent-launches"><a class="header" href="#22-subsequent-launches">2.2 Subsequent Launches</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Launch[App Launch] --> LoadAuth[Load Stored Token]
|
||
LoadAuth --> Validate{Token Valid?}
|
||
Validate -->|Yes| RestoreState[Restore Last Screen]
|
||
Validate -->|No| LoginScreen[Login Screen<br/>/login]
|
||
|
||
RestoreState --> CheckPlayer{Was Player<br/>Active?}
|
||
CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
|
||
CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]
|
||
|
||
ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
|
||
HideMiniPlayer --> HomePage[Home Page<br/>/]
|
||
</code></pre>
|
||
<p><strong>State Restoration:</strong></p>
|
||
<ul>
|
||
<li>Last viewed screen (route) is restored (defaults to <code>/</code> if none)</li>
|
||
<li>If audio was playing, MiniPlayer appears at bottom</li>
|
||
<li>Playback state is NOT automatically resumed (user must press play)</li>
|
||
<li>Queue is restored if it existed</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="3-audio-playback-flows"><a class="header" href="#3-audio-playback-flows">3. Audio Playback Flows</a></h2>
|
||
<h3 id="31-starting-audio-playback"><a class="header" href="#31-starting-audio-playback">3.1 Starting Audio Playback</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Start[User Action] --> Action{Action Type?}
|
||
|
||
Action -->|Click Track| TrackList[TrackList Component]
|
||
Action -->|Click Album| AlbumDetail[Album Detail Page]
|
||
Action -->|Click Play on Album| AlbumPlay[Play Album Button]
|
||
|
||
TrackList --> PlayTrack[Play Single Track]
|
||
PlayTrack --> QueueAll[Queue All Filtered Tracks]
|
||
|
||
AlbumPlay --> PlayAlbum[Play All Album Tracks]
|
||
PlayAlbum --> QueueAlbum[Queue Album Tracks]
|
||
|
||
QueueAll --> InvokePlay[invoke player_play_queue]
|
||
QueueAlbum --> InvokePlay
|
||
|
||
InvokePlay --> PlayerStarts[Player State: Playing]
|
||
PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]
|
||
|
||
MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]
|
||
</code></pre>
|
||
<p><strong>Entry Points for Audio Playback:</strong></p>
|
||
<ol>
|
||
<li>
|
||
<p><strong>TrackList</strong> (<code>/library/music/tracks</code>, <code>/library/music/albums/[id]</code>)</p>
|
||
<ul>
|
||
<li>Click track number → Play track + queue all visible tracks</li>
|
||
<li>Clicking track #3 in an album → Play track 3, queue tracks 1-10</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Album Card</strong> (grid views)</p>
|
||
<ul>
|
||
<li>Click album → Navigate to album detail</li>
|
||
<li>Play button on card → Play album immediately</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Search Results</strong></p>
|
||
<ul>
|
||
<li>Click track → Play track + queue search results</li>
|
||
<li>Click album → Navigate to album detail</li>
|
||
</ul>
|
||
</li>
|
||
</ol>
|
||
<p><strong>MiniPlayer Behavior:</strong></p>
|
||
<ul>
|
||
<li>Slides up from bottom with animation (300ms)</li>
|
||
<li>Height: 64px on mobile, 80px on desktop</li>
|
||
<li>Shows: artwork, title, artist, play/pause, next, favorite</li>
|
||
<li>Stays visible on ALL screens (except video player)</li>
|
||
<li>Click anywhere on MiniPlayer → Navigate to full player</li>
|
||
</ul>
|
||
<p><strong>Track Highlighting:</strong>
|
||
When audio is playing, the currently playing track is visually highlighted in track lists and album pages:</p>
|
||
<ul>
|
||
<li>Subtle blue background tint</li>
|
||
<li>Left border accent in Jellyfin blue</li>
|
||
<li>Title text colored in Jellyfin blue</li>
|
||
<li>Desktop: Animated pulsing dots indicator next to title</li>
|
||
<li>Mobile: Play arrow (▶) inline with title</li>
|
||
<li>Highlight updates automatically when skipping to next/previous track</li>
|
||
</ul>
|
||
<h3 id="32-miniplayer--full-player-transition"><a class="header" href="#32-miniplayer--full-player-transition">3.2 MiniPlayer → Full Player Transition</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Mini[MiniPlayer Visible] --> UserClick{User Action}
|
||
|
||
UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
|
||
UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]
|
||
|
||
NavFullPlayer --> FullPlayer[Full Audio Player Screen]
|
||
SwipeGesture --> FullPlayer
|
||
|
||
FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]
|
||
|
||
ShowControls --> MiniHidden[MiniPlayer Hidden]
|
||
</code></pre>
|
||
<p><strong>Full Player Screen</strong> (<code>/player/[id]</code>)</p>
|
||
<ul>
|
||
<li><strong>Header:</strong> Song title, artist (clickable links to artist/album pages)</li>
|
||
<li><strong>Artwork:</strong> Large album art (centered, dominant)</li>
|
||
<li><strong>Progress:</strong> Seek bar with current time / total duration</li>
|
||
<li><strong>Controls:</strong> Previous, Play/Pause, Next (large touch targets)</li>
|
||
<li><strong>Secondary Controls:</strong> Shuffle, Repeat mode, Queue, Favorite</li>
|
||
<li><strong>Volume:</strong> Volume slider</li>
|
||
<li><strong>Bottom Nav:</strong> Still visible (can navigate away while playing)</li>
|
||
<li><strong>Back button:</strong> Returns to previous screen, MiniPlayer reappears</li>
|
||
</ul>
|
||
<h3 id="33-full-player--back-to-browsing"><a class="header" href="#33-full-player--back-to-browsing">3.3 Full Player → Back to Browsing</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
FullPlayer[Full Player Screen] --> UserAction{User Action}
|
||
|
||
UserAction -->|Back Button / Close| HistoryBack[window.history.back]
|
||
UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]
|
||
|
||
HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
|
||
NavOther --> NewScreen[Navigate to New Screen]
|
||
|
||
PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
|
||
NewScreen --> MiniReappears
|
||
|
||
MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]
|
||
</code></pre>
|
||
<p><strong>Navigation Behavior:</strong></p>
|
||
<ul>
|
||
<li><strong>Back Button:</strong> Uses browser history (<code>window.history.back()</code>) to return to the previous page</li>
|
||
<li><strong>Expected behavior:</strong> Returns user to the screen they were on before opening full player</li>
|
||
<li><strong>Example:</strong> User browsing album → clicks track → full player opens → clicks back → returns to album</li>
|
||
</ul>
|
||
<p><strong>Key UX Principles:</strong></p>
|
||
<ul>
|
||
<li><strong>Playback Never Stops:</strong> Navigating away from player does NOT stop playback</li>
|
||
<li><strong>MiniPlayer Persistence:</strong> MiniPlayer visible on ALL screens (except video/login)</li>
|
||
<li><strong>Queue Preserved:</strong> Current queue remains intact</li>
|
||
<li><strong>State Restoration:</strong> Returning to full player shows same state (position, volume, etc.)</li>
|
||
<li><strong>Natural Navigation:</strong> Back button behaves as expected (returns to previous page, not just closes modal)</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="4-video-playback-flows"><a class="header" href="#4-video-playback-flows">4. Video Playback Flows</a></h2>
|
||
<h3 id="41-starting-video-playback"><a class="header" href="#41-starting-video-playback">4.1 Starting Video Playback</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Start[User Action] --> Action{Action Type?}
|
||
|
||
Action -->|Click Movie| MovieDetail[Movie Detail Page]
|
||
Action -->|Click Episode| EpisodeClick[Episode Click]
|
||
Action -->|Click Play Button| PlayButton[Play Button]
|
||
|
||
MovieDetail --> PlayMovie[Play Movie Button]
|
||
EpisodeClick --> PlayEpisode[Play Episode]
|
||
|
||
PlayMovie --> CheckResume{Resume<br/>Position?}
|
||
PlayEpisode --> CheckResume
|
||
|
||
CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
|
||
CheckResume -->|No| DirectPlay[Start from Beginning]
|
||
|
||
ShowDialog --> UserChoice{User Choice}
|
||
UserChoice -->|Resume| ResumePlay[Start at Saved Position]
|
||
UserChoice -->|Start Over| DirectPlay
|
||
|
||
ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
|
||
DirectPlay --> FullscreenVideo
|
||
|
||
FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]
|
||
</code></pre>
|
||
<p><strong>Resume Dialog:</strong></p>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ Continue Watching? │
|
||
│ │
|
||
│ [Movie Title] │
|
||
│ Resume from 12:34 / 1:45:00 │
|
||
│ │
|
||
│ [Start from Beginning] [Resume] │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<h3 id="42-video-player-screen-ir-003-ir-004-ur-003"><a class="header" href="#42-video-player-screen-ir-003-ir-004-ur-003">4.2 Video Player Screen (IR-003, IR-004, UR-003)</a></h3>
|
||
<p><strong>Initial State (First 3 seconds):</strong></p>
|
||
<ul>
|
||
<li>Controls visible overlay</li>
|
||
<li>Top bar: Back button, title</li>
|
||
<li>Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)</li>
|
||
<li>Center: Large play/pause button</li>
|
||
</ul>
|
||
<p><strong>After 3 Seconds (Idle):</strong></p>
|
||
<ul>
|
||
<li>All controls fade out (500ms animation)</li>
|
||
<li>Fullscreen video only</li>
|
||
<li>System UI hidden (status bar, nav bar)</li>
|
||
</ul>
|
||
<p><strong>User Interaction:</strong></p>
|
||
<ul>
|
||
<li><strong>Tap screen:</strong> Controls reappear for 3 seconds</li>
|
||
<li><strong>Double tap left side:</strong> Rewind 10 seconds (shows animated feedback with "-10" indicator)</li>
|
||
<li><strong>Double tap right side:</strong> Forward 30 seconds (shows animated feedback with "+30" indicator)</li>
|
||
<li><strong>Single tap play/pause is deferred</strong> by the 300 ms double-tap window, so a double tap
|
||
skips without also toggling pause (UR-061)</li>
|
||
<li><strong>Swipe up/down on left side:</strong> Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)</li>
|
||
<li><strong>Swipe up/down on right side:</strong> Adjust volume (0-100%, shows volume indicator with progress bar)</li>
|
||
<li><strong>Keyboard arrows:</strong> ← rewind 10s, → forward 30s (desktop/external keyboard)</li>
|
||
<li><strong>Keyboard space/K:</strong> Toggle play/pause</li>
|
||
<li><strong>Keyboard F:</strong> Toggle fullscreen</li>
|
||
<li><strong>Pinch:</strong> Zoom (planned)</li>
|
||
</ul>
|
||
<h3 id="43-exiting-video-player"><a class="header" href="#43-exiting-video-player">4.3 Exiting Video Player</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
VideoPlaying[Video Playing] --> UserAction{User Action}
|
||
|
||
UserAction -->|Back Button| StopVideo[Stop Playback]
|
||
UserAction -->|Home Button| Background[App to Background]
|
||
UserAction -->|Video Ends| VideoEnd[Playback Ended]
|
||
|
||
StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
|
||
VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
|
||
Background --> PauseVideo[Pause Video]
|
||
|
||
SaveProgress --> ExitFullscreen[Exit Fullscreen]
|
||
SaveComplete --> AutoNext{Next Episode<br/>Available?}
|
||
|
||
AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
|
||
AutoNext -->|No| ExitFullscreen
|
||
|
||
ShowCountdown --> UserCancel{User Cancels?}
|
||
UserCancel -->|Yes| ExitFullscreen
|
||
UserCancel -->|No, timeout| PlayNext[Play Next Episode]
|
||
|
||
ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]
|
||
|
||
PlayNext --> VideoPlaying
|
||
|
||
PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]
|
||
</code></pre>
|
||
<p><strong>Auto-Next Overlay:</strong></p>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ │
|
||
│ [Episode Thumbnail] │
|
||
│ │
|
||
│ Next: S01E02 - Episode Title │
|
||
│ Starting in 5 seconds... │
|
||
│ │
|
||
│ [Cancel] [Play Now] │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<hr />
|
||
<h2 id="5-music-library-navigation-flows"><a class="header" href="#5-music-library-navigation-flows">5. Music Library Navigation Flows</a></h2>
|
||
<h3 id="51-music-category-landing-page"><a class="header" href="#51-music-category-landing-page">5.1 Music Category Landing Page</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]
|
||
|
||
ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]
|
||
|
||
MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]
|
||
|
||
ShowCategories --> UserClick{User Clicks Category}
|
||
|
||
UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
|
||
UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
|
||
UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
|
||
UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
|
||
UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]
|
||
</code></pre>
|
||
<p><strong>Category Cards:</strong></p>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||
│ │ 🎵 │ │ 👤 │ │ 💿 │ │
|
||
│ │Track│ │Artist│ │Album│ │
|
||
│ └──────┘ └──────┘ └──────┘ │
|
||
│ ┌──────┐ ┌──────┐ │
|
||
│ │ 📝 │ │ 🎭 │ │
|
||
│ │List │ │Genre│ │
|
||
│ └──────┘ └──────┘ │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<h3 id="52-albums-view-flow"><a class="header" href="#52-albums-view-flow">5.2 Albums View Flow</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
|
||
|
||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
|
||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||
|
||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||
|
||
ShowAlbum --> TrackAction{User Action}
|
||
|
||
TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
|
||
TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
|
||
TrackAction -->|Download Album| DownloadFlow[Download Flow]
|
||
TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]
|
||
</code></pre>
|
||
<p><strong>Album Detail Layout:</strong></p>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ [←] [♡] [⬇] │
|
||
│ │
|
||
│ ┌────────────────────┐ │
|
||
│ │ │ │
|
||
│ │ Album Artwork │ │
|
||
│ │ │ │
|
||
│ └────────────────────┘ │
|
||
│ │
|
||
│ Album Title │
|
||
│ Artist Name (clickable) │
|
||
│ 2024 • 12 tracks • 45:23 │
|
||
│ │
|
||
│ [▶ Play] [🔀 Shuffle] │
|
||
│ │
|
||
│ ───────────────────────────────────── │
|
||
│ 1 Track Title 3:45 │
|
||
│ 2 Track Title 4:12 │
|
||
│ 3 Track Title 3:28 │
|
||
│ ... │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<h3 id="53-artist-navigation"><a class="header" href="#53-artist-navigation">5.3 Artist Navigation</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]
|
||
|
||
ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]
|
||
|
||
ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]
|
||
|
||
ShowContent --> UserAction{User Action}
|
||
|
||
UserAction -->|Click Album| AlbumDetail[Album Detail Page]
|
||
UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
|
||
UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]
|
||
</code></pre>
|
||
<hr />
|
||
<h2 id="5a-library-page-layouts"><a class="header" href="#5a-library-page-layouts">5A. Library Page Layouts</a></h2>
|
||
<p>Every browse page is one of two shapes: a <strong>card grid</strong> or a <strong>row list</strong>. This
|
||
section is the rule for which shape a page takes, what a card looks like, and
|
||
what the user is allowed to change.</p>
|
||
<h3 id="5a1-card-shape-follows-the-media-not-the-page"><a class="header" href="#5a1-card-shape-follows-the-media-not-the-page">5A.1 Card shape follows the media, not the page</a></h3>
|
||
<p>Card aspect ratio is a property of <em>what the item is</em>, and is never overridden
|
||
per-page. This is the single most important layout rule: a user scanning a grid
|
||
recognises content type by silhouette before reading a word.</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Item type</th><th>Aspect</th><th>Rationale</th></tr></thead><tbody>
|
||
<tr><td>Album, Artist, Track, Playlist</td><td><strong>1:1 square</strong></td><td>Matches album art; the universal music convention (Spotify)</td></tr>
|
||
<tr><td>Movie, Series, Season</td><td><strong>2:3 poster</strong></td><td>Matches printed poster art; the universal video convention (Netflix)</td></tr>
|
||
<tr><td>Episode</td><td><strong>16:9 thumbnail</strong></td><td>A frame from the episode, not cover art — signals "a thing you watch next"</td></tr>
|
||
<tr><td>Library / collection folder</td><td><strong>16:9</strong></td><td>Reads as a container, distinct from the items inside it</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p>Artist cards are square but rendered <strong>circular-masked</strong>, so artists are
|
||
distinguishable from albums at a glance within the same music grid.</p>
|
||
<h3 id="5a2-grid-vs-list"><a class="header" href="#5a2-grid-vs-list">5A.2 Grid vs. list</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Page[Library browse page] --> Kind{Content kind}
|
||
|
||
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
|
||
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
|
||
|
||
Grid --> Toggle[View toggle in page header]
|
||
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||
</code></pre>
|
||
<ul>
|
||
<li><strong>Grids are the default</strong> for anything with cover art worth scanning.</li>
|
||
<li><strong>Lists are mandatory, not optional</strong>, where position carries meaning —
|
||
a track's number within an album, an episode's number within a season.
|
||
A grid destroys that ordering cue, so these pages expose <strong>no toggle</strong>.</li>
|
||
<li><strong>The toggle is global, not per-page.</strong> A user who prefers dense lists
|
||
prefers them everywhere; making them re-set it on each page is friction.
|
||
The choice persists across launches.</li>
|
||
</ul>
|
||
<p><strong>Responsive columns</strong> (grid mode), tuned so cards stay large enough to read
|
||
cover art on a phone and don't become postage stamps on a desktop:</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Breakpoint</th><th>Columns</th></tr></thead><tbody>
|
||
<tr><td>base (phone)</td><td>2</td></tr>
|
||
<tr><td>sm</td><td>3</td></tr>
|
||
<tr><td>md</td><td>4</td></tr>
|
||
<tr><td>lg</td><td>5</td></tr>
|
||
<tr><td>xl</td><td>6</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<h3 id="5a3-what-a-card-shows"><a class="header" href="#5a3-what-a-card-shows">5A.3 What a card shows</a></h3>
|
||
<pre><code> ┌─────────────┐
|
||
│ │ ← cover art (aspect per §5A.1)
|
||
│ artwork │ • progress bar overlay if partially played
|
||
│ │ • watched/played check if complete
|
||
│ [▶] │ • play affordance on hover/focus
|
||
└─────────────┘
|
||
Primary line ← title, truncated to one line
|
||
Secondary line ← artist / year+rating / SxEy — one line, dimmed
|
||
</code></pre>
|
||
<ul>
|
||
<li><strong>Two lines of text maximum.</strong> Titles truncate rather than wrap; a card that
|
||
grows to fit its title breaks grid alignment and makes scanning harder.</li>
|
||
<li><strong>Progress and watched state live on the artwork</strong>, not in the text — they
|
||
must be readable while scanning, without reading.</li>
|
||
<li><strong>Hover/focus reveals play</strong>, so a card is both a navigation target and a
|
||
playback target without a second control competing for space at rest.</li>
|
||
</ul>
|
||
<h3 id="5a4-known-deviations"><a class="header" href="#5a4-known-deviations">5A.4 Known deviations</a></h3>
|
||
<p>These are places the implementation currently diverges from the rules above.
|
||
They are recorded here so the gap is explicit rather than mistaken for intent.</p>
|
||
<ul>
|
||
<li><strong>The view toggle is discoverable only on a browse page.</strong> The preference is
|
||
already global and persisted, but the only control that sets it is the pair
|
||
of icon buttons in a library page header. Settings has no display section, so
|
||
there is nowhere to look for it. <em>(UR-029)</em></li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="5b-video-detail-page-composition"><a class="header" href="#5b-video-detail-page-composition">5B. Video Detail Page Composition</a></h2>
|
||
<p>Movie, Series, and Episode detail pages all live at <code>/library/[id]</code>. Which
|
||
surface renders is decided by item type plus the <code>?episode=</code> query param, and
|
||
<strong>section order is part of the spec</strong> — it is what makes "keep watching this
|
||
show" the path of least resistance.</p>
|
||
<h3 id="5b1-which-surface-renders"><a class="header" href="#5b1-which-surface-renders">5B.1 Which surface renders</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Nav[Navigate to /library/&#91;id&#93;] --> Type{Item type}
|
||
|
||
Type -->|Person| Person[PersonDetailView]
|
||
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
|
||
Type -->|Series| Ep{?episode= param<br/>present?}
|
||
|
||
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
|
||
Ep -->|No| Series[Series detail<br/>§5B.4]
|
||
|
||
Focus -->|Back to series| Series
|
||
Series -->|Click episode| Focus
|
||
</code></pre>
|
||
<p>An episode is <strong>never</strong> browsed as a bare <code>Episode</code> item page. Clicking an
|
||
episode anywhere — a series' season list, a Home carousel (§5B.5), etc. —
|
||
navigates to <code>/library/<seriesId>?episode=<episodeId></code>, so the episode is always
|
||
shown in the context of its series and the series' full episode list is already
|
||
loaded. Should an episode ever arrive without a <code>seriesId</code> (deep link, stale
|
||
cache), the bare Episode page renders as a fallback and links back to its parent
|
||
series and season by title so the user is never stranded.</p>
|
||
<h3 id="5b2-episode-focus-view--section-order"><a class="header" href="#5b2-episode-focus-view--section-order">5B.2 Episode Focus View — section order</a></h3>
|
||
<p><strong>The next episodes appear directly below the current episode, above cast and
|
||
similar shows.</strong> Nothing may be inserted between the episode hero and the
|
||
episode strip.</p>
|
||
<pre><code>┌─────────────────────────────────────────────────┐
|
||
│ [←] │
|
||
│ ┌───────────────────────────────────────────┐ │
|
||
│ │ episode backdrop │ │
|
||
│ │ Series Name │ │ ← 1. HERO
|
||
│ │ Episode Title │ │
|
||
│ │ S2E4 • 48m • ★8.1 │ │
|
||
│ │ Overview… │ │
|
||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||
│ │ [▶ Play] [⬇] [♡] │ │
|
||
│ └───────────────────────────────────────────┘ │
|
||
│ │
|
||
│ More Episodes │ ← 2. EPISODE STRIP
|
||
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
|
||
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
|
||
│ │ ││NOW ││ ││ │ │
|
||
│ └──────┘└──────┘└──────┘└──────┘ │
|
||
│ │
|
||
│ Cast │ ← 3. CAST
|
||
│ ( ○ )( ○ )( ○ )( ○ ) │
|
||
│ │
|
||
│ More Like This │ ← 4. SIMILAR
|
||
│ ┌────┐┌────┐┌────┐┌────┐ │
|
||
└─────────────────────────────────────────────────┘
|
||
</code></pre>
|
||
<p><strong>Rules for the episode strip:</strong></p>
|
||
<ul>
|
||
<li><strong>Position is fixed.</strong> Hero → episode strip → cast → similar. The strip sits
|
||
between the current episode and every other section; cast and related
|
||
content are <em>below</em> it, never above.</li>
|
||
<li><strong>Window, not full list.</strong> The strip shows a window around the current
|
||
episode — roughly 3 before and 6 after — so the immediate next episodes are
|
||
visible without scrolling, and earlier ones remain reachable by scrolling
|
||
left. It is horizontally scrollable, not a wrapped grid.</li>
|
||
<li><strong>Forward bias.</strong> More episodes are shown <em>after</em> the current one than
|
||
before it: the dominant intent on this screen is "watch the next one."</li>
|
||
<li><strong>The current episode is present and marked.</strong> It renders in-strip with a
|
||
"NOW" badge and a highlight ring, and is not clickable. It anchors the
|
||
user's position in the season rather than being hidden.</li>
|
||
<li><strong>Cross-season continuity.</strong> The window spans the whole series in episode
|
||
order, so the strip runs past a season boundary into the next season's first
|
||
episodes rather than dead-ending at the end of a season.</li>
|
||
<li><strong>Per-episode state.</strong> Each card shows a thumbnail, <code>SxEy</code> + title, a resume
|
||
progress bar when partially watched, and a watched checkmark when complete.</li>
|
||
<li><strong>Clicking an episode swaps focus in place</strong> (<code>?episode=</code> changes); it does
|
||
not start playback. Playback starts only from the hero's Play button.</li>
|
||
</ul>
|
||
<h3 id="5b3-movie-detail--section-order"><a class="header" href="#5b3-movie-detail--section-order">5B.3 Movie detail — section order</a></h3>
|
||
<pre><code>Hero (poster, title, metadata, Play / Download / Favorite)
|
||
→ Crew links (Directed by / Written by / Music by)
|
||
→ Genre tags
|
||
→ Cast
|
||
→ More Like This
|
||
</code></pre>
|
||
<p>A movie has no continuation set, so cast follows the hero directly.</p>
|
||
<h3 id="5b4-series-detail--section-order"><a class="header" href="#5b4-series-detail--section-order">5B.4 Series detail — section order</a></h3>
|
||
<pre><code>Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
|
||
→ Crew links
|
||
→ Genre tags
|
||
→ Seasons (collapsible; only the current season expanded)
|
||
→ Cast
|
||
→ More Like This
|
||
</code></pre>
|
||
<p>The same principle as §5B.2: <strong>episodes come before cast and similar shows.</strong>
|
||
The reason a user opens a series page is to pick an episode; discovery content
|
||
is secondary and sits underneath.</p>
|
||
<p><strong>Rules for the seasons block</strong> <em>(UR-062, UR-064)</em>:</p>
|
||
<ul>
|
||
<li><strong>The page opens where the viewer is.</strong> The backend resolves the current
|
||
episode — in progress, else Next Up, else first unwatched, else the premiere —
|
||
and the page scrolls it into view with an <code>Up next</code> badge and a highlight ring.
|
||
Never season 1 by default, unless season 1 <em>is</em> where the viewer is.</li>
|
||
<li><strong>Seasons collapse; only the current one is expanded.</strong> A ten-season show
|
||
otherwise renders hundreds of rows and buries the episode the viewer came for.
|
||
A collapsed season still names its episode count and watched count, so
|
||
progress is readable without expanding it.</li>
|
||
<li><strong>The hero button opens, it does not play.</strong> It reads <code>Resume S2E4</code> /
|
||
<code>Play S1E1</code> — naming its target — and navigates to that episode's Focus View,
|
||
where Play commits. Play on a <em>container</em> is navigation (§5B.5); Play on a
|
||
<em>leaf</em> is the commitment.</li>
|
||
<li><strong>A season is never its own page.</strong> <code>/library/<seasonId></code> redirects to
|
||
<code>/library/<seriesId>#season-N</code>. Every affordance that names a season — the
|
||
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
|
||
the series with that season in view, so the episodes of all seasons stay one
|
||
browsable list.</li>
|
||
<li><strong>Watch history is erasable</strong> per series (hero) and per season (season
|
||
header). It confirms first, cannot be undone, and needs the server. Clearing a
|
||
whole series returns it to S1E1 by the same path a never-watched show takes.</li>
|
||
</ul>
|
||
<h3 id="5b5-home-card-interaction--tap-opens-long-press-plays"><a class="header" href="#5b5-home-card-interaction--tap-opens-long-press-plays">5B.5 Home-card interaction — tap opens, long-press plays</a></h3>
|
||
<p>Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||
Watching, Recently Added, …) <strong>do not play on tap.</strong> A plain tap opens the
|
||
item; playback is the deliberate, second gesture.</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Card kind</th><th>Tap (short)</th><th>Long-press (~500 ms hold)</th></tr></thead><tbody>
|
||
<tr><td>Movie</td><td>Movie detail page (<code>/library/<id></code>)</td><td>Confirm → play now (<code>/player/<id></code>)</td></tr>
|
||
<tr><td>Episode</td><td>Series Episode Focus View (<code>/library/<seriesId>?episode=<id></code>, per §5B.1)</td><td>Confirm → play now (<code>/player/<id></code>)</td></tr>
|
||
<tr><td>Series / Season / Album / Artist / Playlist / Folder</td><td>Detail page (<code>/library/<id></code>)</td><td>Same as tap (no single "play now" target)</td></tr>
|
||
<tr><td>Channel / live leaf</td><td>Player (<code>/player/<id></code>) — no detail page exists</td><td>Confirm → play now</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p>Rationale and rules:</p>
|
||
<ul>
|
||
<li><strong>Tap is navigation, not commitment.</strong> Previously a tap on a movie/episode
|
||
jumped straight into the player, which made it easy to lose your place in a
|
||
half-watched item or start a stream you only meant to inspect. Tap now lands
|
||
on the detail/focus page, where Play is an explicit button.</li>
|
||
<li><strong>Long-press is the shortcut for "just play it."</strong> It surfaces a native
|
||
confirm (<code>Play "<name>" now?</code>) before starting playback, so an accidental
|
||
hold never blows away a resume position silently.</li>
|
||
<li><strong>The long-press must not fight the carousel.</strong> Detection cancels if the
|
||
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
|
||
to scroll never triggers play.</li>
|
||
<li><strong>Episodes still obey §5B.1</strong> — a home tap on an episode opens the series
|
||
Focus View, never a bare Episode page, so the series context loads.</li>
|
||
</ul>
|
||
<p>This behavior lives in <code>MediaCard</code> (<code>onLongPress</code> prop + pointer-based
|
||
detection) so any surface can opt in; today the Home carousels are the only
|
||
opt-in. Grids and other surfaces keep tap-to-open with no long-press.</p>
|
||
<hr />
|
||
<h2 id="5c-favourites"><a class="header" href="#5c-favourites">5C. Favourites</a></h2>
|
||
<p>Favouriting is a two-sided promise: the heart takes the input, and the app must
|
||
be able to give it back. This section covers both sides — where you can mark a
|
||
favourite, and where marked favourites resurface.</p>
|
||
<p>See <a href="architecture/01-rust-backend.html#favorites-system">architecture/01-rust-backend.md</a> for the layer
|
||
assignment and wire shapes.</p>
|
||
<h3 id="5c1-the-heart-appears-wherever-an-item-does"><a class="header" href="#5c1-the-heart-appears-wherever-an-item-does">5C.1 The heart appears wherever an item does</a></h3>
|
||
<p>A favourite is a property of an <em>item</em>, so the affordance follows the item
|
||
rather than living on one privileged screen. Any surface that shows a whole
|
||
item shows its heart.</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Heart position</th><th>Notes</th></tr></thead><tbody>
|
||
<tr><td>Movie / Series detail hero</td><td>In the button row, after Play and Download</td><td>§5B.3, §5B.4</td></tr>
|
||
<tr><td>Episode Focus View hero</td><td>Same row as Play / Download</td><td>§5B.2</td></tr>
|
||
<tr><td>Album, Artist, Playlist detail</td><td>In the header button row</td><td>§5.2</td></tr>
|
||
<tr><td>Media card (any grid or carousel)</td><td>Top-right overlay on the artwork</td><td>Hidden on server-only (greyed) cards</td></tr>
|
||
<tr><td>Mini player</td><td>Right of the track metadata</td><td>Existing behaviour, unchanged</td></tr>
|
||
<tr><td>Full player</td><td>Secondary controls row</td><td>§3.2 — <strong>not yet built</strong>, see §5C.5</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p>Rules:</p>
|
||
<ul>
|
||
<li><strong>The heart never competes with the card.</strong> On a media card it is its own
|
||
button and swallows the tap, so hearting an item never also opens or plays
|
||
it, and never triggers the §5B.5 long-press.</li>
|
||
<li><strong>State is shown, not guessed.</strong> A filled heart means the <em>server</em> considers
|
||
the item a favourite (or you just tapped it). An item favourited in Jellyfin
|
||
Web, on another device, or by another client renders filled here without
|
||
being touched in JellyTau.</li>
|
||
<li><strong>Feedback is immediate.</strong> The heart fills on tap and a toast confirms;
|
||
neither waits for the server round-trip.</li>
|
||
</ul>
|
||
<h3 id="5c2-three-ways-back-to-what-you-favourited"><a class="header" href="#5c2-three-ways-back-to-what-you-favourited">5C.2 Three ways back to what you favourited</a></h3>
|
||
<p>Favourites are not one destination — they are a lens, and the right surface
|
||
depends on whether the user is <em>browsing</em>, <em>deciding</em>, or <em>hunting</em>.</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
User[User wants their favourites] --> How{Intent}
|
||
|
||
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
|
||
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
|
||
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
|
||
|
||
Home -->|See all| Page
|
||
Page --> Detail[Item detail page]
|
||
Filter --> Detail
|
||
</code></pre>
|
||
<p><strong>Home carousels.</strong> Rows for favourite movies, shows and music sit below
|
||
<em>Recently Added</em>. A row with nothing in it <strong>does not render</strong> — a fresh install
|
||
shows no empty favourite rows. Each row ends with <em>See all</em>, landing on the
|
||
matching tab of the Favourites page.</p>
|
||
<p><strong>The Favourites page</strong> (<code>/library/favorites</code>) is the complete collection,
|
||
scoped by tabs:</p>
|
||
<pre><code>┌─────────────────────────────────────────────────┐
|
||
│ [←] Favourites │
|
||
│ ┌─────┬────────┬───────┬───────┐ │
|
||
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
|
||
│ └─────┴────────┴───────┴───────┘ │
|
||
│ │
|
||
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
|
||
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
|
||
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
|
||
└─────────────────────────────────────────────────┘
|
||
</code></pre>
|
||
<ul>
|
||
<li>Cards obey §5A in full — shape follows the media, so a mixed <em>All</em> tab reads
|
||
as posters, squares and thumbnails side by side rather than one forced shape.</li>
|
||
<li>Reached from a card on the library overview (<code>/library</code>) and from <em>See all</em>
|
||
on any home favourites row.</li>
|
||
<li>Sorted by name. Jellyfin does not record <em>when</em> an item was favourited, so
|
||
"recently favourited" is not offerable — see §5C.5.</li>
|
||
<li>Empty state, per tab: <em>"Nothing favourited yet — tap the heart on anything
|
||
you like."</em></li>
|
||
</ul>
|
||
<p><strong>The in-library filter</strong> is for narrowing where the user already is: a
|
||
favourites toggle in the header of the Movies, TV and Music browse pages,
|
||
filtering the current list in place. It is <strong>session-scoped and not persisted</strong> —
|
||
a sticky filter that silently hides most of a library reads as data loss on the
|
||
next launch.</p>
|
||
<h3 id="5c3-removing-a-favourite-removes-it-everywhere-at-once"><a class="header" href="#5c3-removing-a-favourite-removes-it-everywhere-at-once">5C.3 Removing a favourite removes it everywhere, at once</a></h3>
|
||
<p>Un-hearting an item on the Favourites page removes its card from the grid
|
||
immediately; the same item disappears from the home rows and shows an empty
|
||
heart on its detail page without a manual refresh. The reverse holds for
|
||
favouriting. There is no confirmation prompt — the action is one tap to undo.</p>
|
||
<h3 id="5c4-offline"><a class="header" href="#5c4-offline">5C.4 Offline</a></h3>
|
||
<ul>
|
||
<li><strong>Marking works offline.</strong> The heart fills, the toast confirms, and the change
|
||
is held locally.</li>
|
||
<li><strong>It reaches the server on reconnect</strong>, without the user returning to the
|
||
screen where they made it.</li>
|
||
<li><strong>Browsing offline shows favourites among media on the device</strong>, subject to
|
||
the same "Show all server media" gate as every other browse surface (§7.2) —
|
||
with the gate off, an empty Favourites tab means <em>nothing favourited is
|
||
downloaded</em>, and the page does not quietly fall back to the server catalog.</li>
|
||
</ul>
|
||
<h3 id="5c5-known-deviations"><a class="header" href="#5c5-known-deviations">5C.5 Known deviations</a></h3>
|
||
<ul>
|
||
<li><strong>The full player has no heart.</strong> §3.2 and §3.3 list a Favorite button among
|
||
the full player's secondary controls; it was never built, and this pass does
|
||
not add it. The mini player heart above it is the only in-player affordance.
|
||
<em>(UR-067)</em></li>
|
||
<li><strong>No "recently favourited" sort.</strong> Jellyfin's API does not expose a favourite
|
||
timestamp, so favourites can only be ordered by name. Recording the
|
||
timestamp locally at toggle time would order <em>this device's</em> favourites only,
|
||
which is worse than a consistent name sort.</li>
|
||
<li><strong>Music is one tab, not three.</strong> The Music scope mixes albums, artists and
|
||
tracks in a single grid rather than offering sub-tabs. Acceptable while
|
||
favourite counts are small; revisit if the tab becomes unscannable.</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="6-search-flow"><a class="header" href="#6-search-flow">6. Search Flow</a></h2>
|
||
<p>Search is <strong>context-scoped</strong>: what you are looking at when you start a search
|
||
determines what the search covers. A search begun inside the Music library
|
||
searches music. A search begun from Home or the top-level library page searches
|
||
everything. The scope is always shown, and always overridable.</p>
|
||
<h3 id="61-scope-is-inherited-from-context"><a class="header" href="#61-scope-is-inherited-from-context">6.1 Scope is inherited from context</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Start[User starts a search] --> Where{Where from?}
|
||
|
||
Where -->|Home &#40;/&#41;| All[Scope: All]
|
||
Where -->|Library root &#40;/library&#41;| All
|
||
Where -->|Search tab| All
|
||
Where -->|Inside Music| Music[Scope: Music]
|
||
Where -->|Inside Movies| Movies[Scope: Movies]
|
||
Where -->|Inside TV| TV[Scope: TV]
|
||
|
||
All --> Chips[Filter chips shown<br/>All chip selected]
|
||
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
|
||
Movies --> Chips2
|
||
TV --> Chips2
|
||
|
||
Chips --> Results[Results, grouped by type]
|
||
Chips2 --> Results
|
||
|
||
Results --> Change{User taps a chip}
|
||
Change --> Rescope[Re-run search at new scope<br/>query preserved]
|
||
Rescope --> Results
|
||
</code></pre>
|
||
<p><strong>Rules:</strong></p>
|
||
<ul>
|
||
<li>
|
||
<p><strong>Context sets the <em>initial</em> chip, never a locked filter.</strong> Entering search
|
||
from TV preselects the TV chip; the user can tap "All" to widen without
|
||
retyping the query. Scope is a starting point, not a cage.</p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Home, <code>/library</code>, and the search tab all start at "All".</strong> These are the
|
||
places a user has expressed no narrower intent.</p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Changing scope preserves the query</strong> and re-runs the search. Changing the
|
||
query preserves the scope.</p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Scope maps to item types</strong>, resolved at the point of search:</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Chip</th><th><code>includeItemTypes</code></th></tr></thead><tbody>
|
||
<tr><td>All</td><td><em>(unset — every type)</em></td></tr>
|
||
<tr><td>Music</td><td><code>MusicAlbum</code>, <code>MusicArtist</code>, <code>Audio</code>, <code>Playlist</code></td></tr>
|
||
<tr><td>Movies</td><td><code>Movie</code></td></tr>
|
||
<tr><td>TV</td><td><code>Series</code>, <code>Episode</code></td></tr>
|
||
</tbody></table>
|
||
</div></li>
|
||
<li>
|
||
<p><strong>Chips render under the search bar</strong>, on both the dedicated search page and
|
||
the in-library header search. They are horizontally scrollable if they
|
||
overflow, never wrapped onto a second row.</p>
|
||
</li>
|
||
</ul>
|
||
<h3 id="62-search-page-layout"><a class="header" href="#62-search-page-layout">6.2 Search page layout</a></h3>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ [🔍 Search...] [✕] │
|
||
│ │
|
||
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
|
||
│ │
|
||
│ Songs ──────────────────────────── │
|
||
│ ♪ Song Title - Artist 3:45 │
|
||
│ ♪ Song Title - Artist 4:12 │
|
||
│ See all (23) │
|
||
│ │
|
||
│ Albums ─────────────────────────── │
|
||
│ [Cover] Album Title │
|
||
│ See all (8) │
|
||
│ │
|
||
│ Artists ────────────────────────── │
|
||
│ ( Photo ) Artist Name │
|
||
│ See all (5) │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<ul>
|
||
<li>Results stay <strong>grouped by type</strong> even when a scope is selected — a Music
|
||
search still separates Songs / Albums / Artists.</li>
|
||
<li>Each group shows a bounded preview with a <strong>See all (n)</strong> affordance rather
|
||
than an unbounded list, so no single type can bury the others.</li>
|
||
<li>Live search is <strong>debounced</strong> as the user types; a query that becomes empty
|
||
clears results rather than searching for the empty string.</li>
|
||
</ul>
|
||
<h3 id="63-result-group-order-is-user-configurable"><a class="header" href="#63-result-group-order-is-user-configurable">6.3 Result group order is user-configurable</a></h3>
|
||
<p>Which <em>kind</em> of thing a user is usually searching for is personal: a
|
||
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
|
||
than guessing, the group order is a setting.</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Settings[Settings → Search] --> List[Draggable list of result groups]
|
||
|
||
List --> Drag[User drags a group up or down]
|
||
Drag --> Persist[Order persisted]
|
||
|
||
Persist --> Render[Rendering a result set]
|
||
Scope[Active scope chip §6.1] --> Render
|
||
|
||
Render --> Filter[1 - Drop groups outside the active scope]
|
||
Filter --> Sort[2 - Sort remaining groups by user order]
|
||
Sort --> Prune[3 - Omit groups with no results]
|
||
Prune --> Show[Render]
|
||
</code></pre>
|
||
<p><strong>Scope and order compose — they are two independent axes.</strong> The scope chip
|
||
decides <em>which</em> groups are eligible; the settings list decides <em>what sequence</em>
|
||
the eligible ones appear in. Order is preserved as a relative ranking, never
|
||
renumbered per scope:</p>
|
||
<ul>
|
||
<li>Scope <strong>Music</strong> with order <code>Movies → Songs → Albums → Artists → TV</code> renders
|
||
<code>Songs → Albums → Artists</code>. Movies and TV are filtered out; the surviving
|
||
groups keep their relative order.</li>
|
||
<li>Scope <strong>All</strong> with the same setting renders all five in exactly that order.</li>
|
||
<li><strong>Changing scope never rewrites the saved order.</strong> A user who narrows to
|
||
Music and back to All sees their original arrangement intact.</li>
|
||
</ul>
|
||
<p><strong>Rules:</strong></p>
|
||
<ul>
|
||
<li><strong>Drag and drop to reorder</strong>, in a settings list showing every result group
|
||
(Songs, Albums, Artists, Movies, TV Shows).</li>
|
||
<li><strong>The order applies to grouped results everywhere</strong> — the search page and
|
||
the in-library header search alike.</li>
|
||
<li><strong>Order is presentation-only.</strong> It never changes which results are returned
|
||
or how they are ranked <em>within</em> a group, only the sequence groups appear in.</li>
|
||
<li><strong>Empty groups are skipped, not gapped.</strong> A group with no results is omitted
|
||
entirely; it does not reserve space or leave a stray heading.</li>
|
||
<li><strong>A sensible default ships</strong> (Songs → Albums → Artists → Movies → TV Shows)
|
||
so the setting is an adjustment, never a prerequisite.</li>
|
||
<li><strong>Keyboard/accessible reordering must exist</strong> alongside dragging — a
|
||
drag-only control is unusable with a screen reader or without a pointer.</li>
|
||
</ul>
|
||
<h3 id="64-known-deviations"><a class="header" href="#64-known-deviations">6.4 Known deviations</a></h3>
|
||
<p>Recorded so the gap between this spec and the build is explicit.</p>
|
||
<ul>
|
||
<li><strong>Scope is not implemented.</strong> The in-library header search calls the same
|
||
unscoped query as the global search page, so searching inside TV returns
|
||
music. The backend already accepts <code>includeItemTypes</code> on both the online and
|
||
offline paths, and the per-page list search already uses it — only the global
|
||
path ignores it. <em>(UR-049)</em></li>
|
||
<li><strong>Filter chips do not exist</strong> on either search surface. <em>(UR-049)</em></li>
|
||
<li><strong>Group order is hardcoded</strong> to Music → Movies → TV in the results markup,
|
||
with no setting. <em>(UR-050)</em></li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="7-download-flows"><a class="header" href="#7-download-flows">7. Download Flows</a></h2>
|
||
<h3 id="71-initiating-downloads"><a class="header" href="#71-initiating-downloads">7.1 Initiating Downloads</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
User[User on Album/Track Page] --> ClickDownload[Click Download Button]
|
||
|
||
ClickDownload --> CheckType{Download Type?}
|
||
|
||
CheckType -->|Single Track| DownloadTrack[Download Single File]
|
||
CheckType -->|Album| DownloadAlbum[Download All Tracks]
|
||
CheckType -->|Artist| ShowOptions[Show Options Dialog]
|
||
|
||
ShowOptions --> UserChoice{User Choice}
|
||
UserChoice -->|Discography| DownloadAll[Download All Albums]
|
||
UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]
|
||
|
||
DownloadTrack --> QueueDownload[Queue in Download Manager]
|
||
DownloadAlbum --> QueueMultiple[Queue Multiple Files]
|
||
|
||
QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
|
||
QueueMultiple --> ShowProgress
|
||
|
||
ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]
|
||
</code></pre>
|
||
<p><strong>Download Button States:</strong></p>
|
||
<pre><code>States:
|
||
1. [⬇] Available - Gray outline
|
||
2. [○ 45%] Downloading - Blue ring progress
|
||
3. [✓] Downloaded - Green checkmark
|
||
4. [!] Failed - Red with retry option
|
||
5. [⏸] Paused - Yellow pause icon
|
||
</code></pre>
|
||
<h3 id="72-downloads--a-browsable-offline-library-not-a-flat-list"><a class="header" href="#72-downloads--a-browsable-offline-library-not-a-flat-list">7.2 Downloads = a browsable offline library, not a flat list</a></h3>
|
||
<p><strong>The central idea:</strong> "my downloads" is not a list of file-transfer rows — it is
|
||
<em>the library, filtered to what's on the device</em>. A user who has downloaded three
|
||
seasons of a show and two albums thinks in terms of shows and albums, not
|
||
seventy-odd individual episode/track transfers. So the primary Downloads surface
|
||
<strong>reuses the library browse screens</strong>, scoped to downloaded content, and keeps
|
||
the transfer-progress list as a secondary "Transfers" view for the <em>act</em> of
|
||
downloading.</p>
|
||
<p>This splits one overloaded page into two clear jobs:</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Surface</th><th>Answers</th><th>Reuses</th></tr></thead><tbody>
|
||
<tr><td><strong>Downloaded</strong> (browse)</td><td>"What do I have offline, and let me play it"</td><td>Library grids, detail pages, cards (§5A)</td></tr>
|
||
<tr><td><strong>Transfers</strong> (activity)</td><td>"What is downloading right now, and control it"</td><td>The existing progress-row list</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Nav[Open Downloads] --> Downloads[/downloads]
|
||
|
||
Downloads --> View{View}
|
||
View -->|Downloaded &#40;default&#41;| Browse[Offline library browse]
|
||
View -->|Transfers| Activity[Transfer activity list]
|
||
|
||
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||
Grid --> Detail[Detail page<br/>same as online]
|
||
Detail --> Play[Play from local file]
|
||
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
|
||
|
||
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||
</code></pre>
|
||
<p><strong>Why reuse the library screens (not a bespoke list):</strong></p>
|
||
<ul>
|
||
<li><strong>One mental model.</strong> Browsing offline should feel identical to browsing
|
||
online — same grids, same card shapes, same detail pages, same play action.
|
||
The only difference is <em>what's present</em>, not <em>how it looks</em>.</li>
|
||
<li><strong>It already works in the backend.</strong> The offline repository's <code>get_items</code>
|
||
already returns downloaded items <strong>plus</strong> their containers (an album with any
|
||
downloaded track, a series/season with any downloaded episode). That is a
|
||
browsable tree today — see §7.4.</li>
|
||
<li><strong>It scales.</strong> A flat completed-list becomes unusable at a few dozen items; a
|
||
browsable library does not.</li>
|
||
</ul>
|
||
<h3 id="73-the-downloaded-browse-surface"><a class="header" href="#73-the-downloaded-browse-surface">7.3 The Downloaded browse surface</a></h3>
|
||
<pre><code>┌─────────────────────────────────────────────┐
|
||
│ Downloads │
|
||
│ ( Downloaded ) ( Transfers ) ← view switch
|
||
│ │
|
||
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||
│ │
|
||
│ Music │ ← only libraries that
|
||
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||
│ │alb ││alb ││art │ │
|
||
│ └────┘└────┘└────┘ │
|
||
│ │
|
||
│ TV │
|
||
│ ┌────┐┌────┐ │
|
||
│ │show││show│ │
|
||
│ └────┘└────┘ │
|
||
└─────────────────────────────────────────────┘
|
||
</code></pre>
|
||
<p><strong>Rules:</strong></p>
|
||
<ul>
|
||
<li><strong>Libraries with nothing downloaded are omitted</strong>, not shown empty. If only
|
||
music is downloaded, only Music appears.</li>
|
||
<li><strong>Cards, grids, and detail pages are the library's own</strong> (§5A) — offline
|
||
browse is the same components with an offline-scoped data source, never a
|
||
parallel re-implementation.</li>
|
||
<li><strong>A downloaded badge / "on device" affordance</strong> distinguishes fully-downloaded
|
||
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).</li>
|
||
<li><strong>Disk usage is shown where the user already looks</strong>, in familiar units — see
|
||
§7.3.1.</li>
|
||
<li><strong>Play always plays the local file</strong> here; nothing on this surface streams.</li>
|
||
<li><strong>Remove is available at every level</strong> — item, album/season, series — and
|
||
states clearly what it frees. Removing the last downloaded child of a
|
||
container removes the container from the browse.</li>
|
||
<li><strong>This surface works identically online and offline.</strong> It is "what's on the
|
||
device," a question whose answer does not depend on connectivity. It must not
|
||
wait for, or be emptied by, server reachability.</li>
|
||
</ul>
|
||
<h4 id="731-disk-usage--familiar-in-place-not-a-separate-audit"><a class="header" href="#731-disk-usage--familiar-in-place-not-a-separate-audit">7.3.1 Disk usage — familiar, in place, not a separate audit</a></h4>
|
||
<p>Users want to know what each thing costs on disk, but that information has to
|
||
feel like the storage views they already know (phone Settings → Storage, a
|
||
file browser), not a developer's byte dump.</p>
|
||
<ul>
|
||
<li><strong>Size rides along with the item, on the card and the detail page</strong> — a small
|
||
secondary label (<code>1.2 GB</code>, <code>340 MB</code>, <code>48 MB</code>), never a separate "storage
|
||
report" screen the user has to go find.</li>
|
||
<li><strong>Containers show their total.</strong> A series shows the sum of its downloaded
|
||
episodes; an album the sum of its tracks; a season its own subtotal. The
|
||
number a user sees on the "Breaking Bad" card is what removing it frees.</li>
|
||
<li><strong>Human units, rounded, consistent.</strong> Binary or decimal is a choice — pick one
|
||
and use it everywhere. Show 2–3 significant figures (<code>1.2 GB</code>, not
|
||
<code>1,283,048,192 bytes</code> and not <code>1.28394 GB</code>).</li>
|
||
<li><strong>A single device total sits at the top</strong> of the Downloaded surface
|
||
(<code>3.4 GB on device · 12 items</code>) so the headline number is answered before the
|
||
user scans. It reconciles with the sum of what's listed.</li>
|
||
<li><strong>Remove restates the reclaim</strong> in the same units at the point of action
|
||
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
|
||
legible exactly when the user decides.</li>
|
||
<li><strong>Sort/filter by size is a reasonable enhancement</strong> ("biggest first" to find
|
||
what to clear) but is not required for v1.</li>
|
||
</ul>
|
||
<p>The bytes-on-disk per item are a backend fact (the download manager writes the
|
||
files and can stat them); this is a display and aggregation task, not new
|
||
tracking. See §7.7 deviations for what's missing today.</p>
|
||
<h3 id="74-transfers-activity-view"><a class="header" href="#74-transfers-activity-view">7.4 Transfers (activity) view</a></h3>
|
||
<p>The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
|
||
It is about <em>transfers in flight</em>, so it shows only rows that are doing or
|
||
waiting to do something:</p>
|
||
<ul>
|
||
<li><strong>States:</strong> downloading (with progress), queued, paused, failed,
|
||
waiting-for-WiFi (§7.5).</li>
|
||
<li><strong>Controls:</strong> Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
|
||
and auto-pump are backend concerns and are not surfaced as manual controls.</li>
|
||
<li><strong>Completed transfers fall off this view</strong> once done — the finished item lives
|
||
in Downloaded, not here. A transient "just finished" confirmation is fine; a
|
||
permanent completed-list is not (that's what Downloaded is for).</li>
|
||
<li><strong>Empty state</strong> points at the library: "Nothing downloading. Browse your
|
||
library and tap download to save media for offline."</li>
|
||
</ul>
|
||
<h3 id="75-navigation--entry-points"><a class="header" href="#75-navigation--entry-points">7.5 Navigation & entry points</a></h3>
|
||
<ul>
|
||
<li>Reached via the account menu (§1.2) and, on desktop, the header Downloads
|
||
link/icon → <code>/downloads</code>.</li>
|
||
<li><code>/downloads</code> opens on <strong>Downloaded</strong> by default; <strong>Transfers</strong> is one tap away
|
||
and should draw attention (badge/count) only while transfers are active.</li>
|
||
<li>Initiating a download is unchanged (§7.1): the download button lives on
|
||
item/album/series detail pages. The Downloads page manages and browses; it is
|
||
not where you start a download.</li>
|
||
</ul>
|
||
<h3 id="77-known-deviations"><a class="header" href="#77-known-deviations">7.7 Known deviations</a></h3>
|
||
<p>Recorded so the gap between this spec and the build is explicit.</p>
|
||
<ul>
|
||
<li><strong>Downloads is a flat two-tab list today</strong> (Active / Completed), rendering one
|
||
row per individual transfer with no browsing, grouping, or reuse of the
|
||
library screens. Completed downloads never collapse into their album/series.
|
||
<em>(UR-055)</em></li>
|
||
<li><strong>No offline-scoped browse entry point exists in the client.</strong> All browsing
|
||
goes through the hybrid repository, which merges cache <strong>and</strong> server; there is
|
||
no way to ask for "downloaded content only" as a browse surface. The offline
|
||
repository supports it (§7.2) but is not reachable independently. <em>(UR-055,
|
||
DR-082)</em></li>
|
||
<li><strong>The "on device" storage summary and per-container remove</strong> are absent from
|
||
the completed list. <em>(UR-055, UR-056)</em></li>
|
||
<li><strong>Per-item disk usage is not displayed anywhere.</strong> Cards and detail pages show
|
||
no size; there is no device total, no container subtotal, and Remove does not
|
||
state what it frees. <em>(UR-056)</em></li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="8-settings--account-flows"><a class="header" href="#8-settings--account-flows">8. Settings & Account Flows</a></h2>
|
||
<h3 id="81-settings-navigation"><a class="header" href="#81-settings-navigation">8.1 Settings Navigation</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
User[User] --> NavChoice{Navigation Path}
|
||
|
||
NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
|
||
NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
|
||
NavChoice -->|Direct| TypeURL[Navigate to /settings]
|
||
|
||
HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
|
||
OverflowMenu --> SettingsPage
|
||
TypeURL --> SettingsPage
|
||
|
||
SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]
|
||
|
||
ShowSections --> UserClick{User Clicks Section}
|
||
|
||
UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
|
||
UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
|
||
UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
|
||
UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]
|
||
</code></pre>
|
||
<p><strong>Navigation to Settings:</strong></p>
|
||
<ul>
|
||
<li><strong>Desktop:</strong> Click "Settings" link in header navigation</li>
|
||
<li><strong>Mobile:</strong> Click three-dot overflow menu → Select "Settings"</li>
|
||
<li><strong>Direct:</strong> Navigate to <code>/settings</code> route</li>
|
||
</ul>
|
||
<p><strong>Settings apply instantly.</strong> Every control on the Settings page persists the
|
||
moment the user changes it — toggling a switch, picking a level, or releasing a
|
||
slider writes that setting immediately. There is <strong>no "Save" button</strong> and no
|
||
save/dirty state to reason about; leaving the page never risks losing a change.
|
||
Sliders update their live readout while dragging but only persist on release
|
||
(<code>change</code>, not each <code>input</code> tick) to avoid flooding the backend.</p>
|
||
<h3 id="82-logout-flow"><a class="header" href="#82-logout-flow">8.2 Logout Flow</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]
|
||
|
||
ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]
|
||
|
||
ConfirmDialog --> UserConfirm{User Confirms?}
|
||
|
||
UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
|
||
UserConfirm -->|Yes| StopPlayer[Stop Playback]
|
||
|
||
StopPlayer --> ClearToken[Delete Token from Keyring]
|
||
ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]
|
||
|
||
ClearState --> NavLogin[Navigate to Login Screen<br/>/login]
|
||
|
||
NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]
|
||
</code></pre>
|
||
<p><strong>Logout Button Location:</strong></p>
|
||
<ul>
|
||
<li>Always visible in header user menu (logout icon)</li>
|
||
<li>Accessible from any authenticated screen</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="9-background--lock-screen-behavior"><a class="header" href="#9-background--lock-screen-behavior">9. Background & Lock Screen Behavior</a></h2>
|
||
<h3 id="91-audio-playback-in-background-android"><a class="header" href="#91-audio-playback-in-background-android">9.1 Audio Playback in Background (Android)</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Playing[Audio Playing] --> Background{User Action}
|
||
|
||
Background -->|Home Button| AppBackground[App to Background]
|
||
Background -->|Screen Lock| ScreenLock[Screen Locked]
|
||
|
||
AppBackground --> ContinuePlay[Playback Continues]
|
||
ScreenLock --> ContinuePlay
|
||
|
||
ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]
|
||
|
||
ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]
|
||
|
||
LockScreen --> UserInteract{User Interaction}
|
||
|
||
UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
|
||
UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
|
||
UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]
|
||
</code></pre>
|
||
<p><strong>Notification Layout (Android):</strong></p>
|
||
<pre><code>┌─────────────────────────────────────────┐
|
||
│ [Artwork] Song Title │
|
||
│ Artist Name │
|
||
│ Album Name │
|
||
│ │
|
||
│ [⏮] [⏸] [⏭] [✕] │
|
||
└─────────────────────────────────────────┘
|
||
</code></pre>
|
||
<h3 id="92-video-playback-in-background-android--pip--background-audio"><a class="header" href="#92-video-playback-in-background-android--pip--background-audio">9.2 Video Playback in Background (Android — PiP & Background Audio)</a></h3>
|
||
<p>Leaving the app while a <strong>local video</strong> is playing does not simply pause it.
|
||
What happens depends on which background behaviour is active. The two are
|
||
<strong>mutually exclusive</strong>, and both apply <strong>only to locally-rendering video</strong> —
|
||
audio-only playback, library/menu browsing, and remote/cast sessions never
|
||
trigger PiP (see decision gate below).</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
|
||
|
||
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification &#40;§9.1&#41;]
|
||
|
||
Gate -->|Yes| Mode{Background mode armed?}
|
||
|
||
Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView &lt;video&gt; torn down,<br/>video decode stops, audio continues]
|
||
Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
|
||
|
||
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> &#40;reflects live player state&#41;]
|
||
|
||
PiPWindow --> PiPReturn{User action}
|
||
PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
|
||
PiPReturn -->|Close window| Stop[Playback stops]
|
||
|
||
Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
|
||
</code></pre>
|
||
<p><strong>Key rules:</strong></p>
|
||
<ul>
|
||
<li><strong>Video-only gate.</strong> Auto-PiP is guarded by the native <code>canEnterPip</code> check
|
||
(local video surface actively rendering). Audio playback and menu/library
|
||
browsing background normally; remote/cast sessions render nothing locally, so
|
||
a PiP window would be an empty box and is refused. <em>(UR-041, IR-026)</em></li>
|
||
<li><strong>Only one background behaviour at a time.</strong> The background-audio toggle
|
||
(UR-040) disarms auto-PiP while it is on, so a video is either handed to the
|
||
audio service <em>or</em> floated in PiP, never both.</li>
|
||
<li><strong>PiP controls track the player.</strong> The play/pause RemoteAction in the PiP
|
||
window reflects the live player state and updates on every playback-state
|
||
change, not only when the button is pressed. <em>(DR-053)</em></li>
|
||
<li><strong>Non-disruptive transition.</strong> ExoPlayer keeps rendering into the same
|
||
surface across enter/exit, so entering or leaving PiP never interrupts the
|
||
video; on exit the surface is re-fit to full-screen bounds. <em>(DR-053)</em></li>
|
||
</ul>
|
||
<p><strong>PiP window (Android):</strong></p>
|
||
<pre><code> ┌───────────────────┐
|
||
│ │
|
||
│ ▶ video frame │
|
||
│ │
|
||
│ [⏸] │ ← play/pause RemoteAction
|
||
└───────────────────┘
|
||
sized to the video's aspect ratio
|
||
</code></pre>
|
||
<hr />
|
||
<h2 id="10-error-states--edge-cases"><a class="header" href="#10-error-states--edge-cases">10. Error States & Edge Cases</a></h2>
|
||
<h3 id="101-network-loss-during-streaming"><a class="header" href="#101-network-loss-during-streaming">10.1 Network Loss During Streaming</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]
|
||
|
||
LoseNetwork --> CheckLocal{Local Copy<br/>Available?}
|
||
|
||
CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
|
||
CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]
|
||
|
||
ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]
|
||
|
||
WaitReconnect --> Reconnect{Reconnected?}
|
||
|
||
Reconnect -->|Yes| Resume[Resume Streaming]
|
||
Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]
|
||
|
||
ShowError --> OfferRetry[Offer Retry Button]
|
||
ShowError --> OfferDownload[Offer "Download for Offline"]
|
||
</code></pre>
|
||
<h3 id="102-server-unreachable"><a class="header" href="#102-server-unreachable">10.2 Server Unreachable</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Action[User Action Requires Server] --> TryConnect[Attempt Connection]
|
||
|
||
TryConnect --> Timeout{Connection<br/>Timeout?}
|
||
|
||
Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
|
||
Timeout -->|No| Success[Action Succeeds]
|
||
|
||
ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]
|
||
</code></pre>
|
||
<h3 id="103-download-failed"><a class="header" href="#103-download-failed">10.3 Download Failed</a></h3>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
Downloading[Download in Progress] --> Failure{Failure Type?}
|
||
|
||
Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
|
||
Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
|
||
Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]
|
||
|
||
Retry --> RetryCount{Retry Count<br/>< 3?}
|
||
RetryCount -->|Yes| Downloading
|
||
RetryCount -->|No| Failed[Mark as Failed]
|
||
|
||
ShowDiskError --> Failed
|
||
ShowServerError --> Failed
|
||
|
||
Failed --> UserAction[Show in Downloads:<br/>with Retry Button]
|
||
</code></pre>
|
||
<hr />
|
||
<h2 id="11-platform-specific-ux-patterns"><a class="header" href="#11-platform-specific-ux-patterns">11. Platform-Specific UX Patterns</a></h2>
|
||
<h3 id="111-android-specific"><a class="header" href="#111-android-specific">11.1 Android-Specific</a></h3>
|
||
<p><strong>Hardware Back Button:</strong></p>
|
||
<ul>
|
||
<li><strong>In Full Player:</strong> Return to previous screen, show MiniPlayer</li>
|
||
<li><strong>In Video Player:</strong> Stop playback, exit fullscreen</li>
|
||
<li><strong>In Album Detail:</strong> Return to library grid</li>
|
||
<li><strong>At Library Home:</strong> Exit app (show confirmation)</li>
|
||
</ul>
|
||
<p><strong>System Volume Buttons:</strong></p>
|
||
<ul>
|
||
<li><strong>While playing audio:</strong> Adjust playback volume</li>
|
||
<li><strong>While controlling remote session:</strong> Adjust remote session volume (shows session name in volume panel)</li>
|
||
<li><strong>In menus:</strong> Adjust system volume (default behavior)</li>
|
||
</ul>
|
||
<p><strong>Share Integration:</strong></p>
|
||
<ul>
|
||
<li>Long-press album/song → Share menu</li>
|
||
<li>Options: Share with other apps, Copy link</li>
|
||
</ul>
|
||
<h3 id="112-linux-desktop-specific"><a class="header" href="#112-linux-desktop-specific">11.2 Linux Desktop-Specific</a></h3>
|
||
<p><strong>Keyboard Shortcuts:</strong></p>
|
||
<ul>
|
||
<li><code>Space</code>: Play/Pause</li>
|
||
<li><code>→</code>: Next track</li>
|
||
<li><code>←</code>: Previous track</li>
|
||
<li><code>/</code>: Focus search</li>
|
||
<li><code>Ctrl+Q</code>: Quit</li>
|
||
</ul>
|
||
<p><strong>Window Behavior:</strong></p>
|
||
<ul>
|
||
<li>Minimize to tray (playback continues)</li>
|
||
<li>Close window (show confirmation if playing)</li>
|
||
<li>MPRIS integration for desktop media controls</li>
|
||
</ul>
|
||
<p><strong>Mouse Interactions:</strong></p>
|
||
<ul>
|
||
<li>Hover over MiniPlayer: Show additional controls (volume, queue peek)</li>
|
||
<li>Right-click: Context menu (Add to playlist, Go to artist, Download)</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="12-ux-principles-summary"><a class="header" href="#12-ux-principles-summary">12. UX Principles Summary</a></h2>
|
||
<h3 id="121-core-principles"><a class="header" href="#121-core-principles">12.1 Core Principles</a></h3>
|
||
<ol>
|
||
<li>
|
||
<p><strong>Playback Persistence:</strong></p>
|
||
<ul>
|
||
<li>Audio playback never stops unless user explicitly stops it</li>
|
||
<li>MiniPlayer visible on all screens (except video/login)</li>
|
||
<li>Queue and position preserved across navigation</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Non-Blocking UI:</strong></p>
|
||
<ul>
|
||
<li>Downloads happen in background</li>
|
||
<li>Sync operations never block user interaction</li>
|
||
<li>Optimistic updates (favorite, progress) with background sync</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Offline-First:</strong></p>
|
||
<ul>
|
||
<li>Downloaded content works offline</li>
|
||
<li>Seamless switch between online/offline</li>
|
||
<li>Progress and preferences saved locally</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Progressive Disclosure:</strong></p>
|
||
<ul>
|
||
<li>Simple defaults, advanced options hidden</li>
|
||
<li>Context menus for secondary actions</li>
|
||
<li>Settings organized by category</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Responsive Design:</strong></p>
|
||
<ul>
|
||
<li>Mobile-first UI</li>
|
||
<li>Desktop enhancements (hover states, keyboard shortcuts)</li>
|
||
<li>Tablet: Grid layouts with more columns</li>
|
||
</ul>
|
||
</li>
|
||
</ol>
|
||
<h3 id="122-animation--transitions"><a class="header" href="#122-animation--transitions">12.2 Animation & Transitions</a></h3>
|
||
<div class="table-wrapper"><table><thead><tr><th>Transition</th><th>Duration</th><th>Easing</th></tr></thead><tbody>
|
||
<tr><td>MiniPlayer slide up/down</td><td>300ms</td><td>ease-out</td></tr>
|
||
<tr><td>Screen navigation</td><td>200ms</td><td>ease-in-out</td></tr>
|
||
<tr><td>Video controls fade</td><td>500ms</td><td>ease-out</td></tr>
|
||
<tr><td>Download button state change</td><td>150ms</td><td>ease-in-out</td></tr>
|
||
<tr><td>Modal appear</td><td>200ms</td><td>ease-out</td></tr>
|
||
<tr><td>Toast notification</td><td>250ms</td><td>ease-in-out</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<h3 id="123-touch-targets-mobile"><a class="header" href="#123-touch-targets-mobile">12.3 Touch Targets (Mobile)</a></h3>
|
||
<div class="table-wrapper"><table><thead><tr><th>Element</th><th>Minimum Size</th></tr></thead><tbody>
|
||
<tr><td>Bottom nav buttons</td><td>48x48 dp</td></tr>
|
||
<tr><td>List item (track, album)</td><td>Full width x 56 dp</td></tr>
|
||
<tr><td>Player controls</td><td>56x56 dp</td></tr>
|
||
<tr><td>MiniPlayer</td><td>Full width x 64 dp</td></tr>
|
||
<tr><td>Download button</td><td>40x40 dp</td></tr>
|
||
<tr><td>Favorite button</td><td>40x40 dp</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<hr />
|
||
<h2 id="13-future-ux-enhancements"><a class="header" href="#13-future-ux-enhancements">13. Future UX Enhancements</a></h2>
|
||
<h3 id="131-planned-features"><a class="header" href="#131-planned-features">13.1 Planned Features</a></h3>
|
||
<ol>
|
||
<li>
|
||
<p><strong>Gesture Navigation:</strong></p>
|
||
<ul>
|
||
<li>Swipe up on MiniPlayer → Full player</li>
|
||
<li>Swipe down on full player → Back to previous screen</li>
|
||
<li>Swipe between tracks in full player</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Queue Management UI (DR-020):</strong></p>
|
||
<ul>
|
||
<li>Drag to reorder</li>
|
||
<li>Swipe to remove</li>
|
||
<li>Add to queue vs. Play next</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Sleep Timer (UR-026):</strong></p>
|
||
<ul>
|
||
<li>Accessible from full player menu</li>
|
||
<li>Presets: 15min, 30min, 1hr, End of track, End of album</li>
|
||
<li>Countdown visible in MiniPlayer</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Home Screen (UR-034):</strong></p>
|
||
<ul>
|
||
<li>Hero banner carousel</li>
|
||
<li>Continue watching/listening</li>
|
||
<li>Recently added</li>
|
||
<li>Personalized recommendations</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Cast/Remote Control Enhancements:</strong></p>
|
||
<ul>
|
||
<li>Picture-in-picture for remote sessions</li>
|
||
<li>Multi-room audio (play on multiple devices)</li>
|
||
<li>Handoff (transfer playback to phone from TV)</li>
|
||
</ul>
|
||
</li>
|
||
</ol>
|
||
<h3 id="132-accessibility-enhancements"><a class="header" href="#132-accessibility-enhancements">13.2 Accessibility Enhancements</a></h3>
|
||
<ul>
|
||
<li>Screen reader optimization</li>
|
||
<li>High contrast mode</li>
|
||
<li>Larger text option</li>
|
||
<li>Voice control integration</li>
|
||
<li>Haptic feedback for controls</li>
|
||
</ul>
|
||
<hr />
|
||
<p>This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.</p>
|
||
|
||
</main>
|
||
|
||
<nav class="nav-wrapper" aria-label="Page navigation">
|
||
<!-- Mobile navigation buttons -->
|
||
<a rel="prev" href="architecture/09-security.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||
<i class="fa fa-angle-left"></i>
|
||
</a>
|
||
|
||
<a rel="next prefetch" href="specs/index.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||
<i class="fa fa-angle-right"></i>
|
||
</a>
|
||
|
||
<div style="clear: both"></div>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="nav-wide-wrapper" aria-label="Page navigation">
|
||
<a rel="prev" href="architecture/09-security.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
||
<i class="fa fa-angle-left"></i>
|
||
</a>
|
||
|
||
<a rel="next prefetch" href="specs/index.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
||
<i class="fa fa-angle-right"></i>
|
||
</a>
|
||
</nav>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
|
||
<script>
|
||
window.playground_copyable = true;
|
||
</script>
|
||
|
||
|
||
<script src="elasticlunr.min.js"></script>
|
||
<script src="mark.min.js"></script>
|
||
<script src="searcher.js"></script>
|
||
|
||
<script src="clipboard.min.js"></script>
|
||
<script src="highlight.js"></script>
|
||
<script src="book.js"></script>
|
||
|
||
<!-- Custom JS scripts -->
|
||
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|