playlist phantom track matching added, updated search queries for efficiency

This commit is contained in:
2026-02-24 21:23:25 -05:00
parent ff7649600b
commit e192d4625e
31 changed files with 5222 additions and 316 deletions
@@ -5,7 +5,9 @@ import {
state,
} from 'lit/decorators.js';
import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { GetTracksByGenre } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
@@ -20,10 +22,8 @@ export class GenreDetails extends LitElement {
@state()
private loading = true;
private libraryCtrl = new LibraryController(this);
/** Tracks the store's cached array reference to detect refreshes. */
private lastTracksRef: library.Track[] | null = null;
private scanCompleteCleanup: (() => void) | null =
null;
static override styles = css`
:host {
@@ -151,17 +151,19 @@ export class GenreDetails extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.loadTracks();
this.scanCompleteCleanup = EventsOn(
Events.LibraryScanComplete,
() => this.loadTracks(),
);
}
override updated() {
const cached = this.libraryCtrl.cachedTracks;
override disconnectedCallback() {
super.disconnectedCallback();
if (
cached !== null &&
cached !== this.lastTracksRef
) {
this.lastTracksRef = cached;
this.loadTracks();
if (this.scanCompleteCleanup) {
this.scanCompleteCleanup();
this.scanCompleteCleanup = null;
}
}
@@ -173,14 +175,8 @@ export class GenreDetails extends LitElement {
if (!this.genreName) return;
try {
const allTracks =
await this.libraryCtrl.getTracks();
this.tracks = (allTracks ?? []).filter(
(t) =>
(t.Genre ?? []).includes(
this.genreName,
),
this.tracks = await GetTracksByGenre(
this.genreName,
);
} catch (error) {
console.error(
@@ -10,7 +10,12 @@ import type {
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { library } from '@go/models';
import {
GetAllGenresWithCounts,
GetTracksByGenre,
} from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store';
@@ -61,17 +66,13 @@ export class GenresView
private ctxMenu = new ContextMenuController(this);
private wheelListenerAttached = false;
private lastSearchTerm = '';
/** Tracks the store's cached array reference to detect refreshes. */
private lastTracksRef: library.Track[] | null =
private scanCompleteCleanup: (() => void) | null =
null;
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
/** All tracks from the library (used to derive genres). */
private allTracks: library.Track[] = [];
@state()
private genres: Genre[] = [];
@@ -378,12 +379,22 @@ export class GenresView
super.connectedCallback();
this.loadCardSize();
this.loadGenres();
this.scanCompleteCleanup = EventsOn(
Events.LibraryScanComplete,
() => this.loadGenres(),
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.detachWheelListener();
if (this.scanCompleteCleanup) {
this.scanCompleteCleanup();
this.scanCompleteCleanup = null;
}
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
@@ -401,19 +412,6 @@ export class GenresView
this.lastSearchTerm = currentTerm;
this.clearSelection();
}
// Re-fetch when the store delivers fresh
// data after eager refetch on invalidation.
const cached =
this.libraryCtrl.cachedTracks;
if (
cached !== null &&
cached !== this.lastTracksRef
) {
this.lastTracksRef = cached;
this.loadGenres();
}
}
/* ================================================================
@@ -424,18 +422,18 @@ export class GenresView
try {
this.loading = true;
const tracks =
await this.libraryCtrl.getTracks();
const rows =
await GetAllGenresWithCounts();
this.allTracks = tracks ?? [];
this.genres =
this.extractGenres(this.allTracks);
this.genres = (rows ?? []).map((r) => ({
name: r.Name,
trackCount: r.TrackCount,
}));
} catch (error) {
console.error(
'Error loading genres:',
error,
);
this.allTracks = [];
this.genres = [];
} finally {
const saved =
@@ -451,41 +449,6 @@ export class GenresView
this.restoreScrollPosition();
}
/**
* Extract unique genres from all tracks,
* sorted alphabetically by name.
*/
private extractGenres(
tracks: library.Track[],
): Genre[] {
const counts = new Map<string, number>();
for (const track of tracks) {
const genres = track.Genre ?? [];
for (const name of genres) {
if (!name) continue;
counts.set(
name,
(counts.get(name) ?? 0) + 1,
);
}
}
const result: Genre[] = [];
for (const [name, trackCount] of counts) {
result.push({ name, trackCount });
}
result.sort((a, b) =>
a.name.localeCompare(b.name),
);
return result;
}
/* ================================================================
* Scroll position persistence
* ================================================================ */
@@ -743,24 +706,28 @@ export class GenresView
}
/**
* Returns all file paths for every selected
* genre.
* Fetch file paths for a set of genre names by
* querying the backend for each genre.
*/
private getSelectedGenreFilePaths(): string[] {
const allPaths: string[] = [];
private async getFilePathsForGenres(
genreNames: Iterable<string>,
): Promise<string[]> {
const seen = new Set<string>();
const allPaths: string[] = [];
for (const track of this.allTracks) {
if (seen.has(track.FilePath)) continue;
const promises = Array.from(
genreNames,
(name) => GetTracksByGenre(name),
);
const genres = track.Genre ?? [];
const match = genres.some((g) =>
this.selectedGenres.has(g),
);
const results = await Promise.all(promises);
if (match) {
allPaths.push(track.FilePath);
seen.add(track.FilePath);
for (const tracks of results) {
for (const track of tracks ?? []) {
if (!seen.has(track.FilePath)) {
seen.add(track.FilePath);
allPaths.push(track.FilePath);
}
}
}
@@ -774,36 +741,23 @@ export class GenresView
* genres. Otherwise return paths for the
* right-clicked genre only.
*/
private getContextMenuGenreFilePaths(): string[] {
private async getContextMenuGenreFilePaths(): Promise<
string[]
> {
if (
this.contextMenuGenreName !== null &&
!this.selectedGenres.has(
this.contextMenuGenreName,
)
) {
const paths: string[] = [];
const seen = new Set<string>();
for (const track of this.allTracks) {
if (seen.has(track.FilePath)) {
continue;
}
const genres = track.Genre ?? [];
const match = genres.includes(
this.contextMenuGenreName,
);
if (match) {
paths.push(track.FilePath);
seen.add(track.FilePath);
}
}
return paths;
return this.getFilePathsForGenres([
this.contextMenuGenreName,
]);
}
return this.getSelectedGenreFilePaths();
return this.getFilePathsForGenres(
this.selectedGenres,
);
}
/** Clear the current genre selection. */
@@ -889,9 +843,11 @@ export class GenresView
);
};
private onContextMenuAction(action: string) {
private async onContextMenuAction(
action: string,
) {
const filePaths =
this.getContextMenuGenreFilePaths();
await this.getContextMenuGenreFilePaths();
if (filePaths.length === 0) return;
@@ -1063,8 +1019,11 @@ export class GenresView
class="submenu-item"
@mouseenter=${() => {
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(
this.getContextMenuGenreFilePaths(),
void this.getContextMenuGenreFilePaths().then(
(paths) =>
this.ctxMenu.showPlaylistSubmenu(
paths,
),
);
}}
@mouseleave=${this
@@ -1074,8 +1033,11 @@ export class GenresView
e: Event,
) => {
e.stopPropagation();
void this.ctxMenu.showPlaylistSubmenu(
this.getContextMenuGenreFilePaths(),
void this.getContextMenuGenreFilePaths().then(
(paths) =>
this.ctxMenu.showPlaylistSubmenu(
paths,
),
);
}}
>
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ import {
DeletePlaylist,
RenamePlaylist,
ImportPlaylist,
RemovePhantomTracks,
} from '@go/playlist/Service';
import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil';
import type { playlist } from '@go/models';
@@ -44,6 +45,8 @@ import { contextMenuStyles } from '@utils/context-menu-controller.js';
import '@components/track-details/track-details.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import '@components/phantom-resolver/phantom-resolver.js';
import type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js';
const SCROLL_DEBOUNCE_MS = 100;
@@ -191,6 +194,9 @@ export class PlaylistView
/** True when dragging over the "New Playlist" button. */
@state() private dragOverNewButton = false;
/** Error message from the last failed import, auto-clears. */
@state() private importError = '';
/**
* File paths from a drop that landed outside any playlist.
* When non-empty the create form is in "create-and-add" mode.
@@ -211,6 +217,9 @@ export class PlaylistView
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@query('phantom-resolver')
private phantomResolver!: PhantomResolver;
private closePlaylistCtxMenuHandler =
() => this.closePlaylistContextMenu();
@@ -579,23 +588,83 @@ export class PlaylistView
}
.track-item.phantom {
opacity: 0.45;
cursor: not-allowed;
cursor: pointer;
}
.track-item.phantom:hover {
background-color: transparent;
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.phantom-badge {
display: inline-block;
font-size: 10px;
.track-item.phantom.selected {
background-color: var(
--yj-selection-bg,
rgba(100, 160, 255, 0.15)
);
}
.phantom-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.phantom-caution {
flex-shrink: 0;
font-size: 14px;
color: var(--yj-warning, #e67700);
background: rgba(230, 119, 0, 0.15);
padding: 1px 6px;
}
.phantom-path {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.phantom-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.phantom-icon-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
border-radius: 3px;
margin-left: 8px;
vertical-align: middle;
font-size: 13px;
}
.phantom-icon-btn:hover {
color: var(
--yj-text-primary,
#fff
);
background: rgba(
255,
255,
255,
0.08
);
}
.phantom-icon-btn.phantom-icon-remove:hover {
color: var(--yj-error, #e03131);
background: rgba(224, 49, 49, 0.12);
}
.track-item:last-child {
@@ -747,6 +816,23 @@ export class PlaylistView
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent, #ffd43b);
}
.import-error {
padding: 0.5em 0.75em;
margin: 0.5em 16px 0;
font-size: 0.8em;
color: var(--yj-error, #e03131);
background: color-mix(
in srgb,
var(--yj-error, #e03131) 10%,
var(--yj-bg-elevated, #343a40)
);
border-radius: 4px;
border-left: 3px solid
var(--yj-error, #e03131);
}
`];
override connectedCallback() {
@@ -1045,12 +1131,60 @@ export class PlaylistView
case 'track-details':
this.openTrackDetails(filePaths[0]!);
break;
case 'phantom-locate':
if (this.activePlaylistIndex >= 0) {
this.openPhantomResolver(
this.activePlaylistIndex,
);
}
break;
case 'phantom-remove':
void this.removeSelectedPhantoms();
break;
}
this.selection.clear();
this.ctxMenu.close();
}
private async removeSelectedPhantoms(): Promise<void> {
if (this.activePlaylistIndex < 0) return;
const entry =
this.entries[
this.activePlaylistIndex
];
if (!entry) return;
const selectedIndices =
this.selection.getSelectedIndices();
const phantomPaths = selectedIndices
.map((i) => entry.tracks[i])
.filter(
(t): t is playlist.Track =>
t !== undefined &&
t.Phantom,
)
.map((t) => t.FilePath);
if (phantomPaths.length === 0) return;
try {
await RemovePhantomTracks(
entry.summary.ID,
phantomPaths,
);
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to remove phantom tracks:',
err,
);
}
}
private openTrackDetails(filePath: string) {
const tracks =
libraryStore.getCachedTracks();
@@ -1574,6 +1708,143 @@ export class PlaylistView
}
}
/**
* Check whether all currently selected tracks are phantoms.
* Returns false if nothing is selected or the active playlist
* index is unset.
*/
private isPhantomSelection(): boolean {
if (this.activePlaylistIndex < 0) return false;
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return false;
const indices =
this.selection.getSelectedIndices();
if (indices.length === 0) return false;
return indices.every((i) => {
const t = entry.tracks[i];
return t !== undefined && t.Phantom;
});
}
// =================================================================
// Phantom track interactions
// =================================================================
private handlePhantomClick(
e: MouseEvent,
trackIndex: number,
playlistIndex: number,
): void {
this.ensureSelectionScope(playlistIndex);
this.selection.handleItemClick(
e,
String(trackIndex),
trackIndex,
);
}
private handlePhantomContextMenu(
e: MouseEvent,
trackIndex: number,
playlistIndex: number,
): void {
e.preventDefault();
e.stopPropagation();
this.ensureSelectionScope(playlistIndex);
this.selection.handleContextMenu(
String(trackIndex),
);
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private openPhantomResolver(
playlistIndex: number,
trackIndex?: number,
): void {
const entry =
this.entries[playlistIndex];
if (!entry) return;
// Collect selected phantom tracks, or just the
// one that was clicked.
let phantoms: playlist.Track[];
if (
this.activePlaylistIndex ===
playlistIndex
) {
const selectedIndices =
this.selection.getSelectedIndices();
phantoms = selectedIndices
.map(
(i) => entry.tracks[i],
)
.filter(
(t): t is playlist.Track =>
t !== undefined &&
t.Phantom,
);
} else {
phantoms = [];
}
// Fall back to the clicked track.
if (
phantoms.length === 0 &&
trackIndex !== undefined
) {
const track =
entry.tracks[trackIndex];
if (track?.Phantom) {
phantoms = [track];
}
}
if (phantoms.length === 0) return;
this.phantomResolver.show(
entry.summary.ID,
phantoms,
);
}
private async removePhantomTrack(
playlistIndex: number,
trackIndex: number,
): Promise<void> {
const entry =
this.entries[playlistIndex];
if (!entry) return;
const track =
entry.tracks[trackIndex];
if (!track?.Phantom) return;
try {
await RemovePhantomTracks(
entry.summary.ID,
[track.FilePath],
);
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to remove phantom track:',
err,
);
}
}
// =================================================================
// Import playlist
// =================================================================
@@ -1585,13 +1856,20 @@ export class PlaylistView
if (!filePath) return;
this.importError = '';
await ImportPlaylist(filePath);
await this.refreshPlaylists();
} catch (err) {
console.error(
'Failed to import playlist:',
err,
);
this.importError =
err instanceof Error
? err.message
: String(err);
setTimeout(() => {
this.importError = '';
}, 6000);
}
};
@@ -1707,6 +1985,12 @@ export class PlaylistView
</div>
</div>
${this.importError
? html`<div class="import-error">
${this.importError}
</div>`
: nothing}
${this.searchCtrl.term &&
this.filteredEntries.length > 0
? html`<div class="search-indicator">
@@ -1735,111 +2019,144 @@ export class PlaylistView
.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
${this.selection
.selectionCount === 1
? html`
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="circle-info"
></wa-icon>
Track
Details
</wa-dropdown-item>
`
: nothing}
</div>
`
? this.isPhantomSelection()
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'phantom-locate',
)}
>
<wa-icon
slot="icon"
name="magnifying-glass"
></wa-icon>
Locate in
Library
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'phantom-remove',
)}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from
Playlist
</wa-dropdown-item>
</div>
`
: html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from
Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
${this.selection
.selectionCount ===
1
? html`
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="circle-info"
></wa-icon>
Track
Details
</wa-dropdown-item>
`
: nothing}
</div>
`
: nothing}
</wa-popup>
@@ -1917,6 +2234,10 @@ export class PlaylistView
</wa-popup>
<track-details></track-details>
<phantom-resolver
@phantom-resolved=${() =>
this.refreshPlaylists()}
></phantom-resolver>
`;
}
@@ -2153,7 +2474,6 @@ export class PlaylistView
track,
);
const selected =
!isPhantom &&
this.activePlaylistIndex ===
playlistIndex &&
this.selection.isSelected(
@@ -2180,7 +2500,14 @@ export class PlaylistView
? 'false'
: 'true'}
@click=${isPhantom
? nothing
? (
e: MouseEvent,
) =>
this.handlePhantomClick(
e,
trackIndex,
playlistIndex,
)
: (
e: MouseEvent,
) =>
@@ -2199,7 +2526,14 @@ export class PlaylistView
playlistIndex,
)}
@contextmenu=${isPhantom
? nothing
? (
e: MouseEvent,
) =>
this.handlePhantomContextMenu(
e,
trackIndex,
playlistIndex,
)
: (
e: MouseEvent,
) =>
@@ -2224,20 +2558,67 @@ export class PlaylistView
: this
.onTrackDragEnd}
>
<track-info
.trackTitle=${track.Title ||
track.FilePath}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
${isPhantom
? html`<span
class="phantom-badge"
>File not
found</span
>`
: nothing}
? html`<div
class="phantom-row"
>
<wa-icon
class="phantom-caution"
name="triangle-exclamation"
title="File not found"
></wa-icon>
<span
class="phantom-path"
title=${track.FilePath}
>
${track.FilePath}
</span>
<div
class="phantom-actions"
>
<button
class="phantom-icon-btn"
title="Locate in library"
@click=${(
e: Event,
) => {
e.stopPropagation();
this.openPhantomResolver(
playlistIndex,
trackIndex,
);
}}
>
<wa-icon
name="magnifying-glass"
></wa-icon>
</button>
<button
class="phantom-icon-btn phantom-icon-remove"
title="Remove from playlist"
@click=${(
e: Event,
) => {
e.stopPropagation();
void this.removePhantomTrack(
playlistIndex,
trackIndex,
);
}}
>
<wa-icon
name="xmark"
></wa-icon>
</button>
</div>
</div>`
: html`<track-info
.trackTitle=${track.Title ||
track.FilePath}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>`}
</div>
`;
},
@@ -48,10 +48,7 @@ export class TrackDetails extends LitElement {
@state() private editValues: Record<string, string> = {};
@query('wa-dialog')
private dialog!: HTMLElement & {
show: () => void;
hide: () => void;
};
private dialog!: HTMLElement & { open: boolean };
// =================================================================
// PUBLIC API
@@ -68,13 +65,13 @@ export class TrackDetails extends LitElement {
this.editValues = {};
this.updateComplete.then(() => {
this.dialog?.show();
if (this.dialog) this.dialog.open = true;
});
}
/** Close the dialog. */
close(): void {
this.dialog?.hide();
if (this.dialog) this.dialog.open = false;
this.editing = false;
this.editValues = {};
}