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