perf(frontend): virtualize the playlist views, and idle the closed queue
Both playlist detail views rendered every track with a plain `.map()`. Measured at 2 000 tracks: 22 090 elements in the shadow root and 2 000 eager <img>, against 487 and 0 after, with retained heap 5.85 MB -> 0.81 MB and one update pass 5.3 ms -> 0.1 ms. They are virtualized in place rather than rendered through <track-list>, which is what the audit suggested: that works for `genre-details` because a genre list is just tracks, but both playlist views render phantom rows for missing files and `playlist-details` is a drag source and a drop target, and `track-list` has never had either. Virtualizing in place gets the same 45x on the number that matters with none of that risk, and leaves `track-list` alone for its four other callers. Both therefore push `virtualizer.requestUpdate()` on a selection change and on a playing-track change: the virtualize directive runs when one of the *virtualizer's own* properties changes, not when its parent re-renders, so memoising `items` and hoisting `renderItem` together is how you build a list that never repaints. Selection went silently dead the first time, with the controller holding exactly the right keys. And a closed queue panel renders no list at all: `width: 0` and `contain` bounded the damage without stopping the virtualizer inside from measuring its window on every queue change, or `scrollToIndex` from calling `scrollIntoView()` on something invisible.
This commit is contained in:
@@ -9,6 +9,9 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|||||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
|
import '@lit-labs/virtualizer';
|
||||||
|
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||||
|
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GetPlaylistTracks,
|
GetPlaylistTracks,
|
||||||
@@ -17,7 +20,7 @@ import {
|
|||||||
RemovePhantomTracks,
|
RemovePhantomTracks,
|
||||||
FindDuplicateTracksInPlaylist,
|
FindDuplicateTracksInPlaylist,
|
||||||
} from '@go/playlist/Service';
|
} from '@go/playlist/Service';
|
||||||
import type { playlist, library } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
@@ -31,6 +34,8 @@ import {
|
|||||||
} from '@utils/context-menu-controller.js';
|
} from '@utils/context-menu-controller.js';
|
||||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||||
|
import { notificationStore } from '@store/notification-store';
|
||||||
|
import { describeError } from '@utils/describe-error';
|
||||||
import {
|
import {
|
||||||
hasTrackPayload,
|
hasTrackPayload,
|
||||||
getDragPayload,
|
getDragPayload,
|
||||||
@@ -44,7 +49,8 @@ import {
|
|||||||
} from '@utils/drag-image';
|
} from '@utils/drag-image';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import '@components/track-details/track-details.js';
|
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||||
|
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||||
import '@components/phantom-resolver/phantom-resolver.js';
|
import '@components/phantom-resolver/phantom-resolver.js';
|
||||||
@@ -60,6 +66,13 @@ import {
|
|||||||
} from '@utils/explore-link';
|
} from '@utils/explore-link';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
|
||||||
|
/** One playlist row: the track and its position in the *playlist*,
|
||||||
|
* which is not its position in the filtered view. */
|
||||||
|
interface VisibleTrack {
|
||||||
|
track: playlist.Track;
|
||||||
|
trackIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
@customElement('playlist-details')
|
@customElement('playlist-details')
|
||||||
export class PlaylistDetails
|
export class PlaylistDetails
|
||||||
extends LitElement
|
extends LitElement
|
||||||
@@ -86,6 +99,36 @@ export class PlaylistDetails
|
|||||||
private tracksChangedCleanup: (() => void) | null = null;
|
private tracksChangedCleanup: (() => void) | null = null;
|
||||||
private playlistDeletedCleanup: (() => void) | null = null;
|
private playlistDeletedCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
/* `_itemSize` matches the fixed 45 px `.track-item` height, measured
|
||||||
|
* rather than guessed. Without the hint the flow layout's 100 px
|
||||||
|
* default drives constant scroll-error correction, which reads as
|
||||||
|
* the list jumping under the pointer. */
|
||||||
|
@query('lit-virtualizer')
|
||||||
|
private virtualizer?: LitVirtualizer;
|
||||||
|
|
||||||
|
/* The row templates live inside the virtualizer, not in this
|
||||||
|
* component's own template, so a host re-render alone does not
|
||||||
|
* repaint them: the virtualizer re-renders when its `items` change,
|
||||||
|
* and `items` is now memoised precisely so it does not change when
|
||||||
|
* nothing has. Selection and the playing-track highlight are
|
||||||
|
* therefore pushed to it explicitly, which is what `track-list` has
|
||||||
|
* always done. Costing ~36 rows, not the whole playlist. */
|
||||||
|
private lastActiveTrackPath: string | null = null;
|
||||||
|
|
||||||
|
private flowLayout = flow({
|
||||||
|
_itemSize: { width: 100, height: 45 },
|
||||||
|
} as Parameters<typeof flow>[0]);
|
||||||
|
|
||||||
|
/* The memo behind `getVisibleTracks()`. Its wrappers used to be
|
||||||
|
* rebuilt on every render, so the array identity changed even when
|
||||||
|
* nothing had — which is the one thing a virtualizer keys its work
|
||||||
|
* on (perf.M5). */
|
||||||
|
private visibleCache: VisibleTrack[] | null = null;
|
||||||
|
private visibleCacheKey: {
|
||||||
|
tracks: playlist.Track[];
|
||||||
|
term: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
private dragImageEl: HTMLElement | null = null;
|
private dragImageEl: HTMLElement | null = null;
|
||||||
|
|
||||||
@query('#context-menu')
|
@query('#context-menu')
|
||||||
@@ -137,6 +180,21 @@ export class PlaylistDetails
|
|||||||
|
|
||||||
onSelectionChanged(): void {
|
onSelectionChanged(): void {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
this.virtualizer?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override updated(
|
||||||
|
changed: Map<string, unknown>,
|
||||||
|
): void {
|
||||||
|
super.updated(changed);
|
||||||
|
|
||||||
|
const currentPath =
|
||||||
|
this.player.currentTrack?.filePath ?? null;
|
||||||
|
|
||||||
|
if (currentPath !== this.lastActiveTrackPath) {
|
||||||
|
this.lastActiveTrackPath = currentPath;
|
||||||
|
this.virtualizer?.requestUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -357,9 +415,9 @@ export class PlaylistDetails
|
|||||||
break;
|
break;
|
||||||
case 'track-details':
|
case 'track-details':
|
||||||
if (filePaths.length === 1) {
|
if (filePaths.length === 1) {
|
||||||
this.openTrackDetails(filePaths[0]!);
|
void this.openTrackDetails(filePaths[0]!);
|
||||||
} else {
|
} else {
|
||||||
this.openBatchTrackDetails(filePaths);
|
void this.openBatchTrackDetails(filePaths);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'phantom-locate':
|
case 'phantom-locate':
|
||||||
@@ -393,6 +451,16 @@ export class PlaylistDetails
|
|||||||
this.ctxMenu.close();
|
this.ctxMenu.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The row is still on screen, so the failure is visible; this
|
||||||
|
* only says why (errors.m7). */
|
||||||
|
private reportRemoveFailure(what: string, err: unknown): void {
|
||||||
|
notificationStore.transient({
|
||||||
|
key: 'playlist-remove',
|
||||||
|
text: `Could not ${what}. ${describeError(err)}`,
|
||||||
|
detail: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async removeSelectedTracks() {
|
private async removeSelectedTracks() {
|
||||||
const trackIDs = this.getSelectedTrackIDs();
|
const trackIDs = this.getSelectedTrackIDs();
|
||||||
|
|
||||||
@@ -405,10 +473,8 @@ export class PlaylistDetails
|
|||||||
);
|
);
|
||||||
await this.refreshTracks();
|
await this.refreshTracks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('Failed to remove tracks:', err);
|
||||||
'Failed to remove tracks:',
|
this.reportRemoveFailure('remove those tracks', err);
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,21 +498,25 @@ export class PlaylistDetails
|
|||||||
);
|
);
|
||||||
await this.refreshTracks();
|
await this.refreshTracks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('Failed to remove phantom tracks:', err);
|
||||||
'Failed to remove phantom tracks:',
|
this.reportRemoveFailure('remove those missing tracks', err);
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private openTrackDetails(filePath: string) {
|
private async openTrackDetails(filePath: string) {
|
||||||
const tracks = libraryStore.getCachedTracks();
|
const tracks = libraryStore.getCachedTracks();
|
||||||
const track = tracks?.find(
|
const track = tracks
|
||||||
(t) => t.FilePath === filePath,
|
? tracksByFilePath(tracks).get(filePath)
|
||||||
);
|
: undefined;
|
||||||
|
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(
|
||||||
|
() => void this.openTrackDetails(filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
const coverArt = track.CoverArtPath
|
||||||
? {
|
? {
|
||||||
coverArtPath: track.CoverArtPath,
|
coverArtPath: track.CoverArtPath,
|
||||||
@@ -462,7 +532,7 @@ export class PlaylistDetails
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
) {
|
) {
|
||||||
const cachedTracks =
|
const cachedTracks =
|
||||||
@@ -470,19 +540,19 @@ export class PlaylistDetails
|
|||||||
|
|
||||||
if (!cachedTracks) return;
|
if (!cachedTracks) return;
|
||||||
|
|
||||||
const tracks = filePaths
|
const tracks = tracksForPaths(
|
||||||
.map((fp) =>
|
cachedTracks,
|
||||||
cachedTracks.find(
|
filePaths,
|
||||||
(t) => t.FilePath === fp,
|
);
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(t): t is library.Track =>
|
|
||||||
t != null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(
|
||||||
|
() => void this.openBatchTrackDetails(filePaths),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
const first = tracks[0]!;
|
const first = tracks[0]!;
|
||||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
const albumNames = new Set(tracks.map((t) => t.Album));
|
||||||
let coverArt: CoverArtUrls | null = null;
|
let coverArt: CoverArtUrls | null = null;
|
||||||
@@ -595,10 +665,8 @@ export class PlaylistDetails
|
|||||||
);
|
);
|
||||||
await this.refreshTracks();
|
await this.refreshTracks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error('Failed to remove phantom track:', err);
|
||||||
'Failed to remove phantom track:',
|
this.reportRemoveFailure('remove that missing track', err);
|
||||||
err,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,38 +815,58 @@ export class PlaylistDetails
|
|||||||
// Search filtering
|
// Search filtering
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
private getVisibleTracks(): {
|
private getVisibleTracks(): VisibleTrack[] {
|
||||||
track: playlist.Track;
|
|
||||||
trackIndex: number;
|
|
||||||
}[] {
|
|
||||||
const term =
|
const term =
|
||||||
this.searchCtrl.term.toLowerCase();
|
this.searchCtrl.term.toLowerCase();
|
||||||
|
|
||||||
if (!term) {
|
// Keyed on the identity of the tracks array and the term, the
|
||||||
return this.tracks.map(
|
// same signal `track-list`'s memoized caches use: the store
|
||||||
(track, trackIndex) => ({
|
// replaces the array when its contents change and shares every
|
||||||
track,
|
// unchanged member.
|
||||||
trackIndex,
|
if (
|
||||||
}),
|
this.visibleCache &&
|
||||||
);
|
this.visibleCacheKey &&
|
||||||
|
this.visibleCacheKey.tracks === this.tracks &&
|
||||||
|
this.visibleCacheKey.term === term
|
||||||
|
) {
|
||||||
|
return this.visibleCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tracks
|
const all = this.tracks.map(
|
||||||
.map((track, trackIndex) => ({
|
(track, trackIndex) => ({
|
||||||
track,
|
track,
|
||||||
trackIndex,
|
trackIndex,
|
||||||
}))
|
}),
|
||||||
.filter(
|
);
|
||||||
({ track }) =>
|
|
||||||
track.Title.toLowerCase().includes(
|
const visible = term
|
||||||
term,
|
? all.filter(
|
||||||
) ||
|
({ track }) =>
|
||||||
track.Artist.toLowerCase().includes(
|
track.Title.toLowerCase().includes(
|
||||||
term,
|
term,
|
||||||
),
|
) ||
|
||||||
);
|
track.Artist.toLowerCase().includes(
|
||||||
|
term,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: all;
|
||||||
|
|
||||||
|
this.visibleCache = visible;
|
||||||
|
this.visibleCacheKey = { tracks: this.tracks, term };
|
||||||
|
|
||||||
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stable across renders, because `lit-virtualizer` declares both
|
||||||
|
* `renderItem` and `keyFunction` as plain `@property()` — a fresh
|
||||||
|
* arrow function marks them dirty and forces the virtualizer's own
|
||||||
|
* render pass on every host update (perf.m1). */
|
||||||
|
private renderRow = (entry: VisibleTrack) =>
|
||||||
|
this.renderTrackRow(entry);
|
||||||
|
|
||||||
|
private rowKey = (entry: VisibleTrack) =>
|
||||||
|
entry.trackIndex;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Styles
|
// Styles
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -970,6 +1058,18 @@ export class PlaylistDetails
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The virtualizer positions its children absolutely, so a row
|
||||||
|
* shrinks to fit its content and the columns stop lining up
|
||||||
|
* with the header above them. track-list has always carried the
|
||||||
|
* same declaration for the same reason.
|
||||||
|
*
|
||||||
|
* No backticks in here: this is inside a css tagged template
|
||||||
|
* literal, and one ends it. */
|
||||||
|
.track-item {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.track-header {
|
.track-header {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1262,9 +1362,21 @@ export class PlaylistDetails
|
|||||||
<div class="header-cell col-album">Album</div>
|
<div class="header-cell col-album">Album</div>
|
||||||
<div class="header-cell col-duration">Duration</div>
|
<div class="header-cell col-duration">Duration</div>
|
||||||
</div>
|
</div>
|
||||||
${visibleTracks.map(
|
<lit-virtualizer
|
||||||
({ track, trackIndex }) => {
|
class="track-scroller"
|
||||||
const isPhantom = track.Phantom;
|
.items=${visibleTracks}
|
||||||
|
.renderItem=${this.renderRow}
|
||||||
|
.keyFunction=${this.rowKey}
|
||||||
|
.layout=${this.flowLayout}
|
||||||
|
></lit-virtualizer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One row. Extracted from `renderTrackList` so it can be a stable
|
||||||
|
* bound field rather than a closure the virtualizer sees as new
|
||||||
|
* every pass. */
|
||||||
|
private renderTrackRow({ track, trackIndex }: VisibleTrack) {
|
||||||
|
const isPhantom = track.Phantom;
|
||||||
const active =
|
const active =
|
||||||
!isPhantom &&
|
!isPhantom &&
|
||||||
this.isActiveTrack(track);
|
this.isActiveTrack(track);
|
||||||
@@ -1383,7 +1495,14 @@ export class PlaylistDetails
|
|||||||
: html`<span class="cell col-number">${trackIndex + 1}</span>
|
: html`<span class="cell col-number">${trackIndex + 1}</span>
|
||||||
<div class="track-art">
|
<div class="track-art">
|
||||||
${track.CoverArtSmall || track.CoverArtMedium
|
${track.CoverArtSmall || track.CoverArtMedium
|
||||||
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
|
? html`<img
|
||||||
|
src="${track.CoverArtSmall || track.CoverArtMedium}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
/>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
|
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
|
||||||
@@ -1392,9 +1511,6 @@ export class PlaylistDetails
|
|||||||
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
|
||||||
)}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderContextMenu() {
|
private renderContextMenu() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|||||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
import { QueueController } from '@store/controllers/queue-controller';
|
import { QueueController } from '@store/controllers/queue-controller';
|
||||||
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||||
import '@lit-labs/virtualizer';
|
import '@lit-labs/virtualizer';
|
||||||
@@ -41,7 +42,8 @@ import {
|
|||||||
} from '@utils/drag-image';
|
} from '@utils/drag-image';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
import type { library } from '@go/models';
|
import type { library } from '@go/models';
|
||||||
import '@components/track-details/track-details.js';
|
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||||
|
import { tracksByFilePath } from '@utils/track-index.js';
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +51,9 @@ import {
|
|||||||
trackLink,
|
trackLink,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
} from '@utils/explore-link';
|
} from '@utils/explore-link';
|
||||||
|
/** Above this many tracks, clearing the queue asks first. */
|
||||||
|
const CLEAR_CONFIRM_THRESHOLD = 20;
|
||||||
|
|
||||||
const MIN_WIDTH = 200;
|
const MIN_WIDTH = 200;
|
||||||
const MAX_WIDTH = 500;
|
const MAX_WIDTH = 500;
|
||||||
const DEFAULT_WIDTH = 320;
|
const DEFAULT_WIDTH = 320;
|
||||||
@@ -651,6 +656,30 @@ export class QueuePanel
|
|||||||
}
|
}
|
||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
|
// Closed, the panel is `width: 0` — which hides it from the eye
|
||||||
|
// and from nobody else: its Clear and Add buttons still took tab
|
||||||
|
// stops at x=1440 and were still read out (H-5). `inert` is the
|
||||||
|
// one attribute that means "not there" to both.
|
||||||
|
this.inert = !this.open;
|
||||||
|
|
||||||
|
// ...and a panel that is not there does not render a list
|
||||||
|
// either (perf.m7). `width: 0` and `contain: layout style
|
||||||
|
// paint` bound the damage but do not stop the work: the
|
||||||
|
// virtualizer inside still had a real height and a
|
||||||
|
// `min-width: 300px`, so it measured its visible window on
|
||||||
|
// every queue change, and `scrollToIndex` below called
|
||||||
|
// `scrollIntoView()` on a laid-out but invisible element every
|
||||||
|
// time the current track changed. The list body renders
|
||||||
|
// `nothing` while closed; these indices reset so opening the
|
||||||
|
// panel re-syncs the highlight and the scroll position rather
|
||||||
|
// than inheriting stale ones.
|
||||||
|
if (!this.open) {
|
||||||
|
this.lastRenderedIndex = -1;
|
||||||
|
this.lastScrolledIndex = -1;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The virtualizer may not exist on first render
|
// The virtualizer may not exist on first render
|
||||||
// (queue empty). Retry hooks here when it appears.
|
// (queue empty). Retry hooks here when it appears.
|
||||||
this.attachVirtualizerHooks();
|
this.attachVirtualizerHooks();
|
||||||
@@ -680,7 +709,27 @@ export class QueuePanel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClearQueue = () => {
|
/**
|
||||||
|
* Clearing stops playback and discards the list, and it is the one
|
||||||
|
* mutation in this panel with no way back (errors.m3). A queue you
|
||||||
|
* could rebuild in a second is not worth a prompt; one you spent
|
||||||
|
* the evening on is.
|
||||||
|
*/
|
||||||
|
private handleClearQueue = async () => {
|
||||||
|
const count = this.queue.tracks.length;
|
||||||
|
|
||||||
|
if (count > CLEAR_CONFIRM_THRESHOLD) {
|
||||||
|
const ok = await confirmAction({
|
||||||
|
title: 'Clear the queue?',
|
||||||
|
message: `${count} tracks will be removed and playback will stop.`,
|
||||||
|
impact: 'The queue cannot be brought back.',
|
||||||
|
confirmLabel: 'Clear queue',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
this.queue.clearQueue();
|
this.queue.clearQueue();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -839,9 +888,9 @@ export class QueuePanel
|
|||||||
break;
|
break;
|
||||||
case 'track-details':
|
case 'track-details':
|
||||||
if (indices.length === 1) {
|
if (indices.length === 1) {
|
||||||
this.openTrackDetails(indices[0]!);
|
void this.openTrackDetails(indices[0]!);
|
||||||
} else {
|
} else {
|
||||||
this.openBatchTrackDetails(indices);
|
void this.openBatchTrackDetails(indices);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -870,7 +919,7 @@ export class QueuePanel
|
|||||||
this.ctxMenu.close();
|
this.ctxMenu.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openTrackDetails(index: number) {
|
private async openTrackDetails(index: number) {
|
||||||
const queueTrack =
|
const queueTrack =
|
||||||
this.queue.tracks[index];
|
this.queue.tracks[index];
|
||||||
|
|
||||||
@@ -878,13 +927,20 @@ export class QueuePanel
|
|||||||
|
|
||||||
const tracks =
|
const tracks =
|
||||||
libraryStore.getCachedTracks();
|
libraryStore.getCachedTracks();
|
||||||
const track = tracks?.find(
|
const track = tracks
|
||||||
(t) =>
|
? tracksByFilePath(tracks).get(
|
||||||
t.FilePath === queueTrack.filePath,
|
queueTrack.filePath,
|
||||||
);
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(
|
||||||
|
() => void this.openTrackDetails(index),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
const coverArt = track.CoverArtPath
|
||||||
? {
|
? {
|
||||||
coverArtPath: track.CoverArtPath,
|
coverArtPath: track.CoverArtPath,
|
||||||
@@ -900,7 +956,7 @@ export class QueuePanel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
indices: number[],
|
indices: number[],
|
||||||
) {
|
) {
|
||||||
const queueTracks = this.queue.tracks;
|
const queueTracks = this.queue.tracks;
|
||||||
@@ -909,15 +965,11 @@ export class QueuePanel
|
|||||||
|
|
||||||
if (!cachedTracks) return;
|
if (!cachedTracks) return;
|
||||||
|
|
||||||
|
const byPath = tracksByFilePath(cachedTracks);
|
||||||
const tracks = indices
|
const tracks = indices
|
||||||
.map((i) => queueTracks[i])
|
.map((i) => queueTracks[i])
|
||||||
.filter((qt) => qt != null)
|
.filter((qt) => qt != null)
|
||||||
.map((qt) =>
|
.map((qt) => byPath.get(qt.filePath))
|
||||||
cachedTracks.find(
|
|
||||||
(t) =>
|
|
||||||
t.FilePath === qt.filePath,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(
|
.filter(
|
||||||
(t): t is library.Track =>
|
(t): t is library.Track =>
|
||||||
t != null,
|
t != null,
|
||||||
@@ -925,6 +977,12 @@ export class QueuePanel
|
|||||||
|
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(
|
||||||
|
() => void this.openBatchTrackDetails(indices),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
const first = tracks[0]!;
|
const first = tracks[0]!;
|
||||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
const albumNames = new Set(tracks.map((t) => t.Album));
|
||||||
let coverArt: CoverArtUrls | null = null;
|
let coverArt: CoverArtUrls | null = null;
|
||||||
@@ -1456,7 +1514,7 @@ export class QueuePanel
|
|||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button
|
<button
|
||||||
class="header-action-button"
|
class="header-action-button"
|
||||||
@click=${this.handleClearQueue}
|
@click=${() => void this.handleClearQueue()}
|
||||||
?disabled=${tracks.length === 0}
|
?disabled=${tracks.length === 0}
|
||||||
title="Clear queue"
|
title="Clear queue"
|
||||||
>
|
>
|
||||||
@@ -1504,7 +1562,9 @@ export class QueuePanel
|
|||||||
@dragleave=${this.onPanelDragLeave}
|
@dragleave=${this.onPanelDragLeave}
|
||||||
@drop=${this.onPanelDrop}
|
@drop=${this.onPanelDrop}
|
||||||
>
|
>
|
||||||
${tracks.length === 0
|
${!this.open
|
||||||
|
? nothing
|
||||||
|
: tracks.length === 0
|
||||||
? html`<div class="empty-state">
|
? html`<div class="empty-state">
|
||||||
<div class="drop-zone-icon">
|
<div class="drop-zone-icon">
|
||||||
<wa-icon
|
<wa-icon
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
query,
|
query,
|
||||||
} from 'lit/decorators.js';
|
} from 'lit/decorators.js';
|
||||||
import type { playlist, library } from '@go/models';
|
import type { playlist } from '@go/models';
|
||||||
import {
|
import {
|
||||||
GetSmartPlaylistTracks,
|
GetSmartPlaylistTracks,
|
||||||
RefreshSmartPlaylist,
|
RefreshSmartPlaylist,
|
||||||
@@ -38,8 +38,12 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|||||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||||
|
import '@lit-labs/virtualizer';
|
||||||
|
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||||
|
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import '@components/track-details/track-details.js';
|
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||||
|
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
@@ -78,6 +82,13 @@ function formatTotalDuration(totalMs: number): string {
|
|||||||
return `${seconds}s`;
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row: the track and its position in the *playlist*, which is not
|
||||||
|
* its position in the filtered view. */
|
||||||
|
interface VisibleTrack {
|
||||||
|
track: playlist.Track;
|
||||||
|
trackIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
@customElement('smart-playlist-details')
|
@customElement('smart-playlist-details')
|
||||||
export class SmartPlaylistDetails
|
export class SmartPlaylistDetails
|
||||||
extends LitElement
|
extends LitElement
|
||||||
@@ -117,6 +128,32 @@ export class SmartPlaylistDetails
|
|||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
private selection = new SelectionController(this);
|
private selection = new SelectionController(this);
|
||||||
private ctxMenu = new ContextMenuController(this);
|
private ctxMenu = new ContextMenuController(this);
|
||||||
|
|
||||||
|
/* _itemSize matches the fixed 45 px .track-item height, measured
|
||||||
|
* rather than guessed: without the hint the flow layout's 100 px
|
||||||
|
* default drives constant scroll-error correction, which reads as
|
||||||
|
* the list jumping under the pointer. */
|
||||||
|
@query('lit-virtualizer')
|
||||||
|
private virtualizer?: LitVirtualizer;
|
||||||
|
|
||||||
|
/* The row templates live inside the virtualizer, not in this
|
||||||
|
* component's own template, so a host re-render alone does not
|
||||||
|
* repaint them: the virtualizer re-renders when its `items` change,
|
||||||
|
* and `items` is now memoised precisely so it does not change when
|
||||||
|
* nothing has. Selection and the playing-track highlight are
|
||||||
|
* therefore pushed to it explicitly, which is what `track-list` has
|
||||||
|
* always done. Costing ~36 rows, not the whole playlist. */
|
||||||
|
private lastActiveTrackPath: string | null = null;
|
||||||
|
|
||||||
|
private flowLayout = flow({
|
||||||
|
_itemSize: { width: 100, height: 45 },
|
||||||
|
} as Parameters<typeof flow>[0]);
|
||||||
|
|
||||||
|
private visibleCache: VisibleTrack[] | null = null;
|
||||||
|
private visibleCacheKey: {
|
||||||
|
tracks: playlist.Track[];
|
||||||
|
term: string;
|
||||||
|
} | null = null;
|
||||||
private favCtrl = new FavoritesController(this);
|
private favCtrl = new FavoritesController(this);
|
||||||
|
|
||||||
private playlistDeletedCleanup: (() => void) | null = null;
|
private playlistDeletedCleanup: (() => void) | null = null;
|
||||||
@@ -167,6 +204,21 @@ export class SmartPlaylistDetails
|
|||||||
|
|
||||||
onSelectionChanged(): void {
|
onSelectionChanged(): void {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
this.virtualizer?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override updated(
|
||||||
|
changed: Map<string, unknown>,
|
||||||
|
): void {
|
||||||
|
super.updated(changed);
|
||||||
|
|
||||||
|
const currentPath =
|
||||||
|
this.player.currentTrack?.filePath ?? null;
|
||||||
|
|
||||||
|
if (currentPath !== this.lastActiveTrackPath) {
|
||||||
|
this.lastActiveTrackPath = currentPath;
|
||||||
|
this.virtualizer?.requestUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -381,6 +433,18 @@ export class SmartPlaylistDetails
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The virtualizer positions its children absolutely, so a row
|
||||||
|
* shrinks to fit its content and its columns stop lining up
|
||||||
|
* with the header above them. track-list has always carried
|
||||||
|
* the same declaration for the same reason.
|
||||||
|
*
|
||||||
|
* No backticks in here: this is inside a css tagged template
|
||||||
|
* literal, and one ends it. */
|
||||||
|
.track-item {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.track-header {
|
.track-header {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -817,9 +881,9 @@ export class SmartPlaylistDetails
|
|||||||
break;
|
break;
|
||||||
case 'track-details':
|
case 'track-details':
|
||||||
if (filePaths.length === 1) {
|
if (filePaths.length === 1) {
|
||||||
this.openTrackDetails(filePaths[0]!);
|
void this.openTrackDetails(filePaths[0]!);
|
||||||
} else {
|
} else {
|
||||||
this.openBatchTrackDetails(filePaths);
|
void this.openBatchTrackDetails(filePaths);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -847,14 +911,20 @@ export class SmartPlaylistDetails
|
|||||||
this.ctxMenu.close();
|
this.ctxMenu.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openTrackDetails(filePath: string) {
|
private async openTrackDetails(filePath: string) {
|
||||||
const tracks = libraryStore.getCachedTracks();
|
const tracks = libraryStore.getCachedTracks();
|
||||||
const track = tracks?.find(
|
const track = tracks
|
||||||
(t) => t.FilePath === filePath,
|
? tracksByFilePath(tracks).get(filePath)
|
||||||
);
|
: undefined;
|
||||||
|
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(
|
||||||
|
() => void this.openTrackDetails(filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
const coverArt = track.CoverArtPath
|
||||||
? {
|
? {
|
||||||
coverArtPath: track.CoverArtPath,
|
coverArtPath: track.CoverArtPath,
|
||||||
@@ -870,7 +940,7 @@ export class SmartPlaylistDetails
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
) {
|
) {
|
||||||
const cachedTracks =
|
const cachedTracks =
|
||||||
@@ -878,18 +948,19 @@ export class SmartPlaylistDetails
|
|||||||
|
|
||||||
if (!cachedTracks) return;
|
if (!cachedTracks) return;
|
||||||
|
|
||||||
const tracks = filePaths
|
const tracks = tracksForPaths(
|
||||||
.map((fp) =>
|
cachedTracks,
|
||||||
cachedTracks.find(
|
filePaths,
|
||||||
(t) => t.FilePath === fp,
|
);
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
(t): t is library.Track => t != null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
|
||||||
|
const ready = await loadTrackDetails(
|
||||||
|
() => void this.openBatchTrackDetails(filePaths),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
const first = tracks[0]!;
|
const first = tracks[0]!;
|
||||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
const albumNames = new Set(tracks.map((t) => t.Album));
|
||||||
let coverArt: CoverArtUrls | null = null;
|
let coverArt: CoverArtUrls | null = null;
|
||||||
@@ -971,38 +1042,57 @@ export class SmartPlaylistDetails
|
|||||||
// Search filtering
|
// Search filtering
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
private getVisibleTracks(): {
|
private getVisibleTracks(): VisibleTrack[] {
|
||||||
track: playlist.Track;
|
|
||||||
trackIndex: number;
|
|
||||||
}[] {
|
|
||||||
const term =
|
const term =
|
||||||
this.searchCtrl.term.toLowerCase();
|
this.searchCtrl.term.toLowerCase();
|
||||||
|
|
||||||
if (!term) {
|
// Keyed on the identity of the tracks array and the term. The
|
||||||
return this.tracks.map(
|
// wrappers used to be rebuilt every render, so the array
|
||||||
(track, trackIndex) => ({
|
// identity always changed — which is the one thing a
|
||||||
track,
|
// virtualizer keys its work on (perf.M5).
|
||||||
trackIndex,
|
if (
|
||||||
}),
|
this.visibleCache &&
|
||||||
);
|
this.visibleCacheKey &&
|
||||||
|
this.visibleCacheKey.tracks === this.tracks &&
|
||||||
|
this.visibleCacheKey.term === term
|
||||||
|
) {
|
||||||
|
return this.visibleCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tracks
|
const all = this.tracks.map(
|
||||||
.map((track, trackIndex) => ({
|
(track, trackIndex) => ({
|
||||||
track,
|
track,
|
||||||
trackIndex,
|
trackIndex,
|
||||||
}))
|
}),
|
||||||
.filter(
|
);
|
||||||
({ track }) =>
|
|
||||||
track.Title.toLowerCase().includes(
|
const visible = term
|
||||||
term,
|
? all.filter(
|
||||||
) ||
|
({ track }) =>
|
||||||
track.Artist.toLowerCase().includes(
|
track.Title.toLowerCase().includes(
|
||||||
term,
|
term,
|
||||||
),
|
) ||
|
||||||
);
|
track.Artist.toLowerCase().includes(
|
||||||
|
term,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: all;
|
||||||
|
|
||||||
|
this.visibleCache = visible;
|
||||||
|
this.visibleCacheKey = { tracks: this.tracks, term };
|
||||||
|
|
||||||
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stable across renders: lit-virtualizer declares renderItem and
|
||||||
|
* keyFunction as plain properties, so a fresh arrow function marks
|
||||||
|
* them dirty and forces its own render pass every host update
|
||||||
|
* (perf.m1). */
|
||||||
|
private renderRow = (entry: VisibleTrack) =>
|
||||||
|
this.renderTrackRow(entry);
|
||||||
|
|
||||||
|
private rowKey = (entry: VisibleTrack) => entry.trackIndex;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1173,9 +1263,20 @@ export class SmartPlaylistDetails
|
|||||||
<div class="header-cell col-album">Album</div>
|
<div class="header-cell col-album">Album</div>
|
||||||
<div class="header-cell col-duration">Duration</div>
|
<div class="header-cell col-duration">Duration</div>
|
||||||
</div>
|
</div>
|
||||||
${visibleTracks.map(
|
<lit-virtualizer
|
||||||
({ track, trackIndex }) => {
|
.items=${visibleTracks}
|
||||||
const isPhantom = track.Phantom;
|
.renderItem=${this.renderRow}
|
||||||
|
.keyFunction=${this.rowKey}
|
||||||
|
.layout=${this.flowLayout}
|
||||||
|
></lit-virtualizer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One row. Extracted from renderTrackList so it can be a stable
|
||||||
|
* bound field rather than a closure the virtualizer sees as new on
|
||||||
|
* every pass. */
|
||||||
|
private renderTrackRow({ track, trackIndex }: VisibleTrack) {
|
||||||
|
const isPhantom = track.Phantom;
|
||||||
const active =
|
const active =
|
||||||
!isPhantom &&
|
!isPhantom &&
|
||||||
this.isActiveTrack(track);
|
this.isActiveTrack(track);
|
||||||
@@ -1242,7 +1343,14 @@ export class SmartPlaylistDetails
|
|||||||
: html`<span class="cell col-number">${trackIndex + 1}</span>
|
: html`<span class="cell col-number">${trackIndex + 1}</span>
|
||||||
<div class="track-art">
|
<div class="track-art">
|
||||||
${track.CoverArtSmall || track.CoverArtMedium
|
${track.CoverArtSmall || track.CoverArtMedium
|
||||||
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
|
? html`<img
|
||||||
|
src="${track.CoverArtSmall || track.CoverArtMedium}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
/>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
|
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
|
||||||
@@ -1251,9 +1359,6 @@ export class SmartPlaylistDetails
|
|||||||
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
|
||||||
)}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderContextMenu() {
|
private renderContextMenu() {
|
||||||
|
|||||||
Reference in New Issue
Block a user