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
This commit is contained in:
@@ -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<void> {
|
||||
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`
|
||||
<wa-dialog label="Duplicate Tracks Found">
|
||||
${current ? this.renderContent(current) : nothing}
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderContent(current: DuplicateTrack) {
|
||||
const total = this.duplicates.length;
|
||||
const num = this.currentIndex + 1;
|
||||
|
||||
return html`
|
||||
<div class="summary">
|
||||
<strong>${total} duplicate track${total !== 1 ? 's' : ''}</strong>
|
||||
already exist in this playlist.
|
||||
</div>
|
||||
<div class="progress">
|
||||
Track ${num} of ${total}
|
||||
</div>
|
||||
<div class="track-card">
|
||||
<div class="track-title">
|
||||
${current.Title || current.FilePath}
|
||||
</div>
|
||||
${current.Artist
|
||||
? html`<div class="track-artist">
|
||||
${current.Artist}
|
||||
</div>`
|
||||
: nothing}
|
||||
${current.Album
|
||||
? html`<div class="track-album">
|
||||
${current.Album}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="track-duration">
|
||||
${formatMilliseconds(current.Duration)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<wa-switch
|
||||
size="small"
|
||||
?checked=${this.applyToAll}
|
||||
@wa-change=${(e: Event) => {
|
||||
this.applyToAll = (
|
||||
e.target as HTMLInputElement
|
||||
).checked;
|
||||
}}
|
||||
>
|
||||
Apply to all remaining
|
||||
</wa-switch>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" @click=${this.handleSkip}>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click=${this.handleAdd}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'duplicate-tracks-dialog': DuplicateTracksDialog;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user