adjusted frontend event handling to optimize json payload size for different events, fixed incorrect behavior when currently playing track is removed from queue

This commit is contained in:
2026-02-18 00:49:29 -05:00
parent be6f4e9764
commit 026ab1c333
14 changed files with 2221 additions and 574 deletions
@@ -1,13 +1,42 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private queue = new QueueController(this);
private unsubscribeQueue?: () => void;
@state() private shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off';
override connectedCallback(): void {
super.connectedCallback();
const s = queueStore.getState();
this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode;
this.unsubscribeQueue = queueStore.subscribe(() => {
const qs = queueStore.getState();
if (
qs.shuffleMode !== this.shuffleMode ||
qs.repeatMode !== this.repeatMode
) {
this.shuffleMode = qs.shuffleMode;
this.repeatMode = qs.repeatMode;
}
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
}
static override styles = css`
#player-control-buttons {
@@ -59,19 +88,19 @@ export class PlayerControls extends LitElement {
};
private handleNextClick = () => {
this.queue.next();
queueStore.next();
};
private handlePreviousClick = () => {
this.queue.previous();
queueStore.previous();
};
private handleShuffleClick = () => {
this.queue.toggleShuffle();
queueStore.toggleShuffle();
};
private handleRepeatClick = () => {
this.queue.cycleRepeat();
queueStore.cycleRepeat();
};
override render() {
@@ -80,8 +109,8 @@ export class PlayerControls extends LitElement {
? this.handlePauseClick
: this.handlePlayClick;
const shuffleClass = this.queue.shuffleMode ? 'active' : '';
const repeatMode = this.queue.repeatMode;
const shuffleClass = this.shuffleMode ? 'active' : '';
const repeatMode = this.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
@@ -358,10 +358,11 @@ export class CoverGrid extends LitElement {
typeof setTimeout
> | null = null;
private pendingFocus: {
row: number;
albumIndex: number;
viewportOffset: number;
} | null = null;
private currentColumnCount = 0;
private isResizing = false;
/* ====================================================================
* Lifecycle
@@ -510,6 +511,12 @@ export class CoverGrid extends LitElement {
private onVisibilityChanged = (
e: VisibilityChangedEvent,
) => {
// Skip saves while a resize reflow is in
// progress — the virtualizer reports
// intermediate positions that would overwrite
// the real scroll position in the store.
if (this.isResizing) return;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
@@ -534,10 +541,11 @@ export class CoverGrid extends LitElement {
* open/close, window resize) the grid reflows and
* the pixel scroll position becomes stale.
*
* We compute a fractional album index at a focus
* point before the resize, then after the reflow we
* place that same index back at the same viewport
* offset.
* We identify the album at the viewport center
* before the resize, then after the reflow we
* place that same album back at the same viewport
* offset. Integer album indices ensure zero
* scroll creep across repeated open/close cycles.
*
* If a dropdown is open the expanded album is the
* focus; otherwise the album at the viewport center
@@ -569,6 +577,7 @@ export class CoverGrid extends LitElement {
const pending = this.pendingFocus;
this.pendingFocus = null;
this.isResizing = false;
if (!pending) return;
@@ -585,10 +594,15 @@ export class CoverGrid extends LitElement {
return;
}
// No dropdown — restore the
// center-of-viewport position.
// Derive the album's row under the new
// column count. Both albumIndex and
// newColumns are integers, so newRow is
// also an integer — no fractional drift.
const newRow = Math.floor(
pending.albumIndex / newColumns,
);
const newY =
GRID_PADDING + pending.row * rowStep;
GRID_PADDING + newRow * rowStep;
container.scrollTop =
newY - pending.viewportOffset;
@@ -599,6 +613,8 @@ export class CoverGrid extends LitElement {
// Capture on the first event using
// the pre-resize column count.
if (this.pendingFocus === null) {
this.isResizing = true;
this.captureFocusPoint(
container,
rowStep,
@@ -655,12 +671,18 @@ export class CoverGrid extends LitElement {
* If a dropdown is open, the expanded album is the
* focus and its current viewport offset is preserved.
* Otherwise the album at the viewport center is used.
*
* Stores an integer album index and the pixel offset
* from that album's top edge to the viewport top.
* Integer indices ensure zero drift across repeated
* open/close cycles (no fractional accumulation).
*/
private captureFocusPoint(
container: HTMLElement,
rowStep: number,
) {
const { GRID_PADDING } = CoverGrid;
const cols = this.currentColumnCount;
// Prefer the expanded album as focus.
if (this.expandedAlbumId !== null) {
@@ -670,13 +692,13 @@ export class CoverGrid extends LitElement {
if (idx >= 0) {
const albumRow = Math.floor(
idx / this.currentColumnCount,
idx / cols,
);
const albumY =
GRID_PADDING + albumRow * rowStep;
this.pendingFocus = {
row: albumRow,
albumIndex: idx,
viewportOffset:
albumY - container.scrollTop,
};
@@ -685,17 +707,30 @@ export class CoverGrid extends LitElement {
}
}
// Fall back to the viewport center.
const halfViewport =
container.clientHeight / 2;
// Fall back to the album whose row contains
// the viewport center.
const centerY =
container.scrollTop + halfViewport;
const row =
(centerY - GRID_PADDING) / rowStep;
container.scrollTop +
container.clientHeight / 2;
const centerRow = Math.floor(
Math.max(0, centerY - GRID_PADDING) /
rowStep,
);
const albumIndex = Math.min(
centerRow * cols,
Math.max(0, this.albums.length - 1),
);
// Pixel offset from that album's top edge
// to the viewport top — used exactly once in
// restoreScroll, never fed back.
const albumY =
GRID_PADDING + centerRow * rowStep;
this.pendingFocus = {
row,
viewportOffset: halfViewport,
albumIndex,
viewportOffset:
albumY - container.scrollTop,
};
}
@@ -1,13 +1,23 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { CreatePlaylist } from '@go/playlist/Service';
import {
CreatePlaylist,
RemoveTracksFromPlaylist,
} from '@go/playlist/Service';
import type { playlist } from '@go/models';
import { QueueController } from '@store/controllers/queue-controller';
import { queueStore } from '@store/queue-store';
import { PlayerController } from '@store/controllers/player-controller';
import { PlaylistController } from '@store/controllers/playlist-controller';
import '@components/track-info/track-info';
import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
const SCROLL_DEBOUNCE_MS = 100;
@@ -18,16 +28,131 @@ interface PlaylistEntry {
}
@customElement('playlist-view')
export class PlaylistView extends LitElement {
private queue = new QueueController(this);
export class PlaylistView
extends LitElement
implements SelectionHost
{
private player = new PlayerController(this);
private playlistCtrl = new PlaylistController(this);
private scrollDebounceTimer: ReturnType<typeof setTimeout> | null =
null;
private selection = new SelectionController(this);
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
/**
* Index of the playlist whose tracks are currently
* selectable. -1 means no active selection scope.
*/
private activePlaylistIndex = -1;
@state() private entries: PlaylistEntry[] = [];
@state() private loading = true;
@state() private creating = false;
@state() private newPlaylistName = '';
@state() private contextMenuOpen = false;
@state() private playlistSubmenuOpen = false;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
private closeContextMenuHandler = () =>
this.closeContextMenu();
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('track-item') &&
this.shadowRoot?.contains(el),
);
if (!isTrackClick) {
this.selection.clear();
}
};
// =================================================================
// SelectionHost interface
// =================================================================
getItemKey(index: number): string | undefined {
if (this.activePlaylistIndex < 0) return undefined;
const entry =
this.entries[this.activePlaylistIndex];
if (
!entry ||
index < 0 ||
index >= entry.tracks.length
) {
return undefined;
}
return String(index);
}
getItemCount(): number {
if (this.activePlaylistIndex < 0) return 0;
const entry =
this.entries[this.activePlaylistIndex];
return entry?.tracks.length ?? 0;
}
/**
* Return the selected playlist track IDs (database IDs)
* in order, for removal operations.
*/
private getSelectedTrackIDs(): number[] {
if (this.activePlaylistIndex < 0) return [];
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return [];
return this.selection
.getSelectedIndices()
.map((i) => entry.tracks[i]!.ID);
}
/**
* Derive file paths from selected indices for
* operations that need file paths.
*/
private getSelectedFilePaths(): string[] {
if (this.activePlaylistIndex < 0) return [];
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return [];
return this.selection
.getSelectedIndices()
.map((i) => entry.tracks[i]!.FilePath);
}
/**
* Ensure the selection scope matches the given playlist
* index. If switching playlists, clear the old selection.
*/
private ensureSelectionScope(
playlistIndex: number,
): void {
if (
this.activePlaylistIndex !== playlistIndex
) {
this.selection.clear();
this.activePlaylistIndex = playlistIndex;
}
}
static override styles = css`
:host {
@@ -139,7 +264,8 @@ export class PlaylistView extends LitElement {
}
.playlist-item {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid
rgba(255, 255, 255, 0.05);
}
.playlist-header {
@@ -219,7 +345,27 @@ export class PlaylistView extends LitElement {
.track-item {
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
border-bottom: 1px solid
rgba(255, 255, 255, 0.03);
cursor: default;
user-select: none;
}
.track-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.track-item.selected {
background-color: rgba(100, 160, 255, 0.15);
}
.track-item.active {
background-color: rgba(255, 212, 59, 0.1);
color: #ffd43b;
}
.track-item.selected.active {
background-color: rgba(100, 160, 255, 0.15);
}
.track-item:last-child {
@@ -258,11 +404,60 @@ export class PlaylistView extends LitElement {
.empty-state p {
margin: 4px 0;
}
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: #fff;
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.submenu-item {
position: relative;
}
.submenu-arrow {
font-size: 10px;
margin-left: auto;
padding-left: 12px;
}
#playlist-submenu {
z-index: 210;
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadPlaylists();
document.addEventListener(
'click',
this.closeContextMenuHandler,
);
document.addEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.addEventListener(
'click',
this.clearSelectionHandler,
);
}
override disconnectedCallback() {
@@ -272,6 +467,19 @@ export class PlaylistView extends LitElement {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
document.removeEventListener(
'click',
this.closeContextMenuHandler,
);
document.removeEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.removeEventListener(
'click',
this.clearSelectionHandler,
);
}
private get scrollContainer(): HTMLElement | null {
@@ -318,7 +526,10 @@ export class PlaylistView extends LitElement {
tracks: p.Tracks ?? [],
}));
} catch (err) {
console.error('Failed to load playlists:', err);
console.error(
'Failed to load playlists:',
err,
);
this.entries = [];
} finally {
this.loading = false;
@@ -333,6 +544,15 @@ export class PlaylistView extends LitElement {
if (!entry) return;
// If collapsing the active playlist, clear selection.
if (
entry.expanded &&
this.activePlaylistIndex === index
) {
this.selection.clear();
this.activePlaylistIndex = -1;
}
this.entries = this.entries.map((e, i) =>
i === index
? { ...e, expanded: !e.expanded }
@@ -345,10 +565,211 @@ export class PlaylistView extends LitElement {
if (!entry || entry.tracks.length === 0) return;
const filePaths = entry.tracks.map((t) => t.FilePath);
this.queue.setQueue(filePaths, 0);
const filePaths = entry.tracks.map(
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, 0);
};
// =================================================================
// Track selection & context menu
// =================================================================
private handleTrackClick(
e: MouseEvent,
_track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) {
this.ensureSelectionScope(playlistIndex);
this.selection.handleItemClick(
e,
String(trackIndex),
trackIndex,
);
}
private handleTrackDblClick(
_track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) {
const entry = this.entries[playlistIndex];
if (!entry) return;
this.selection.clear();
const filePaths = entry.tracks.map(
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, trackIndex);
}
private handleTrackContextMenu(
e: MouseEvent,
trackIndex: number,
playlistIndex: number,
) {
e.preventDefault();
e.stopPropagation();
this.ensureSelectionScope(playlistIndex);
this.selection.handleContextMenu(
String(trackIndex),
);
this.contextMenuOpen = true;
// Position at mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
}
private onContextMenuAction(action: string) {
const filePaths =
this.getSelectedFilePaths();
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
queueStore.playTracksNext(filePaths);
break;
case 'remove':
void this.removeSelectedTracks();
break;
}
this.closeContextMenu(true);
}
private async removeSelectedTracks() {
if (this.activePlaylistIndex < 0) return;
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return;
const trackIDs = this.getSelectedTrackIDs();
if (trackIDs.length === 0) return;
try {
await RemoveTracksFromPlaylist(
entry.summary.ID,
trackIDs,
);
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error(
'Failed to remove tracks:',
err,
);
}
}
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
if (clearSelection) {
this.selection.clear();
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private async showPlaylistSubmenu() {
if (this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = true;
await this.updateComplete;
const submenu = this.playlistSubmenuPopup;
const trigger =
this.shadowRoot?.querySelector(
'.submenu-item',
);
if (submenu && trigger) {
(submenu as any).anchor = trigger;
(submenu as any).active = true;
}
const picker = this.shadowRoot?.querySelector(
'playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
private closePlaylistSubmenu() {
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu(true);
};
private isActiveTrack(
track: playlist.Track,
): boolean {
const currentTrack = this.player.currentTrack;
if (!currentTrack) return false;
return currentTrack.filePath === track.FilePath;
}
// =================================================================
// Create playlist
// =================================================================
private handleNewPlaylistClick = () => {
this.creating = true;
this.newPlaylistName = '';
@@ -379,7 +800,10 @@ export class PlaylistView extends LitElement {
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error('Failed to create playlist:', err);
console.error(
'Failed to create playlist:',
err,
);
}
};
@@ -396,6 +820,10 @@ export class PlaylistView extends LitElement {
}
};
// =================================================================
// Render
// =================================================================
override render() {
return html`
<div class="header">
@@ -409,17 +837,120 @@ export class PlaylistView extends LitElement {
</button>
</div>
${this.creating ? this.renderCreateForm() : nothing}
${this.creating
? this.renderCreateForm()
: nothing}
${this.loading
? html`<div class="loading">
Loading playlists...
</div>`
: this.renderPlaylistList()}
<wa-popup
id="context-menu"
placement="bottom-start"
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() =>
this.showPlaylistSubmenu()}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
this.selection.hasSelection
? html`
<playlist-picker
.filePaths=${this.getSelectedFilePaths()}
@playlist-action-complete=${this
.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
`
: nothing}
</wa-popup>
`;
}
private renderCreateForm() {
const canCreate = this.newPlaylistName.trim().length > 0;
const canCreate =
this.newPlaylistName.trim().length > 0;
return html`
<div class="create-form">
@@ -430,7 +961,9 @@ export class PlaylistView extends LitElement {
@input=${this.handleInputChange}
@keydown=${this.handleInputKeydown}
/>
<button @click=${this.handleCancelCreate}>
<button
@click=${this.handleCancelCreate}
>
Cancel
</button>
<button
@@ -458,7 +991,10 @@ export class PlaylistView extends LitElement {
}
return html`
<ul class="playlist-list" @scroll=${this.onScroll}>
<ul
class="playlist-list"
@scroll=${this.onScroll}
>
${this.entries.map((entry, i) =>
this.renderPlaylistItem(entry, i),
)}
@@ -466,16 +1002,19 @@ export class PlaylistView extends LitElement {
`;
}
private renderPlaylistItem(entry: PlaylistEntry, index: number) {
private renderPlaylistItem(
entry: PlaylistEntry,
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel =
`${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
return html`
<li class="playlist-item">
<div
class="playlist-header"
@click=${() => this.handleToggle(index)}
@click=${() =>
this.handleToggle(index)}
>
<wa-icon
class="chevron ${entry.expanded
@@ -495,7 +1034,10 @@ export class PlaylistView extends LitElement {
</span>
</div>
${entry.expanded
? this.renderPlaylistBody(entry, index)
? this.renderPlaylistBody(
entry,
index,
)
: nothing}
</li>
`;
@@ -503,7 +1045,7 @@ export class PlaylistView extends LitElement {
private renderPlaylistBody(
entry: PlaylistEntry,
index: number,
playlistIndex: number,
) {
if (entry.tracks.length === 0) {
return html`
@@ -522,7 +1064,9 @@ export class PlaylistView extends LitElement {
class="play-all-button"
@click=${(e: Event) => {
e.stopPropagation();
this.handlePlayAll(index);
this.handlePlayAll(
playlistIndex,
);
}}
>
<wa-icon name="play"></wa-icon>
@@ -530,16 +1074,60 @@ export class PlaylistView extends LitElement {
</button>
</div>
${entry.tracks.map(
(track) => html`
<div class="track-item">
<track-info
.trackTitle=${track.Title}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
</div>
`,
(track, trackIndex) => {
const active =
this.isActiveTrack(track);
const selected =
this.activePlaylistIndex ===
playlistIndex &&
this.selection.isSelected(
String(trackIndex),
);
const classes = [
'track-item',
active ? 'active' : '',
selected ? 'selected' : '',
]
.filter(Boolean)
.join(' ');
return html`
<div
class=${classes}
@click=${(
e: MouseEvent,
) =>
this.handleTrackClick(
e,
track,
trackIndex,
playlistIndex,
)}
@dblclick=${() =>
this.handleTrackDblClick(
track,
trackIndex,
playlistIndex,
)}
@contextmenu=${(
e: MouseEvent,
) =>
this.handleTrackContextMenu(
e,
trackIndex,
playlistIndex,
)}
>
<track-info
.trackTitle=${track.Title}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
</div>
`;
},
)}
</div>
`;
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,10 @@ import { library } from '@go/models';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { formatMilliseconds } from '@utils/time';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
import { queueStore } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller';
import '@lit-labs/virtualizer';
import type {
@@ -23,17 +25,14 @@ const DEFAULT_DURATION_WIDTH = 80;
const COLUMN_COUNT = 3;
@customElement('track-list')
export class TrackList extends LitElement {
export class TrackList extends LitElement implements SelectionHost {
private player = new PlayerController(this);
private queue = new QueueController(this);
private libraryCtrl = new LibraryController(this);
private selection = new SelectionController(this);
@state()
private tracks: library.Track[] = [];
@state()
private selectedTracks: Set<string> = new Set();
@state()
private contextMenuOpen = false;
@@ -49,11 +48,24 @@ export class TrackList extends LitElement {
@query('lit-virtualizer')
private virtualizer!: LitVirtualizer;
private lastSelectedIndex: number | null = null;
private lastActiveTrackPath: string | null = null;
private closeHandler = () => this.closeContextMenu();
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('track-row') &&
this.shadowRoot?.contains(el),
);
if (!isTrackClick) {
this.selection.clear();
}
};
@state()
private columnWidths: number[] = [];
@@ -64,6 +76,22 @@ export class TrackList extends LitElement {
private flowLayout = flow();
private hasRestoredScroll = false;
// =================================================================
// SelectionHost interface
// =================================================================
getItemKey(index: number): string | undefined {
return this.tracks[index]?.FilePath;
}
getItemCount(): number {
return this.tracks.length;
}
onSelectionChanged(): void {
this.virtualizer?.requestUpdate();
}
private get gridTemplateColumns(): string {
if (this.columnWidths.length === 0) {
return '1fr 1fr 80px';
@@ -361,6 +389,7 @@ export class TrackList extends LitElement {
this.loadTracks();
document.addEventListener('click', this.closeHandler);
document.addEventListener('contextmenu', this.closeHandler);
document.addEventListener('click', this.clearSelectionHandler);
document.addEventListener('mousemove', this.onColResizeMove);
document.addEventListener('mouseup', this.onColResizeEnd);
@@ -380,6 +409,7 @@ export class TrackList extends LitElement {
super.disconnectedCallback();
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('contextmenu', this.closeHandler);
document.removeEventListener('click', this.clearSelectionHandler);
document.removeEventListener('mousemove', this.onColResizeMove);
document.removeEventListener('mouseup', this.onColResizeEnd);
@@ -399,10 +429,6 @@ export class TrackList extends LitElement {
);
}
if (changed.has('selectedTracks')) {
this.virtualizer?.requestUpdate();
}
const currentPath =
this.player.currentTrack?.filePath ?? null;
@@ -455,8 +481,7 @@ export class TrackList extends LitElement {
try {
const tracks = await this.libraryCtrl.getTracks();
this.tracks = tracks;
this.selectedTracks = new Set();
this.lastSelectedIndex = null;
this.selection.clear();
await this.updateComplete;
if (this.isConnected && this.virtualizer) {
@@ -494,94 +519,24 @@ export class TrackList extends LitElement {
this.libraryCtrl.setScrollPosition('tracks', first);
};
private getSelectedFilePaths(): string[] {
return this.tracks
.filter((t) => this.selectedTracks.has(t.FilePath))
.map((t) => t.FilePath);
}
private selectRange(from: number, to: number): Set<string> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const paths = new Set<string>();
for (let i = start; i <= end; i++) {
const track = this.tracks[i];
if (track) {
paths.add(track.FilePath);
}
}
return paths;
}
private onTrackRowClick(
e: MouseEvent,
track: library.Track,
index: number,
) {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isShift && this.lastSelectedIndex !== null) {
const range = this.selectRange(
this.lastSelectedIndex,
index,
);
if (isCtrl) {
// Ctrl+Shift: add range to existing selection.
const next = new Set(this.selectedTracks);
for (const path of range) {
next.add(path);
}
this.selectedTracks = next;
} else {
// Shift only: add range to existing selection.
const next = new Set(this.selectedTracks);
for (const path of range) {
next.add(path);
}
this.selectedTracks = next;
}
// Don't update anchor on shift-click so user can
// adjust the range endpoint with another shift-click.
} else if (isCtrl) {
const next = new Set(this.selectedTracks);
if (next.has(track.FilePath)) {
next.delete(track.FilePath);
} else {
next.add(track.FilePath);
}
this.selectedTracks = next;
this.lastSelectedIndex = index;
} else {
this.selectedTracks = new Set([track.FilePath]);
this.lastSelectedIndex = index;
}
this.selection.handleItemClick(e, track.FilePath, index);
}
private onTrackRowDblClick(track: library.Track) {
this.selectedTracks = new Set();
this.queue.setQueue([track.FilePath], 0);
this.selection.clear();
queueStore.setQueue([track.FilePath], 0);
}
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
e.preventDefault();
e.stopPropagation();
if (!this.selectedTracks.has(track.FilePath)) {
this.selectedTracks = new Set([track.FilePath]);
}
this.selection.handleContextMenu(track.FilePath);
this.contextMenuOpen = true;
// Position the popup at the mouse cursor using a virtual anchor.
@@ -609,19 +564,19 @@ export class TrackList extends LitElement {
}
private onContextMenuAction(action: string) {
const filePaths = this.getSelectedFilePaths();
const filePaths = this.selection.getSelectedKeysOrdered();
if (filePaths.length === 0) return;
switch (action) {
case 'play':
this.queue.setQueue(filePaths, 0);
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
this.queue.addTracksToQueue(filePaths);
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
this.queue.playTracksNext(filePaths);
queueStore.playTracksNext(filePaths);
break;
}
@@ -635,7 +590,7 @@ export class TrackList extends LitElement {
this.contextMenuOpen = false;
if (clearSelection) {
this.selectedTracks = new Set();
this.selection.clear();
}
const popup = this.contextMenuPopup;
@@ -696,7 +651,7 @@ export class TrackList extends LitElement {
index: number,
): unknown => {
const active = this.isActiveTrack(track);
const selected = this.selectedTracks.has(track.FilePath);
const selected = this.selection.isSelected(track.FilePath);
const classes = [
'track-row',
@@ -803,10 +758,10 @@ export class TrackList extends LitElement {
placement="right-start"
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen && this.selectedTracks.size > 0
${this.playlistSubmenuOpen && this.selection.hasSelection
? html`
<playlist-picker
.filePaths=${this.getSelectedFilePaths()}
.filePaths=${this.selection.getSelectedKeysOrdered()}
@playlist-action-complete=${this.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>