added "default playlist"/ favorites system
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
@@ -60,6 +61,7 @@ export class ArtistsView
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
private wheelListenerAttached = false;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
@@ -889,6 +891,25 @@ export class ArtistsView
|
||||
void this.ctxMenu.showPlaylistSubmenu(paths);
|
||||
}
|
||||
|
||||
private async onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
await this.getContextMenuArtistFilePaths();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (this.favCtrl.allFavorited(filePaths)) {
|
||||
void this.favCtrl.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
} else {
|
||||
void this.favCtrl.addToFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* File path resolution
|
||||
* ================================================================ */
|
||||
@@ -1102,6 +1123,18 @@ export class ArtistsView
|
||||
>▶</span
|
||||
>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuFavoriteToggle()}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
></wa-icon>
|
||||
${this.favCtrl.allFavorited(this.ctxMenu.playlistFilePaths) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
||||
</wa-dropdown-item>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import { ThemeController } from '@store/controllers/theme-controller';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { GetAllPlaylists } from '@go/playlist/Service';
|
||||
import type { playlist } from '@go/models';
|
||||
import { Events } from '../../events';
|
||||
import type { ConfigFieldChangeEvent } from './config-field';
|
||||
import type { BackgroundShade } from '@store/theme-store';
|
||||
import type { IconStyle } from '@store/favorites-store';
|
||||
import {
|
||||
COLUMN_DEFS,
|
||||
ALL_COLUMN_IDS,
|
||||
@@ -185,6 +189,12 @@ export class ConfigPage extends LitElement {
|
||||
// --- Track-list column config controller ---
|
||||
private trackListCtrl = new TrackListController(this);
|
||||
|
||||
// --- Favorites controller ---
|
||||
private favCtrl = new FavoritesController(this);
|
||||
|
||||
// --- Favorites state ---
|
||||
@state() private playlists: playlist.Summary[] = [];
|
||||
|
||||
// --- Library state ---
|
||||
@state() private libraryDirectory = '';
|
||||
@state() private selectedDirectory = '';
|
||||
@@ -552,6 +562,7 @@ export class ConfigPage extends LitElement {
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.loadLibraryConfig();
|
||||
void this.loadPlaylists();
|
||||
|
||||
this.cancelScanStarted = EventsOn(
|
||||
Events.LibraryScanStarted,
|
||||
@@ -758,6 +769,56 @@ export class ConfigPage extends LitElement {
|
||||
});
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// FAVORITES HANDLERS
|
||||
// ===================================================================
|
||||
|
||||
private async loadPlaylists(): Promise<void> {
|
||||
try {
|
||||
this.playlists =
|
||||
await GetAllPlaylists();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to load playlists:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFavIconStyleChange = (
|
||||
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||
): void => {
|
||||
const style = String(
|
||||
e.detail.value,
|
||||
) as IconStyle;
|
||||
|
||||
this.favCtrl
|
||||
.setIconStyle(style)
|
||||
.catch((err: unknown) => {
|
||||
console.error(
|
||||
'Failed to set icon style:',
|
||||
err,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
private handleFavPlaylistChange = (
|
||||
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||
): void => {
|
||||
const id = Number(e.detail.value);
|
||||
|
||||
if (Number.isNaN(id)) return;
|
||||
|
||||
this.favCtrl
|
||||
.setDefaultPlaylist(id)
|
||||
.catch((err: unknown) => {
|
||||
console.error(
|
||||
'Failed to set default playlist:',
|
||||
err,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ===================================================================
|
||||
// TRACK LIST COLUMN HANDLERS
|
||||
// ===================================================================
|
||||
@@ -877,6 +938,7 @@ export class ConfigPage extends LitElement {
|
||||
<h2>Settings</h2>
|
||||
|
||||
${this.renderThemeSection()}
|
||||
${this.renderFavoritesSection()}
|
||||
${this.renderTrackListSection()}
|
||||
${this.renderLibrarySection()}
|
||||
`;
|
||||
@@ -969,6 +1031,61 @@ export class ConfigPage extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Favorites section ---
|
||||
|
||||
private renderFavoritesSection() {
|
||||
const playlistOptions =
|
||||
this.playlists.map((p) => ({
|
||||
value: String(p.ID),
|
||||
label: p.Name,
|
||||
}));
|
||||
|
||||
return html`
|
||||
<config-section
|
||||
heading="Favorites"
|
||||
description="Configure the default playlist used for quick-favouriting tracks."
|
||||
>
|
||||
<config-field
|
||||
.schema=${{
|
||||
key: 'favoritesPlaylist',
|
||||
label: 'Default Playlist',
|
||||
description:
|
||||
'The playlist used when toggling the favourite icon on tracks.',
|
||||
type: 'select' as const,
|
||||
options: playlistOptions,
|
||||
}}
|
||||
.value=${String(
|
||||
this.favCtrl.playlistId,
|
||||
)}
|
||||
@config-change=${this.handleFavPlaylistChange}
|
||||
></config-field>
|
||||
|
||||
<config-field
|
||||
.schema=${{
|
||||
key: 'favoritesIcon',
|
||||
label: 'Icon Style',
|
||||
description:
|
||||
'Choose heart or star for the favourite indicator.',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{
|
||||
value: 'heart',
|
||||
label: '\u2665 Heart',
|
||||
},
|
||||
{
|
||||
value: 'star',
|
||||
label: '\u2605 Star',
|
||||
},
|
||||
],
|
||||
}}
|
||||
.value=${this.favCtrl
|
||||
.iconStyle}
|
||||
@config-change=${this.handleFavIconStyleChange}
|
||||
></config-field>
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Track list section ---
|
||||
|
||||
private renderTrackListSection() {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import type { DragPayload } from '@utils/drag-controller';
|
||||
import { ContextMenuController } from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import {
|
||||
createAlbumArtDragImage,
|
||||
createDragImage,
|
||||
@@ -88,6 +89,7 @@ export class CoverGrid
|
||||
private static readonly CARD_PADDING = 5;
|
||||
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
private selMgr = new AlbumSelectionManager();
|
||||
private scrollMgr = new ScrollManager(this, {
|
||||
GRID_GAP: CoverGrid.GRID_GAP,
|
||||
@@ -1508,6 +1510,26 @@ export class CoverGrid
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private async onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
await this.getPlaylistSubmenuFilePaths();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (this.favCtrl.allFavorited(filePaths)) {
|
||||
void this.favCtrl.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
} else {
|
||||
void this.favCtrl.addToFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
this.clearContextMenuSelection();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/** Clear the selection that was active for the context menu. */
|
||||
private clearContextMenuSelection() {
|
||||
if (this.contextMenuTarget.kind === 'track') {
|
||||
@@ -1990,6 +2012,18 @@ export class CoverGrid
|
||||
>▶</span
|
||||
>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuFavoriteToggle()}
|
||||
@mouseenter=${() =>
|
||||
ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
></wa-icon>
|
||||
${this.favCtrl.allFavorited(this.ctxMenu.playlistFilePaths) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
||||
</wa-dropdown-item>
|
||||
${this.contextMenuTarget
|
||||
.kind ===
|
||||
'track' &&
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
@@ -61,6 +62,7 @@ export class GenresView
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
private wheelListenerAttached = false;
|
||||
private lastSearchTerm = '';
|
||||
|
||||
@@ -873,6 +875,25 @@ export class GenresView
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private async onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
await this.getContextMenuGenreFilePaths();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (this.favCtrl.allFavorited(filePaths)) {
|
||||
void this.favCtrl.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
} else {
|
||||
void this.favCtrl.addToFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Helpers
|
||||
* ================================================================ */
|
||||
@@ -1054,6 +1075,18 @@ export class GenresView
|
||||
>▶</span
|
||||
>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuFavoriteToggle()}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
></wa-icon>
|
||||
${this.favCtrl.allFavorited(this.ctxMenu.playlistFilePaths) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
||||
</wa-dropdown-item>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
|
||||
const MIN_WIDTH = 120;
|
||||
const MAX_WIDTH = 350;
|
||||
@@ -12,6 +13,7 @@ const DEFAULT_WIDTH = 200;
|
||||
@customElement('now-playing')
|
||||
export class NowPlaying extends LitElement {
|
||||
private player = new PlayerController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
|
||||
@state()
|
||||
private isDragging = false;
|
||||
@@ -83,6 +85,14 @@ export class NowPlaying extends LitElement {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -90,6 +100,33 @@ export class NowPlaying extends LitElement {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
font-size: 14px;
|
||||
transition: color 0.1s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.fav-btn.favorited {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.fav-btn.favorited:hover {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -154,6 +191,11 @@ export class NowPlaying extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const isFav = track.filePath
|
||||
? this.favCtrl.isFavorited(track.filePath)
|
||||
: false;
|
||||
const favVariant = isFav ? 'solid' : 'regular';
|
||||
|
||||
return html`
|
||||
<div class="now-playing">
|
||||
<div class="cover-art-wrapper">
|
||||
@@ -199,11 +241,32 @@ export class NowPlaying extends LitElement {
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
</div>
|
||||
<div class="track-info">
|
||||
<span class="track-title">${track.title}</span>
|
||||
<span class="track-artist">
|
||||
${track.artist || 'Unknown Artist'}
|
||||
</span>
|
||||
<div class="track-info-wrapper">
|
||||
<div class="track-info">
|
||||
<span class="track-title">
|
||||
${track.title}
|
||||
</span>
|
||||
<span class="track-artist">
|
||||
${track.artist || 'Unknown Artist'}
|
||||
</span>
|
||||
</div>
|
||||
${track.filePath
|
||||
? html`
|
||||
<button
|
||||
class="fav-btn ${isFav ? 'favorited' : ''}"
|
||||
title="${isFav ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}"
|
||||
@click=${() =>
|
||||
void this.favCtrl.toggleFavorite(
|
||||
track.filePath,
|
||||
)}
|
||||
>
|
||||
<wa-icon
|
||||
name=${this.favCtrl.iconName}
|
||||
variant=${favVariant}
|
||||
></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -42,6 +42,7 @@ import { libraryStore } from '@store/library-store';
|
||||
import { ContextMenuController } from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { contextMenuStyles } from '@utils/context-menu-controller.js';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
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';
|
||||
@@ -66,6 +67,7 @@ export class PlaylistView
|
||||
private searchCtrl = new SearchController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
|
||||
getContextMenuPopup(): WaPopup | undefined {
|
||||
return this.contextMenuPopup;
|
||||
@@ -1148,6 +1150,26 @@ export class PlaylistView
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
this.getSelectedFilePaths();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (this.favCtrl.allFavorited(filePaths)) {
|
||||
void this.favCtrl.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
} else {
|
||||
void this.favCtrl.addToFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private async removeSelectedPhantoms(): Promise<void> {
|
||||
if (this.activePlaylistIndex < 0) return;
|
||||
|
||||
@@ -2134,6 +2156,18 @@ export class PlaylistView
|
||||
▶
|
||||
</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuFavoriteToggle()}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
></wa-icon>
|
||||
${this.favCtrl.allFavorited(this.getSelectedFilePaths()) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
||||
</wa-dropdown-item>
|
||||
${this.selection
|
||||
.selectionCount ===
|
||||
1
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
contextMenuStyles,
|
||||
} from '@utils/context-menu-controller.js';
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import {
|
||||
hasTrackPayload,
|
||||
getDragPayload,
|
||||
@@ -52,6 +53,7 @@ export class QueuePanel
|
||||
private queue = new QueueController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
open = false;
|
||||
@@ -622,6 +624,26 @@ export class QueuePanel
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
this.getSelectedFilePaths();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (this.favCtrl.allFavorited(filePaths)) {
|
||||
void this.favCtrl.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
} else {
|
||||
void this.favCtrl.addToFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private openTrackDetails(index: number) {
|
||||
const queueTrack =
|
||||
this.queue.tracks[index];
|
||||
@@ -1330,6 +1352,18 @@ export class QueuePanel
|
||||
▶
|
||||
</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuFavoriteToggle()}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
></wa-icon>
|
||||
${this.favCtrl.allFavorited(this.getSelectedFilePaths()) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
||||
</wa-dropdown-item>
|
||||
${this.selection
|
||||
.selectionCount === 1
|
||||
? html`
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import {
|
||||
@@ -74,6 +75,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
private libraryCtrl = new LibraryController(this);
|
||||
private searchCtrl = new SearchController(this);
|
||||
private trackListCtrl = new TrackListController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
private selection = new SelectionController(this);
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private lastSearchTerm = '';
|
||||
@@ -312,24 +314,34 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
|
||||
private get gridTemplateColumns(): string {
|
||||
const cols = this.activeColumns;
|
||||
const favCol = '24px';
|
||||
|
||||
if (this.columnWidths.length === 0) {
|
||||
return cols
|
||||
.map((c) => c.defaultWidth)
|
||||
.join(' ');
|
||||
return (
|
||||
favCol +
|
||||
' ' +
|
||||
cols
|
||||
.map((c) => c.defaultWidth)
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
return this.columnWidths
|
||||
.map((w) => `${w}px`)
|
||||
.join(' ');
|
||||
return (
|
||||
favCol +
|
||||
' ' +
|
||||
this.columnWidths
|
||||
.map((w) => `${w}px`)
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
private get colBoundaryPositions(): number[] {
|
||||
if (this.columnWidths.length === 0) return [];
|
||||
|
||||
const padding = 8;
|
||||
const favColWidth = 24;
|
||||
const positions: number[] = [];
|
||||
let cumulative = padding;
|
||||
let cumulative = padding + favColWidth;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
@@ -930,7 +942,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-cell + .header-cell,
|
||||
.header-row > :not(:first-child),
|
||||
.track-row > :not(:first-child) {
|
||||
padding-left: 6px;
|
||||
}
|
||||
@@ -971,6 +983,31 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
font-size: 12px;
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
|
||||
.fav-icon:hover {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.fav-icon.favorited {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.fav-icon.favorited:hover {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-match {
|
||||
background-color: rgba(255, 212, 59, 0.15);
|
||||
border-radius: 2px;
|
||||
@@ -1258,6 +1295,26 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
this.selection.getSelectedKeysOrdered();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
if (this.favCtrl.allFavorited(filePaths)) {
|
||||
void this.favCtrl.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
} else {
|
||||
void this.favCtrl.addToFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
private openTrackDetails(filePath: string) {
|
||||
const track = this.tracks.find(
|
||||
(t) => t.FilePath === filePath,
|
||||
@@ -1479,6 +1536,13 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
|
||||
const cols = this.activeColumns;
|
||||
|
||||
const isFav = this.favCtrl.isFavorited(
|
||||
track.FilePath,
|
||||
);
|
||||
const favVariant = isFav
|
||||
? 'solid'
|
||||
: 'regular';
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classes}
|
||||
@@ -1493,6 +1557,20 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
this.onTrackDragStart(e, track)}
|
||||
@dragend=${this.onTrackDragEnd}
|
||||
>
|
||||
<div
|
||||
class="fav-icon ${isFav ? 'favorited' : ''}"
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void this.favCtrl.toggleFavorite(
|
||||
track.FilePath,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<wa-icon
|
||||
name=${this.favCtrl.iconName}
|
||||
variant=${favVariant}
|
||||
></wa-icon>
|
||||
</div>
|
||||
${cols.map((col) => {
|
||||
const val = col.accessor(track);
|
||||
const centered = val === '\u2014';
|
||||
@@ -1628,6 +1706,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
${this.renderSortToolbar()}
|
||||
<div class="table-container">
|
||||
<div class="header-row">
|
||||
<div></div>
|
||||
${cols.map(
|
||||
(col) => html`
|
||||
<div
|
||||
@@ -1731,6 +1810,18 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
Add to Playlist
|
||||
<span class="submenu-arrow">▶</span>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuFavoriteToggle()}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
></wa-icon>
|
||||
${this.favCtrl.allFavorited(this.selection.getSelectedKeysOrdered()) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
||||
</wa-dropdown-item>
|
||||
${this.selection.selectionCount === 1
|
||||
? html`
|
||||
<wa-dropdown-item
|
||||
|
||||
@@ -21,10 +21,12 @@ export const Events = {
|
||||
PlaylistRenamed: "PlaylistRenamed",
|
||||
PlaylistTracksChanged: "PlaylistTracksChanged",
|
||||
PlaylistsRestored: "PlaylistsRestored",
|
||||
DefaultPlaylistChanged: "DefaultPlaylistChanged",
|
||||
|
||||
// Config events
|
||||
ThemeConfigChanged: "ThemeConfigChanged",
|
||||
TrackListConfigChanged: "TrackListConfigChanged",
|
||||
FavoritesConfigChanged: "FavoritesConfigChanged",
|
||||
|
||||
// Library events
|
||||
LibraryScanStarted: "LibraryScanStarted",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from 'lit';
|
||||
import {
|
||||
favoritesStore,
|
||||
} from '../favorites-store';
|
||||
import type { IconStyle } from '../favorites-store';
|
||||
|
||||
/**
|
||||
* FavoritesController connects a Lit component to the
|
||||
* FavoritesStore.
|
||||
*
|
||||
* Usage in a component:
|
||||
*
|
||||
* private favCtrl = new FavoritesController(this);
|
||||
*
|
||||
* render() {
|
||||
* const isFav = this.favCtrl.isFavorited(filePath);
|
||||
* }
|
||||
*/
|
||||
export class FavoritesController
|
||||
implements ReactiveController
|
||||
{
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// LIFECYCLE HOOKS
|
||||
// ===============================================================
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe =
|
||||
favoritesStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// DATA ACCESS
|
||||
// ===============================================================
|
||||
|
||||
isFavorited(filePath: string): boolean {
|
||||
return favoritesStore.isFavorited(filePath);
|
||||
}
|
||||
|
||||
allFavorited(filePaths: string[]): boolean {
|
||||
return favoritesStore.allFavorited(filePaths);
|
||||
}
|
||||
|
||||
get iconStyle(): IconStyle {
|
||||
return favoritesStore.getIconStyle();
|
||||
}
|
||||
|
||||
get playlistName(): string {
|
||||
return favoritesStore.getPlaylistName();
|
||||
}
|
||||
|
||||
get playlistId(): number {
|
||||
return favoritesStore.getPlaylistId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the icon name for the current icon style.
|
||||
*/
|
||||
get iconName(): string {
|
||||
return this.iconStyle === 'star'
|
||||
? 'star'
|
||||
: 'heart';
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// ACTIONS
|
||||
// ===============================================================
|
||||
|
||||
async toggleFavorite(
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
await favoritesStore.toggleFavorite(filePath);
|
||||
}
|
||||
|
||||
async addToFavorites(
|
||||
filePaths: string[],
|
||||
): Promise<void> {
|
||||
await favoritesStore.addToFavorites(filePaths);
|
||||
}
|
||||
|
||||
async removeFromFavorites(
|
||||
filePaths: string[],
|
||||
): Promise<void> {
|
||||
await favoritesStore.removeFromFavorites(
|
||||
filePaths,
|
||||
);
|
||||
}
|
||||
|
||||
async setIconStyle(
|
||||
style: IconStyle,
|
||||
): Promise<void> {
|
||||
await favoritesStore.setIconStyle(style);
|
||||
}
|
||||
|
||||
async setDefaultPlaylist(
|
||||
id: number,
|
||||
): Promise<void> {
|
||||
await favoritesStore.setDefaultPlaylist(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
GetDefaultPlaylistTrackPaths,
|
||||
GetDefaultPlaylistInfo,
|
||||
ToggleDefaultPlaylistTrack,
|
||||
AddToDefaultPlaylist,
|
||||
RemoveFromDefaultPlaylist,
|
||||
} from '@go/playlist/Service';
|
||||
import {
|
||||
GetFavoritesIconStyle,
|
||||
GetFavoritesPlaylistID,
|
||||
SetFavoritesIconStyle,
|
||||
SetFavoritesPlaylistID,
|
||||
} from '@go/config/Config';
|
||||
import { Events } from '../events';
|
||||
|
||||
export type IconStyle = 'heart' | 'star';
|
||||
|
||||
export interface FavoritesState {
|
||||
playlistId: number;
|
||||
playlistName: string;
|
||||
iconStyle: IconStyle;
|
||||
favoritedPaths: Set<string>;
|
||||
}
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
class FavoritesStore {
|
||||
private playlistId = 0;
|
||||
private playlistName = 'Favorites';
|
||||
private iconStyle: IconStyle = 'heart';
|
||||
private favoritedPaths = new Set<string>();
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private loading = false;
|
||||
|
||||
constructor() {
|
||||
// Load initial state.
|
||||
void this.loadConfig();
|
||||
void this.loadPaths();
|
||||
|
||||
// React to changes from the backend.
|
||||
EventsOn(
|
||||
Events.FavoritesConfigChanged,
|
||||
(data: {
|
||||
PlaylistID: number;
|
||||
IconStyle: string;
|
||||
}) => {
|
||||
this.playlistId = data.PlaylistID;
|
||||
this.iconStyle =
|
||||
data.IconStyle as IconStyle;
|
||||
void this.loadPlaylistName();
|
||||
void this.loadPaths();
|
||||
},
|
||||
);
|
||||
|
||||
EventsOn(
|
||||
Events.DefaultPlaylistChanged,
|
||||
() => {
|
||||
void this.loadPaths();
|
||||
},
|
||||
);
|
||||
|
||||
// When a playlist's tracks change, check if it's
|
||||
// our default playlist and reload if so.
|
||||
EventsOn(
|
||||
Events.PlaylistTracksChanged,
|
||||
(playlistId: number) => {
|
||||
if (playlistId === this.playlistId) {
|
||||
void this.loadPaths();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// When a playlist is deleted and recreated,
|
||||
// reload everything.
|
||||
EventsOn(Events.PlaylistDeleted, () => {
|
||||
void this.loadConfig();
|
||||
void this.loadPaths();
|
||||
});
|
||||
|
||||
EventsOn(Events.PlaylistRenamed, () => {
|
||||
void this.loadPlaylistName();
|
||||
});
|
||||
|
||||
EventsOn(Events.PlaylistsRestored, () => {
|
||||
void this.loadPaths();
|
||||
});
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// DATA ACCESS
|
||||
// ===============================================================
|
||||
|
||||
isFavorited(filePath: string): boolean {
|
||||
return this.favoritedPaths.has(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all given file paths are in the default
|
||||
* playlist.
|
||||
*/
|
||||
allFavorited(filePaths: string[]): boolean {
|
||||
if (filePaths.length === 0) return false;
|
||||
|
||||
return filePaths.every((fp) =>
|
||||
this.favoritedPaths.has(fp),
|
||||
);
|
||||
}
|
||||
|
||||
getIconStyle(): IconStyle {
|
||||
return this.iconStyle;
|
||||
}
|
||||
|
||||
getPlaylistName(): string {
|
||||
return this.playlistName;
|
||||
}
|
||||
|
||||
getPlaylistId(): number {
|
||||
return this.playlistId;
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// ACTIONS
|
||||
// ===============================================================
|
||||
|
||||
async toggleFavorite(filePath: string): Promise<void> {
|
||||
// Optimistic update.
|
||||
const wasIn = this.favoritedPaths.has(filePath);
|
||||
|
||||
if (wasIn) {
|
||||
this.favoritedPaths.delete(filePath);
|
||||
} else {
|
||||
this.favoritedPaths.add(filePath);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await ToggleDefaultPlaylistTrack(filePath);
|
||||
} catch {
|
||||
// Revert optimistic update.
|
||||
if (wasIn) {
|
||||
this.favoritedPaths.add(filePath);
|
||||
} else {
|
||||
this.favoritedPaths.delete(filePath);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async addToFavorites(
|
||||
filePaths: string[],
|
||||
): Promise<void> {
|
||||
for (const fp of filePaths) {
|
||||
this.favoritedPaths.add(fp);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await AddToDefaultPlaylist(filePaths);
|
||||
} catch {
|
||||
void this.loadPaths();
|
||||
}
|
||||
}
|
||||
|
||||
async removeFromFavorites(
|
||||
filePaths: string[],
|
||||
): Promise<void> {
|
||||
for (const fp of filePaths) {
|
||||
this.favoritedPaths.delete(fp);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await RemoveFromDefaultPlaylist(filePaths);
|
||||
} catch {
|
||||
void this.loadPaths();
|
||||
}
|
||||
}
|
||||
|
||||
async setIconStyle(
|
||||
style: IconStyle,
|
||||
): Promise<void> {
|
||||
this.iconStyle = style;
|
||||
this.notify();
|
||||
await SetFavoritesIconStyle(style);
|
||||
}
|
||||
|
||||
async setDefaultPlaylist(
|
||||
id: number,
|
||||
): Promise<void> {
|
||||
this.playlistId = id;
|
||||
this.notify();
|
||||
await SetFavoritesPlaylistID(id);
|
||||
await this.loadPlaylistName();
|
||||
await this.loadPaths();
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// SUBSCRIPTION SYSTEM
|
||||
// ===============================================================
|
||||
|
||||
subscribe(callback: Subscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.subscribers.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// LOADING HELPERS
|
||||
// ===============================================================
|
||||
|
||||
private async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const [id, style] = await Promise.all([
|
||||
GetFavoritesPlaylistID(),
|
||||
GetFavoritesIconStyle(),
|
||||
]);
|
||||
|
||||
this.playlistId = id;
|
||||
this.iconStyle = style as IconStyle;
|
||||
await this.loadPlaylistName();
|
||||
this.notify();
|
||||
} catch {
|
||||
// Defaults are already set.
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPlaylistName(): Promise<void> {
|
||||
if (this.playlistId === 0) {
|
||||
this.playlistName = 'Favorites';
|
||||
this.notify();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info =
|
||||
await GetDefaultPlaylistInfo();
|
||||
|
||||
if (info?.Name) {
|
||||
this.playlistName = info.Name;
|
||||
this.notify();
|
||||
}
|
||||
} catch {
|
||||
// Keep current name.
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPaths(): Promise<void> {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const paths =
|
||||
await GetDefaultPlaylistTrackPaths();
|
||||
this.favoritedPaths = new Set(paths ?? []);
|
||||
} catch {
|
||||
// Keep current set.
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance.
|
||||
export const favoritesStore = new FavoritesStore();
|
||||
Reference in New Issue
Block a user