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:
@@ -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"
|
||||
>
|
||||
▶
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user