added "default playlist"/ favorites system

This commit is contained in:
2026-02-25 22:53:57 -05:00
parent 51da5cfb07
commit d01f5e5d14
23 changed files with 1547 additions and 22 deletions
@@ -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
>&#9654;</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
>&#9654;</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
>&#9654;</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
&#9654;
</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
&#9654;
</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">&#9654;</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