From 9f3ba2b9d474fa30dcb4934b01d4650e0d0d3cba Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 1 Mar 2026 11:13:32 -0500 Subject: [PATCH] feat(quick-8): create duplicate-tracks-dialog component - Step through duplicate tracks one-by-one with Add/Skip buttons - Apply to all remaining toggle for batch operations - Dispatches playlist-action-complete when done - Follows project wa-dialog patterns from track-details and phantom-resolver --- .../duplicate-tracks-dialog.ts | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts diff --git a/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts b/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts new file mode 100644 index 0000000..39f26dc --- /dev/null +++ b/frontend/src/components/duplicate-tracks-dialog/duplicate-tracks-dialog.ts @@ -0,0 +1,346 @@ +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; + } +}