Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user