playlist creation, viewing, integration with other views. still wip for full playlist functionality
This commit is contained in:
@@ -9,6 +9,8 @@ import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
||||
import '@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/icon/icon.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
@customElement('cover-grid')
|
||||
export class CoverGrid extends LitElement {
|
||||
@@ -143,6 +145,20 @@ export class CoverGrid extends LitElement {
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
|
||||
@state()
|
||||
@@ -157,9 +173,18 @@ export class CoverGrid extends LitElement {
|
||||
@state()
|
||||
private contextMenuAlbum: library.Album | null = null;
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
|
||||
@state()
|
||||
private playlistFilePaths: string[] = [];
|
||||
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: HTMLElement;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup!: HTMLElement;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadAlbums();
|
||||
@@ -253,8 +278,10 @@ export class CoverGrid extends LitElement {
|
||||
private closeContextMenu() {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
this.contextMenuAlbum = null;
|
||||
this.playlistFilePaths = [];
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
@@ -263,6 +290,52 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async showPlaylistSubmenu() {
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
if (this.contextMenuAlbum) {
|
||||
this.playlistFilePaths = await this.getAlbumFilePaths(
|
||||
this.contextMenuAlbum,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
private renderAlbumCard = (album: library.Album): unknown => {
|
||||
return html`
|
||||
<div
|
||||
@@ -376,10 +449,38 @@ export class CoverGrid extends LitElement {
|
||||
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||
Play Next
|
||||
</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
|
||||
? html`
|
||||
<playlist-picker
|
||||
.filePaths=${this.playlistFilePaths}
|
||||
@playlist-action-complete=${this.onPlaylistActionComplete}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
></playlist-picker>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
|
||||
import {
|
||||
GetAllPlaylists,
|
||||
AddTracksToPlaylist,
|
||||
CreatePlaylistWithTracks,
|
||||
} from '@go/playlist/Service';
|
||||
import type { playlist } from '@go/models';
|
||||
|
||||
/**
|
||||
* A reusable playlist picker that displays existing playlists
|
||||
* and allows creating new ones. Accepts file paths and handles
|
||||
* adding tracks to the selected/created playlist.
|
||||
*
|
||||
* @fires playlist-action-complete - When tracks have been added successfully.
|
||||
*/
|
||||
@customElement('playlist-picker')
|
||||
export class PlaylistPicker extends LitElement {
|
||||
/** File paths to add when a playlist is selected or created. */
|
||||
@property({ type: Array }) filePaths: string[] = [];
|
||||
|
||||
@state() private mode: 'list' | 'create' = 'list';
|
||||
@state() private playlists: playlist.Summary[] = [];
|
||||
@state() private newPlaylistName = '';
|
||||
@state() private loading = false;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.picker-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: 180px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.picker-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picker-panel wa-dropdown-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: #555;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
background: #2a2d30;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.create-form input:focus {
|
||||
border-color: #ffd43b;
|
||||
}
|
||||
|
||||
.create-form input::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button-row button {
|
||||
background: #495057;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.button-row button:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.button-row button.primary {
|
||||
background: #ffd43b;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.button-row button.primary:hover {
|
||||
background: #ffe066;
|
||||
}
|
||||
|
||||
.button-row button.primary:disabled {
|
||||
background: #665a1e;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 8px 12px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadPlaylists();
|
||||
}
|
||||
|
||||
private async loadPlaylists() {
|
||||
try {
|
||||
this.playlists = await GetAllPlaylists();
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlists:', err);
|
||||
this.playlists = [];
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelectPlaylist = async (playlistId: number) => {
|
||||
if (this.loading || this.filePaths.length === 0) return;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
await AddTracksToPlaylist(playlistId, this.filePaths);
|
||||
this.dispatchComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to add tracks to playlist:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleShowCreate = () => {
|
||||
this.mode = 'create';
|
||||
this.newPlaylistName = '';
|
||||
|
||||
void this.updateComplete.then(() => {
|
||||
const input =
|
||||
this.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'.create-form input',
|
||||
);
|
||||
|
||||
input?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
private handleCancelCreate = () => {
|
||||
this.mode = 'list';
|
||||
this.newPlaylistName = '';
|
||||
};
|
||||
|
||||
private handleCreatePlaylist = async () => {
|
||||
const name = this.newPlaylistName.trim();
|
||||
if (!name || this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
await CreatePlaylistWithTracks(name, this.filePaths);
|
||||
this.dispatchComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to create playlist:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleInputChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.newPlaylistName = input.value;
|
||||
};
|
||||
|
||||
private handleInputKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
void this.handleCreatePlaylist();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.handleCancelCreate();
|
||||
}
|
||||
|
||||
// Stop propagation so parent context menu handlers don't interfere.
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private dispatchComplete() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('playlist-action-complete', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resets the picker to its initial list state. */
|
||||
reset() {
|
||||
this.mode = 'list';
|
||||
this.newPlaylistName = '';
|
||||
this.loading = false;
|
||||
this.loadPlaylists();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.mode === 'create') {
|
||||
return this.renderCreateForm();
|
||||
}
|
||||
|
||||
return this.renderPlaylistList();
|
||||
}
|
||||
|
||||
private renderPlaylistList() {
|
||||
return html`
|
||||
<div class="picker-panel">
|
||||
${this.playlists.length > 0
|
||||
? html`
|
||||
${this.playlists.map(
|
||||
(p) => html`
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.handleSelectPlaylist(p.ID)}
|
||||
>
|
||||
${p.Name}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
<div class="separator"></div>
|
||||
`
|
||||
: nothing}
|
||||
<wa-dropdown-item @click=${this.handleShowCreate}>
|
||||
<wa-icon slot="icon" name="plus"></wa-icon>
|
||||
New Playlist
|
||||
</wa-dropdown-item>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCreateForm() {
|
||||
const canCreate = this.newPlaylistName.trim().length > 0;
|
||||
|
||||
return html`
|
||||
<div class="picker-panel">
|
||||
<div class="create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Playlist name"
|
||||
.value=${this.newPlaylistName}
|
||||
@input=${this.handleInputChange}
|
||||
@keydown=${this.handleInputKeydown}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="button-row">
|
||||
<button @click=${this.handleCancelCreate}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
?disabled=${!canCreate || this.loading}
|
||||
@click=${this.handleCreatePlaylist}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'playlist-picker': PlaylistPicker;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
import {
|
||||
GetAllPlaylists,
|
||||
CreatePlaylist,
|
||||
} from '@go/playlist/Service';
|
||||
import type { playlist } from '@go/models';
|
||||
|
||||
@customElement('playlist-view')
|
||||
export class PlaylistView extends LitElement {
|
||||
@state() private playlists: playlist.Summary[] = [];
|
||||
@state() private loading = true;
|
||||
@state() private creating = false;
|
||||
@state() private newPlaylistName = '';
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.new-playlist-button {
|
||||
background: none;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.new-playlist-button:hover {
|
||||
border-color: #ffd43b;
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
flex: 1;
|
||||
background: #2a2d30;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.create-form input:focus {
|
||||
border-color: #ffd43b;
|
||||
}
|
||||
|
||||
.create-form input::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.create-form button {
|
||||
background: #495057;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.create-form button:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.create-form button.primary {
|
||||
background: #ffd43b;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.create-form button.primary:hover {
|
||||
background: #ffe066;
|
||||
}
|
||||
|
||||
.create-form button.primary:disabled {
|
||||
background: #665a1e;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.playlist-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.playlist-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.playlist-icon {
|
||||
font-size: 18px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: #b3b3b3;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state wa-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadPlaylists();
|
||||
}
|
||||
|
||||
private async loadPlaylists() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const result = await GetAllPlaylists();
|
||||
this.playlists = result ?? [];
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlists:', err);
|
||||
this.playlists = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleNewPlaylistClick = () => {
|
||||
this.creating = true;
|
||||
this.newPlaylistName = '';
|
||||
|
||||
void this.updateComplete.then(() => {
|
||||
const input =
|
||||
this.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'.create-form input',
|
||||
);
|
||||
|
||||
input?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
private handleCancelCreate = () => {
|
||||
this.creating = false;
|
||||
this.newPlaylistName = '';
|
||||
};
|
||||
|
||||
private handleCreatePlaylist = async () => {
|
||||
const name = this.newPlaylistName.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await CreatePlaylist(name);
|
||||
this.creating = false;
|
||||
this.newPlaylistName = '';
|
||||
await this.loadPlaylists();
|
||||
} catch (err) {
|
||||
console.error('Failed to create playlist:', err);
|
||||
}
|
||||
};
|
||||
|
||||
private handleInputChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.newPlaylistName = input.value;
|
||||
};
|
||||
|
||||
private handleInputKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
void this.handleCreatePlaylist();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.handleCancelCreate();
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>Playlists</h2>
|
||||
<button
|
||||
class="new-playlist-button"
|
||||
@click=${this.handleNewPlaylistClick}
|
||||
>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
New Playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this.creating ? this.renderCreateForm() : ''}
|
||||
${this.loading
|
||||
? html`<div class="loading">Loading playlists...</div>`
|
||||
: this.renderPlaylistList()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCreateForm() {
|
||||
const canCreate = this.newPlaylistName.trim().length > 0;
|
||||
|
||||
return html`
|
||||
<div class="create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Playlist name"
|
||||
.value=${this.newPlaylistName}
|
||||
@input=${this.handleInputChange}
|
||||
@keydown=${this.handleInputKeydown}
|
||||
/>
|
||||
<button @click=${this.handleCancelCreate}>Cancel</button>
|
||||
<button
|
||||
class="primary"
|
||||
?disabled=${!canCreate}
|
||||
@click=${this.handleCreatePlaylist}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaylistList() {
|
||||
if (this.playlists.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<wa-icon name="list"></wa-icon>
|
||||
<p>No playlists yet</p>
|
||||
<p style="font-size: 12px;">
|
||||
Create a playlist to get started.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ul class="playlist-list">
|
||||
${this.playlists.map(
|
||||
(p) => html`
|
||||
<li class="playlist-item">
|
||||
<wa-icon
|
||||
class="playlist-icon"
|
||||
name="list"
|
||||
></wa-icon>
|
||||
<span class="playlist-name">${p.Name}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'playlist-view': PlaylistView;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { LitElement, html, css, unsafeCSS } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { LitElement, html, css, nothing, unsafeCSS } from 'lit';
|
||||
import { customElement, property, 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 { QueueController } from '@store/controllers/queue-controller';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 500;
|
||||
@@ -17,6 +20,22 @@ export class QueuePanel extends LitElement {
|
||||
@state()
|
||||
private isDragging = false;
|
||||
|
||||
@state()
|
||||
private playlistPickerOpen = false;
|
||||
|
||||
@query('#save-playlist-popup')
|
||||
private savePlaylistPopup!: HTMLElement;
|
||||
|
||||
private closePickerHandler = (e: MouseEvent) => {
|
||||
const path = e.composedPath();
|
||||
const popup = this.savePlaylistPopup;
|
||||
const btn = this.shadowRoot?.querySelector('.save-playlist-button');
|
||||
|
||||
if (popup && !path.includes(popup) && (!btn || !path.includes(btn))) {
|
||||
this.closePlaylistPicker();
|
||||
}
|
||||
};
|
||||
|
||||
private panelWidth = DEFAULT_WIDTH;
|
||||
|
||||
static override styles = css`
|
||||
@@ -75,7 +94,7 @@ export class QueuePanel extends LitElement {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
.save-playlist-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
@@ -85,10 +104,19 @@ export class QueuePanel extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
.save-playlist-button:hover {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.save-playlist-button:disabled {
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#save-playlist-popup {
|
||||
z-index: 210;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -193,24 +221,56 @@ export class QueuePanel extends LitElement {
|
||||
this.style.setProperty('--queue-width', `${this.panelWidth}px`);
|
||||
document.addEventListener('mousemove', this.handleMouseMove);
|
||||
document.addEventListener('mouseup', this.handleMouseUp);
|
||||
document.addEventListener('click', this.closePickerHandler);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
document.removeEventListener('click', this.closePickerHandler);
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('queue-panel-close', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
private async handleSaveAsPlaylist() {
|
||||
if (this.queue.tracks.length === 0) return;
|
||||
|
||||
this.playlistPickerOpen = !this.playlistPickerOpen;
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
const popup = this.savePlaylistPopup;
|
||||
const btn = this.shadowRoot?.querySelector('.save-playlist-button');
|
||||
|
||||
if (popup && btn) {
|
||||
(popup as any).anchor = btn;
|
||||
(popup as any).active = this.playlistPickerOpen;
|
||||
}
|
||||
|
||||
if (this.playlistPickerOpen) {
|
||||
const picker = this.shadowRoot?.querySelector(
|
||||
'playlist-picker',
|
||||
) as PlaylistPicker | null;
|
||||
|
||||
picker?.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private closePlaylistPicker() {
|
||||
if (!this.playlistPickerOpen) return;
|
||||
|
||||
this.playlistPickerOpen = false;
|
||||
|
||||
const popup = this.savePlaylistPopup;
|
||||
|
||||
if (popup) {
|
||||
(popup as any).active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaylistActionComplete = () => {
|
||||
this.closePlaylistPicker();
|
||||
};
|
||||
|
||||
private handleRemoveTrack(e: Event, position: number) {
|
||||
e.stopPropagation();
|
||||
this.queue.removeFromQueue(position);
|
||||
@@ -275,11 +335,32 @@ export class QueuePanel extends LitElement {
|
||||
></div>
|
||||
<div class="header">
|
||||
<h3>Queue</h3>
|
||||
<button class="close-button" @click=${this.handleClose}>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
<button
|
||||
class="save-playlist-button"
|
||||
@click=${this.handleSaveAsPlaylist}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Save queue as playlist"
|
||||
>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<wa-popup
|
||||
id="save-playlist-popup"
|
||||
placement="bottom-end"
|
||||
.active=${this.playlistPickerOpen}
|
||||
>
|
||||
${this.playlistPickerOpen
|
||||
? html`
|
||||
<playlist-picker
|
||||
.filePaths=${tracks.map((t) => t.filePath)}
|
||||
@playlist-action-complete=${this.onPlaylistActionComplete}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
></playlist-picker>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
|
||||
${tracks.length === 0
|
||||
? html`
|
||||
<div class="empty-state">
|
||||
|
||||
@@ -11,6 +11,8 @@ import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
||||
import '@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/icon/icon.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||
|
||||
@customElement('track-list')
|
||||
export class TrackList extends LitElement {
|
||||
@@ -26,9 +28,15 @@ export class TrackList extends LitElement {
|
||||
@state()
|
||||
private contextMenuTrack: library.Track | null = null;
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: HTMLElement;
|
||||
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup!: HTMLElement;
|
||||
|
||||
private closeHandler = () => this.closeContextMenu();
|
||||
|
||||
static override styles = css`
|
||||
@@ -128,6 +136,20 @@ export class TrackList extends LitElement {
|
||||
.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() {
|
||||
@@ -214,6 +236,7 @@ export class TrackList extends LitElement {
|
||||
private closeContextMenu() {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
this.contextMenuTrack = null;
|
||||
|
||||
@@ -224,6 +247,44 @@ export class TrackList extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
private isActiveTrack(track: library.Track): boolean {
|
||||
const currentTrack = this.player.currentTrack;
|
||||
|
||||
@@ -298,10 +359,38 @@ export class TrackList extends LitElement {
|
||||
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
||||
Play Next
|
||||
</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.contextMenuTrack
|
||||
? html`
|
||||
<playlist-picker
|
||||
.filePaths=${[this.contextMenuTrack.FilePath]}
|
||||
@playlist-action-complete=${this.onPlaylistActionComplete}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
></playlist-picker>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user