Merge remote-tracking branch 'origin/main' into wails-v3

This commit is contained in:
2026-08-17 08:29:21 -04:00
96 changed files with 11952 additions and 90 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 608 B

@@ -32,6 +32,23 @@ export class AudioPlayer extends LitElement {
flex: 1;
}
/* The phone transport (plan 016 B2): the buttons, and nothing
else. A media query inside a shadow root is answered by the
viewport, not by the host, so this is the component saying what
it drops at phone width rather than the shell reaching in.
Volume goes because the hardware keys own it on a phone --
Android routes them to the media stream, which is also why
mediacontrols' Android handler implements no volume callback.
The seek bar goes because a 4px-tall target dragged with a thumb
is not a seek control; seeking belongs to the full-screen
now-playing view, which is the next phase. */
@media (max-width: 599px) {
volume-control,
seek-bar {
display: none;
}
}
`];
override render() {
@@ -27,6 +27,18 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider {
--track-size: 6px;
flex: 1;
@@ -0,0 +1,253 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
interface Tab {
id: View;
label: string;
icon: string;
}
/**
* The phone's primary navigation: a bottom tab bar, shown only below
* the phone breakpoint (index.css owns that; this element is
* `display: none` above it).
*
* **Four destinations and a way to everything else.** A tab bar is
* three to five items before the targets stop being thumb-sized —
* 360 px over eleven sidebar entries is 32 px each — so the four here
* are the ones plan 016's subset says a phone is *for*, and "More"
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
* a reuse rather than a second nav: two lists of destinations is two
* places to add the next view to, and the sidebar already carries the
* drag-to-navigate behaviour, the active state and the labels.
*
* It emits the same bubbling, composed `navigate` event the sidebar
* does, so `index.ts` needs no knowledge of it, and it listens for that
* event globally for the same reason the sidebar does: a navigation it
* did not send (a card click, a detail view, the drawer) still has to
* move the highlight.
*/
@customElement('bottom-nav')
export class BottomNav extends LitElement {
static override styles = [designTokens, css`
:host {
display: block;
background-color: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border, #495057);
/* The home indicator on a gesture-navigation phone sits
under the last few pixels of the viewport, so the bar
pads itself out of the way where the browser reports
one and by nothing where it does not. */
padding-bottom: env(safe-area-inset-bottom, 0);
}
nav ul {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
margin: 0;
padding: 0;
list-style: none;
}
button {
width: 100%;
/* 48px is the smallest target this should ever be; the
label sits under the icon rather than beside it, which
is what keeps five of them legible at 360px. */
min-height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 0;
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-font-size-xs, 0.7rem);
}
button wa-icon {
font-size: 1.15rem;
}
button.active {
color: var(--yj-accent, #ffd43b);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.label {
/* A tab label is an aid, not the name: the button's own
accessible name comes from its text, and truncating it
visually does not change that. */
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
wa-drawer::part(body) {
padding: 0;
}
app-sidebar {
/* The sidebar sizes itself inline and collapses to icons
below 900px, which is every phone. In the drawer there
is room for the labels, so it is told not to. */
height: 100%;
}
`];
@state()
private activeView = 'home';
/**
* Whether the drawer has been asked for.
*
* The sidebar inside it is rendered only while this is true, and
* that is not an optimisation. `app-sidebar` carries a
* `data-testid` per destination, so a second copy standing by in
* the DOM makes every `nav-*` testid ambiguous **for the whole
* app** -- 30 existing specs failed with "strict mode violation:
* resolved to 2 elements" on a desktop viewport where this element
* is not even visible. A duplicate of a shared component is a
* duplicate of its handles.
*/
@state()
private drawerOpen = false;
@query('wa-drawer')
private drawer?: WaDrawer;
private static readonly TABS: Tab[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
];
override connectedCallback() {
super.connectedCallback();
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the drawer would
// otherwise be announced unnamed -- the same fix, and the same
// reason, as every wa-dialog in the app. A drawer's shadow root
// has the same shape, so the helper needs no change.
nameDialog(this.drawer);
}
private onGlobalNavigate = (e: Event) => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
// A navigation from inside the drawer is the drawer's job done.
this.drawerOpen = false;
};
private openDrawer = () => {
this.drawerOpen = true;
};
/**
* Web Awesome closes itself on Escape and on a click outside, and
* tells us afterwards rather than asking -- so the flag follows the
* element, or the next `open` would be a no-op against a drawer
* that thinks it is already open.
*/
private onDrawerHide = () => {
this.drawerOpen = false;
};
private navigate(view: View) {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
override render() {
return html`
<nav aria-label="Primary">
<ul>
${BottomNav.TABS.map((tab) => html`
<li>
<button
type="button"
class=${this.activeView === tab.id ? 'active' : ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
>
<wa-icon name=${tab.icon}></wa-icon>
<span class="label">${tab.label}</span>
</button>
</li>
`)}
<li>
<button
type="button"
data-testid="tab-more"
aria-haspopup="dialog"
@click=${this.openDrawer}
>
<wa-icon name="bars"></wa-icon>
<span class="label">More</span>
</button>
</li>
</ul>
</nav>
<wa-drawer
placement="start"
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
</wa-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bottom-nav': BottomNav;
}
}
@@ -19,7 +19,6 @@ import {
SetQueueFallback,
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { notificationStore } from '@store/notification-store';
import { describeError, explainError } from '@utils/describe-error';
import type * as library from '@go/library/models.js';
@@ -50,6 +49,7 @@ import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
import { list } from '@utils/binding';
import { pickDirectory } from '../../utils/pick-directory';
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
@@ -916,7 +916,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
let dir = '';
try {
dir = await DirectoryPicker();
dir = (await pickDirectory()) ?? '';
if (!dir) return;
@@ -14,7 +14,6 @@ import type {
ProviderField,
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
import { SetPreferences } from '@go/download/service.js';
import type * as download from '@go/download/models.js';
@@ -23,6 +22,7 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
import { pickDirectory } from '../../utils/pick-directory';
/**
* Allowed audio formats for auto-download, mirrored from
@@ -580,7 +580,7 @@ export class DownloadClients extends LitElement {
private browseForFolder = async (field: ProviderField) => {
try {
const dir = await DirectoryPicker();
const dir = await pickDirectory();
if (dir) {
this.draft = { ...this.draft, [field.key]: dir };
@@ -6,9 +6,9 @@ import {
AddLibrary,
GetAllLibrariesWithTrackCounts,
} from '@go/library/library.js';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { describeError, explainError } from '@utils/describe-error';
import { nameDialogsIn } from '@utils/name-dialog';
import { pickDirectory } from '../../utils/pick-directory';
/**
* First-run setup wizard.
@@ -243,7 +243,7 @@ export class FirstRunWizard extends LitElement {
this.errorMessage = '';
try {
const dir = await DirectoryPicker();
const dir = await pickDirectory();
if (dir) this.selectedDirectory = dir;
} catch (err) {
@@ -0,0 +1,279 @@
/**
* Choosing a directory, where the platform will not do it for us.
*
* Wails' file dialog can select directories on every desktop platform.
* On Android it returns an error, because the Storage Access Framework
* yields tree URIs rather than filesystem paths — and a path is what
* this app's whole library model is keyed on. So the app browses the
* filesystem itself, through `ListDirectories`, which it can do because
* it holds all-files access.
*
* Deliberately not a general file browser: it lists directories only,
* because the thing being chosen is a library root.
*
* The shape is `confirm-dialog`'s — a promise-returning `choose()` on a
* `wa-dialog`, so callers `await` a path or `null` and there is no
* second dialog pattern in the codebase.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import {
DefaultBrowseRoot,
ListDirectories,
} from '@go/frontendutil/frontendutil.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { describeError } from '../../utils/describe-error';
import { nameDialogsIn } from '../../utils/name-dialog';
interface Entry {
name: string;
path: string;
}
@customElement('folder-picker')
export class FolderPicker extends LitElement {
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
@state() private path = '';
@state() private parent = '';
@state() private entries: Entry[] = [];
@state() private loading = false;
@state() private errorMessage = '';
@state() private isOpen = false;
private settle: ((path: string | null) => void) | null = null;
static override styles = [
designTokens,
srOnly,
css`
:host {
display: contents;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
.current {
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--yj-text-sm, 0.8125rem);
margin-bottom: 8px;
overflow-wrap: anywhere;
}
ul {
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
list-style: none;
margin: 0;
max-height: 45vh;
min-height: 8em;
overflow-y: auto;
padding: 0;
}
li button {
align-items: center;
background: none;
border: 0;
color: inherit;
cursor: pointer;
display: flex;
font: inherit;
gap: 8px;
padding: 10px 12px;
text-align: left;
width: 100%;
}
li button:hover,
li button:focus-visible {
background: var(--yj-bg-elevated, #343a40);
}
.empty {
color: var(--yj-text-secondary, #b3b3b3);
padding: 12px;
}
.error {
color: var(--yj-error-text, #ff8787);
padding: 8px 0;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.actions button {
background: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: inherit;
cursor: pointer;
font: inherit;
padding: 6px 14px;
}
.actions button.primary {
background: var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
}
`,
];
/** Browse. Resolves with an absolute path, or null if cancelled. */
async choose(startAt?: string): Promise<string | null> {
this.settle?.(null);
this.settle = null;
let start = startAt ?? '';
if (!start) {
try {
start = await DefaultBrowseRoot();
} catch {
start = '';
}
}
this.isOpen = true;
await this.load(start);
await this.updateComplete;
if (this.dialog) this.dialog.open = true;
return new Promise<string | null>((resolve) => {
this.settle = resolve;
});
}
private async load(path: string): Promise<void> {
this.loading = true;
this.errorMessage = '';
try {
const listing = await ListDirectories(path);
this.path = listing.path;
this.parent = listing.parent;
this.entries = listing.entries ?? [];
} catch (err) {
// A directory that cannot be read is not a failed picker —
// stay where we are and say so, or the user is stranded
// with an empty dialog and no way back.
this.errorMessage = describeError(
err,
'That folder could not be opened.',
);
console.error('folder-picker: listing failed:', err);
} finally {
this.loading = false;
}
}
private close(path: string | null): void {
const settle = this.settle;
this.settle = null;
if (this.dialog) this.dialog.open = false;
this.isOpen = false;
settle?.(path);
}
override updated(): void {
nameDialogsIn(this.shadowRoot);
}
override render() {
if (!this.isOpen) return nothing;
return html`
<wa-dialog
label="Choose a folder"
data-testid="folder-picker"
@wa-hide=${() => this.close(null)}
>
<p class="current" data-testid="folder-picker-path">
${this.path || '\u2026'}
</p>
${this.errorMessage
? html`<p class="error" role="alert">
${this.errorMessage}
</p>`
: nothing}
<ul data-testid="folder-picker-list">
${this.parent
? html`<li>
<button
@click=${() => void this.load(this.parent)}
data-testid="folder-picker-up"
>
<span aria-hidden="true">\u2191</span> Up one
level
</button>
</li>`
: nothing}
${this.entries.map(
(entry) => html`
<li>
<button
@click=${() => void this.load(entry.path)}
>
<span aria-hidden="true">\u{1F4C1}</span>
${entry.name}
</button>
</li>
`,
)}
${!this.loading && this.entries.length === 0
? html`<li class="empty">No folders here.</li>`
: nothing}
</ul>
<!--
The live region is in the DOM before it has anything to
say, because most screen readers announce a change to a
region they are already watching and ignore one that
appears with its content already in it.
-->
<p class="sr-only" role="status" aria-live="polite">
${this.loading
? 'Loading folders'
: `${this.entries.length} folders in ${this.path}`}
</p>
<div class="actions">
<button @click=${() => this.close(null)}>Cancel</button>
<button
class="primary"
?disabled=${!this.path}
@click=${() => this.close(this.path)}
data-testid="folder-picker-select"
>
Use this folder
</button>
</div>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'folder-picker': FolderPicker;
}
}
@@ -149,6 +149,19 @@ export class JobIndicator extends LitElement {
text-overflow: ellipsis;
}
/* On a phone the ring is the whole indicator: "3 background
jobs" is 114px of a 360px header, and it pushed the
header past the viewport. Only the *visible* label
goes -- the live region in render() is what announces
this, and it is unaffected, so the ring keeps its
accessible name and screen readers keep hearing the
state change. */
@media (max-width: 599px) {
.label {
display: none;
}
}
.alert-dot {
width: 6px;
height: 6px;
@@ -0,0 +1,332 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../audio-player/controls/player-controls';
import '../audio-player/seekbar/seek-bar';
import '../audio-player/volume-control/volume-control';
import {
artistLink,
albumLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
* phase 2).
*
* Phase 1 took the seek bar and the volume out of the bottom bar,
* because 4px of height is not a thumb target and a phone's volume
* belongs to its hardware keys. This is where they went: the same
* `<seek-bar>`, `<player-controls>` and `<volume-control>` elements the
* desktop transport uses, given room. **Not copies of them** — a phone
* layout that reimplements the transport is a second transport to fix
* every bug in, and the seek bar in particular carries the
* interpolation rules that took a plan of their own to get right.
*
* It is a *detail* view rather than a primary one: it is somewhere you
* go and come back from, so `index.ts` pushes the current view onto the
* nav stack and Back pops it. That is also why it is not in the tab
* bar — a tab you cannot leave by pressing the same tab again is not a
* tab.
*/
@customElement('now-playing-view')
export class NowPlayingView extends LitElement {
private player = new PlayerController(this);
private favCtrl = new FavoritesController(this);
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 0.75em 1em 1.25em;
gap: 0.75em;
background-color: var(--yj-bg-surface, #212529);
overflow-y: auto;
}
header {
display: flex;
align-items: center;
gap: 0.5em;
flex: 0 0 auto;
}
.context {
flex: 1 1 auto;
}
.back {
background: none;
border: none;
color: var(--yj-text-primary, #f8f9fa);
/* 48px is the touch-target floor, and this is the control
that gets a user out of a full-screen view. */
min-width: 48px;
min-height: 48px;
font-size: 1.1rem;
cursor: pointer;
border-radius: 6px;
}
.back:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.context {
font-size: var(--yj-font-size-xs, 0.75rem);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yj-text-secondary, #adb5bd);
}
.art {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone. */
width: min(100%, 60vh);
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
.art .placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--yj-text-tertiary, #868e96);
}
.meta {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.75em;
min-width: 0;
}
.names {
flex: 1 1 auto;
min-width: 0;
}
.title {
font-size: 1.15rem;
font-weight: 600;
margin: 0;
/* Two lines, then an ellipsis. A marquee is the bottom
bar's answer to a 320px box; here there is room to wrap,
and wrapping does not move. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist,
.album {
margin: 0;
font-size: 0.9rem;
color: var(--yj-text-secondary, #adb5bd);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite {
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
min-width: 48px;
min-height: 48px;
font-size: 1.25rem;
cursor: pointer;
border-radius: 6px;
}
.favorite.on {
color: var(--yj-accent, #ffd43b);
}
.favorite:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.transport {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* The seek bar is the reason this view exists. Its own
stylesheet thickens the track below the phone breakpoint --
the track size is set on the wa-slider inside its shadow
root, so a custom property set from here would not reach
it. */
seek-bar {
display: block;
}
.empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
`];
private back() {
this.dispatchEvent(new CustomEvent('navigate-back', {
bubbles: true,
composed: true,
}));
}
/**
* Open the queue.
*
* This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen
* would take the queue away. It toggles the same `open` attribute
* `index.ts` does, because the panel's state is an attribute on one
* element and a second mechanism for it is a second thing to keep
* in step.
*/
private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', '');
}
private toggleFavorite() {
const path = this.player.currentTrack?.filePath;
if (path) void this.favCtrl.toggleFavorite(path);
}
override render() {
const track = this.player.currentTrack;
if (!track) {
return html`
${this.renderHeader()}
<p class="empty" data-testid="npv-empty">
Nothing is playing.
</p>
`;
}
const favorited = this.favCtrl.isFavorited(track.filePath);
// The largest kept tier, which is what `saveCoverArt` records as
// the path -- there is no full-resolution original to reach for.
const art = track.coverArtLarge || track.coverArt;
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${artistLink(track.artist, track.artistMbid)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
</button>
</div>
<div class="transport">
<seek-bar></seek-bar>
<player-controls></player-controls>
<volume-control></volume-control>
</div>
`;
}
private renderHeader() {
return html`
<header>
<button
type="button"
class="back"
data-testid="npv-back"
aria-label="Back"
@click=${this.back}
>
<wa-icon name="chevron-down"></wa-icon>
</button>
<span class="context">Now playing</span>
<button
type="button"
class="back"
data-testid="npv-queue"
aria-label="Show the queue"
@click=${this.openQueue}
>
<wa-icon name="list"></wa-icon>
</button>
</header>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'now-playing-view': NowPlayingView;
}
}
@@ -147,6 +147,41 @@ export class NowPlaying extends LitElement {
position: relative;
}
/* The phone's way into the full-screen now-playing view (016 B2
phase 2). It sits over the cover art rather than being a
thirteenth control in a 360px bar, and it is a *button* rather
than a click handler on the art because it is an action with a
name -- the art itself is decorative and the title beside it
already navigates somewhere else (the catalog page).
CSS owns whether it exists, the same way it does for bottom-nav:
there is no viewport check in the component. */
.expand {
display: none;
}
@media (max-width: 599px) {
.expand {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
padding: 0;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
/* The art shows through; this is a target, not a picture. */
color: transparent;
}
.expand:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
}
.cover-preview-panel {
width: 500px;
height: 500px;
@@ -392,6 +427,13 @@ export class NowPlaying extends LitElement {
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art-wrapper">
<button
type="button"
class="expand"
data-testid="open-now-playing"
aria-label="Open now playing"
@click=${this.openNowPlaying}
></button>
<div
class="cover-art"
@mouseenter=${this.handleCoverMouseEnter}
@@ -500,6 +542,15 @@ export class NowPlaying extends LitElement {
`;
}
/** Open the full-screen view. Phone only; see `.expand`. */
private openNowPlaying = () => {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
composed: true,
}));
};
// ===================================================================
// SCROLL LOGIC
// ===================================================================
@@ -67,6 +67,21 @@ export class SearchBar extends LitElement {
transition: border-color 0.15s ease;
}
/* The 200px floor is a desktop floor. On a phone the header is
the whole width there is, and a min-width in a flex row is a
*hard* one -- it does not shrink, so the header stayed 580px
wide inside a 360px viewport and the shell scrolled
sideways. Measured at 360px: 580 -> 360. */
@media (max-width: 599px) {
:host {
min-width: 0;
}
.search-container {
min-width: 0;
}
}
.search-container:focus-within {
border-color: var(--yj-accent, #ffd43b);
}
+14 -2
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { customElement, state, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
@@ -166,6 +166,17 @@ export class AppSidebar extends LitElement {
@state()
private collapsed = false;
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s drawer, which is the whole
* screen wide on the phone where this would otherwise auto-collapse
* to icons. The auto-collapse is a *width* response to a narrow
* shell, and inside a drawer the shell is not what the sidebar is
* sharing space with.
*/
@property({ type: Boolean, reflect: true })
expanded = false;
/** The width the user chose, restored when the window grows back. */
private userWidth = DEFAULT_WIDTH;
@@ -344,7 +355,8 @@ export class AppSidebar extends LitElement {
*/
private applyViewportWidth() {
const narrow =
this.narrowViewport?.matches ?? false;
!this.expanded &&
(this.narrowViewport?.matches ?? false);
const width = narrow
? MIN_WIDTH
: this.userWidth;
+1
View File
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
solid/backward-step
solid/bars
regular/bookmark
solid/bookmark
solid/box-open
+201
View File
@@ -0,0 +1,201 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event —
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler — delegated or
* per-row, in any shadow root — runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture — where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* "Ask the user for a folder", once.
*
* Three call sites want a directory — the first-run wizard, the library
* settings and the download clients' save path — and on desktop all
* three can use the platform's own dialog. On Android that dialog
* *returns an error*: the Storage Access Framework yields tree URIs
* rather than filesystem paths, and a path is what this app's library
* model is keyed on.
*
* So the platform test lives here rather than at each call site, which
* is the same rule `utils/binding.ts` and `utils/library-status.ts`
* follow: a fact about the platform is stated once, at the boundary.
*
* *Which* platform is asked of the backend, not of the Wails runtime's
* `System.IsAndroid()`. The dialog is backend code, so the backend is
* what knows whether it can open one; it answers for iOS at the same
* time; and it keeps this testable through the ordinary transport fake
* rather than a module mock.
*
* Returns the chosen path, or `null` if the user cancelled. Callers
* treat those the same way they always did — a falsy result means "no
* change" — so adopting this is a one-line edit at each site.
*/
import {
DirectoryPicker,
HasNativeDirectoryPicker,
} from "@go/frontendutil/frontendutil.js";
import type { FolderPicker } from "../components/folder-picker/folder-picker";
let picker: FolderPicker | null = null;
let native: Promise<boolean> | null = null;
/**
* Asked once per session and remembered: it cannot change while the app
* is running, and a folder picker should not pay a round trip to find
* out which kind it is.
*/
function hasNative(): Promise<boolean> {
native ??= HasNativeDirectoryPicker().catch(() => true);
return native;
}
/** Testing seam: forget the cached platform answer. */
export function resetDirectoryPickerCache(): void {
native = null;
picker = null;
}
/**
* The in-app browser is mounted on first use and then kept.
*
* Mounting it on demand and awaiting its module in the same update as
* `showModal()` is the trap `index.ts` documents for views — so the
* element is created, appended and *then* asked to open, on separate
* turns.
*/
async function inAppPicker(): Promise<FolderPicker> {
if (picker) return picker;
await import("../components/folder-picker/folder-picker");
const el = document.createElement("folder-picker");
document.body.appendChild(el);
picker = el;
return el;
}
/** Ask for a directory. Resolves to an absolute path, or null. */
export async function pickDirectory(startAt?: string): Promise<string | null> {
if (!(await hasNative())) {
const el = await inAppPicker();
return el.choose(startAt);
}
// The desktop dialog returns '' when dismissed; normalise that to
// null so every caller has one falsy case to handle rather than
// two.
const chosen = await DirectoryPicker();
return chosen === "" ? null : chosen;
}