Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
||||
SetDefaultPage,
|
||||
GetQueueFallback,
|
||||
SetQueueFallback,
|
||||
GetAllowMeteredCatalogDownload,
|
||||
SetAllowMeteredCatalogDownload,
|
||||
} from '@go/config/config.js';
|
||||
import { GetIndexStatus } from '@go/explore/service.js';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
@@ -75,6 +77,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
// --- Now Playing state ---
|
||||
@state() private scrollMode = 'hover';
|
||||
|
||||
/** Whether the ~0.6 GB catalog may be fetched on mobile data. */
|
||||
@state() private allowMeteredCatalogDownload = false;
|
||||
|
||||
// --- Favorites state ---
|
||||
@state() private playlists: playlist.Summary[] = [];
|
||||
|
||||
@@ -888,17 +893,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
private async loadLibraries(): Promise<void> {
|
||||
try {
|
||||
const [libs, mode, defaultPage, queueFallback] = await Promise.all([
|
||||
GetAllLibrariesWithTrackCounts(),
|
||||
GetScanConcurrency(),
|
||||
GetDefaultPage(),
|
||||
GetQueueFallback(),
|
||||
]);
|
||||
const [libs, mode, defaultPage, queueFallback, allowMetered] =
|
||||
await Promise.all([
|
||||
GetAllLibrariesWithTrackCounts(),
|
||||
GetScanConcurrency(),
|
||||
GetDefaultPage(),
|
||||
GetQueueFallback(),
|
||||
GetAllowMeteredCatalogDownload(),
|
||||
]);
|
||||
|
||||
this.libraries = libs ?? [];
|
||||
this.concurrencyMode = mode;
|
||||
this.defaultPage = defaultPage;
|
||||
this.queueFallback = queueFallback;
|
||||
this.allowMeteredCatalogDownload = allowMetered;
|
||||
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -1500,10 +1508,53 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
</div>`
|
||||
: html`<div class="index-loading">Loading status…</div>`}
|
||||
</div>
|
||||
|
||||
<config-field
|
||||
.schema=${{
|
||||
key: 'allowMeteredCatalogDownload',
|
||||
label: 'Download the catalog on mobile data',
|
||||
description:
|
||||
'The catalog is about 0.6 GB. It is skipped on a '
|
||||
+ 'cellular connection unless this is on; a '
|
||||
+ 'metered Wi-Fi network cannot be detected.',
|
||||
type: 'toggle' as const,
|
||||
}}
|
||||
.value=${this.allowMeteredCatalogDownload}
|
||||
@config-change=${this.handleAllowMeteredChange}
|
||||
></config-field>
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog download's one permission (plan 016 B4).
|
||||
*
|
||||
* It is in this section rather than General because it is about
|
||||
* *this* download and nothing else, and because the section already
|
||||
* explains what the catalog is — the toggle would be unreadable
|
||||
* beside "Default page".
|
||||
*/
|
||||
private handleAllowMeteredChange = (
|
||||
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||
): void => {
|
||||
const allow = Boolean(e.detail.value);
|
||||
const previous = this.allowMeteredCatalogDownload;
|
||||
|
||||
this.allowMeteredCatalogDownload = allow;
|
||||
|
||||
void SetAllowMeteredCatalogDownload(allow).catch((err: unknown) => {
|
||||
console.error('failed to save metered download permission', err);
|
||||
// The visible state reverted, so this is the Transient case:
|
||||
// a small action the user can simply repeat.
|
||||
this.allowMeteredCatalogDownload = previous;
|
||||
notificationStore.transient({
|
||||
key: 'metered-catalog-setting',
|
||||
title: 'Setting not saved',
|
||||
text: describeError(err, 'That setting could not be saved.'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private tierIcon(state: string): string {
|
||||
switch (state) {
|
||||
case 'complete':
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { formatMilliseconds } from '@utils/time';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import { highlightText } from './search-ranking';
|
||||
|
||||
/** Compares two strings using locale-aware ordering. */
|
||||
const compareStr = (
|
||||
a: string,
|
||||
@@ -36,8 +38,17 @@ export interface ColumnDef {
|
||||
defaultWidth: string;
|
||||
/** Text alignment. Defaults to left. */
|
||||
align?: 'left' | 'right';
|
||||
/** Optional custom render function returning an HTML template. */
|
||||
renderCell?: (track: library.Track) => unknown;
|
||||
/**
|
||||
* Optional custom render function returning an HTML template.
|
||||
*
|
||||
* `term` is the active search term, for a cell that wants to
|
||||
* highlight its own text: the default path applies `highlightText`
|
||||
* to `accessor`'s value, and a cell that renders itself has to do
|
||||
* that itself or silently lose the highlight. Only `titleArtist`
|
||||
* needs it, which is why it is optional rather than a second
|
||||
* required parameter on all of them.
|
||||
*/
|
||||
renderCell?: (track: library.Track, term?: string) => unknown;
|
||||
/**
|
||||
* Comparison function for sorting two tracks by this column.
|
||||
* Returns negative if a < b, positive if a > b, zero if equal.
|
||||
@@ -85,6 +96,27 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
||||
/>`;
|
||||
},
|
||||
},
|
||||
titleArtist: {
|
||||
id: 'titleArtist',
|
||||
// Named for what it sorts by, since that is the only place the
|
||||
// label is user-visible: the phone has no column headers, and
|
||||
// the page header's sort list is built from the *configured*
|
||||
// columns rather than the drawn ones.
|
||||
label: 'Track Name',
|
||||
accessor: (t) => t.TrackName,
|
||||
defaultWidth: '1fr',
|
||||
comparator: (a, b) => compareStr(a.TrackName, b.TrackName),
|
||||
renderCell: (t, term) => html`
|
||||
<div class="stacked">
|
||||
<span class="stacked-title"
|
||||
>${term ? highlightText(t.TrackName, term) : t.TrackName}</span
|
||||
>
|
||||
<span class="stacked-sub"
|
||||
>${term ? highlightText(t.ArtistName, term) : t.ArtistName}</span
|
||||
>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
trackName: {
|
||||
id: 'trackName',
|
||||
label: 'Track Name',
|
||||
@@ -249,6 +281,24 @@ export const CORE_SEARCH_COLUMN_IDS: string[] = [
|
||||
'album',
|
||||
];
|
||||
|
||||
/**
|
||||
* The one column a phone shows, and it is two lines.
|
||||
*
|
||||
* At 424 CSS px -- the width of the phone this was measured on -- four
|
||||
* columns fit the row exactly and none of them fits its *content*:
|
||||
* `--grid-cols` came out `24px 102px 101px 101px 80px`, so "Duration"
|
||||
* did not fit its own header and a title had ~20 characters. The
|
||||
* columns were never too wide; there were too many of them.
|
||||
*
|
||||
* So the phone gets the title with the artist under it, which is the
|
||||
* shape every phone music list has, and the full row width to put them
|
||||
* in. It is a *column definition* rather than a second row template on
|
||||
* purpose: the row, the delegated events, the selection semantics, the
|
||||
* playing marker and the virtualizer all keep working, because from
|
||||
* their side nothing has changed except how many columns there are.
|
||||
*/
|
||||
export const PHONE_COLUMN_IDS: string[] = ['titleArtist', 'trackLength'];
|
||||
|
||||
/**
|
||||
* Default column IDs. Album is in them (H-15): without it, the three
|
||||
* `Tideline / Aurora Fields / 00:06` rows in this app's own fixture
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LibraryController } from '@store/controllers/library-controller';
|
||||
import {
|
||||
COLUMN_DEFS,
|
||||
DEFAULT_COLUMN_IDS,
|
||||
PHONE_COLUMN_IDS,
|
||||
} from './columns';
|
||||
import type { ColumnDef } from './columns';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
@@ -92,6 +93,18 @@ const ROW_PADDING_X = 8;
|
||||
const ROW_CHROME_WIDTH =
|
||||
FAV_COL_WIDTH + ROW_PADDING_X * 2;
|
||||
|
||||
/**
|
||||
* Row heights, in the same relationship as the widths above: the number
|
||||
* is read by the CSS *and* by the virtualizer's layout, so they cannot
|
||||
* disagree. A phone row is two lines (title over artist).
|
||||
*/
|
||||
const ROW_HEIGHT = 33;
|
||||
const PHONE_ROW_HEIGHT = 52;
|
||||
|
||||
/** The shell's phone breakpoint, as `index.css` and every component
|
||||
* stylesheet spells it. */
|
||||
const PHONE_QUERY = '(max-width: 599px)';
|
||||
|
||||
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
|
||||
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
|
||||
const FAV_ICONS = {
|
||||
@@ -177,19 +190,34 @@ export class TrackList
|
||||
* Resolved column definitions for the currently configured
|
||||
* column IDs. Falls back to defaults for any unknown ID.
|
||||
*/
|
||||
private get activeColumns(): ColumnDef[] {
|
||||
/**
|
||||
* The columns the user has chosen — what a desktop draws, and what
|
||||
* *anything* may be sorted by.
|
||||
*
|
||||
* This is deliberately separate from `activeColumns`: "which columns
|
||||
* are drawn" and "what can I sort by" are different questions, and
|
||||
* the phone is exactly where they diverge. Building the sort list
|
||||
* from the drawn columns would silently take sort-by-artist and
|
||||
* sort-by-album away from the phone, which has no other route to
|
||||
* them since it has no column headers either.
|
||||
*/
|
||||
private get configuredColumns(): ColumnDef[] {
|
||||
const ids = this.trackListCtrl.columnIds;
|
||||
const chosen = !ids || ids.length === 0 ? DEFAULT_COLUMN_IDS : ids;
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
return DEFAULT_COLUMN_IDS
|
||||
.map((id) => COLUMN_DEFS[id])
|
||||
.filter(
|
||||
(d): d is ColumnDef =>
|
||||
d !== undefined,
|
||||
);
|
||||
}
|
||||
return chosen
|
||||
.map((id) => COLUMN_DEFS[id])
|
||||
.filter(
|
||||
(d): d is ColumnDef =>
|
||||
d !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return ids
|
||||
/** The columns actually drawn: two stacked lines on a phone. */
|
||||
private get activeColumns(): ColumnDef[] {
|
||||
if (!this.phone) return this.configuredColumns;
|
||||
|
||||
return PHONE_COLUMN_IDS
|
||||
.map((id) => COLUMN_DEFS[id])
|
||||
.filter(
|
||||
(d): d is ColumnDef =>
|
||||
@@ -376,9 +404,42 @@ export class TrackList
|
||||
// doesn't need to measure items. Without this hint, the default 100px
|
||||
// estimate causes constant scroll error correction (scrollTo() calls)
|
||||
// that produce visible jumping/skipping during scroll.
|
||||
/**
|
||||
* The virtualizer's item size and the CSS row height are the same
|
||||
* number in two places, and they must agree: the layout positions
|
||||
* rows from this figure, so a row that is really taller overlaps its
|
||||
* neighbour and a shorter one leaves a gap. Both come from here.
|
||||
*/
|
||||
private flowLayout = flow({
|
||||
_itemSize: { width: 100, height: 33 },
|
||||
_itemSize: { width: 100, height: ROW_HEIGHT },
|
||||
} as Parameters<typeof flow>[0]);
|
||||
|
||||
private phoneFlowLayout = flow({
|
||||
_itemSize: { width: 100, height: PHONE_ROW_HEIGHT },
|
||||
} as Parameters<typeof flow>[0]);
|
||||
|
||||
private get rowLayout(): Parameters<typeof flow>[0] {
|
||||
return this.phone ? this.phoneFlowLayout : this.flowLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone width, from the shell's own breakpoint.
|
||||
*
|
||||
* A media query *inside* a shadow root is answered by the viewport,
|
||||
* which is what lets every other component state what it drops at
|
||||
* phone width in its own stylesheet. This list cannot: its grid is
|
||||
* computed in JS from the host width, so the same threshold has to
|
||||
* be readable from JS as well. One breakpoint, two expressions of
|
||||
* it, and the reason is written here rather than inferred.
|
||||
*/
|
||||
@state()
|
||||
private phone = matchMedia(PHONE_QUERY).matches;
|
||||
|
||||
private phoneQuery = matchMedia(PHONE_QUERY);
|
||||
|
||||
private onPhoneChange = (e: MediaQueryListEvent): void => {
|
||||
this.phone = e.matches;
|
||||
};
|
||||
private hasRestoredScroll = false;
|
||||
private scrollSaveRAFId: number | null = null;
|
||||
|
||||
@@ -545,6 +606,20 @@ export class TrackList
|
||||
}
|
||||
|
||||
private initColumnWidths() {
|
||||
// A phone's widths are never the saved ones. `loadColumnWidths`
|
||||
// is keyed by column *id* and fills a gap with
|
||||
// `MIN_COLUMN_WIDTH`, so the phone's stacked column -- which
|
||||
// nothing has ever saved a width for, there being no handles to
|
||||
// drag -- came out at the minimum while the duration column
|
||||
// inherited a width saved for a four-column desktop row. Found
|
||||
// on the device: `24px 148px 236px`, the duration column with
|
||||
// 55% of a phone's row.
|
||||
if (this.phone) {
|
||||
this.computeDefaultWidths();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = this.loadColumnWidths();
|
||||
const cols = this.activeColumns;
|
||||
|
||||
@@ -675,6 +750,13 @@ export class TrackList
|
||||
}
|
||||
|
||||
private saveColumnWidths() {
|
||||
// And a phone's widths are never *saved*: they are computed from
|
||||
// a column set the user did not choose, and writing them would
|
||||
// overwrite the width they dragged for the same column on a
|
||||
// desktop. Nothing on a phone can resize a column anyway, so
|
||||
// this is only reachable by a window crossing the breakpoint.
|
||||
if (this.phone) return;
|
||||
|
||||
try {
|
||||
const cols = this.activeColumns;
|
||||
|
||||
@@ -1047,6 +1129,35 @@ export class TrackList
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
/* A phone row is two lines, and this height must equal
|
||||
PHONE_ROW_HEIGHT: the virtualizer positions rows from that number,
|
||||
so a taller row overlaps its neighbour and a shorter one gaps. */
|
||||
@media (max-width: 599px) {
|
||||
.track-row {
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
.stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stacked-title,
|
||||
.stacked-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stacked-sub {
|
||||
font-size: var(--yj-text-xs);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.track-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1173,9 +1284,17 @@ export class TrackList
|
||||
);
|
||||
|
||||
this.resizeObserver.observe(this);
|
||||
|
||||
// Connection, not the view lifecycle: this only sets state, so
|
||||
// it is harmless (and wanted) while the list is off screen -- a
|
||||
// rotation on another view must not leave this one laid out for
|
||||
// the wrong width when the user comes back to it.
|
||||
this.phoneQuery.addEventListener('change', this.onPhoneChange);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.phoneQuery.removeEventListener('change', this.onPhoneChange);
|
||||
|
||||
// Remove delegated event handlers from virtualizer.
|
||||
const virt = this.virtualizer;
|
||||
if (virt) {
|
||||
@@ -2030,7 +2149,7 @@ export class TrackList
|
||||
</svg>
|
||||
</div>
|
||||
${cols.map((col) => {
|
||||
const customCell = col.renderCell?.(track);
|
||||
const customCell = col.renderCell?.(track, term);
|
||||
if (customCell !== undefined && customCell !== nothing) {
|
||||
return html`<div role="gridcell" class="cell">${customCell}</div>`;
|
||||
}
|
||||
@@ -2086,7 +2205,7 @@ export class TrackList
|
||||
private renderPageHeader() {
|
||||
const options: SortOption[] = [
|
||||
{ id: '', label: 'Default' },
|
||||
...this.activeColumns
|
||||
...this.configuredColumns
|
||||
.filter((c) => c.comparator)
|
||||
.map((c) => ({ id: c.id, label: c.label })),
|
||||
];
|
||||
@@ -2136,7 +2255,7 @@ export class TrackList
|
||||
aria-busy=${this.loadingTracks}
|
||||
@keydown=${this.onListKeydown}
|
||||
>
|
||||
<div class="header-row" role="row">
|
||||
${this.phone ? nothing : html`<div class="header-row" role="row">
|
||||
<div role="columnheader" aria-label="Favourite"></div>
|
||||
${cols.map(
|
||||
(col) => html`
|
||||
@@ -2170,7 +2289,7 @@ export class TrackList
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>`}
|
||||
${visibleTracks.length === 0
|
||||
? html`<p class="no-results">
|
||||
No tracks match your search.
|
||||
@@ -2181,12 +2300,14 @@ export class TrackList
|
||||
.items=${visibleTracks}
|
||||
.renderItem=${this.renderTrackRow}
|
||||
.keyFunction=${(track: library.Track) => track.FilePath}
|
||||
.layout=${this.flowLayout}
|
||||
.layout=${this.rowLayout}
|
||||
></lit-virtualizer>
|
||||
`}
|
||||
|
||||
<!-- Resizing is a pointer gesture with no touch equivalent, and
|
||||
the phone's two columns are not the user's to arrange. -->
|
||||
<div class="resize-overlay">
|
||||
${this.colBoundaryPositions.map(
|
||||
${(this.phone ? [] : this.colBoundaryPositions).map(
|
||||
(pos, i) => html`
|
||||
<div
|
||||
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
|
||||
|
||||
Reference in New Issue
Block a user