Always-visible is not the same as always-in-the-way: the overlay is inset:0 at 50% black, so gating it on hover left every touch device with the artwork it is editing permanently darkened. It is only a hint — .cover-art-edit carries the click, so tapping the art always worked — while the × really is the only route to its action and stays. The chip borrows the remove button's size, disc and alpha. Also corrects the claim that no tier can render as a touch device: no committed one does, which is a choice about projects rather than a limit.
2319 lines
68 KiB
TypeScript
2319 lines
68 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
||
import {
|
||
customElement,
|
||
state,
|
||
query,
|
||
} from 'lit/decorators.js';
|
||
import type * as library from '@go/library/models.js';
|
||
import {
|
||
formatSampleRate,
|
||
formatBitDepth,
|
||
formatChannels,
|
||
formatBitrate,
|
||
formatFileSize,
|
||
} from '@utils/format';
|
||
import { formatMilliseconds } from '@utils/time';
|
||
import { nameDialogsIn } from '@utils/name-dialog';
|
||
import { WriteTrackTagsByPath } from '@go/tagwriter/tagwriter.js';
|
||
import {
|
||
BatchWriteTrackTags,
|
||
CancelBatchWrite,
|
||
} from '@go/tagwriter/tagwriter.js';
|
||
import { GetTrackMBIDs } from '@go/library/library.js';
|
||
type TrackMBIDs = library.TrackMBIDs;
|
||
import { ImageFilePicker, ReadFile } from '@go/frontendutil/frontendutil.js';
|
||
import { libraryStore } from '../../store/library-store';
|
||
import { EventsOn } from '@runtime/runtime';
|
||
import { Events } from '../../events';
|
||
|
||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||
import { designTokens } from '../../styles/tokens.css';
|
||
|
||
/** Cover art URLs resolved from the album cache. */
|
||
export interface CoverArtUrls {
|
||
coverArtPath: string;
|
||
coverArtSmall: string;
|
||
coverArtMedium: string;
|
||
coverArtLarge: string;
|
||
}
|
||
|
||
/** Editable field definition. */
|
||
interface MetadataField {
|
||
key: string;
|
||
label: string;
|
||
value: string;
|
||
editable: boolean;
|
||
type: 'text' | 'number';
|
||
mixed?: boolean;
|
||
}
|
||
|
||
/** Human-readable label map for fields shown in confirmations. */
|
||
const FIELD_LABELS: Record<string, string> = {
|
||
title: 'Title',
|
||
artist: 'Artist',
|
||
album: 'Album',
|
||
genre: 'Genre',
|
||
year: 'Year',
|
||
composer: 'Composer',
|
||
trackNumber: 'Track #',
|
||
discNumber: 'Disc #',
|
||
};
|
||
|
||
/**
|
||
* Modal dialog displaying detailed metadata for a single track
|
||
* or a batch of selected tracks.
|
||
*
|
||
* Call `show(track, coverArt?)` for single-track mode.
|
||
* Call `showBatch(tracks, coverArt, coverArtMixed)` for batch mode.
|
||
* Call `close()` to dismiss.
|
||
*/
|
||
@customElement('track-details')
|
||
export class TrackDetails extends LitElement {
|
||
// -- Single-track state --
|
||
@state() private track: library.Track | null = null;
|
||
@state() private coverArt: CoverArtUrls | null = null;
|
||
|
||
// -- Shared edit state --
|
||
@state() private editing = false;
|
||
@state() private editValues: Record<string, string> = {};
|
||
@state() private saving = false;
|
||
@state() private errorMessage = '';
|
||
@state() private pendingCoverArt: {
|
||
data: ArrayBuffer;
|
||
previewUrl: string;
|
||
} | null = null;
|
||
@state() private clearCoverArt = false;
|
||
|
||
// -- Batch-specific state --
|
||
@state() private batchMode = false;
|
||
@state() private batchTracks: library.Track[] = [];
|
||
@state() private batchFilePaths: string[] = [];
|
||
@state() private batchCoverArtMixed = false;
|
||
@state() private batchProgress: {
|
||
current: number;
|
||
total: number;
|
||
} | null = null;
|
||
@state() private batchResult: {
|
||
succeeded: number;
|
||
failed: number;
|
||
cancelled: boolean;
|
||
failures: Array<{ filePath: string; error: string }>;
|
||
} | null = null;
|
||
@state() private showConfirmation = false;
|
||
@state() private trackMBIDs: TrackMBIDs | null = null;
|
||
|
||
@query('wa-dialog')
|
||
private dialog!: HTMLElement & { open: boolean };
|
||
|
||
// =================================================================
|
||
// PUBLIC API
|
||
// =================================================================
|
||
|
||
/** Open the dialog for a single track. */
|
||
show(
|
||
track: library.Track,
|
||
coverArt?: CoverArtUrls,
|
||
): void {
|
||
this.track = track;
|
||
this.coverArt = coverArt ?? null;
|
||
this.batchMode = false;
|
||
this.editing = false;
|
||
this.editValues = {};
|
||
this.errorMessage = '';
|
||
this.trackMBIDs = null;
|
||
this.cleanupPendingCoverArt();
|
||
this.resetBatchState();
|
||
|
||
// Load MBIDs asynchronously.
|
||
this.loadMBIDs(track.FilePath);
|
||
|
||
this.updateComplete.then(() => {
|
||
if (this.dialog) this.dialog.open = true;
|
||
});
|
||
}
|
||
|
||
/** Open the dialog for batch editing multiple tracks. */
|
||
showBatch(
|
||
tracks: library.Track[],
|
||
coverArt: CoverArtUrls | null,
|
||
coverArtMixed: boolean,
|
||
): void {
|
||
this.batchMode = true;
|
||
this.batchTracks = tracks;
|
||
this.batchFilePaths = tracks.map(
|
||
(t) => t.FilePath,
|
||
);
|
||
this.batchCoverArtMixed = coverArtMixed;
|
||
this.coverArt = coverArt;
|
||
|
||
// Clear single-track state.
|
||
this.track = null;
|
||
|
||
// Reset edit state.
|
||
this.editing = false;
|
||
this.editValues = {};
|
||
this.errorMessage = '';
|
||
this.batchProgress = null;
|
||
this.batchResult = null;
|
||
this.showConfirmation = false;
|
||
this.cleanupPendingCoverArt();
|
||
|
||
this.updateComplete.then(() => {
|
||
if (this.dialog) this.dialog.open = true;
|
||
});
|
||
}
|
||
|
||
/** Close the dialog. */
|
||
close(): void {
|
||
// Cancel in-flight batch write if active.
|
||
if (this.batchProgress) {
|
||
void CancelBatchWrite();
|
||
}
|
||
|
||
if (this.dialog) this.dialog.open = false;
|
||
this.editing = false;
|
||
this.editValues = {};
|
||
this.errorMessage = '';
|
||
this.cleanupPendingCoverArt();
|
||
this.resetBatchState();
|
||
}
|
||
|
||
// =================================================================
|
||
// STYLES
|
||
// =================================================================
|
||
|
||
static override styles = [designTokens, css`
|
||
wa-dialog {
|
||
--width: 640px;
|
||
}
|
||
|
||
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; /* dialog header — outside type scale */
|
||
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;
|
||
}
|
||
|
||
.top-section {
|
||
display: flex;
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.cover-art {
|
||
width: 200px;
|
||
height: 200px;
|
||
flex-shrink: 0;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.cover-art img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.cover-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: var(
|
||
--yj-bg-elevated,
|
||
#343a40
|
||
);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.cover-placeholder wa-icon {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-size: 64px; /* large decorative placeholder */
|
||
}
|
||
|
||
.main-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
justify-content: center;
|
||
}
|
||
|
||
.main-meta .title {
|
||
font-size: 22px; /* dialog title — outside type scale */
|
||
font-weight: 600;
|
||
color: var(--yj-text-primary, #fff);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.main-meta .artist {
|
||
font-size: var(--yj-text-lg);
|
||
color: var(--yj-text-secondary, #b3b3b3);
|
||
}
|
||
|
||
.main-meta .album {
|
||
font-size: var(--yj-text-lg);
|
||
color: var(--yj-text-tertiary, #888);
|
||
}
|
||
|
||
.main-meta .duration {
|
||
font-size: var(--yj-text-md);
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.divider {
|
||
height: 1px;
|
||
background: var(--yj-border-subtle, #333);
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.section-label {
|
||
font-size: var(--yj-text-xs);
|
||
font-weight: 600;
|
||
color: var(--yj-text-tertiary, #888);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.metadata-grid {
|
||
display: grid;
|
||
grid-template-columns: 120px 1fr;
|
||
gap: 8px 12px;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.meta-label {
|
||
font-size: var(--yj-text-sm);
|
||
font-weight: 500;
|
||
color: var(--yj-text-tertiary, #888);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.meta-value {
|
||
font-size: var(--yj-text-md);
|
||
color: var(--yj-text-secondary, #b3b3b3);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.meta-value.empty {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* MusicBrainz badge + links */
|
||
.mb-verified-badge {
|
||
color: var(--yj-success-text, #51cf66);
|
||
font-size: 14px;
|
||
margin-left: 6px;
|
||
vertical-align: middle;
|
||
cursor: help;
|
||
}
|
||
|
||
.mb-section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.mb-icon {
|
||
color: var(--yj-success-text, #51cf66);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.mb-link {
|
||
font-size: var(--yj-text-xs);
|
||
color: var(--yj-text-secondary, #b3b3b3);
|
||
text-decoration: none;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.mb-link:hover {
|
||
color: var(--yj-success-text, #51cf66);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* Edit mode inputs */
|
||
.meta-input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
background: var(--yj-bg-elevated, #343a40);
|
||
border: 1px solid
|
||
var(--yj-border-subtle, #333);
|
||
border-radius: 4px;
|
||
color: var(--yj-text-primary, #fff);
|
||
font-size: var(--yj-text-md);
|
||
padding: 4px 8px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.meta-input:focus {
|
||
outline: none;
|
||
border-color: var(--yj-accent, #ffd43b);
|
||
}
|
||
|
||
.meta-input::placeholder {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
.main-input {
|
||
background: var(--yj-bg-elevated, #343a40);
|
||
border: 1px solid
|
||
var(--yj-border-subtle, #333);
|
||
border-radius: 4px;
|
||
color: var(--yj-text-primary, #fff);
|
||
font-family: inherit;
|
||
padding: 4px 8px;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.main-input:focus {
|
||
outline: none;
|
||
border-color: var(--yj-accent, #ffd43b);
|
||
}
|
||
|
||
.main-input::placeholder {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
.main-input.title-input {
|
||
font-size: 20px; /* edit mode title — outside type scale */
|
||
font-weight: 600;
|
||
}
|
||
|
||
.main-input.artist-input {
|
||
font-size: var(--yj-text-lg);
|
||
}
|
||
|
||
.main-input.album-input {
|
||
font-size: var(--yj-text-md);
|
||
}
|
||
|
||
/* Action bar */
|
||
.action-bar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.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: var(--yj-text-md);
|
||
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: var(--yj-accent-fg, #000);
|
||
border-color: var(--yj-accent, #ffd43b);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: var(
|
||
--yj-accent-hover,
|
||
#ffe066
|
||
);
|
||
border-color: var(
|
||
--yj-accent-hover,
|
||
#ffe066
|
||
);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-danger {
|
||
color: var(--yj-error-text, #ff8787);
|
||
border-color: var(--yj-error, #e03131);
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: var(--yj-error, #e03131);
|
||
color: var(--yj-error-fg, #fff);
|
||
}
|
||
|
||
/* Cover art edit mode */
|
||
.cover-art-edit {
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
.cover-art-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.15s ease;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.cover-art-edit:hover .cover-art-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.cover-art-overlay wa-icon {
|
||
color: #fff;
|
||
font-size: 32px;
|
||
}
|
||
|
||
.cover-art-remove {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0;
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
|
||
.cover-art-edit:hover .cover-art-remove {
|
||
opacity: 1;
|
||
}
|
||
|
||
.cover-art-remove:hover {
|
||
background: var(--yj-error, #e03131);
|
||
}
|
||
|
||
/*
|
||
* Both are the *only* route to changing or removing a track's
|
||
* cover art, so where the device has no hover they are always
|
||
* visible rather than hidden — the inverse of #68's rule, which
|
||
* applies where the hover control is redundant. Revealed by
|
||
* opacity, so what is on screen is what the desktop reveal shows
|
||
* and nothing about the layout moves.
|
||
*/
|
||
@media not all and (hover: hover) {
|
||
/* The × is genuinely the only route to removing the art, so
|
||
on a device that cannot hover it is simply always there.
|
||
|
||
The pen is not: .cover-art-edit carries the click that
|
||
opens the file picker, so tapping the artwork already
|
||
worked while the overlay was invisible. It is a discovery
|
||
hint — and paying for discovery by covering the artwork
|
||
being edited in 50% black, permanently, on every touch
|
||
device, is heavier than the hint is worth. It becomes a
|
||
corner chip in the remove button's own visual language
|
||
instead: same size, same disc, same alpha. */
|
||
.cover-art-remove {
|
||
opacity: 1;
|
||
}
|
||
|
||
.cover-art-overlay {
|
||
opacity: 1;
|
||
inset: auto 4px 4px auto;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
}
|
||
|
||
.cover-art-overlay wa-icon {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
/* Error message */
|
||
.error-message {
|
||
flex: 1;
|
||
color: var(--yj-error-text, #ff8787);
|
||
font-size: var(--yj-text-sm);
|
||
padding: 4px 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* ---- Batch mode styles ---- */
|
||
|
||
.batch-header {
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
color: var(--yj-text-primary, #fff);
|
||
}
|
||
|
||
.batch-subheader {
|
||
font-size: var(--yj-text-md);
|
||
color: var(--yj-text-tertiary, #888);
|
||
}
|
||
|
||
.main-field-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.main-field-label {
|
||
font-size: var(--yj-text-xs);
|
||
font-weight: 500;
|
||
color: var(--yj-text-tertiary, #888);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.mixed-value {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-style: italic;
|
||
}
|
||
|
||
.cover-art-mixed {
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: var(--yj-bg-elevated, #343a40);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.cover-art-mixed wa-icon {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-size: 48px;
|
||
}
|
||
|
||
.cover-art-mixed span {
|
||
color: var(--yj-text-tertiary, #888);
|
||
font-size: var(--yj-text-xs);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Confirmation overlay */
|
||
.confirmation-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.confirmation-content {
|
||
background: var(--yj-bg-surface, #212529);
|
||
border: 1px solid var(--yj-border, #444);
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
max-width: 420px;
|
||
width: 90%;
|
||
}
|
||
|
||
.confirmation-content h3 {
|
||
margin: 0 0 16px;
|
||
font-size: var(--yj-text-lg);
|
||
color: var(--yj-text-primary, #fff);
|
||
}
|
||
|
||
.confirmation-summary {
|
||
margin-bottom: 16px;
|
||
font-size: var(--yj-text-md);
|
||
color: var(--yj-text-secondary, #b3b3b3);
|
||
}
|
||
|
||
.confirmation-summary .change-item {
|
||
padding: 4px 0;
|
||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||
}
|
||
|
||
.confirmation-summary .change-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.confirmation-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Progress bar */
|
||
.batch-progress {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
.progress-text {
|
||
font-size: var(--yj-text-lg);
|
||
color: var(--yj-text-primary, #fff);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.progress-bar-track {
|
||
width: 100%;
|
||
height: 8px;
|
||
background: var(--yj-bg-elevated, #343a40);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background: var(--yj-accent, #4a9eff);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
/* Batch results */
|
||
.batch-result {
|
||
padding: 20px 0;
|
||
}
|
||
|
||
.batch-result .result-title {
|
||
font-size: var(--yj-text-lg);
|
||
font-weight: 600;
|
||
color: var(--yj-text-primary, #fff);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.batch-result .result-success {
|
||
color: var(--yj-success-text, #51cf66);
|
||
}
|
||
|
||
.batch-result .result-partial {
|
||
color: var(--yj-warning-text, #ffa94d);
|
||
}
|
||
|
||
.batch-result .result-cancelled {
|
||
color: var(--yj-text-tertiary, #888);
|
||
}
|
||
|
||
.failure-list {
|
||
margin-top: 12px;
|
||
font-size: var(--yj-text-sm);
|
||
}
|
||
|
||
.failure-list summary {
|
||
cursor: pointer;
|
||
color: var(--yj-error-text, #ff8787);
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.failure-item {
|
||
padding: 4px 0;
|
||
color: var(--yj-text-secondary, #b3b3b3);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.failure-item .failure-path {
|
||
font-weight: 500;
|
||
color: var(--yj-text-primary, #fff);
|
||
}
|
||
|
||
.failure-item .failure-error {
|
||
color: var(--yj-error-text, #ff8787);
|
||
font-style: italic;
|
||
}
|
||
`];
|
||
|
||
// =================================================================
|
||
// RENDER
|
||
// =================================================================
|
||
|
||
/**
|
||
* Web Awesome renders `label` into a heading it never points the
|
||
* `<dialog>` at, so the dialog has no accessible name until
|
||
* something sets one. See `utils/name-dialog.ts`.
|
||
*/
|
||
override updated() {
|
||
nameDialogsIn(this.shadowRoot);
|
||
}
|
||
|
||
override render() {
|
||
const label = this.batchMode
|
||
? 'Batch Edit'
|
||
: 'Track Details';
|
||
|
||
return html`
|
||
<wa-dialog label="${label}">
|
||
${this.batchMode
|
||
? this.renderBatchContent()
|
||
: this.track
|
||
? this.renderContent()
|
||
: nothing}
|
||
</wa-dialog>
|
||
`;
|
||
}
|
||
|
||
// -- Single-track render (unchanged) --
|
||
|
||
private renderContent() {
|
||
const t = this.track!;
|
||
|
||
return html`
|
||
<div class="top-section">
|
||
${this.renderCoverArt()}
|
||
<div class="main-meta">
|
||
${this.renderMainFields(t)}
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="metadata-grid">
|
||
${this.renderDetailFields(t)}
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="section-label">
|
||
Audio Properties
|
||
</div>
|
||
<div class="metadata-grid">
|
||
${this.renderAudioProperties(t)}
|
||
</div>
|
||
${this.renderMusicBrainzSection()}
|
||
<div class="action-bar">
|
||
${this.renderActions()}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// -- Batch render --
|
||
|
||
private renderBatchContent() {
|
||
if (this.batchProgress) {
|
||
return this.renderBatchProgress();
|
||
}
|
||
|
||
if (this.batchResult) {
|
||
return this.renderBatchResult();
|
||
}
|
||
|
||
const fields = this.getMergedFields();
|
||
|
||
return html`
|
||
<div class="top-section">
|
||
${this.renderBatchCoverArt()}
|
||
<div class="main-meta">
|
||
${this.renderBatchMainFields(fields)}
|
||
</div>
|
||
</div>
|
||
<div class="divider"></div>
|
||
<div class="metadata-grid">
|
||
${this.renderBatchDetailFields(fields)}
|
||
</div>
|
||
<div class="action-bar">
|
||
${this.renderBatchActions()}
|
||
</div>
|
||
${this.showConfirmation
|
||
? this.renderConfirmation()
|
||
: nothing}
|
||
`;
|
||
}
|
||
|
||
private renderBatchCoverArt() {
|
||
if (this.editing) {
|
||
return this.renderCoverArtEditable();
|
||
}
|
||
|
||
if (this.batchCoverArtMixed && !this.coverArt) {
|
||
return html`
|
||
<div class="cover-art">
|
||
<div class="cover-art-mixed">
|
||
<wa-icon name="images"></wa-icon>
|
||
<span>Multiple cover arts</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const src =
|
||
this.coverArt?.coverArtLarge ??
|
||
this.coverArt?.coverArtMedium ??
|
||
this.coverArt?.coverArtPath;
|
||
|
||
if (!src) {
|
||
return html`
|
||
<div class="cover-art">
|
||
<div class="cover-placeholder">
|
||
<wa-icon
|
||
name="music"
|
||
></wa-icon>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return html`
|
||
<div class="cover-art">
|
||
<img
|
||
src="${src}"
|
||
alt="Album cover"
|
||
@error=${this.handleImageError}
|
||
/>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderBatchMainFields(
|
||
fields: MetadataField[],
|
||
) {
|
||
const titleField = fields.find(
|
||
(f) => f.key === 'title',
|
||
);
|
||
const artistField = fields.find(
|
||
(f) => f.key === 'artist',
|
||
);
|
||
const albumField = fields.find(
|
||
(f) => f.key === 'album',
|
||
);
|
||
|
||
if (this.editing) {
|
||
return html`
|
||
<span class="batch-header">
|
||
Editing ${this.batchTracks.length} tracks
|
||
</span>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Title</label>
|
||
<input
|
||
class="main-input title-input"
|
||
.value=${this.getEditValue(
|
||
'title',
|
||
titleField?.value ?? '',
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput('title', e)}
|
||
placeholder=${titleField?.mixed
|
||
? 'Multiple values'
|
||
: 'Title'}
|
||
/>
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Artist</label>
|
||
<input
|
||
class="main-input artist-input"
|
||
.value=${this.getEditValue(
|
||
'artist',
|
||
artistField?.value ?? '',
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput('artist', e)}
|
||
placeholder=${artistField?.mixed
|
||
? 'Multiple values'
|
||
: 'Artist'}
|
||
/>
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Album</label>
|
||
<input
|
||
class="main-input album-input"
|
||
.value=${this.getEditValue(
|
||
'album',
|
||
albumField?.value ?? '',
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput('album', e)}
|
||
placeholder=${albumField?.mixed
|
||
? 'Multiple values'
|
||
: 'Album'}
|
||
/>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return html`
|
||
<span class="batch-header">
|
||
${this.batchTracks.length} tracks selected
|
||
</span>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Title</label>
|
||
${titleField?.mixed
|
||
? html`<span class="mixed-value">
|
||
${this.countDistinctValues('title')} different titles
|
||
</span>`
|
||
: html`<span class="title">
|
||
${titleField?.value || 'None'}
|
||
</span>`}
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Artist</label>
|
||
${artistField?.mixed
|
||
? html`<span class="mixed-value">
|
||
${this.countDistinctValues('artist')} different artists
|
||
</span>`
|
||
: html`<span class="artist">
|
||
${artistField?.value || 'Unknown Artist'}
|
||
</span>`}
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Album</label>
|
||
${albumField?.mixed
|
||
? html`<span class="mixed-value">
|
||
${this.countDistinctValues('album')} different albums
|
||
</span>`
|
||
: html`<span class="album">
|
||
${albumField?.value || 'No album'}
|
||
</span>`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderBatchDetailFields(
|
||
fields: MetadataField[],
|
||
) {
|
||
// Only show editable detail fields in batch mode
|
||
// (genre, year, composer, track#, disc#).
|
||
const detailFields = fields.filter(
|
||
(f) =>
|
||
f.editable &&
|
||
f.key !== 'title' &&
|
||
f.key !== 'artist' &&
|
||
f.key !== 'album',
|
||
);
|
||
|
||
return detailFields.map((f) =>
|
||
this.renderBatchField(f),
|
||
);
|
||
}
|
||
|
||
private renderBatchField(f: MetadataField) {
|
||
if (this.editing) {
|
||
return html`
|
||
<span class="meta-label">${f.label}</span>
|
||
<input
|
||
class="meta-input"
|
||
type=${f.type}
|
||
.value=${this.getEditValue(
|
||
f.key,
|
||
f.value,
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput(f.key, e)}
|
||
placeholder=${f.mixed
|
||
? 'Multiple values'
|
||
: ''}
|
||
/>
|
||
`;
|
||
}
|
||
|
||
if (f.mixed) {
|
||
return html`
|
||
<span class="meta-label">${f.label}</span>
|
||
<span class="meta-value mixed-value">
|
||
${this.countDistinctValues(f.key)} different values
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
const display = f.value;
|
||
|
||
return html`
|
||
<span class="meta-label">${f.label}</span>
|
||
<span
|
||
class="meta-value ${display
|
||
? ''
|
||
: 'empty'}"
|
||
>
|
||
${display || 'None'}
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
private renderBatchActions() {
|
||
if (this.editing) {
|
||
return html`
|
||
${this.errorMessage
|
||
? html`<div class="error-message">
|
||
${this.errorMessage}
|
||
</div>`
|
||
: nothing}
|
||
<button
|
||
class="btn"
|
||
@click=${this.cancelEdit}
|
||
?disabled=${this.saving}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
class="btn btn-primary"
|
||
@click=${this.saveBatchEdit}
|
||
?disabled=${this.saving}
|
||
>
|
||
${this.saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
return html`
|
||
<button
|
||
class="btn"
|
||
@click=${this.startEdit}
|
||
>
|
||
<wa-icon name="pen-to-square"></wa-icon>
|
||
Edit
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
private renderConfirmation() {
|
||
const changes = this.getConfirmationSummary();
|
||
|
||
return html`
|
||
<div class="confirmation-overlay">
|
||
<div class="confirmation-content">
|
||
<h3>Apply changes to ${this.batchTracks.length} tracks?</h3>
|
||
<div class="confirmation-summary">
|
||
${changes.map(
|
||
(c) => html`
|
||
<div class="change-item">
|
||
${c}
|
||
</div>
|
||
`,
|
||
)}
|
||
</div>
|
||
<div class="confirmation-actions">
|
||
<button
|
||
class="btn"
|
||
@click=${this.cancelConfirmation}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
class="btn btn-primary"
|
||
@click=${this.confirmSave}
|
||
>
|
||
Apply
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderBatchProgress() {
|
||
const progress = this.batchProgress!;
|
||
const pct =
|
||
progress.total > 0
|
||
? (progress.current / progress.total) * 100
|
||
: 0;
|
||
|
||
return html`
|
||
<div class="batch-progress">
|
||
<div class="progress-text">
|
||
${progress.current} of ${progress.total} tracks
|
||
</div>
|
||
<div class="progress-bar-track">
|
||
<div
|
||
class="progress-bar-fill"
|
||
style="width: ${pct}%"
|
||
></div>
|
||
</div>
|
||
<button
|
||
class="btn btn-danger"
|
||
@click=${this.cancelBatchWrite}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderBatchResult() {
|
||
const r = this.batchResult!;
|
||
|
||
let titleClass = 'result-success';
|
||
let titleText = `All ${r.succeeded} tracks updated successfully`;
|
||
|
||
if (r.cancelled) {
|
||
titleClass = 'result-cancelled';
|
||
titleText = `Batch cancelled \u2014 ${r.succeeded} of ${r.succeeded + r.failed + (this.batchFilePaths.length - r.succeeded - r.failed)} tracks updated`;
|
||
} else if (r.failed > 0) {
|
||
titleClass = 'result-partial';
|
||
titleText = `${r.succeeded} tracks updated, ${r.failed} failed`;
|
||
}
|
||
|
||
return html`
|
||
<div class="batch-result">
|
||
<div class="result-title ${titleClass}">
|
||
${titleText}
|
||
</div>
|
||
${r.failures.length > 0
|
||
? html`
|
||
<details class="failure-list">
|
||
<summary>
|
||
${r.failures.length} failure${r.failures.length > 1 ? 's' : ''}
|
||
</summary>
|
||
${r.failures.map(
|
||
(f) => html`
|
||
<div class="failure-item">
|
||
<span class="failure-path">
|
||
${this.fileNameFromPath(f.filePath)}
|
||
</span>
|
||
\u2014
|
||
<span class="failure-error">
|
||
${f.error}
|
||
</span>
|
||
</div>
|
||
`,
|
||
)}
|
||
</details>
|
||
`
|
||
: nothing}
|
||
<div class="action-bar">
|
||
<button
|
||
class="btn"
|
||
@click=${this.closeBatchResult}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// -- Single-track render helpers (unchanged) --
|
||
|
||
private renderCoverArt() {
|
||
if (this.editing) {
|
||
return this.renderCoverArtEditable();
|
||
}
|
||
|
||
const src =
|
||
this.coverArt?.coverArtLarge ??
|
||
this.coverArt?.coverArtMedium ??
|
||
this.coverArt?.coverArtPath;
|
||
|
||
if (!src) {
|
||
return html`
|
||
<div class="cover-art">
|
||
<div class="cover-placeholder">
|
||
<wa-icon
|
||
name="music"
|
||
></wa-icon>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
return html`
|
||
<div class="cover-art">
|
||
<img
|
||
src="${src}"
|
||
alt="Album cover"
|
||
@error=${this.handleImageError}
|
||
/>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderCoverArtEditable() {
|
||
const showRemove =
|
||
!this.clearCoverArt &&
|
||
(this.pendingCoverArt || this.coverArt);
|
||
|
||
// Determine which image to show.
|
||
let src: string | undefined;
|
||
|
||
if (this.clearCoverArt) {
|
||
src = undefined; // Show placeholder.
|
||
} else if (this.pendingCoverArt) {
|
||
src = this.pendingCoverArt.previewUrl;
|
||
} else {
|
||
src =
|
||
this.coverArt?.coverArtLarge ??
|
||
this.coverArt?.coverArtMedium ??
|
||
this.coverArt?.coverArtPath;
|
||
}
|
||
|
||
return html`
|
||
<div
|
||
class="cover-art cover-art-edit"
|
||
@click=${this.selectCoverArt}
|
||
>
|
||
${src
|
||
? html`<img
|
||
src="${src}"
|
||
alt="Album cover"
|
||
@error=${this.handleImageError}
|
||
/>`
|
||
: html`<div class="cover-placeholder">
|
||
<wa-icon
|
||
name="music"
|
||
></wa-icon>
|
||
</div>`}
|
||
<div class="cover-art-overlay">
|
||
<wa-icon
|
||
name="pen-to-square"
|
||
></wa-icon>
|
||
</div>
|
||
${showRemove
|
||
? html`<button
|
||
class="cover-art-remove"
|
||
@click=${(e: Event) => {
|
||
e.stopPropagation();
|
||
this.removeCoverArt();
|
||
}}
|
||
title="Remove cover art"
|
||
>
|
||
×
|
||
</button>`
|
||
: nothing}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private handleImageError = (e: Event) => {
|
||
const img = e.target as HTMLImageElement;
|
||
const fallback = this.coverArt?.coverArtPath;
|
||
|
||
if (fallback && img.src !== fallback) {
|
||
img.src = fallback;
|
||
|
||
return;
|
||
}
|
||
|
||
const container = img.parentElement;
|
||
|
||
if (container) {
|
||
container.innerHTML =
|
||
'<div class="cover-placeholder">' +
|
||
'<wa-icon name="music"></wa-icon>' +
|
||
'</div>';
|
||
}
|
||
};
|
||
|
||
private renderMainFields(t: library.Track) {
|
||
if (this.editing) {
|
||
return html`
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Title</label>
|
||
<input
|
||
class="main-input title-input"
|
||
.value=${this.getEditValue(
|
||
'title',
|
||
t.TrackName,
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput(
|
||
'title',
|
||
e,
|
||
)}
|
||
placeholder="Title"
|
||
/>
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Artist</label>
|
||
<input
|
||
class="main-input artist-input"
|
||
.value=${this.getEditValue(
|
||
'artist',
|
||
t.ArtistName,
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput(
|
||
'artist',
|
||
e,
|
||
)}
|
||
placeholder="Artist"
|
||
/>
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Album</label>
|
||
<input
|
||
class="main-input album-input"
|
||
.value=${this.getEditValue(
|
||
'album',
|
||
t.Album,
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput(
|
||
'album',
|
||
e,
|
||
)}
|
||
placeholder="Album"
|
||
/>
|
||
</div>
|
||
<span class="duration">
|
||
${formatMilliseconds(t.TrackLength)}
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
return html`
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Title</label>
|
||
<span class="title">
|
||
${t.TrackName || this.fileNameFromPath(t.FilePath)}
|
||
${this.trackMBIDs?.recordingMbid
|
||
? html`<span
|
||
class="mb-verified-badge"
|
||
title="Metadata verified by MusicBrainz"
|
||
>
|
||
<wa-icon name="circle-check"></wa-icon>
|
||
</span>`
|
||
: nothing}
|
||
</span>
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Artist</label>
|
||
<span class="artist">
|
||
${t.ArtistName || 'Unknown Artist'}
|
||
</span>
|
||
</div>
|
||
<div class="main-field-group">
|
||
<label class="main-field-label">Album</label>
|
||
${t.Album
|
||
? html`<span class="album"
|
||
>${t.Album}</span
|
||
>`
|
||
: html`<span class="album" style="font-style: italic"
|
||
>No album</span
|
||
>`}
|
||
</div>
|
||
<span class="duration">
|
||
${formatMilliseconds(t.TrackLength)}
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
private renderDetailFields(t: library.Track) {
|
||
const fields: MetadataField[] = [
|
||
{
|
||
key: 'genre',
|
||
label: 'Genre',
|
||
value: (t.Genre ?? []).join(', '),
|
||
editable: true,
|
||
type: 'text',
|
||
},
|
||
{
|
||
key: 'year',
|
||
label: 'Year',
|
||
value: t.Year ? String(t.Year) : '',
|
||
editable: true,
|
||
type: 'number',
|
||
},
|
||
{
|
||
key: 'composer',
|
||
label: 'Composer',
|
||
value: t.Composer ?? '',
|
||
editable: true,
|
||
type: 'text',
|
||
},
|
||
{
|
||
key: 'trackNumber',
|
||
label: 'Track #',
|
||
value: t.TrackNumber
|
||
? String(t.TrackNumber)
|
||
: '',
|
||
editable: true,
|
||
type: 'number',
|
||
},
|
||
{
|
||
key: 'discNumber',
|
||
label: 'Disc #',
|
||
value: t.DiscNumber
|
||
? String(t.DiscNumber)
|
||
: '',
|
||
editable: true,
|
||
type: 'number',
|
||
},
|
||
{
|
||
key: 'fileType',
|
||
label: 'File Type',
|
||
value: t.FileType ?? '',
|
||
editable: false,
|
||
type: 'text',
|
||
},
|
||
{
|
||
key: 'filePath',
|
||
label: 'File Path',
|
||
value: t.FilePath ?? '',
|
||
editable: false,
|
||
type: 'text',
|
||
},
|
||
];
|
||
|
||
return fields.map((f) => this.renderField(f));
|
||
}
|
||
|
||
private renderAudioProperties(t: library.Track) {
|
||
const props: { label: string; value: string }[] = [
|
||
{
|
||
label: 'Sample Rate',
|
||
value: formatSampleRate(t.SampleRate),
|
||
},
|
||
{
|
||
label: 'Bit Depth',
|
||
value: formatBitDepth(t.BitDepth),
|
||
},
|
||
{
|
||
label: 'Channels',
|
||
value: formatChannels(t.Channels),
|
||
},
|
||
{
|
||
label: 'Bitrate',
|
||
value: formatBitrate(t.Bitrate),
|
||
},
|
||
{
|
||
label: 'File Size',
|
||
value: formatFileSize(t.FileSize),
|
||
},
|
||
];
|
||
|
||
return props.map(
|
||
(p) => html`
|
||
<span class="meta-label">${p.label}</span>
|
||
<span
|
||
class="meta-value ${p.value ===
|
||
'\u2014'
|
||
? 'empty'
|
||
: ''}"
|
||
>
|
||
${p.value}
|
||
</span>
|
||
`,
|
||
);
|
||
}
|
||
|
||
private renderMusicBrainzSection() {
|
||
if (!this.trackMBIDs) return nothing;
|
||
|
||
const mbids = this.trackMBIDs;
|
||
const links: Array<{ label: string; mbid: string; type: string }> = [];
|
||
|
||
if (mbids.recordingMbid) {
|
||
links.push({
|
||
label: 'Recording',
|
||
mbid: mbids.recordingMbid,
|
||
type: 'recording',
|
||
});
|
||
}
|
||
|
||
if (mbids.releaseGroupMbid) {
|
||
links.push({
|
||
label: 'Release Group',
|
||
mbid: mbids.releaseGroupMbid,
|
||
type: 'release-group',
|
||
});
|
||
}
|
||
|
||
if (mbids.artistMbid) {
|
||
links.push({
|
||
label: 'Artist',
|
||
mbid: mbids.artistMbid,
|
||
type: 'artist',
|
||
});
|
||
}
|
||
|
||
if (links.length === 0) return nothing;
|
||
|
||
return html`
|
||
<div class="section-header mb-section-header">
|
||
<wa-icon name="circle-check" class="mb-icon"></wa-icon>
|
||
MusicBrainz
|
||
</div>
|
||
<div class="metadata-grid">
|
||
${links.map(
|
||
(l) => html`
|
||
<span class="meta-label">${l.label}</span>
|
||
<a
|
||
class="mb-link"
|
||
href="https://musicbrainz.org/${l.type}/${l.mbid}"
|
||
target="_blank"
|
||
title="View on MusicBrainz"
|
||
>
|
||
${l.mbid}
|
||
</a>
|
||
`,
|
||
)}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderField(f: MetadataField) {
|
||
const display =
|
||
this.getEditValue(f.key, f.value) || f.value;
|
||
|
||
return html`
|
||
<span class="meta-label">${f.label}</span>
|
||
${this.editing && f.editable
|
||
? html`
|
||
<input
|
||
class="meta-input"
|
||
type=${f.type}
|
||
.value=${this.getEditValue(
|
||
f.key,
|
||
f.value,
|
||
)}
|
||
@input=${(e: Event) =>
|
||
this.onEditInput(
|
||
f.key,
|
||
e,
|
||
)}
|
||
/>
|
||
`
|
||
: html`
|
||
<span
|
||
class="meta-value ${display
|
||
? ''
|
||
: 'empty'}"
|
||
>
|
||
${display || 'None'}
|
||
</span>
|
||
`}
|
||
`;
|
||
}
|
||
|
||
private renderActions() {
|
||
if (this.editing) {
|
||
return html`
|
||
${this.errorMessage
|
||
? html`<div class="error-message">
|
||
${this.errorMessage}
|
||
</div>`
|
||
: nothing}
|
||
<button
|
||
class="btn"
|
||
@click=${this.cancelEdit}
|
||
?disabled=${this.saving}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
class="btn btn-primary"
|
||
@click=${this.saveEdit}
|
||
?disabled=${this.saving}
|
||
>
|
||
${this.saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
return html`
|
||
<button
|
||
class="btn"
|
||
@click=${this.startEdit}
|
||
>
|
||
<wa-icon name="pen-to-square"></wa-icon>
|
||
Edit
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
// =================================================================
|
||
// EDIT LOGIC
|
||
// =================================================================
|
||
|
||
private startEdit = () => {
|
||
this.editing = true;
|
||
this.editValues = {};
|
||
this.errorMessage = '';
|
||
this.cleanupPendingCoverArt();
|
||
};
|
||
|
||
private cancelEdit = () => {
|
||
this.exitEditMode();
|
||
};
|
||
|
||
// -- Single-track save --
|
||
|
||
private saveEdit = async () => {
|
||
if (this.batchMode) {
|
||
this.saveBatchEdit();
|
||
|
||
return;
|
||
}
|
||
|
||
if (!this.track || this.saving) return;
|
||
|
||
this.saving = true;
|
||
this.errorMessage = '';
|
||
|
||
try {
|
||
const changes = this.buildChanges();
|
||
|
||
if (Object.keys(changes).length === 0) {
|
||
// No actual changes — just exit edit mode.
|
||
this.exitEditMode();
|
||
|
||
return;
|
||
}
|
||
|
||
const filePath = this.track.FilePath;
|
||
|
||
await WriteTrackTagsByPath(filePath, changes);
|
||
|
||
// Success — switch to read-only view. The
|
||
// TrackMetadataChanged event triggers library store
|
||
// invalidation, which refreshes all other views.
|
||
this.exitEditMode();
|
||
|
||
// Re-fetch track and album data so the dialog
|
||
// shows updated values and cover art. The store
|
||
// invalidation is already in-flight from the event;
|
||
// these calls await the pending fetch or start one.
|
||
// Awaited for the side effect of refreshing the
|
||
// store; the album list is consumed elsewhere.
|
||
const [tracks] = await Promise.all([
|
||
libraryStore.getTracks(),
|
||
libraryStore.getAlbums(),
|
||
]);
|
||
|
||
const updated = tracks.find(
|
||
(t) => t.FilePath === filePath,
|
||
);
|
||
|
||
if (updated) {
|
||
this.track = updated;
|
||
|
||
// Re-resolve cover art from the refreshed track data.
|
||
if (updated.CoverArtPath) {
|
||
this.coverArt = {
|
||
coverArtPath: updated.CoverArtPath,
|
||
coverArtSmall: updated.CoverArtSmall,
|
||
coverArtMedium: updated.CoverArtMedium,
|
||
coverArtLarge: updated.CoverArtLarge,
|
||
};
|
||
} else {
|
||
this.coverArt = null;
|
||
}
|
||
}
|
||
} catch (err: unknown) {
|
||
const msg =
|
||
err instanceof Error
|
||
? err.message
|
||
: String(err);
|
||
this.errorMessage = msg;
|
||
} finally {
|
||
this.saving = false;
|
||
}
|
||
};
|
||
|
||
// -- Batch save --
|
||
|
||
private saveBatchEdit = () => {
|
||
if (!this.batchMode) return;
|
||
|
||
const changes = this.buildBatchChanges();
|
||
|
||
if (Object.keys(changes).length === 0) {
|
||
this.exitEditMode();
|
||
|
||
return;
|
||
}
|
||
|
||
// Show confirmation before executing.
|
||
this.showConfirmation = true;
|
||
};
|
||
|
||
private cancelConfirmation = () => {
|
||
this.showConfirmation = false;
|
||
};
|
||
|
||
private confirmSave = async () => {
|
||
this.showConfirmation = false;
|
||
this.batchProgress = {
|
||
current: 0,
|
||
total: this.batchFilePaths.length,
|
||
};
|
||
this.editing = false;
|
||
|
||
const changes = this.buildBatchChanges();
|
||
|
||
// Listen for progress events. Kept as the unsubscribe function
|
||
// EventsOn returns, rather than EventsOff(name), which removes
|
||
// *every* listener for that event (errors.p3).
|
||
const stopProgress = EventsOn(
|
||
Events.BatchWriteProgress,
|
||
(data: {
|
||
current: number;
|
||
total: number;
|
||
}) => {
|
||
this.batchProgress = {
|
||
current: data.current,
|
||
total: data.total,
|
||
};
|
||
},
|
||
);
|
||
|
||
try {
|
||
const result = await BatchWriteTrackTags(
|
||
this.batchFilePaths,
|
||
changes,
|
||
);
|
||
|
||
this.batchResult = {
|
||
succeeded: result.succeeded,
|
||
failed: result.failed,
|
||
cancelled: result.cancelled,
|
||
failures: (result.failures ?? []).map(
|
||
(f) => ({
|
||
filePath: f.filePath,
|
||
error: f.error,
|
||
}),
|
||
),
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg =
|
||
err instanceof Error
|
||
? err.message
|
||
: String(err);
|
||
this.batchResult = {
|
||
succeeded: 0,
|
||
failed: this.batchFilePaths.length,
|
||
cancelled: false,
|
||
failures: [
|
||
{
|
||
filePath: '(batch)',
|
||
error: msg,
|
||
},
|
||
],
|
||
};
|
||
} finally {
|
||
stopProgress();
|
||
this.batchProgress = null;
|
||
}
|
||
};
|
||
|
||
private cancelBatchWrite = () => {
|
||
void CancelBatchWrite();
|
||
};
|
||
|
||
private closeBatchResult = async () => {
|
||
this.batchResult = null;
|
||
this.batchProgress = null;
|
||
this.editValues = {};
|
||
this.errorMessage = '';
|
||
this.cleanupPendingCoverArt();
|
||
|
||
// Refresh data from library store; album list is
|
||
// refreshed for side effects only.
|
||
const [tracks] = await Promise.all([
|
||
libraryStore.getTracks(),
|
||
libraryStore.getAlbums(),
|
||
]);
|
||
|
||
// Re-resolve batch tracks.
|
||
const pathSet = new Set(this.batchFilePaths);
|
||
const refreshed = tracks.filter((t) =>
|
||
pathSet.has(t.FilePath),
|
||
);
|
||
this.batchTracks = refreshed;
|
||
|
||
// Re-resolve cover art state.
|
||
const first = refreshed[0];
|
||
const albumNames = new Set(refreshed.map((t) => t.Album));
|
||
|
||
if (albumNames.size === 1 && first?.CoverArtPath) {
|
||
this.coverArt = {
|
||
coverArtPath: first.CoverArtPath,
|
||
coverArtSmall: first.CoverArtSmall,
|
||
coverArtMedium: first.CoverArtMedium,
|
||
coverArtLarge: first.CoverArtLarge,
|
||
};
|
||
this.batchCoverArtMixed = false;
|
||
} else if (albumNames.size > 1) {
|
||
this.coverArt = null;
|
||
this.batchCoverArtMixed = true;
|
||
} else {
|
||
this.coverArt = null;
|
||
this.batchCoverArtMixed = false;
|
||
}
|
||
};
|
||
|
||
// -- Shared edit logic --
|
||
|
||
private buildChanges(): Record<string, unknown> {
|
||
const t = this.track!;
|
||
const changes: Record<string, unknown> = {};
|
||
|
||
// Map frontend edit keys to backend field constants
|
||
// and original values.
|
||
const fieldMap: Array<{
|
||
editKey: string;
|
||
backendKey: string;
|
||
original: string;
|
||
transform?: (v: string) => unknown;
|
||
}> = [
|
||
{
|
||
editKey: 'title',
|
||
backendKey: 'title',
|
||
original: t.TrackName,
|
||
},
|
||
{
|
||
editKey: 'artist',
|
||
backendKey: 'artist',
|
||
original: t.ArtistName,
|
||
},
|
||
{
|
||
editKey: 'album',
|
||
backendKey: 'album',
|
||
original: t.Album,
|
||
},
|
||
{
|
||
editKey: 'genre',
|
||
backendKey: 'genre',
|
||
original: (t.Genre ?? []).join(', '),
|
||
},
|
||
{
|
||
editKey: 'year',
|
||
backendKey: 'year',
|
||
original: t.Year ? String(t.Year) : '',
|
||
transform: (v) =>
|
||
v ? parseInt(v, 10) : 0,
|
||
},
|
||
{
|
||
editKey: 'composer',
|
||
backendKey: 'composer',
|
||
original: t.Composer ?? '',
|
||
},
|
||
{
|
||
editKey: 'trackNumber',
|
||
backendKey: 'track_number',
|
||
original: t.TrackNumber
|
||
? String(t.TrackNumber)
|
||
: '',
|
||
transform: (v) =>
|
||
v ? parseInt(v, 10) : 0,
|
||
},
|
||
{
|
||
editKey: 'discNumber',
|
||
backendKey: 'disc_number',
|
||
original: t.DiscNumber
|
||
? String(t.DiscNumber)
|
||
: '',
|
||
transform: (v) =>
|
||
v ? parseInt(v, 10) : 0,
|
||
},
|
||
];
|
||
|
||
for (const {
|
||
editKey,
|
||
backendKey,
|
||
original,
|
||
transform,
|
||
} of fieldMap) {
|
||
if (editKey in this.editValues) {
|
||
const newVal = this.editValues[editKey]!;
|
||
|
||
if (newVal !== original) {
|
||
changes[backendKey] = transform
|
||
? transform(newVal)
|
||
: newVal;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cover art changes.
|
||
if (this.pendingCoverArt) {
|
||
// Convert ArrayBuffer to number[] for JSON
|
||
// serialization (Wails passes as []byte on Go side).
|
||
changes['cover_art'] = Array.from(
|
||
new Uint8Array(this.pendingCoverArt.data),
|
||
);
|
||
} else if (this.clearCoverArt) {
|
||
changes['cover_art'] = null;
|
||
}
|
||
|
||
return changes;
|
||
}
|
||
|
||
private buildBatchChanges(): Record<string, unknown> {
|
||
const changes: Record<string, unknown> = {};
|
||
|
||
const fieldMap: Array<{
|
||
editKey: string;
|
||
backendKey: string;
|
||
transform?: (v: string) => unknown;
|
||
}> = [
|
||
{ editKey: 'title', backendKey: 'title' },
|
||
{ editKey: 'artist', backendKey: 'artist' },
|
||
{ editKey: 'album', backendKey: 'album' },
|
||
{ editKey: 'genre', backendKey: 'genre' },
|
||
{
|
||
editKey: 'year',
|
||
backendKey: 'year',
|
||
transform: (v) =>
|
||
v ? parseInt(v, 10) : 0,
|
||
},
|
||
{
|
||
editKey: 'composer',
|
||
backendKey: 'composer',
|
||
},
|
||
{
|
||
editKey: 'trackNumber',
|
||
backendKey: 'track_number',
|
||
transform: (v) =>
|
||
v ? parseInt(v, 10) : 0,
|
||
},
|
||
{
|
||
editKey: 'discNumber',
|
||
backendKey: 'disc_number',
|
||
transform: (v) =>
|
||
v ? parseInt(v, 10) : 0,
|
||
},
|
||
];
|
||
|
||
for (const {
|
||
editKey,
|
||
backendKey,
|
||
transform,
|
||
} of fieldMap) {
|
||
if (editKey in this.editValues) {
|
||
const val = this.editValues[editKey]!;
|
||
changes[backendKey] = transform
|
||
? transform(val)
|
||
: val;
|
||
}
|
||
}
|
||
|
||
// Cover art.
|
||
if (this.pendingCoverArt) {
|
||
changes['cover_art'] = Array.from(
|
||
new Uint8Array(this.pendingCoverArt.data),
|
||
);
|
||
} else if (this.clearCoverArt) {
|
||
changes['cover_art'] = null;
|
||
}
|
||
|
||
return changes;
|
||
}
|
||
|
||
private exitEditMode(): void {
|
||
this.editing = false;
|
||
this.editValues = {};
|
||
this.errorMessage = '';
|
||
this.showConfirmation = false;
|
||
this.cleanupPendingCoverArt();
|
||
}
|
||
|
||
private async loadMBIDs(filePath: string): Promise<void> {
|
||
try {
|
||
const mbids = await GetTrackMBIDs(filePath);
|
||
|
||
if (mbids.recordingMbid || mbids.releaseGroupMbid || mbids.artistMbid) {
|
||
this.trackMBIDs = mbids;
|
||
}
|
||
} catch {
|
||
// Non-critical — MBIDs just won't show.
|
||
}
|
||
}
|
||
|
||
private cleanupPendingCoverArt(): void {
|
||
if (this.pendingCoverArt?.previewUrl) {
|
||
URL.revokeObjectURL(
|
||
this.pendingCoverArt.previewUrl,
|
||
);
|
||
}
|
||
this.pendingCoverArt = null;
|
||
this.clearCoverArt = false;
|
||
}
|
||
|
||
private resetBatchState(): void {
|
||
this.batchMode = false;
|
||
this.batchTracks = [];
|
||
this.batchFilePaths = [];
|
||
this.batchCoverArtMixed = false;
|
||
this.batchProgress = null;
|
||
this.batchResult = null;
|
||
this.showConfirmation = false;
|
||
}
|
||
|
||
private selectCoverArt = async () => {
|
||
try {
|
||
const filePath = await ImageFilePicker();
|
||
|
||
if (!filePath) return; // User cancelled.
|
||
|
||
const bytes =
|
||
await this.readCoverArtFile(filePath);
|
||
|
||
if (!bytes) return;
|
||
|
||
// Create preview URL from bytes.
|
||
const buffer = (bytes.buffer as ArrayBuffer).slice(
|
||
bytes.byteOffset,
|
||
bytes.byteOffset + bytes.byteLength,
|
||
);
|
||
const blob = new Blob([buffer]);
|
||
const previewUrl =
|
||
URL.createObjectURL(blob);
|
||
|
||
this.cleanupPendingCoverArt();
|
||
this.pendingCoverArt = {
|
||
data: buffer,
|
||
previewUrl,
|
||
};
|
||
this.clearCoverArt = false;
|
||
} catch (err) {
|
||
console.error(
|
||
'Failed to select cover art:',
|
||
err,
|
||
);
|
||
}
|
||
};
|
||
|
||
private async readCoverArtFile(
|
||
filePath: string,
|
||
): Promise<Uint8Array | null> {
|
||
try {
|
||
// ReadFile returns Go []byte which Wails serializes
|
||
// as a base64-encoded string (standard encoding/json
|
||
// behaviour for []byte).
|
||
const result = await ReadFile(filePath);
|
||
const b64 = result as unknown as string;
|
||
const binary = atob(b64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
|
||
for (let i = 0; i < binary.length; i++) {
|
||
bytes[i] = binary.charCodeAt(i);
|
||
}
|
||
|
||
return bytes;
|
||
} catch (err) {
|
||
console.error(
|
||
'Failed to read cover art file:',
|
||
err,
|
||
);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private removeCoverArt = () => {
|
||
this.cleanupPendingCoverArt();
|
||
this.clearCoverArt = true;
|
||
};
|
||
|
||
private getEditValue(
|
||
key: string,
|
||
fallback: string,
|
||
): string {
|
||
return key in this.editValues
|
||
? this.editValues[key]!
|
||
: fallback;
|
||
}
|
||
|
||
private onEditInput(key: string, e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
|
||
this.editValues = {
|
||
...this.editValues,
|
||
[key]: input.value,
|
||
};
|
||
}
|
||
|
||
// =================================================================
|
||
// BATCH HELPERS
|
||
// =================================================================
|
||
|
||
/** Compute merged field values across all batch tracks. */
|
||
private getMergedFields(): MetadataField[] {
|
||
const tracks = this.batchTracks;
|
||
|
||
const extractors: Array<{
|
||
key: string;
|
||
label: string;
|
||
type: 'text' | 'number';
|
||
extract: (t: library.Track) => string;
|
||
}> = [
|
||
{
|
||
key: 'title',
|
||
label: 'Title',
|
||
type: 'text',
|
||
extract: (t) => t.TrackName ?? '',
|
||
},
|
||
{
|
||
key: 'artist',
|
||
label: 'Artist',
|
||
type: 'text',
|
||
extract: (t) => t.ArtistName ?? '',
|
||
},
|
||
{
|
||
key: 'album',
|
||
label: 'Album',
|
||
type: 'text',
|
||
extract: (t) => t.Album ?? '',
|
||
},
|
||
{
|
||
key: 'genre',
|
||
label: 'Genre',
|
||
type: 'text',
|
||
extract: (t) =>
|
||
(t.Genre ?? []).join(', '),
|
||
},
|
||
{
|
||
key: 'year',
|
||
label: 'Year',
|
||
type: 'number',
|
||
extract: (t) =>
|
||
t.Year ? String(t.Year) : '',
|
||
},
|
||
{
|
||
key: 'composer',
|
||
label: 'Composer',
|
||
type: 'text',
|
||
extract: (t) => t.Composer ?? '',
|
||
},
|
||
{
|
||
key: 'trackNumber',
|
||
label: 'Track #',
|
||
type: 'number',
|
||
extract: (t) =>
|
||
t.TrackNumber
|
||
? String(t.TrackNumber)
|
||
: '',
|
||
},
|
||
{
|
||
key: 'discNumber',
|
||
label: 'Disc #',
|
||
type: 'number',
|
||
extract: (t) =>
|
||
t.DiscNumber
|
||
? String(t.DiscNumber)
|
||
: '',
|
||
},
|
||
];
|
||
|
||
return extractors.map(
|
||
({ key, label, type, extract }) => {
|
||
const values = tracks.map(extract);
|
||
const unique = new Set(values);
|
||
const allSame = unique.size <= 1;
|
||
|
||
return {
|
||
key,
|
||
label,
|
||
value: allSame
|
||
? (values[0] ?? '')
|
||
: '',
|
||
editable: true,
|
||
type,
|
||
mixed: !allSame,
|
||
};
|
||
},
|
||
);
|
||
}
|
||
|
||
/** Count distinct values for a field across batch tracks. */
|
||
private countDistinctValues(key: string): number {
|
||
const extractMap: Record<
|
||
string,
|
||
(t: library.Track) => string
|
||
> = {
|
||
title: (t) => t.TrackName ?? '',
|
||
artist: (t) => t.ArtistName ?? '',
|
||
album: (t) => t.Album ?? '',
|
||
genre: (t) => (t.Genre ?? []).join(', '),
|
||
year: (t) =>
|
||
t.Year ? String(t.Year) : '',
|
||
composer: (t) => t.Composer ?? '',
|
||
trackNumber: (t) =>
|
||
t.TrackNumber
|
||
? String(t.TrackNumber)
|
||
: '',
|
||
discNumber: (t) =>
|
||
t.DiscNumber
|
||
? String(t.DiscNumber)
|
||
: '',
|
||
};
|
||
|
||
const extract = extractMap[key];
|
||
|
||
if (!extract) return 0;
|
||
|
||
const unique = new Set(
|
||
this.batchTracks.map(extract),
|
||
);
|
||
|
||
return unique.size;
|
||
}
|
||
|
||
/** Build human-readable list of changes for confirmation. */
|
||
private getConfirmationSummary(): string[] {
|
||
const summary: string[] = [];
|
||
|
||
for (const [key, value] of Object.entries(
|
||
this.editValues,
|
||
)) {
|
||
const label = FIELD_LABELS[key] ?? key;
|
||
|
||
if (value === '') {
|
||
summary.push(`Clear ${label}`);
|
||
} else {
|
||
summary.push(
|
||
`Set ${label} to \u201c${value}\u201d`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (this.pendingCoverArt) {
|
||
summary.push('Set cover art');
|
||
} else if (this.clearCoverArt) {
|
||
summary.push('Remove cover art');
|
||
}
|
||
|
||
return summary;
|
||
}
|
||
|
||
// =================================================================
|
||
// HELPERS
|
||
// =================================================================
|
||
|
||
private fileNameFromPath(filePath: string): string {
|
||
const parts = filePath.split(/[\\/]/);
|
||
const filename =
|
||
parts[parts.length - 1] ?? filePath;
|
||
|
||
return filename.replace(/\.[^.]+$/, '');
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
'track-details': TrackDetails;
|
||
}
|
||
}
|