Files
yellowjacket/frontend/src/components/track-details/track-details.ts
T
logan d78830aa52
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m19s
fix(ui): make the touch pen a corner chip, not a scrim over the art
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.
2026-08-21 15:59:45 +00:00

2319 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}