import { LitElement, html, css, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/switch/switch.js'; import { AddTracksToPlaylist } from '@go/playlist/Service'; import { formatMilliseconds } from '@utils/time'; interface DuplicateTrack { FilePath: string; Title: string; Artist: string; Album: string; Duration: string; } /** * Modal dialog for resolving duplicate tracks when adding * to a playlist. Steps through each duplicate one at a time, * allowing the user to Add or Skip with an "apply to all" * toggle for batch operations. * * @fires playlist-action-complete - When all tracks have been processed. */ @customElement('duplicate-tracks-dialog') export class DuplicateTracksDialog extends LitElement { @query('wa-dialog') private dialog!: HTMLElement & { open: boolean }; @state() private duplicates: DuplicateTrack[] = []; @state() private currentIndex = 0; @state() private applyToAll = false; private playlistId = 0; private uniquePaths: string[] = []; private tracksToAdd: string[] = []; /** Opens the dialog. Called by playlist-picker when duplicates are found. */ show( playlistId: number, duplicates: DuplicateTrack[], uniquePaths: string[], ): void { this.playlistId = playlistId; this.duplicates = duplicates; this.uniquePaths = uniquePaths; this.currentIndex = 0; this.applyToAll = false; this.tracksToAdd = []; this.updateComplete.then(() => { if (this.dialog) this.dialog.open = true; }); } close(): void { if (this.dialog) this.dialog.open = false; } // ================================================================= // STYLES // ================================================================= static override styles = css` wa-dialog { --width: 480px; } wa-dialog::part(dialog) { background: var(--yj-bg-surface, #212529); color: var(--yj-text-primary, #fff); border: 1px solid var(--yj-border, #444); border-radius: 8px; } wa-dialog::part(title) { font-size: 16px; font-weight: 600; color: var(--yj-text-primary, #fff); padding: 16px 20px 8px; } wa-dialog::part(header-actions) { padding: 16px 20px 8px; } wa-dialog::part(close-button__base) { color: var(--yj-text-tertiary, #888); } wa-dialog::part(body) { padding: 0 20px 20px; } .summary { font-size: 13px; color: var(--yj-text-secondary, #b3b3b3); margin-bottom: 16px; } .summary strong { color: var(--yj-text-primary, #fff); } .progress { font-size: 12px; color: var(--yj-text-tertiary, #888); margin-bottom: 12px; } .track-card { background: var(--yj-bg-elevated, #343a40); border-radius: 6px; padding: 16px; margin-bottom: 16px; } .track-title { font-size: 15px; font-weight: 600; color: var(--yj-text-primary, #fff); margin-bottom: 4px; word-break: break-word; } .track-artist { font-size: 13px; color: var(--yj-text-secondary, #b3b3b3); margin-bottom: 2px; } .track-album { font-size: 13px; color: var(--yj-text-tertiary, #888); margin-bottom: 2px; } .track-duration { font-size: 12px; color: var(--yj-text-tertiary, #888); font-variant-numeric: tabular-nums; } .toggle-row { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; font-size: 13px; color: var(--yj-text-secondary, #b3b3b3); } .actions { display: flex; justify-content: flex-end; gap: 8px; } .btn { padding: 6px 16px; border-radius: 4px; border: 1px solid var(--yj-border, #444); background: var(--yj-bg-elevated, #343a40); color: var(--yj-text-primary, #fff); font-size: 13px; cursor: pointer; font-family: inherit; transition: background-color 0.15s ease; } .btn:hover { background: var(--yj-bg-overlay, #495057); } .btn-primary { background: var(--yj-accent, #ffd43b); color: #000; border-color: var(--yj-accent, #ffd43b); } .btn-primary:hover { background: var(--yj-accent-hover, #ffe066); border-color: var(--yj-accent-hover, #ffe066); } `; // ================================================================= // HANDLERS // ================================================================= private handleAdd = () => { const current = this.duplicates[this.currentIndex]; if (!current) return; this.tracksToAdd.push(current.FilePath); if (this.applyToAll) { // Add all remaining duplicates. for ( let i = this.currentIndex + 1; i < this.duplicates.length; i++ ) { this.tracksToAdd.push( this.duplicates[i]!.FilePath, ); } void this.finalize(); return; } this.currentIndex++; if (this.currentIndex >= this.duplicates.length) { void this.finalize(); } else { this.requestUpdate(); } }; private handleSkip = () => { if (this.applyToAll) { // Skip all remaining — finalize immediately. void this.finalize(); return; } this.currentIndex++; if (this.currentIndex >= this.duplicates.length) { void this.finalize(); } else { this.requestUpdate(); } }; private async finalize(): Promise { const combined = [ ...this.uniquePaths, ...this.tracksToAdd, ]; if (combined.length > 0) { try { await AddTracksToPlaylist( this.playlistId, combined, ); } catch (err) { console.error( 'Failed to add tracks to playlist:', err, ); } } this.dispatchEvent( new CustomEvent('playlist-action-complete', { bubbles: true, composed: true, }), ); this.close(); } // ================================================================= // RENDER // ================================================================= override render() { const current = this.duplicates[this.currentIndex]; return html` ${current ? this.renderContent(current) : nothing} `; } private renderContent(current: DuplicateTrack) { const total = this.duplicates.length; const num = this.currentIndex + 1; return html`
${total} duplicate track${total !== 1 ? 's' : ''} already exist in this playlist.
Track ${num} of ${total}
${current.Title || current.FilePath}
${current.Artist ? html`
${current.Artist}
` : nothing} ${current.Album ? html`
${current.Album}
` : nothing}
${formatMilliseconds(current.Duration)}
{ this.applyToAll = ( e.target as HTMLInputElement ).checked; }} > Apply to all remaining
`; } } declare global { interface HTMLElementTagNameMap { 'duplicate-tracks-dialog': DuplicateTracksDialog; } }