Compare commits

...

30 Commits

Author SHA1 Message Date
jeffvli 3ad5447871 enable album play buttons if yt enabled 2026-02-07 14:44:24 -08:00
jeffvli 3a50dee7a2 revert settings migrations, lint 2026-02-07 13:40:05 -08:00
jeffvli c4ecfeedec add automatic country prioritization based on existing releases 2026-02-07 13:36:17 -08:00
jeffvli f43655ed5a refactor mbz country priority to be multiselect 2026-02-07 13:24:32 -08:00
jeffvli fddda70190 handle release group image for song normalization 2026-02-07 13:14:02 -08:00
jeffvli a5992943d0 fetch image by release group instead of release 2026-02-07 13:12:19 -08:00
jeffvli 56e2611992 serve electron renderer via express to allow yt iframe playback 2026-02-07 12:59:59 -08:00
jeffvli c8ae128ac4 handle imageUrl in drag preview and context menu 2026-02-07 02:33:31 -08:00
jeffvli ba56ab8844 redesign external song indicator on itemcard 2026-02-07 02:30:41 -08:00
jeffvli 7cb7dfb62b add external song indicator for queue 2026-02-07 02:14:24 -08:00
jeffvli 86537a8d1e increase cache time for youtube queries 2026-02-07 01:53:19 -08:00
jeffvli 3b3e77b672 handle external imageUrl 2026-02-07 01:52:58 -08:00
jeffvli bec6464a44 move external playback fetch to context 2026-02-07 01:26:04 -08:00
jeffvli 812ca5302a fix audio context breaking on source change 2026-02-07 01:01:59 -08:00
jeffvli 1824083b99 adjust yt search query format 2026-02-07 00:41:55 -08:00
jeffvli f46ca8cd35 handle playback from ItemCard 2026-02-07 00:41:36 -08:00
jeffvli f04ea3bca0 fix artist name joining from mbz 2026-02-06 22:29:16 -08:00
jeffvli a547be1577 add settings configuration for integrations 2026-02-06 22:19:42 -08:00
jeffvli 8ae29407ec support ytmusic controls on web/mpv players 2026-02-06 21:38:05 -08:00
jeffvli 8e603871b7 add experimental ytmusic playback for external songs 2026-02-06 20:47:27 -08:00
jeffvli 40ec16e191 support mbz album detail view 2026-02-06 20:13:58 -08:00
jeffvli 0bb30ab0da decouple internal and external album count in releasetype sections 2026-02-06 14:47:02 -08:00
jeffvli 9919ff9626 improve card styling on external items 2026-02-06 14:40:15 -08:00
jeffvli f6cec17710 progress 2026-02-06 13:02:44 -08:00
jeffvli 03b01472f8 remove duplicate ServerType enum 2026-02-06 13:02:44 -08:00
jeffvli 3550177f67 ignore external albums in album section playback handler 2026-02-06 05:23:33 -08:00
jeffvli 82914c27f0 add missing releases from musicbrainz to artist page 2026-02-06 05:23:33 -08:00
jeffvli 10d02087d0 add selector to convert musicbrainz releases to Album type 2026-02-06 05:23:33 -08:00
jeffvli 4b509951a5 add musicbrainz artist query 2026-02-06 05:23:33 -08:00
jeffvli 2869aab728 add musicbrainz-api package 2026-02-06 05:23:33 -08:00
64 changed files with 2790 additions and 249 deletions
+3
View File
@@ -98,6 +98,7 @@
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"express": "^5.2.1",
"fast-average-color": "^9.5.0",
"fast-xml-parser": "^5.3.2",
"format-duration": "^3.0.2",
@@ -111,6 +112,7 @@
"md5": "^2.3.0",
"motion": "^12.23.24",
"mpris-service": "^2.1.2",
"musicbrainz-api": "^0.27.1",
"nanoid": "^3.3.11",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"nuqs": "^2.7.1",
@@ -134,6 +136,7 @@
"string-to-color": "^2.2.2",
"wavesurfer.js": "^7.11.1",
"ws": "^8.18.2",
"ytmusic-api": "^5.3.0",
"zod": "^3.22.3",
"zustand": "^5.0.5"
},
+652 -3
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -79,6 +79,7 @@
"dismiss": "dismiss",
"doNotShowAgain": "do not show this again",
"duration": "duration",
"external": "external",
"view": "view",
"edit": "edit",
"enable": "enable",
@@ -152,6 +153,7 @@
"trackPeak": "track peak",
"translation": "translation",
"unknown": "unknown",
"unavailable": "unavailable",
"version": "version",
"year": "year",
"yes": "yes",
@@ -582,6 +584,7 @@
"analytics": "analytics",
"generalTab": "general",
"hotkeysTab": "hotkeys",
"integrationsTab": "integrations",
"playbackTab": "playback",
"windowTab": "window",
"updates": "update",
@@ -892,6 +895,16 @@
"mpvExtraParameters_help": "one per line",
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
"musicbrainz": "show MusicBrainz links",
"musicBrainzQueries": "enable MusicBrainz integration",
"musicBrainzQueries_description": "the integration will query MusicBrainz for missing artist releases and other miscellaneous data",
"musicbrainzExcludeReleaseTypes": "MusicBrainz release type exclusion",
"musicbrainzExcludeReleaseTypes_description": "release types to exclude when loading MusicBrainz artist releases",
"musicbrainzPrioritizeCountries": "MusicBrainz country priority",
"musicbrainzPrioritizeCountries_description": "countries to prioritize when ordering MusicBrainz releases (first in list has highest priority)",
"musicbrainzAutoCountryPriority": "automatic country priority",
"musicbrainzAutoCountryPriority_description": "derive country priority from the artist's MusicBrainz releases (countries with more releases are ranked higher)",
"youtube": "enable YouTube playback",
"youtube_description": "external songs will attempt to use YouTube to resolve stream URLs (desktop only)",
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
"neteaseTranslation": "Enable NetEase translations",
"notify": "enable song notifications",
+2 -1
View File
@@ -1,7 +1,8 @@
import { createSocket } from 'dgram';
import { ipcMain } from 'electron';
import { DiscoveredServerItem, ServerType } from '/@/shared/types/types';
import { ServerType } from '/@/shared/types/domain-types';
import { DiscoveredServerItem } from '/@/shared/types/types';
type JellyfinResponse = {
Address: string;
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './youtube';
+1
View File
@@ -38,6 +38,7 @@ export const store = new Store<any>({
lyrics: ['NetEase', 'lrclib.net'],
mediaSession: false,
playbackType: 'web',
renderer_server_port: 38472,
should_prompt_accessibility: true,
shown_accessibility_warning: false,
window_enable_tray: true,
+18
View File
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron';
import YTMusic from 'ytmusic-api';
let youtubeApi: InstanceType<typeof YTMusic> | null = null;
const getYoutubeApi = async (): Promise<InstanceType<typeof YTMusic>> => {
if (!youtubeApi) {
youtubeApi = new YTMusic();
await youtubeApi.initialize();
}
return youtubeApi;
};
ipcMain.handle('youtube-search', async (_event, query: string) => {
const api = await getYoutubeApi();
const results = await api.search(query);
return results;
});
+47 -4
View File
@@ -19,6 +19,7 @@ import {
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import express from 'express';
import { access, constants } from 'fs';
import path, { join } from 'path';
@@ -218,6 +219,37 @@ const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
const DEFAULT_RENDERER_SERVER_PORT = 38472;
const getRendererServerPort = (): number => {
const port = Number(store.get('renderer_server_port', DEFAULT_RENDERER_SERVER_PORT));
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
return DEFAULT_RENDERER_SERVER_PORT;
}
return port;
};
let rendererServerUrl: null | string = null;
let rendererHttpServer: null | ReturnType<express.Application['listen']> = null;
const startRendererServer = (): Promise<string> => {
return new Promise((resolve, reject) => {
if (rendererServerUrl) {
resolve(rendererServerUrl);
return;
}
const port = getRendererServerPort();
const rendererPath = join(__dirname, '../renderer');
const app = express();
app.use(express.static(rendererPath));
rendererHttpServer = app.listen(port, () => {
rendererServerUrl = `http://localhost:${port}`;
resolve(rendererServerUrl);
});
rendererHttpServer.on('error', reject);
});
};
export const getMainWindow = () => {
return mainWindow;
};
@@ -580,12 +612,11 @@ async function createWindow(first = true): Promise<void> {
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
// HMR for renderer: use Vite dev server URL in development, otherwise the local HTTP server.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
mainWindow.loadURL(rendererServerUrl!);
}
}
@@ -739,6 +770,14 @@ app.on('window-all-closed', () => {
}
});
app.on('before-quit', () => {
if (rendererHttpServer) {
rendererHttpServer.close();
rendererHttpServer = null;
rendererServerUrl = null;
}
});
const FONT_HEADERS = [
'font/collection',
'font/otf',
@@ -766,7 +805,7 @@ if (!singleInstance) {
});
app.whenReady()
.then(() => {
.then(async () => {
protocol.handle('feishin', async (request) => {
const filePath = `file://${request.url.slice('feishin://'.length)}`;
const response = await net.fetch(filePath);
@@ -784,6 +823,10 @@ if (!singleInstance) {
return response;
});
if (!(is.dev && process.env['ELECTRON_RENDERER_URL'])) {
await startRendererServer();
}
createWindow();
if (store.get('window_enable_tray', true)) {
createTray();
+2
View File
@@ -11,6 +11,7 @@ import { mpris } from './mpris';
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
import { remote } from './remote';
import { utils } from './utils';
import { youtube } from './youtube';
// Custom APIs for renderer
const api = {
@@ -25,6 +26,7 @@ const api = {
mpvPlayerListener,
remote,
utils,
youtube,
};
export type PreloadApi = typeof api;
+11
View File
@@ -0,0 +1,11 @@
import { ipcRenderer } from 'electron';
const search = (query: string) => {
return ipcRenderer.invoke('youtube-search', query);
};
export const youtube = {
search,
};
export type Youtube = typeof youtube;
@@ -242,7 +242,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server?.userId,
},
query: {
Fields: JF_FIELDS.ALBUM_ARTIST_DETAIL,
Fields: 'Genres, Overview, SortName, ProviderIds',
},
}),
jfApiClient(apiClientProps).getSimilarArtistList({
@@ -269,7 +269,7 @@ export const JellyfinController: InternalControllerEndpoint = {
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
Fields: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
ImageTypeLimit: 1,
Limit: query.limit,
ParentId: getLibraryId(query.musicFolderId),
@@ -321,7 +321,7 @@ export const JellyfinController: InternalControllerEndpoint = {
userId: apiClientProps.server.userId,
},
query: {
Fields: JF_FIELDS.SONG,
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName, ProviderIds',
IncludeItemTypes: 'Audio',
ParentId: query.id,
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
+26
View File
@@ -270,6 +270,32 @@ export const queryKeys: Record<
},
root: (serverId: string) => [serverId, 'genres'] as const,
},
musicbrainz: {
artist: (
limit: number | undefined,
mbzArtistId: string,
config?: {
autoCountryPriority: boolean;
excludeReleaseTypes: string[];
prioritizeCountries: string[];
},
) =>
[
'musicbrainz',
'artist',
mbzArtistId,
limit,
config
? [
String(config.autoCountryPriority),
[...config.excludeReleaseTypes].sort().join(','),
[...config.prioritizeCountries].sort().join(','),
]
: null,
] as const,
release: (releaseId: string) => ['musicbrainz', 'release', releaseId] as const,
root: () => ['musicbrainz'] as const,
},
musicFolders: {
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
},
@@ -32,8 +32,10 @@ export const DragPreview = memo(({ data }: DragPreviewProps) => {
const itemName = firstItem ? getItemName(firstItem) : 'Item';
const itemImage = useItemImageUrl({
id: (firstItem as { imageId: string })?.imageId,
id: (firstItem as { imageId?: string })?.imageId,
imageUrl: (firstItem as { imageUrl?: string })?.imageUrl,
itemType: data.itemType || LibraryItem.SONG,
serverId: (firstItem as { _serverId?: string })?._serverId,
type: 'table',
});
@@ -59,12 +59,33 @@
}
}
.image-container.external {
img {
opacity: 0.5;
transition: all 0.2s ease-in-out;
}
&:hover {
img {
opacity: 1;
}
}
}
.image-container.is-round {
&::before {
border-radius: 50%;
}
}
.image-container.no-hover-overlay {
&:hover {
&::before {
opacity: 0 !important;
}
}
}
.favorite-badge {
position: absolute;
top: -50px;
@@ -100,9 +121,19 @@
transition: opacity 0.2s ease-in-out;
}
.image-container:hover .favorite-badge,
.image-container:hover .rating-badge {
opacity: 0;
.external-badge {
position: absolute;
bottom: var(--theme-spacing-sm);
left: var(--theme-spacing-sm);
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
pointer-events: none;
background-color: alpha(var(--theme-colors-state-error), 0.85);
border-radius: var(--theme-radius-md);
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
}
.image {
+63 -21
View File
@@ -19,7 +19,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { useShowRatings } from '/@/renderer/store';
import { useIntegrationsSettings, useShowRatings } from '/@/renderer/store';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
@@ -29,6 +29,7 @@ import {
} from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Separator } from '/@/shared/components/separator/separator';
@@ -42,6 +43,7 @@ import {
Genre,
LibraryItem,
Playlist,
ServerType,
Song,
} from '/@/shared/types/domain-types';
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
@@ -178,6 +180,7 @@ const CompactItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
@@ -339,9 +342,15 @@ const CompactItemCard = ({
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const isExternal = data._serverType === ServerType.EXTERNAL;
const showItemCardControls =
withControls && showControls && data && (!isExternal || youtubeIntegrationEnabled);
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.external]: isExternal,
[styles.isRound]: isRound,
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
});
const imageContainerContent = (
@@ -373,8 +382,13 @@ const CompactItemCard = ({
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
{isExternal && (
<div className={styles.externalBadge} title={i18n.t('common.external')}>
<ExternalSongIndicator isExternal size="sm" withSpace={false} />
</div>
)}
<AnimatePresence>
{withControls && showControls && data && (
{showItemCardControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
@@ -409,6 +423,7 @@ const CompactItemCard = ({
<div
className={clsx(styles.container, styles.compact, {
[styles.dragging]: isDragging,
[styles.external]: isExternal,
[styles.selected]: isSelected,
})}
ref={ref}
@@ -482,6 +497,7 @@ const DefaultItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
@@ -570,10 +586,6 @@ const DefaultItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
@@ -582,6 +594,16 @@ const DefaultItemCard = ({
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const isExternal = data._serverType === ServerType.EXTERNAL;
const showItemCardControls =
withControls && showControls && data && (!isExternal || youtubeIntegrationEnabled);
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.external]: isExternal,
[styles.isRound]: isRound,
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
});
const imageContainerContent = (
<>
@@ -610,8 +632,13 @@ const DefaultItemCard = ({
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
{isExternal && (
<div className={styles.externalBadge} title={i18n.t('common.external')}>
<ExternalSongIndicator isExternal size="sm" withSpace={false} />
</div>
)}
<AnimatePresence>
{withControls && showControls && (
{showItemCardControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
@@ -628,6 +655,7 @@ const DefaultItemCard = ({
return (
<div
className={clsx(styles.container, {
[styles.external]: isExternal,
[styles.selected]: isSelected,
})}
>
@@ -717,6 +745,7 @@ const PosterItemCard = ({
showRating,
withControls,
}: ItemCardDerivativeProps) => {
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
const [showControls, setShowControls] = useState(false);
const itemRowId =
data && internalState && typeof data === 'object' && 'id' in data
@@ -870,10 +899,6 @@ const PosterItemCard = ({
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const userRating =
@@ -882,6 +907,16 @@ const PosterItemCard = ({
? (data as { userRating: null | number }).userRating
: null;
const hasRating = showRating && userRating !== null && userRating > 0;
const isExternal = data._serverType === ServerType.EXTERNAL;
const showItemCardControls =
withControls && showControls && data && (!isExternal || youtubeIntegrationEnabled);
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.external]: isExternal,
[styles.isRound]: isRound,
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
});
const imageContainerContent = (
<>
@@ -910,8 +945,13 @@ const PosterItemCard = ({
)}
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
{isExternal && (
<div className={styles.externalBadge}>
<ExternalSongIndicator isExternal size="xl" withSpace={false} />
</div>
)}
<AnimatePresence>
{withControls && showControls && data && (
{showItemCardControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
@@ -930,6 +970,7 @@ const PosterItemCard = ({
<div
className={clsx(styles.container, styles.poster, {
[styles.dragging]: isDragging,
[styles.external]: isExternal,
[styles.selected]: isSelected,
})}
ref={ref}
@@ -1025,18 +1066,20 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
if ('id' in data && data.id) {
if ('_itemType' in data) {
switch (data._itemType) {
case LibraryItem.ALBUM:
return (
<Link
state={{ item: data }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: data.id,
})}
>
case LibraryItem.ALBUM: {
const albumPath = getTitlePath(LibraryItem.ALBUM, data.id);
return albumPath ? (
<Link state={{ item: data }} to={albumPath}>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</Link>
) : (
<>
<ExplicitIndicator explicitStatus={explicitStatus} />
{data.name}
</>
);
}
case LibraryItem.ALBUM_ARTIST:
return (
<Link
@@ -1333,7 +1376,6 @@ const getItemNavigationPath = (
}
const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;
return getTitlePath(effectiveItemType, data.id);
};
@@ -8,7 +8,7 @@ import { ContextMenuController } from '/@/renderer/features/context-menu/context
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
import { LibraryItem, QueueSong, ServerType, Song } from '/@/shared/types/domain-types';
import { Play, TableColumn } from '/@/shared/types/types';
interface UseDefaultItemListControlsArgs {
@@ -384,6 +384,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
return;
}
const isExternal =
(item as Song & { _serverType?: ServerType })._serverType ===
ServerType.EXTERNAL;
if (isExternal && itemType === LibraryItem.SONG) {
player.addToQueueByData([item as Song], playType, item.id);
return;
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
@@ -417,9 +426,9 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
};
}, [
enableMultiSelect,
overrides,
onColumnReordered,
onColumnResized,
overrides,
player,
setFavorite,
setRating,
@@ -5,8 +5,6 @@
height: 100%;
}
.text-container {
display: grid;
grid-template-rows: 1fr 1fr;
@@ -43,7 +41,6 @@ a.title {
width: auto;
}
.artists {
display: block;
width: 100%;
@@ -65,3 +62,4 @@ a.title {
.active {
color: var(--theme-colors-primary);
}
@@ -14,9 +14,10 @@ import {
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
@@ -54,6 +55,10 @@ export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
>
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
<ExternalSongIndicator
isExternal={item?._serverType === ServerType.EXTERNAL}
size="sm"
/>
{item.name as string}
</Text>
<div className={styles.artists}>
@@ -123,6 +128,10 @@ export const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {
{...titleLinkProps}
>
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
<ExternalSongIndicator
isExternal={song?._serverType === ServerType.EXTERNAL}
size="sm"
/>
{row.name as string}
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
<Text
@@ -23,3 +23,4 @@ a.name-container {
.active {
color: var(--theme-colors-primary);
}
@@ -12,8 +12,9 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
const TitleColumnBase = (props: ItemTableListInnerColumn) => {
const { itemType } = props;
@@ -60,6 +61,10 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
{...titleLinkProps}
>
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
<ExternalSongIndicator
isExternal={item?._serverType === ServerType.EXTERNAL}
size="sm"
/>
{row}
</Text>
</TableColumnContainer>
@@ -106,6 +111,7 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
{...titleLinkProps}
>
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
<ExternalSongIndicator isExternal={song?._serverType === ServerType.EXTERNAL} />
{row}
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
<Text
@@ -42,7 +42,6 @@ a.title {
width: auto;
}
.artists {
display: block;
width: 100%;
@@ -94,3 +93,4 @@ a.title {
.active {
color: var(--theme-colors-primary);
}
@@ -21,9 +21,10 @@ import {
} from '/@/renderer/features/shared/components/play-button-group';
import { usePlayButtonBehavior } from '/@/renderer/store';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { ExternalSongIndicator } from '/@/shared/components/external-song-indicator/external-song-indicator';
import { Icon } from '/@/shared/components/icon/icon';
import { Text } from '/@/shared/components/text/text';
import { Folder, LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { Folder, LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
@@ -143,6 +144,10 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
>
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
<ExternalSongIndicator
isExternal={item?._serverType === ServerType.EXTERNAL}
size="sm"
/>
{item.name as string}
</Text>
<div className={styles.artists}>
@@ -294,6 +299,10 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
{...titleLinkProps}
>
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
<ExternalSongIndicator
isExternal={song?._serverType === ServerType.EXTERNAL}
size="sm"
/>
{row.name as string}
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
<Text
@@ -4,13 +4,21 @@ import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import { getOptimizedListCount } from '/@/renderer/api/utils-list-count';
import { fetchMbzReleaseAsAlbum } from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
import { getMbzReleaseIdFromAlbumId } from '/@/renderer/features/musicbrainz/utils';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { AlbumDetailQuery, AlbumListQuery, ListCountQuery } from '/@/shared/types/domain-types';
export const albumQueries = {
detail: (args: QueryHookArgs<AlbumDetailQuery>) => {
return queryOptions({
queryFn: ({ signal }) => {
queryFn: async ({ signal }) => {
const mbzReleaseId = getMbzReleaseIdFromAlbumId(args.query.id);
if (mbzReleaseId !== null) {
return fetchMbzReleaseAsAlbum(mbzReleaseId);
}
return api.controller.getAlbumDetail({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
@@ -20,6 +20,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table
import { ItemControls } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
@@ -29,7 +30,7 @@ import {
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, usePlayerSong } from '/@/renderer/store';
import { useCurrentServerId, usePlayerSong } from '/@/renderer/store';
import { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
import { sentenceCase, titleCase } from '/@/renderer/utils';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
@@ -119,6 +120,13 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
const items: Array<{ id: string; value: ReactNode | string | undefined }> = [];
if (album._serverType === ServerType.EXTERNAL) {
items.push({
id: 'unavailable',
value: t('common.unavailable', { postProcess: 'sentenceCase' }),
});
}
items.push(
...releaseTypes,
{
@@ -362,9 +370,14 @@ const AlbumMetadataExternalLinks = ({
export const AlbumDetailContent = () => {
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const serverId = useCurrentServerId();
const isMbz = isMbzAlbumId(albumId);
const detailQuery = useSuspenseQuery(
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
albumQueries.detail({
query: { id: albumId },
serverId: isMbz ? 'musicbrainz' : serverId,
}),
);
const { externalLinks, lastFM, musicBrainz } = useExternalLinks();
@@ -8,6 +8,7 @@ import styles from './album-detail-header.module.css';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
LibraryHeader,
@@ -16,7 +17,7 @@ import {
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useCurrentServerId, useIntegrationsSettings, useShowRatings } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
@@ -30,13 +31,22 @@ import { Play } from '/@/shared/types/types';
export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const { albumId } = useParams() as { albumId: string };
const { t } = useTranslation();
const server = useCurrentServer();
const serverId = useCurrentServerId();
const showRatings = useShowRatings();
const isMbz = isMbzAlbumId(albumId);
const detailQuery = useQuery(
albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
albumQueries.detail({
query: { id: albumId },
serverId: isMbz ? 'musicbrainz' : serverId,
}),
);
const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL;
const { youtube: youtubeEnabled } = useIntegrationsSettings();
const showRating =
!isExternal &&
showRatings &&
(detailQuery?.data?._serverType === ServerType.NAVIDROME ||
detailQuery?.data?._serverType === ServerType.SUBSONIC);
@@ -80,8 +90,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
: undefined;
const handlePlay = (type?: Play) => {
if (!server?.id || !albumId) return;
addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
if (isExternal || !serverId || !albumId) return;
addToQueueByFetch(serverId, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
};
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
@@ -248,6 +258,7 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
/>
</Group>
<LibraryHeaderMenu
disabled={isExternal && !youtubeEnabled}
favorite={detailQuery?.data?.userFavorite}
onFavorite={handleFavorite}
onMore={handleMoreOptions}
@@ -7,6 +7,7 @@ import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/nati
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
import { isMbzAlbumId } from '/@/renderer/features/musicbrainz/utils';
import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
import {
LibraryBackgroundImage,
@@ -16,9 +17,9 @@ import { LibraryContainer } from '/@/renderer/features/shared/components/library
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useAlbumBackground, useCurrentServer } from '/@/renderer/store';
import { useAlbumBackground, useCurrentServerId, useIntegrationsSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
const AlbumDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
@@ -26,18 +27,24 @@ const AlbumDetailRoute = () => {
const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
const { albumId } = useParams() as { albumId: string };
const server = useCurrentServer();
const serverId = useCurrentServerId();
const { youtube: youtubeEnabled } = useIntegrationsSettings();
const isMbz = isMbzAlbumId(albumId);
const location = useLocation();
const detailQuery = useQuery({
...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
...albumQueries.detail({
query: { id: albumId },
serverId: isMbz ? 'musicbrainz' : serverId,
}),
placeholderData: location.state?.item,
});
const imageUrl =
useItemImageUrl({
id: detailQuery?.data?.imageId || undefined,
imageUrl: detailQuery?.data?.imageUrl || undefined,
itemType: LibraryItem.ALBUM,
type: 'itemCard',
}) || '';
@@ -52,10 +59,12 @@ const AlbumDetailRoute = () => {
const showBlurredImage = albumBackground;
if (isColorLoading) {
if (isColorLoading || (detailQuery.isLoading && !detailQuery.data)) {
return <Spinner container />;
}
const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL;
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
@@ -64,6 +73,7 @@ const AlbumDetailRoute = () => {
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
disabled={isExternal && !youtubeEnabled}
ids={[albumId]}
itemType={LibraryItem.ALBUM}
variant="default"
@@ -24,6 +24,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table
import { ItemControls } from '/@/renderer/components/item-list/types';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
import { musicbrainzQueries } from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import {
@@ -1069,21 +1070,46 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
const { t } = useTranslation();
const itemsPerRow = getItemsPerRow(cq);
const albumCount = albums.length;
const [showAll, setShowAll] = useState(false);
const player = usePlayer();
const serverId = useCurrentServerId();
const { albumCountBadge, nonExternalAlbums } = useMemo(() => {
const { external, nonExternal } = albums.reduce<{
external: typeof albums;
nonExternal: typeof albums;
}>(
(acc, album) => {
if (album._serverType === ServerType.EXTERNAL) {
acc.external.push(album);
} else {
acc.nonExternal.push(album);
}
return acc;
},
{ external: [], nonExternal: [] },
);
const originalCount = nonExternal.length;
const externalCount = external.length;
return {
albumCountBadge:
externalCount > 0 ? `${originalCount} + ${externalCount}` : String(originalCount),
nonExternalAlbums: nonExternal,
};
}, [albums]);
const displayedAlbums = showAll ? albums : albums.slice(0, MAX_SECTION_CARDS);
const hasMoreAlbums = albums.length > MAX_SECTION_CARDS;
const handlePlay = useCallback(
(playType: Play) => {
if (albums.length === 0) return;
const albumIds = albums.map((album) => album.id);
if (nonExternalAlbums.length === 0) return;
const albumIds = nonExternalAlbums.map((album) => album.id);
player.addToQueueByFetch(serverId, albumIds, LibraryItem.ALBUM, playType);
},
[albums, player, serverId],
[nonExternalAlbums, player, serverId],
);
const handlePlayNext = usePlayButtonClick({
@@ -1120,11 +1146,11 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
<TextTitle fw={700} order={3}>
{title}
</TextTitle>
<Badge variant="default">{albumCount}</Badge>
<Badge variant="default">{albumCountBadge}</Badge>
</Group>
<div className={styles.albumSectionDividerContainer}>
<div className={styles.albumSectionDivider} />
{albumCount > 0 && (
{nonExternalAlbums.length > 0 && (
<ActionIconGroup>
<PlayTooltip type={Play.NOW}>
<ActionIcon
@@ -1352,6 +1378,15 @@ interface ArtistAlbumsProps {
const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
const { t } = useTranslation();
const artistReleaseTypeItems = useArtistReleaseTypeItems();
const musicBrainzExcludeReleaseTypes = useSettingsStore(
(state) => state.integrations.musicBrainzExcludeReleaseTypes,
);
const musicbrainzAutoCountryPriority = useSettingsStore(
(state) => state.integrations.musicbrainzAutoCountryPriority,
);
const musicBrainzPrioritizeCountries = useSettingsStore(
(state) => state.integrations.musicBrainzPrioritizeCountries,
);
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
@@ -1364,16 +1399,55 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
albumArtistId?: string;
artistId?: string;
};
const serverId = useCurrentServerId();
const routeId = (artistId || albumArtistId) as string;
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: serverId,
}),
);
const musicBrainzEnabled = useSettingsStore((state) => state.integrations.musicBrainz);
const musicbrainzArtistQuery = useQuery({
...musicbrainzQueries.artist({
autoCountryPriority: musicbrainzAutoCountryPriority,
excludeReleaseTypes: musicBrainzExcludeReleaseTypes,
mbzArtistId: detailQuery.data?.mbz as string,
prioritizeCountries: musicBrainzPrioritizeCountries,
}),
enabled: Boolean(musicBrainzEnabled && detailQuery.data?.mbz),
meta: {
albumArtist: detailQuery.data,
albums: albumsQuery.data?.items || [],
autoCountryPriority: musicbrainzAutoCountryPriority,
excludeReleaseTypes: musicBrainzExcludeReleaseTypes,
prioritizeCountries: musicBrainzPrioritizeCountries,
},
});
const musicbrainzAlbums = useMemo(() => {
return musicbrainzArtistQuery.data || [];
}, [musicbrainzArtistQuery.data]);
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
const controls = useDefaultItemListControls();
const filteredAndSortedAlbums = useMemo(() => {
const albums = albumsQuery.data?.items || [];
const existingReleaseIds = new Set(
albumsQuery.data?.items?.map((item) => item.mbzId) || [],
);
const newMusicbrainzAlbums = musicbrainzAlbums.filter(
(album) => !existingReleaseIds.has(album.mbzId),
);
const albums = [...(albumsQuery.data?.items || []), ...newMusicbrainzAlbums];
const searched = searchLibraryItems(albums, debouncedSearchTerm, LibraryItem.ALBUM);
return sortAlbumList(searched, sortBy, sortOrder);
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
}, [albumsQuery.data?.items, debouncedSearchTerm, musicbrainzAlbums, sortBy, sortOrder]);
const albumsByReleaseType = useMemo(() => {
return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType);
@@ -5,8 +5,7 @@ import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCurrentServer, useCurrentServerId, useShowRatings } from '/@/renderer/store';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Rating } from '/@/shared/components/rating/rating';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ServerType } from '/@/shared/types/types';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
interface SetRatingActionProps {
ids: string[];
@@ -24,29 +24,16 @@ const getItemName = (item: unknown): string => {
return 'Item';
};
const getItemImage = (item: unknown): null | string => {
if (item && typeof item === 'object') {
if ('imageId' in item && typeof item.imageId === 'string') {
return item.imageId;
}
if ('imageUrl' in item && typeof item.imageUrl === 'string') {
return item.imageUrl;
}
}
return null;
};
export const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps) => {
const { t } = useTranslation();
const itemCount = items.length;
const firstItem = items[0];
const itemName = firstItem ? getItemName(firstItem) : 'Item';
const itemImage = firstItem ? getItemImage(firstItem) : null;
const isMultiple = itemCount > 1;
const imageUrl = useItemImageUrl({
id: (firstItem as { imageId?: string })?.imageId,
imageUrl: (firstItem as { imageUrl?: string })?.imageUrl,
itemType: itemType || LibraryItem.SONG,
serverId: (firstItem as { _serverId?: string })?._serverId,
type: 'table',
@@ -61,7 +48,7 @@ export const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps)
<div className={styles.divider} />
<div className={styles.preview}>
<div className={styles.content}>
{itemImage ? (
{imageUrl ? (
<div className={styles.imageContainer}>
<img alt={itemName} className={styles.image} src={imageUrl} />
<div className={styles.imageOverlay} />
@@ -29,18 +29,22 @@ import { TextTitle } from '/@/shared/components/text-title/text-title';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
import { ServerType, toServerType } from '/@/shared/types/types';
import {
AuthenticationResponse,
ServerListItemWithCredential,
ServerType,
} from '/@/shared/types/domain-types';
import { toServerType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const SERVER_ICONS: Record<ServerType, string> = {
const SERVER_ICONS: Record<Exclude<ServerType, ServerType.EXTERNAL>, string> = {
[ServerType.JELLYFIN]: JellyfinIcon,
[ServerType.NAVIDROME]: NavidromeIcon,
[ServerType.SUBSONIC]: SubsonicIcon,
};
const SERVER_NAMES: Record<ServerType, string> = {
const SERVER_NAMES: Record<Exclude<ServerType, ServerType.EXTERNAL>, string> = {
[ServerType.JELLYFIN]: 'Jellyfin',
[ServerType.NAVIDROME]: 'Navidrome',
[ServerType.SUBSONIC]: 'OpenSubsonic',
@@ -0,0 +1,434 @@
import { queryOptions } from '@tanstack/react-query';
import memoize from 'lodash/memoize';
import {
IArtist,
IBrowseReleasesResult,
IRelation,
IRelease,
IReleaseGroup,
IWork,
MusicBrainzApi,
} from 'musicbrainz-api';
import packageJson from '../../../../../package.json';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
collectWorksFromRelease,
getImageUrlByReleaseGroupId,
normalizeReleaseToAlbum,
} from '/@/renderer/features/musicbrainz/utils';
import { logFn } from '/@/renderer/utils/logger';
import {
Album,
AlbumArtist,
LibraryItem,
RelatedArtist,
ServerType,
} from '/@/shared/types/domain-types';
export const musicbrainzApi = new MusicBrainzApi({
appContactInfo: packageJson.homepage,
appName: packageJson.name,
appVersion: packageJson.version,
});
const CACHE_TIME = 1000 * 60 * 5;
export type IRelationWithWork = IRelation & { work?: IWork };
export type MusicBrainzArtistSelectMeta = {
albumArtist: AlbumArtist;
albums?: Album[];
autoCountryPriority?: boolean;
excludeReleaseTypes?: string[];
prioritizeCountries?: string[];
};
const artistSelect = memoize(
({
data,
meta,
}: {
data: {
artist: IArtist;
releases: IBrowseReleasesResult;
};
meta: MusicBrainzArtistSelectMeta;
}) => {
const albumArtist: RelatedArtist = {
id: meta.albumArtist.id,
imageId: meta.albumArtist.imageId,
imageUrl: meta.albumArtist.imageUrl,
name: meta.albumArtist.name,
userFavorite: meta.albumArtist.userFavorite,
userRating: meta.albumArtist.userRating,
};
const ownedMbzReleaseGroupIds = new Set<string>();
const ownedMbzReleaseIds = new Set<string>();
const counts = {
existingMbzReleaseGroupIds: 0,
existingMbzReleaseIds: 0,
};
for (const album of meta.albums || []) {
if (album.mbzReleaseGroupId) {
ownedMbzReleaseGroupIds.add(album.mbzReleaseGroupId);
counts.existingMbzReleaseGroupIds++;
}
if (album.mbzId) {
ownedMbzReleaseIds.add(album.mbzId);
counts.existingMbzReleaseIds++;
}
}
const albumArtistName = meta.albumArtist.name;
const existingReleaseGroups = new Map<string, IRelease>();
const existingReleases = new Map<string, IRelease>();
const unownedReleases = new Map<string, IRelease>();
const unownedReleaseGroups = new Map<string, IReleaseGroup>();
for (const release of data.releases.releases) {
const releaseGroup = release['release-group'];
const hasReleaseGroup = releaseGroup?.id !== undefined;
if (hasReleaseGroup && ownedMbzReleaseGroupIds.has(releaseGroup.id)) {
existingReleaseGroups.set(releaseGroup.id, release);
}
if (ownedMbzReleaseIds.has(release.id)) {
existingReleases.set(release.id, release);
}
}
for (const release of data.releases.releases) {
const releaseGroupId = release['release-group']?.id;
if (
releaseGroupId &&
!ownedMbzReleaseIds.has(release.id) &&
!ownedMbzReleaseGroupIds.has(releaseGroupId)
) {
unownedReleases.set(release.id, release);
if (releaseGroupId && release['release-group']) {
unownedReleaseGroups.set(releaseGroupId, release['release-group']);
}
} else if (!releaseGroupId && !ownedMbzReleaseIds.has(release.id)) {
console.log('adding unowned release by release id', release.id);
unownedReleases.set(release.id, release);
}
}
const excludeReleaseTypes = (meta.excludeReleaseTypes ?? []).map((t) => t.toLowerCase());
const excludeSet = new Set(excludeReleaseTypes);
let prioritizeCountries: string[];
if (meta.autoCountryPriority) {
const countryCounts = new Map<string, number>();
for (const release of data.releases.releases) {
const country = release.country?.toUpperCase();
if (country) {
countryCounts.set(country, (countryCounts.get(country) ?? 0) + 1);
}
}
prioritizeCountries = [...countryCounts.entries()]
.sort(([, a], [, b]) => b - a)
.map(([code]) => code);
} else {
prioritizeCountries = (meta.prioritizeCountries ?? []).map((c) => c.toUpperCase());
}
const releaseEntries = Array.from(unownedReleases.entries())
.filter(([, release]) => {
if (excludeSet.size === 0) return true;
const releaseGroup = release['release-group'];
const primary = releaseGroup?.['primary-type']?.toLowerCase();
const secondary =
releaseGroup?.['secondary-types']?.map((t) => t.toLowerCase()) ?? [];
const types = [primary, ...secondary].filter(Boolean) as string[];
return !types.some((t) => excludeSet.has(t));
})
.sort(([, a], [, b]) => {
if (prioritizeCountries.length === 0) return 0;
const indexA = a.country
? prioritizeCountries.indexOf(a.country.toUpperCase())
: -1;
const indexB = b.country
? prioritizeCountries.indexOf(b.country.toUpperCase())
: -1;
const posA = indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA;
const posB = indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB;
return posA - posB;
});
const seenReleaseGroupIds = new Set<string>();
const releaseEntriesUniqueByGroup = releaseEntries.filter(([, release]) => {
const releaseGroupId = release['release-group']?.id;
if (releaseGroupId == null) return true;
if (seenReleaseGroupIds.has(releaseGroupId)) return false;
seenReleaseGroupIds.add(releaseGroupId);
return true;
});
const albums: Album[] = releaseEntriesUniqueByGroup
.map(([, release]) => {
const releaseGroup = release['release-group'];
const hasArtwork = releaseGroup;
const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null;
const secondaryReleaseTypes =
releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || [];
const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter(
(type) => type !== null,
) as string[];
const isCompilation = releaseTypes.includes('compilation');
const originalDate = releaseGroup?.['first-release-date'] || null;
const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null;
const releaseDate = release.date ? release.date : null;
const releaseYear = release.date ? Number(release.date.split('-')[0]) : null;
const imageUrl = hasArtwork ? getImageUrlByReleaseGroupId(releaseGroup.id) : null;
const album: Album = {
_itemType: LibraryItem.ALBUM,
_serverId: 'musicbrainz',
_serverType: ServerType.EXTERNAL,
albumArtistName: albumArtistName,
albumArtists: [albumArtist],
artists: [],
comment: null,
createdAt: '',
duration: null,
explicitStatus: null,
genres: [],
id: `musicbrainz-${release.id}`,
imageId: null,
imageUrl: imageUrl,
isCompilation: isCompilation,
lastPlayedAt: null,
mbzId: release.id,
mbzReleaseGroupId: releaseGroup?.id || null,
name: release.title,
originalDate: originalDate,
originalYear: originalYear,
participants: {},
playCount: null,
recordLabels: [],
releaseDate: releaseDate,
releaseType: primaryReleaseType,
releaseTypes: releaseTypes,
releaseYear: releaseYear,
size: null,
songCount: null,
sortName: release.title,
tags: {},
updatedAt: '',
userFavorite: false,
userRating: null,
version: null,
};
return album;
})
.filter((album): album is Album => album !== null);
return albums;
},
);
async function fetchMbzReleasesByArtistId(mbzArtistId: string): Promise<IBrowseReleasesResult> {
const PAGE_SIZE = 100;
const includes: Array<'media' | 'release-groups'> = ['media', 'release-groups'];
// Fetch first page to get total count
const firstPage = (await musicbrainzApi.browse(
'release',
{
artist: mbzArtistId,
limit: PAGE_SIZE,
offset: 0,
},
includes,
)) as unknown as IBrowseReleasesResult;
const totalCount = firstPage['release-count'];
const allReleases = [...firstPage.releases];
if (allReleases.length >= totalCount) {
return firstPage;
}
const remainingCount = totalCount - allReleases.length;
const numberOfPages = Math.ceil(remainingCount / PAGE_SIZE);
const pagePromises = Array.from({ length: numberOfPages }, (_, i) => {
const offset = (i + 1) * PAGE_SIZE;
return musicbrainzApi.browse(
'release',
{
artist: mbzArtistId,
limit: PAGE_SIZE,
offset: offset,
},
includes,
) as unknown as Promise<IBrowseReleasesResult>;
});
const remainingPages = await Promise.all(pagePromises);
for (const page of remainingPages) {
allReleases.push(...page.releases);
}
return {
'release-count': totalCount,
'release-offset': 0,
releases: allReleases,
};
}
const RELEASE_INCLUDES: Array<
| 'artist-credits'
| 'artists'
| 'media'
| 'recording-level-rels'
| 'recordings'
| 'release-groups'
> = ['artist-credits', 'artists', 'media', 'recording-level-rels', 'recordings', 'release-groups'];
const EMPTY_BROWSE_RELEASES: IBrowseReleasesResult = {
'release-count': 0,
'release-offset': 0,
releases: [],
};
export const musicbrainzQueries = {
artist: (args: {
autoCountryPriority?: boolean;
excludeReleaseTypes?: string[];
mbzArtistId: string;
prioritizeCountries?: string[];
}) => {
const config = {
autoCountryPriority: args.autoCountryPriority ?? false,
excludeReleaseTypes: args.excludeReleaseTypes ?? [],
prioritizeCountries: args.prioritizeCountries ?? [],
};
return queryOptions({
gcTime: CACHE_TIME,
queryFn: async ({ meta }) => {
try {
const artist = await musicbrainzApi.lookup('artist', args.mbzArtistId);
const releases = await fetchMbzReleasesByArtistId(args.mbzArtistId);
logFn.debug('MusicBrainz artist lookup API queried', {
meta: { artistId: args.mbzArtistId, releases },
});
return {
data: { artist, releases },
meta: meta as MusicBrainzArtistSelectMeta,
};
} catch (error) {
logFn.warn('MusicBrainz artist lookup failed', {
meta: { artistId: args.mbzArtistId, error },
});
return {
data: {
artist: {} as IArtist,
releases: EMPTY_BROWSE_RELEASES,
},
meta: meta as MusicBrainzArtistSelectMeta,
};
}
},
queryKey: queryKeys.musicbrainz.artist(undefined, args.mbzArtistId, config),
select: artistSelect,
staleTime: CACHE_TIME,
});
},
release: (args: { releaseId: string }) =>
queryOptions({
gcTime: CACHE_TIME,
queryFn: async () => {
try {
const mbzRelease = await musicbrainzApi.lookup(
'release',
args.releaseId,
RELEASE_INCLUDES,
);
const release = normalizeReleaseToAlbum(mbzRelease);
const works = collectWorksFromRelease(mbzRelease);
logFn.debug('MusicBrainz release lookup API queried', {
meta: { release, releaseId: args.releaseId },
});
return { release, works };
} catch (error) {
logFn.warn('MusicBrainz release lookup failed', {
meta: { error, releaseId: args.releaseId },
});
return { release: null, works: [] };
}
},
queryKey: queryKeys.musicbrainz.release(args.releaseId),
staleTime: CACHE_TIME,
}),
};
export const MUSICBRAINZ_ID_PREFIX = 'musicbrainz-';
export async function fetchMbzReleaseAsAlbum(releaseId: string): Promise<Album> {
try {
const mbzRelease = await musicbrainzApi.lookup('release', releaseId, RELEASE_INCLUDES);
return normalizeReleaseToAlbum(mbzRelease);
} catch (error) {
logFn.warn('MusicBrainz release fetch failed', { meta: { error, releaseId } });
return createEmptyMbzAlbum(releaseId);
}
}
function createEmptyMbzAlbum(releaseId: string): Album {
return {
_itemType: LibraryItem.ALBUM,
_serverId: 'musicbrainz',
_serverType: ServerType.EXTERNAL,
albumArtistName: '',
albumArtists: [],
artists: [],
comment: null,
createdAt: '',
duration: null,
explicitStatus: null,
genres: [],
id: `musicbrainz-${releaseId}`,
imageId: null,
imageUrl: null,
isCompilation: null,
lastPlayedAt: null,
mbzId: releaseId,
mbzReleaseGroupId: null,
name: '',
originalDate: null,
originalYear: null,
participants: {},
playCount: null,
recordLabels: [],
releaseDate: null,
releaseType: null,
releaseTypes: [],
releaseYear: null,
size: null,
songCount: null,
sortName: '',
tags: {},
updatedAt: '',
userFavorite: false,
userRating: null,
version: null,
};
}
@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query';
import { logFn } from '/@/renderer/utils/logger';
async function searchYoutube(query: string): Promise<Array<{ type: string; videoId?: string }>> {
if (typeof window !== 'undefined' && window.api?.youtube) {
return window.api.youtube.search(query);
}
return [];
}
export const youtubeQueries = {
search: (args: { query: string }) => {
return queryOptions({
gcTime: 1000 * 60 * 5,
queryFn: async () => {
const results = await searchYoutube(args.query);
logFn.debug('Youtube API queried', { meta: { query: args.query, results } });
return results;
},
queryKey: ['youtube', 'search', args.query],
staleTime: 1000 * 60 * 5,
});
},
};
+224
View File
@@ -0,0 +1,224 @@
import { IArtist, IMedium, IRelease, ITrack, IWork } from 'musicbrainz-api';
import {
IRelationWithWork,
MUSICBRAINZ_ID_PREFIX,
} from '/@/renderer/features/musicbrainz/api/musicbrainz-api';
import { Album, LibraryItem, RelatedArtist, ServerType, Song } from '/@/shared/types/domain-types';
export function collectWorksFromRelease(release: IRelease): IWork[] {
const works: IWork[] = [];
const seenIds = new Set<string>();
for (const medium of release.media ?? []) {
for (const track of medium.tracks ?? []) {
const recording = track.recording;
const relations = (recording as { relations?: IRelationWithWork[] })?.relations ?? [];
for (const rel of relations) {
const work = (rel as IRelationWithWork).work;
if (work?.id && !seenIds.has(work.id)) {
seenIds.add(work.id);
works.push(work);
}
}
}
}
return works;
}
export function getImageUrl(releaseId: string): string {
return `https://coverartarchive.org/release/${releaseId}/front-250.jpg`;
}
export function getImageUrlByReleaseGroupId(releaseGroupId: string): string {
return `https://coverartarchive.org/release-group/${releaseGroupId}/front-250.jpg`;
}
export function getMbzReleaseIdFromAlbumId(albumId: string): null | string {
if (!albumId.startsWith(MUSICBRAINZ_ID_PREFIX)) return null;
return albumId.slice(MUSICBRAINZ_ID_PREFIX.length);
}
export function isMbzAlbumId(albumId: string): boolean {
return albumId.startsWith(MUSICBRAINZ_ID_PREFIX);
}
export function normalizeReleaseToAlbum(release: IRelease): Album {
const releaseGroup = release['release-group'];
const artistCredit = release['artist-credit'] ?? releaseGroup?.['artist-credit'] ?? [];
const albumArtistName = artistCredit
.map((entry) => `${entry.name}${entry.joinphrase ?? ''}`)
.join(' ');
const albumArtists: RelatedArtist[] = (artistCredit as { artist: IArtist; name: string }[]).map(
(ac) => ({
id: `musicbrainz-${ac.artist.id}`,
imageId: null,
imageUrl: null,
name: ac.name || ac.artist.name,
userFavorite: false,
userRating: null,
}),
);
const hasArtwork = releaseGroup;
const primaryReleaseType = releaseGroup?.['primary-type']?.toLowerCase() || null;
const secondaryReleaseTypes =
releaseGroup?.['secondary-types']?.map((type) => type.toLowerCase()) || [];
const releaseTypes = [primaryReleaseType, ...secondaryReleaseTypes].filter(
(type) => type !== null,
) as string[];
const isCompilation = releaseTypes.includes('compilation');
const originalDate = releaseGroup?.['first-release-date'] || null;
const originalYear = originalDate ? Number(originalDate.split('-')[0]) : null;
const releaseDate = release.date ? release.date : null;
const releaseYear = release.date ? Number(release.date.split('-')[0]) : null;
const imageUrl = hasArtwork ? getImageUrlByReleaseGroupId(releaseGroup.id) : null;
const albumId = `musicbrainz-${release.id}`;
const songs: Song[] = [];
for (const medium of release.media ?? []) {
for (const track of medium.tracks ?? []) {
songs.push(
normalizeRecordingToSong(
release,
medium,
track,
albumArtistName,
albumArtists,
albumId,
imageUrl,
releaseDate,
releaseYear,
),
);
}
}
const totalDuration = songs.reduce((sum, s) => sum + s.duration, 0);
return {
_itemType: LibraryItem.ALBUM,
_serverId: 'musicbrainz',
_serverType: ServerType.EXTERNAL,
albumArtistName,
albumArtists,
artists: [],
comment: null,
createdAt: '',
duration: totalDuration || null,
explicitStatus: null,
genres: [],
id: albumId,
imageId: null,
imageUrl,
isCompilation,
lastPlayedAt: null,
mbzId: release.id,
mbzReleaseGroupId: releaseGroup?.id || null,
name: release.title,
originalDate,
originalYear,
participants: {},
playCount: null,
recordLabels: [],
releaseDate,
releaseType: primaryReleaseType,
releaseTypes,
releaseYear,
size: null,
songCount: songs.length,
songs,
sortName: release.title,
tags: {},
updatedAt: '',
userFavorite: false,
userRating: null,
version: null,
};
}
function normalizeArtistCreditToRelatedArtists(
artistCredit: Array<{ artist: IArtist; name: string }>,
): RelatedArtist[] {
return artistCredit.map((ac) => ({
id: `musicbrainz-${ac.artist.id}`,
imageId: null,
imageUrl: null,
name: ac.name || ac.artist.name,
userFavorite: false,
userRating: null,
}));
}
function normalizeRecordingToSong(
release: IRelease,
medium: IMedium,
track: ITrack,
albumArtistName: string,
albumArtists: RelatedArtist[],
albumId: string,
imageUrl: null | string,
releaseDate: null | string,
releaseYear: null | number,
): Song {
const recording = track.recording;
const trackArtistCredit = track['artist-credit'] ?? recording['artist-credit'] ?? [];
const artistName = trackArtistCredit
.map((entry) => `${entry.name}${entry.joinphrase ?? ''}`)
.join(' ');
const artists = normalizeArtistCreditToRelatedArtists(
trackArtistCredit as Array<{ artist: IArtist; name: string }>,
);
const durationMilliseconds = track.length || recording.length || 0;
const trackNumber = track.position || parseInt(track.number, 10) || 0;
return {
_itemType: LibraryItem.SONG,
_serverId: 'musicbrainz',
_serverType: ServerType.EXTERNAL,
album: release.title,
albumArtistName,
albumArtists,
albumId,
artistName,
artists,
bitDepth: null,
bitRate: 0,
bpm: null,
channels: null,
comment: null,
compilation: null,
container: null,
createdAt: '',
discNumber: medium.position || 1,
discSubtitle: medium.title || null,
duration: durationMilliseconds,
explicitStatus: null,
gain: null,
genres: [],
id: `musicbrainz-${release.id}-${recording.id}-${track.position}-${track.number}`,
imageId: null,
imageUrl,
lastPlayedAt: null,
lyrics: null,
mbzRecordingId: recording.id,
mbzTrackId: track.id,
name: track.title || recording.title,
participants: {},
path: null,
peak: null,
playCount: 0,
releaseDate,
releaseYear,
sampleRate: null,
size: 0,
sortName: track.title || recording.title,
tags: null,
trackNumber,
trackSubtitle: null,
updatedAt: '',
userFavorite: false,
userRating: null,
};
}
@@ -237,7 +237,6 @@ const EmptyQueueDropZone = () => {
const sourceServerId = (
args.source.item?.[0] as unknown as { _serverId: string }
)?._serverId;
const sourceItemType = args.source.itemType as LibraryItem;
switch (args.source.type) {
@@ -297,7 +296,7 @@ const EmptyQueueDropZone = () => {
const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
if (folderIds.length > 0 && sourceServerId) {
playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
@@ -21,8 +21,10 @@ import { PlayerStatus } from '/@/shared/types/types';
export interface MpvPlayerEngineHandle extends AudioPlayer {}
interface MpvPlayerEngineProps {
currentSongUrl: string | undefined;
isMuted: boolean;
isTransitioning: boolean;
nextSongUrl: string | undefined;
onEnded: () => void;
onProgress: (e: PlayerOnProgressProps) => void;
playerRef: RefObject<MpvPlayerEngineHandle | null>;
@@ -39,8 +41,10 @@ const PROGRESS_UPDATE_INTERVAL = 250;
export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const {
currentSongUrl: currentSongUrlProp,
isMuted,
isTransitioning,
nextSongUrl: nextSongUrlProp,
onEnded,
onProgress,
playerRef,
@@ -56,6 +60,11 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const isInitializedRef = useRef<boolean>(false);
const hasPopulatedQueueRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const currentSongUrlRef = useRef<string | undefined>(currentSongUrlProp);
const nextSongUrlRef = useRef<string | undefined>(nextSongUrlProp);
currentSongUrlRef.current = currentSongUrlProp;
nextSongUrlRef.current = nextSongUrlProp;
const { mpvAudioDeviceId, transcode } = usePlaybackSettings();
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
@@ -124,15 +133,17 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (!radioState.currentStreamUrl) {
const playerData = usePlayerStore.getState().getPlayerData();
const currentSongUrl = playerData.currentSong
? getSongUrl(playerData.currentSong, transcode)
: undefined;
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
: undefined;
const currentResolved =
currentSongUrlProp ??
(playerData.currentSong
? getSongUrl(playerData.currentSong, transcode)
: undefined);
const nextResolved =
nextSongUrlProp ??
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true);
if (currentResolved && !hasPopulatedQueueRef.current && mpvPlayer) {
mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, true);
hasPopulatedQueueRef.current = true;
}
}
@@ -157,6 +168,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mpvExtraParameters, mpvProperties, mpvAudioDeviceId, reloadTrigger]);
// Sync queue when current/next song URLs change (e.g. user selects song, or external URL resolves from useSongUrl)
useEffect(() => {
if (!mpvPlayer || !isInitializedRef.current) {
return;
}
const radioState = useRadioStore.getState();
if (radioState.currentStreamUrl) {
return;
}
const playerData = usePlayerStore.getState().getPlayerData();
const currentResolved =
currentSongUrlProp ??
(playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined);
const nextResolved =
nextSongUrlProp ??
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
if (currentResolved) {
mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, false);
}
}, [currentSongUrlProp, nextSongUrlProp, currentSong?.id, currentSong?._uniqueId, transcode]);
// Update volume
useEffect(() => {
if (!mpvPlayer) {
@@ -257,7 +292,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const handleOnAutoNext = () => {
mediaAutoNext();
handleMpvAutoNext(transcode);
handleMpvAutoNext(transcode, nextSongUrlRef.current);
};
mpvPlayerListener.rendererAutoNext(handleOnAutoNext);
@@ -270,10 +305,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
usePlayerEvents(
{
onMediaNext: () => {
replaceMpvQueue(transcode);
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
},
onMediaPrev: () => {
replaceMpvQueue(transcode);
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
},
onNextSongInsertion: (song) => {
const radioState = useRadioStore.getState();
@@ -282,11 +317,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
return;
}
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
const nextSongUrl =
nextSongUrlRef.current ?? (song ? getSongUrl(song, transcode) : undefined);
mpvPlayer?.setQueueNext(nextSongUrl);
},
onPlayerPlay: () => {
replaceMpvQueue(transcode);
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
},
onQueueCleared: () => {},
},
@@ -337,24 +373,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
function handleMpvAutoNext(transcode: {
bitrate?: number | undefined;
enabled: boolean;
format?: string | undefined;
}) {
function handleMpvAutoNext(
transcode: {
bitrate?: number | undefined;
enabled: boolean;
format?: string | undefined;
},
nextUrlOverride?: string,
) {
const playerData = usePlayerStore.getState().getPlayerData();
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
: undefined;
const nextSongUrl =
nextUrlOverride ??
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
mpvPlayer?.autoNext(nextSongUrl);
}
function replaceMpvQueue(transcode: {
bitrate?: number | undefined;
enabled: boolean;
format?: string | undefined;
}) {
// Don't override queue if radio is active
function replaceMpvQueue(
transcode: {
bitrate?: number | undefined;
enabled: boolean;
format?: string | undefined;
},
currentUrlOverride?: string,
nextUrlOverride?: string,
) {
const radioState = useRadioStore.getState();
if (radioState.currentStreamUrl) {
@@ -362,11 +404,14 @@ function replaceMpvQueue(transcode: {
}
const playerData = usePlayerStore.getState().getPlayerData();
const currentSongUrl = playerData.currentSong
? getSongUrl(playerData.currentSong, transcode)
: undefined;
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
: undefined;
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
const currentSongUrl =
currentUrlOverride ??
(playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined);
const nextSongUrl =
nextUrlOverride ??
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
if (currentSongUrl) {
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl ?? currentSongUrl, false);
}
}
@@ -10,14 +10,17 @@ import { logMsg } from '/@/renderer/utils/logger-message';
import { PlayerStatus } from '/@/shared/types/types';
export interface WebPlayerEngineHandle extends AudioPlayer {
player1(): {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
player2(): {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
player1(): WebPlayerEnginePlayerHandle;
player2(): WebPlayerEnginePlayerHandle;
}
export interface WebPlayerEnginePlayerHandle {
getCurrentTime: () => number;
getDuration: () => number;
pause: () => void;
play: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
}
interface WebPlayerEngineProps {
@@ -39,6 +42,70 @@ interface WebPlayerEngineProps {
volume: number;
}
interface YouTubePlayer {
getCurrentTime?: () => number;
getDuration?: () => number;
pauseVideo?: () => void;
playVideo?: () => void;
}
function getInternalCurrentTime(ref: null | ReactPlayer): number {
const internal = ref?.getInternalPlayer();
if (!internal) return 0;
if (internal instanceof HTMLMediaElement) {
return (internal as HTMLMediaElement).currentTime ?? 0;
}
if (isYouTubePlayer(internal) && typeof internal.getCurrentTime === 'function') {
return internal.getCurrentTime() ?? 0;
}
return 0;
}
function getInternalDuration(ref: null | ReactPlayer): number {
const internal = ref?.getInternalPlayer();
if (!internal) return 0;
if (internal instanceof HTMLMediaElement) {
return (internal as HTMLMediaElement).duration ?? 0;
}
if (isYouTubePlayer(internal) && typeof internal.getDuration === 'function') {
return internal.getDuration() ?? 0;
}
return 0;
}
function isYouTubePlayer(internal: unknown): internal is YouTubePlayer {
return (
typeof internal === 'object' &&
internal !== null &&
'playVideo' in internal &&
typeof (internal as YouTubePlayer).playVideo === 'function'
);
}
function pauseInternalPlayer(ref: null | ReactPlayer): void {
const internal = ref?.getInternalPlayer();
if (!internal) return;
if (internal instanceof HTMLMediaElement) {
(internal as HTMLMediaElement).pause();
return;
}
if (isYouTubePlayer(internal)) {
internal.pauseVideo?.();
}
}
function playInternalPlayer(ref: null | ReactPlayer): void {
const internal = ref?.getInternalPlayer();
if (!internal) return;
if (internal instanceof HTMLMediaElement) {
void (internal as HTMLMediaElement).play().catch(() => {});
return;
}
if (isYouTubePlayer(internal)) {
internal.playVideo?.();
}
}
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
@@ -108,25 +175,33 @@ export const WebPlayerEngine = (props: WebPlayerEngineProps) => {
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
},
pause() {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
pauseInternalPlayer(player1Ref.current);
pauseInternalPlayer(player2Ref.current);
},
play() {
if (playerNum === 1) {
player1Ref.current?.getInternalPlayer()?.play();
playInternalPlayer(player1Ref.current);
} else {
player2Ref.current?.getInternalPlayer()?.play();
playInternalPlayer(player2Ref.current);
}
},
player1() {
player1(): WebPlayerEnginePlayerHandle {
return {
ref: player1Ref?.current,
getCurrentTime: () => getInternalCurrentTime(player1Ref.current),
getDuration: () => getInternalDuration(player1Ref.current),
pause: () => pauseInternalPlayer(player1Ref.current),
play: () => playInternalPlayer(player1Ref.current),
ref: player1Ref?.current ?? null,
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
};
},
player2() {
player2(): WebPlayerEnginePlayerHandle {
return {
ref: player2Ref?.current,
getCurrentTime: () => getInternalCurrentTime(player2Ref.current),
getDuration: () => getInternalDuration(player2Ref.current),
pause: () => pauseInternalPlayer(player2Ref.current),
play: () => playInternalPlayer(player2Ref.current),
ref: player2Ref?.current ?? null,
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
};
},
@@ -1,8 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { api } from '/@/renderer/api';
import { TranscodingConfig } from '/@/renderer/store';
import { QueueSong } from '/@/shared/types/domain-types';
import { youtubeQueries } from '/@/renderer/features/musicbrainz/api/youtube-api';
import { TranscodingConfig, useSettingsStore } from '/@/renderer/store';
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
const YOUTUBE_WATCH_BASE = 'https://www.youtube.com/watch?v=';
export function useSongUrl(
song: QueueSong | undefined,
@@ -11,10 +15,38 @@ export function useSongUrl(
): string | undefined {
const prior = useRef(['', '']);
const isExternal = song?._serverType === ServerType.EXTERNAL;
const youtubeEnabled = useSettingsStore((state) => state.integrations.youtube);
const searchQuery =
song && isExternal ? buildYoutubeSearchQuery(song.name, song.artistName) : '';
const youtubeSearch = useQuery({
...youtubeQueries.search({ query: searchQuery }),
enabled: Boolean(song && isExternal && searchQuery && youtubeEnabled),
});
const externalUrl = useMemo(() => {
if (!song || !isExternal) return undefined;
if (current && prior.current[0] === song._uniqueId && prior.current[1]) {
return prior.current[1];
}
const url = getYoutubeUrlFromSearchResults(youtubeSearch.data);
if (url) prior.current = [song._uniqueId, url];
return url;
}, [song, isExternal, current, youtubeSearch.data]);
return useMemo(() => {
if (song?._serverId) {
// If we are the current track, we do not want a transcoding
// reconfiguration to force a restart.
if (!song) {
prior.current = ['', ''];
return undefined;
}
if (isExternal) {
return externalUrl;
}
if (song._serverId) {
if (current && prior.current[0] === song._uniqueId) {
return prior.current[1];
}
@@ -29,18 +61,16 @@ export function useSongUrl(
},
});
// transcoding enabled; save the updated result
prior.current = [song._uniqueId, url];
return url;
}
// no track; clear result
prior.current = ['', ''];
return undefined;
}, [
song?._serverId,
song?._uniqueId,
song?.id,
song,
isExternal,
externalUrl,
current,
transcode.bitrate,
transcode.format,
@@ -48,7 +78,31 @@ export function useSongUrl(
]);
}
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
function buildYoutubeSearchQuery(
title: string | undefined,
artistName: string | undefined,
): string {
const t = (title ?? '').trim();
const a = (artistName ?? '').trim();
if (t && a) return `${a} - ${t}`;
return t || a || '';
}
function getYoutubeUrlFromSearchResults(
results: Array<{ type: string; videoId?: string }> | undefined,
): string | undefined {
if (!results?.length) return undefined;
const first = results.find((r) => r.type === 'SONG' || r.type === 'VIDEO');
return first && 'videoId' in first && first.videoId
? `${YOUTUBE_WATCH_BASE}${first.videoId}`
: undefined;
}
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig): string => {
if (song._serverType === ServerType.EXTERNAL) {
return '';
}
return api.controller.getStreamUrl({
apiClientProps: { serverId: song._serverId },
query: {
@@ -59,3 +113,33 @@ export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
},
});
};
export async function getSongUrlAsync(
song: QueueSong | undefined,
transcode: TranscodingConfig,
): Promise<string | undefined> {
if (!song) {
return undefined;
}
if (song._serverType === ServerType.EXTERNAL) {
const youtubeEnabled = useSettingsStore.getState().integrations?.youtube ?? true;
if (!youtubeEnabled || typeof window === 'undefined' || !window.api?.youtube) {
return undefined;
}
const searchQuery = buildYoutubeSearchQuery(song.name, song.artistName);
if (!searchQuery) {
return undefined;
}
try {
const results = await window.api.youtube.search(searchQuery);
console.log('results', results);
return getYoutubeUrlFromSearchResults(results);
} catch {
return undefined;
}
}
const url = getSongUrl(song, transcode);
return url || undefined;
}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
usePlaybackSettings,
@@ -23,12 +24,15 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
export function MpvPlayer() {
const playerRef = useRef<MpvPlayerEngineHandle>(null);
const { currentSong, status } = usePlayerData();
const { currentSong, nextSong, status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { speed } = usePlayerProperties();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
const { audioFadeOnStatusChange } = usePlaybackSettings();
const { audioFadeOnStatusChange, transcode } = usePlaybackSettings();
const currentSongUrl = useSongUrl(currentSong, true, transcode);
const nextSongUrl = useSongUrl(nextSong, false, transcode);
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
const [isTransitioning, setIsTransitioning] = useState(false);
@@ -174,8 +178,10 @@ export function MpvPlayer() {
return (
<MpvPlayerEngine
currentSongUrl={currentSongUrl}
isMuted={isMuted}
isTransitioning={isTransitioning}
nextSongUrl={nextSongUrl}
onEnded={handleOnEnded}
onProgress={onProgress}
playerRef={playerRef}
@@ -46,6 +46,26 @@ export function WebPlayer() {
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(null);
// `react-player` may swap its underlying internal player when switching URLs
// (e.g. file/http streams => HTMLMediaElement, YouTube => iframe player). A
// MediaElementAudioSourceNode is permanently bound to a specific element, so we
// must recreate the node when the element changes (or disconnect when it stops
// being a media element).
const player1InternalRef = useRef<HTMLMediaElement | null>(null);
const player2InternalRef = useRef<HTMLMediaElement | null>(null);
const player1SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const player2SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const player1ConnectInFlightRef = useRef<null | Promise<void>>(null);
const player2ConnectInFlightRef = useRef<null | Promise<void>>(null);
useEffect(() => {
player1SourceRef.current = player1Source;
}, [player1Source]);
useEffect(() => {
player2SourceRef.current = player2Source;
}, [player2Source]);
const fadeAndSetStatus = useCallback(
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
// Cancel any in-progress fade
@@ -106,7 +126,7 @@ export function WebPlayer() {
currentPlayer: playerRef.current.player1(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
duration: getDuration(playerRef.current.player1()),
hasNextSong: Boolean(player2),
isTransitioning,
nextPlayer: playerRef.current.player2(),
@@ -118,7 +138,7 @@ export function WebPlayer() {
case PlayerStyle.GAPLESS:
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
duration: getDuration(playerRef.current.player1()),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player2(),
@@ -144,7 +164,7 @@ export function WebPlayer() {
currentPlayer: playerRef.current.player2(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
duration: getDuration(playerRef.current.player2()),
hasNextSong: Boolean(player1),
isTransitioning,
nextPlayer: playerRef.current.player1(),
@@ -156,7 +176,7 @@ export function WebPlayer() {
case PlayerStyle.GAPLESS:
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
duration: getDuration(playerRef.current.player2()),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player1(),
@@ -175,7 +195,7 @@ export function WebPlayer() {
});
promise.then(() => {
playerRef.current?.player1()?.ref?.getInternalPlayer().pause();
playerRef.current?.player1()?.pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
@@ -188,7 +208,7 @@ export function WebPlayer() {
});
promise.then(() => {
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
playerRef.current?.player2()?.pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
@@ -213,11 +233,11 @@ export function WebPlayer() {
if (num === 1) {
playerRef.current?.player1()?.setVolume(volume);
playerRef.current?.player2()?.setVolume(0);
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player2()?.pause();
} else {
playerRef.current?.player2()?.setVolume(volume);
playerRef.current?.player1()?.setVolume(0);
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player1()?.pause();
}
}
@@ -241,11 +261,11 @@ export function WebPlayer() {
if (num === 1) {
playerRef.current?.player1()?.setVolume(volume);
playerRef.current?.player2()?.setVolume(0);
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player2()?.pause();
} else {
playerRef.current?.player2()?.setVolume(volume);
playerRef.current?.player1()?.setVolume(0);
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
playerRef.current?.player1()?.pause();
}
}
@@ -294,14 +314,12 @@ export function WebPlayer() {
const interval = setInterval(() => {
const activePlayer =
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
const internalPlayer =
activePlayer?.ref?.getInternalPlayer() as HTMLAudioElement | null;
if (!internalPlayer) {
if (!activePlayer) {
return;
}
const currentTime = internalPlayer.currentTime;
const currentTime = activePlayer.getCurrentTime();
if (
transitionType === PlayerStyle.CROSSFADE ||
@@ -400,46 +418,110 @@ export function WebPlayer() {
const player1Url = useSongUrl(player1, num === 1, transcode);
const player2Url = useSongUrl(player2, num === 2, transcode);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio || player1Source) return;
if (player1Url) {
// This should fire once, only if the source is real (meaning we
// saw the dummy source) and the context is not ready
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
const disconnectPlayerSource = useCallback(
(playerNum: 1 | 2) => {
const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef;
const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source;
const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef;
if (sourceRef.current) {
try {
sourceRef.current.disconnect();
} catch {
// ignore
}
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (internal) {
const { context, gains } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gains[0]);
setPlayer1Source(source);
}
sourceRef.current = null;
internalRef.current = null;
setSource(null);
},
[player1Source, player1Url, webAudio],
[setPlayer1Source, setPlayer2Source],
);
const connectPlayerToWebAudio = useCallback(
async (playerNum: 1 | 2, player: ReactPlayer) => {
if (!webAudio) return;
const inFlightRef =
playerNum === 1 ? player1ConnectInFlightRef : player2ConnectInFlightRef;
if (inFlightRef.current) {
await inFlightRef.current;
return;
}
const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef;
const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef;
const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source;
const gain = webAudio.gains[playerNum === 1 ? 0 : 1];
const task = (async () => {
const internal = player.getInternalPlayer() as unknown;
// YouTube (and some other sources) are not HTMLMediaElements, so WebAudio
// can't attach; ensure we drop any stale node from a prior media element.
if (!(internal instanceof HTMLMediaElement)) {
disconnectPlayerSource(playerNum);
return;
}
if (webAudio.context.state !== 'running') {
try {
await webAudio.context.resume();
} catch {
// ignore resume failures; we'll try again on next ready
}
}
// If the internal media element changed, we must recreate the source node.
if (internalRef.current === internal && sourceRef.current) {
return;
}
if (sourceRef.current) {
try {
sourceRef.current.disconnect();
} catch {
// ignore
}
}
internalRef.current = internal;
try {
const source = webAudio.context.createMediaElementSource(internal);
source.connect(gain);
sourceRef.current = source;
setSource(source);
} catch (error) {
// Most commonly: trying to create another MediaElementSourceNode for the
// same element, or attempting to attach a tainted/cross-origin element.
console.error('Error connecting WebAudio source', { error, playerNum });
disconnectPlayerSource(playerNum);
}
})();
inFlightRef.current = task.finally(() => {
inFlightRef.current = null;
});
await inFlightRef.current;
},
[disconnectPlayerSource, webAudio],
);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
await connectPlayerToWebAudio(1, player);
},
[connectPlayerToWebAudio],
);
const handlePlayer2Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio || player2Source) return;
if (player2Url) {
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (internal) {
const { context, gains } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gains[1]);
setPlayer2Source(source);
}
await connectPlayerToWebAudio(2, player);
},
[player2Source, player2Url, webAudio],
[connectPlayerToWebAudio],
);
return (
@@ -468,6 +550,7 @@ function crossfadeHandler(args: {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: {
pause: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
@@ -477,6 +560,8 @@ function crossfadeHandler(args: {
hasNextSong: boolean;
isTransitioning: boolean | string;
nextPlayer: {
pause: () => void;
play: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
@@ -504,7 +589,7 @@ function crossfadeHandler(args: {
if (!hasNextSong) {
currentPlayer.setVolume(volume);
nextPlayer.setVolume(0);
nextPlayer.ref?.getInternalPlayer()?.pause();
nextPlayer.pause();
if (isTransitioning) {
setIsTransitioning(false);
@@ -516,7 +601,7 @@ function crossfadeHandler(args: {
if (!isTransitioning) {
if (duration > 0 && currentTime > duration - crossfadeDuration) {
nextPlayer.setVolume(0);
nextPlayer.ref?.getInternalPlayer().play();
nextPlayer.play();
return setIsTransitioning(player);
}
@@ -586,6 +671,7 @@ function gaplessHandler(args: {
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
play: () => void;
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
};
@@ -604,10 +690,8 @@ function gaplessHandler(args: {
const durationPadding = getDurationPadding(isFlac);
if (currentTime + durationPadding >= duration) {
return nextPlayer.ref
?.getInternalPlayer()
?.play()
.catch(() => {});
nextPlayer.play();
return;
}
return null;
@@ -647,8 +731,14 @@ function getCrossfadeEasing(style: CrossfadeStyle): {
}
}
function getDuration(ref: null | ReactPlayer | undefined) {
return ref?.getInternalPlayer()?.duration || 0;
function getDuration(
player:
| undefined
| {
getDuration: () => number;
},
) {
return player?.getDuration?.() ?? 0;
}
function getDurationPadding(isFlac: boolean) {
@@ -94,6 +94,7 @@ export const FullScreenPlayerImage = () => {
const currentImageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: currentSong?._serverId,
type: 'fullScreenPlayer',
@@ -101,6 +102,7 @@ export const FullScreenPlayerImage = () => {
const nextImageUrl = useItemImageUrl({
id: nextSong?.imageId || undefined,
imageUrl: nextSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: nextSong?._serverId,
type: 'fullScreenPlayer',
@@ -83,13 +83,17 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentImageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: currentSong?._serverId,
type: 'itemCard',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.imageId || undefined,
imageUrl: nextSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: nextSong?._serverId,
type: 'itemCard',
});
@@ -140,6 +140,7 @@ export const LeftControls = () => {
id={currentSong?.imageId}
itemType={LibraryItem.SONG}
serverId={currentSong?._serverId}
src={currentSong?.imageUrl}
type="table"
/>
)}
@@ -102,14 +102,18 @@ export const MobileFullscreenPlayerAlbumArt = () => {
const currentImageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: currentSong?._serverId,
size: mainImageDimensions.idealSize,
type: 'fullScreenPlayer',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.imageId || undefined,
imageUrl: nextSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: nextSong?._serverId,
size: mainImageDimensions.idealSize,
type: 'fullScreenPlayer',
});
@@ -81,13 +81,17 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
const currentImageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: currentSong?._serverId,
type: 'itemCard',
});
const nextImageUrl = useItemImageUrl({
id: nextSong?.imageId || undefined,
imageUrl: nextSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: nextSong?._serverId,
type: 'itemCard',
});
@@ -310,6 +314,7 @@ const MobilePlayerContainer = memo(
id: currentSong?.imageId || undefined,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: currentSong?._serverId,
type: 'itemCard',
});
const { background } = useFastAverageColor({
@@ -98,6 +98,8 @@ export const MobilePlayerbar = () => {
fetchPriority="high"
id={currentSong.imageId}
itemType={LibraryItem.SONG}
serverId={currentSong?._serverId}
src={currentSong?.imageUrl}
type="table"
/>
</Tooltip>
@@ -890,6 +890,9 @@ export const usePlayer = () => {
* @param args - The arguments to use to fetch the data
* @returns The songs to add to the queue
*/
const EXTERNAL_SERVER_ID = 'musicbrainz';
export async function fetchSongsByItemType(
queryClient: QueryClient,
serverId: string,
@@ -901,6 +904,23 @@ export async function fetchSongsByItemType(
) {
const songs: Song[] = [];
if (serverId === EXTERNAL_SERVER_ID) {
if (args.itemType === LibraryItem.ALBUM) {
for (const albumId of args.id) {
const album = await queryClient.fetchQuery(
albumQueries.detail({
query: { id: albumId },
serverId: EXTERNAL_SERVER_ID,
}),
);
songs.push(...(album?.songs ?? []));
}
return songs;
}
return songs;
}
switch (args.itemType) {
case LibraryItem.ALBUM: {
const albumSongsResponse = await getAlbumSongsById({
@@ -8,8 +8,8 @@ import {
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { PlayerShuffle, ServerType } from '/@/shared/types/types';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { PlayerShuffle } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
@@ -7,7 +7,7 @@ import { queryKeys } from '/@/renderer/api/query-keys';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { updateQueueSong } from '/@/renderer/store/player.store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { QueueSong, SongDetailQuery } from '/@/shared/types/domain-types';
import { QueueSong, ServerType, SongDetailQuery } from '/@/shared/types/domain-types';
export const useUpdateCurrentSong = () => {
const queryClient = useQueryClient();
@@ -16,7 +16,11 @@ export const useUpdateCurrentSong = () => {
async (properties: { index: number; song: QueueSong | undefined }) => {
const currentSong = properties.song;
if (!currentSong?.id || !currentSong?._serverId) {
if (
!currentSong?.id ||
!currentSong?._serverId ||
currentSong?._serverType === ServerType.EXTERNAL
) {
return;
}
@@ -27,8 +27,12 @@ import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { useFocusTrap } from '/@/shared/hooks/use-focus-trap';
import { useForm } from '/@/shared/hooks/use-form';
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
import { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';
import {
AuthenticationResponse,
ServerListItemWithCredential,
ServerType,
} from '/@/shared/types/domain-types';
import { DiscoveredServerItem, toServerType } from '/@/shared/types/types';
const autodiscover = isElectron() ? window.api.autodiscover : null;
const localSettings = isElectron() ? window.api.localSettings : null;
@@ -70,7 +74,7 @@ function useAutodiscovery() {
return { isDone, servers };
}
const SERVER_TYPES: Record<ServerType, ServerDetails> = {
const SERVER_TYPES: Record<Exclude<ServerType, ServerType.EXTERNAL>, ServerDetails> = {
[ServerType.JELLYFIN]: {
icon: JellyfinIcon,
name: 'Jellyfin',
@@ -0,0 +1,424 @@
import isElectron from 'is-electron';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import {
useGeneralSettings,
useIntegrationsSettings,
useSettingsStoreActions,
} from '/@/renderer/store';
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
const MUSICBRAINZ_RELEASE_TYPES = [
'album',
'single',
'ep',
'broadcast',
'compilation',
'live',
'remix',
'appears-on',
'audiobook',
'audio drama',
'demo',
'dj-mix',
'field recording',
'interview',
'mixtape/street',
'other',
'soundtrack',
'spokenword',
];
const MUSICBRAINZ_COUNTRY_CODES: Record<string, string> = {
AD: 'Andorra',
AE: 'United Arab Emirates',
AF: 'Afghanistan',
AG: 'Antigua and Barbuda',
AI: 'Anguilla',
AL: 'Albania',
AM: 'Armenia',
AN: 'Netherlands Antilles',
AO: 'Angola',
AQ: 'Antarctica',
AR: 'Argentina',
AS: 'American Samoa',
AT: 'Austria',
AU: 'Australia',
AW: 'Aruba',
AX: 'Åland Islands',
AZ: 'Azerbaijan',
BA: 'Bosnia and Herzegovina',
BB: 'Barbados',
BD: 'Bangladesh',
BE: 'Belgium',
BF: 'Burkina Faso',
BG: 'Bulgaria',
BH: 'Bahrain',
BI: 'Burundi',
BJ: 'Benin',
BL: 'Saint Barthélemy',
BM: 'Bermuda',
BN: 'Brunei',
BO: 'Bolivia',
BQ: 'Bonaire, Sint Eustatius and Saba',
BR: 'Brazil',
BS: 'Bahamas',
BT: 'Bhutan',
BV: 'Bouvet Island',
BW: 'Botswana',
BY: 'Belarus',
BZ: 'Belize',
CA: 'Canada',
CC: 'Cocos (Keeling) Islands',
CD: 'Democratic Republic of the Congo',
CF: 'Central African Republic',
CG: 'Congo',
CH: 'Switzerland',
CI: "Côte d'Ivoire",
CK: 'Cook Islands',
CL: 'Chile',
CM: 'Cameroon',
CN: 'China',
CO: 'Colombia',
CR: 'Costa Rica',
CS: 'Serbia and Montenegro',
CU: 'Cuba',
CV: 'Cape Verde',
CW: 'Curaçao',
CX: 'Christmas Island',
CY: 'Cyprus',
CZ: 'Czechia',
DE: 'Germany',
DJ: 'Djibouti',
DK: 'Denmark',
DM: 'Dominica',
DO: 'Dominican Republic',
DZ: 'Algeria',
EC: 'Ecuador',
EE: 'Estonia',
EG: 'Egypt',
EH: 'Western Sahara',
ER: 'Eritrea',
ES: 'Spain',
ET: 'Ethiopia',
FI: 'Finland',
FJ: 'Fiji',
FK: 'Falkland Islands',
FM: 'Federated States of Micronesia',
FO: 'Faroe Islands',
FR: 'France',
GA: 'Gabon',
GB: 'United Kingdom',
GD: 'Grenada',
GE: 'Georgia',
GF: 'French Guiana',
GG: 'Guernsey',
GH: 'Ghana',
GI: 'Gibraltar',
GL: 'Greenland',
GM: 'Gambia',
GN: 'Guinea',
GP: 'Guadeloupe',
GQ: 'Equatorial Guinea',
GR: 'Greece',
GS: 'South Georgia and the South Sandwich Islands',
GT: 'Guatemala',
GU: 'Guam',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HK: 'Hong Kong',
HM: 'Heard Island and McDonald Islands',
HN: 'Honduras',
HR: 'Croatia',
HT: 'Haiti',
HU: 'Hungary',
ID: 'Indonesia',
IE: 'Ireland',
IL: 'Israel',
IM: 'Isle of Man',
IN: 'India',
IO: 'British Indian Ocean Territory',
IQ: 'Iraq',
IR: 'Iran',
IS: 'Iceland',
IT: 'Italy',
JE: 'Jersey',
JM: 'Jamaica',
JO: 'Jordan',
JP: 'Japan',
KE: 'Kenya',
KG: 'Kyrgyzstan',
KH: 'Cambodia',
KI: 'Kiribati',
KM: 'Comoros',
KN: 'Saint Kitts and Nevis',
KP: 'North Korea',
KR: 'South Korea',
KW: 'Kuwait',
KY: 'Cayman Islands',
KZ: 'Kazakhstan',
LA: 'Laos',
LB: 'Lebanon',
LC: 'Saint Lucia',
LI: 'Liechtenstein',
LK: 'Sri Lanka',
LR: 'Liberia',
LS: 'Lesotho',
LT: 'Lithuania',
LU: 'Luxembourg',
LV: 'Latvia',
LY: 'Libya',
MA: 'Morocco',
MC: 'Monaco',
MD: 'Moldova',
ME: 'Montenegro',
MF: 'Saint Martin (French part)',
MG: 'Madagascar',
MH: 'Marshall Islands',
MK: 'North Macedonia',
ML: 'Mali',
MM: 'Myanmar',
MN: 'Mongolia',
MO: 'Macao',
MP: 'Northern Mariana Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MS: 'Montserrat',
MT: 'Malta',
MU: 'Mauritius',
MV: 'Maldives',
MW: 'Malawi',
MX: 'Mexico',
MY: 'Malaysia',
MZ: 'Mozambique',
NA: 'Namibia',
NC: 'New Caledonia',
NE: 'Niger',
NF: 'Norfolk Island',
NG: 'Nigeria',
NI: 'Nicaragua',
NL: 'Netherlands',
NO: 'Norway',
NP: 'Nepal',
NR: 'Nauru',
NU: 'Niue',
NZ: 'New Zealand',
OM: 'Oman',
PA: 'Panama',
PE: 'Peru',
PF: 'French Polynesia',
PG: 'Papua New Guinea',
PH: 'Philippines',
PK: 'Pakistan',
PL: 'Poland',
PM: 'Saint Pierre and Miquelon',
PN: 'Pitcairn',
PR: 'Puerto Rico',
PS: 'Palestine',
PT: 'Portugal',
PW: 'Palau',
PY: 'Paraguay',
QA: 'Qatar',
RE: 'Réunion',
RO: 'Romania',
RS: 'Serbia',
RU: 'Russia',
RW: 'Rwanda',
SA: 'Saudi Arabia',
SB: 'Solomon Islands',
SC: 'Seychelles',
SD: 'Sudan',
SE: 'Sweden',
SG: 'Singapore',
SH: 'Saint Helena, Ascension and Tristan da Cunha',
SI: 'Slovenia',
SJ: 'Svalbard and Jan Mayen',
SK: 'Slovakia',
SL: 'Sierra Leone',
SM: 'San Marino',
SN: 'Senegal',
SO: 'Somalia',
SR: 'Suriname',
SS: 'South Sudan',
ST: 'Sao Tome and Principe',
SU: 'Soviet Union',
SV: 'El Salvador',
SX: 'Sint Maarten (Dutch part)',
SY: 'Syria',
SZ: 'Eswatini',
TC: 'Turks and Caicos Islands',
TD: 'Chad',
TF: 'French Southern Territories',
TG: 'Togo',
TH: 'Thailand',
TJ: 'Tajikistan',
TK: 'Tokelau',
TL: 'Timor-Leste',
TM: 'Turkmenistan',
TN: 'Tunisia',
TO: 'Tonga',
TR: 'Turkey',
TT: 'Trinidad and Tobago',
TV: 'Tuvalu',
TW: 'Taiwan',
TZ: 'Tanzania',
UA: 'Ukraine',
UG: 'Uganda',
UM: 'United States Minor Outlying Islands',
US: 'United States',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VA: 'Vatican City',
VC: 'Saint Vincent and The Grenadines',
VE: 'Venezuela',
VG: 'British Virgin Islands',
VI: 'U.S. Virgin Islands',
VN: 'Vietnam',
VU: 'Vanuatu',
WF: 'Wallis and Futuna',
WS: 'Samoa',
XC: 'Czechoslovakia',
XE: 'Europe',
XG: 'East Germany',
XK: 'Kosovo',
XW: '[Worldwide]',
YE: 'Yemen',
YT: 'Mayotte',
YU: 'Yugoslavia',
ZA: 'South Africa',
ZM: 'Zambia',
ZW: 'Zimbabwe',
};
const MUSICBRAINZ_COUNTRY_OPTIONS = Object.entries(MUSICBRAINZ_COUNTRY_CODES)
.map(([code, name]) => ({ label: `${code} - ${name}`, value: code }))
.sort((a, b) => a.label.localeCompare(b.label));
export const IntegrationsTab = memo(() => {
const { t } = useTranslation();
const { musicBrainz } = useGeneralSettings();
const settings = useIntegrationsSettings();
const { setSettings } = useSettingsStoreActions();
const updateIntegrations = (updates: Partial<typeof settings>) => {
setSettings({
integrations: {
...settings,
...updates,
},
});
};
const options: SettingOption[] = [
{
control: (
<Switch
aria-label={t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' })}
defaultChecked={settings.musicBrainz}
onChange={(e) => updateIntegrations({ musicBrainz: e.currentTarget.checked })}
/>
),
description: t('setting.musicBrainzQueries', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' }),
},
{
control: (
<MultiSelect
aria-label={t('setting.musicbrainzExcludeReleaseTypes', {
postProcess: 'sentenceCase',
})}
clearable
data={MUSICBRAINZ_RELEASE_TYPES}
defaultValue={settings.musicBrainzExcludeReleaseTypes}
onChange={(value) =>
updateIntegrations({ musicBrainzExcludeReleaseTypes: value })
}
width={300}
/>
),
description: t('setting.musicbrainzExcludeReleaseTypes', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !musicBrainz || !settings.musicBrainz,
title: t('setting.musicbrainzExcludeReleaseTypes', { postProcess: 'sentenceCase' }),
},
{
control: (
<MultiSelect
aria-label={t('setting.musicbrainzPrioritizeCountries', {
postProcess: 'sentenceCase',
})}
clearable
data={MUSICBRAINZ_COUNTRY_OPTIONS}
defaultValue={settings.musicBrainzPrioritizeCountries
.map((c) => c.toUpperCase())
.filter((code) => code in MUSICBRAINZ_COUNTRY_CODES)}
onChange={(value) =>
updateIntegrations({ musicBrainzPrioritizeCountries: value })
}
searchable
width={300}
/>
),
description: t('setting.musicbrainzPrioritizeCountries', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !musicBrainz || !settings.musicBrainz,
title: t('setting.musicbrainzPrioritizeCountries', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label={t('setting.musicbrainzAutoCountryPriority', {
postProcess: 'sentenceCase',
})}
defaultChecked={settings.musicbrainzAutoCountryPriority}
onChange={(e) =>
updateIntegrations({
musicbrainzAutoCountryPriority: e.currentTarget.checked,
})
}
/>
),
description: t('setting.musicbrainzAutoCountryPriority', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !musicBrainz || !settings.musicBrainz,
title: t('setting.musicbrainzAutoCountryPriority', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label={t('setting.youtube', { postProcess: 'sentenceCase' })}
defaultChecked={settings.youtube}
disabled={!isElectron()}
onChange={(e) => updateIntegrations({ youtube: e.currentTarget.checked })}
/>
),
description: t('setting.youtube', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.youtube', { postProcess: 'sentenceCase' }),
},
];
return (
<Stack gap="md">
<SettingsSection options={options} title={'MusicBrainz'} />
</Stack>
);
});
@@ -24,6 +24,14 @@ const HotkeysTab = lazy(() =>
})),
);
const IntegrationsTab = lazy(() =>
import('/@/renderer/features/settings/components/integrations/integrations-tab').then(
(module) => ({
default: module.IntegrationsTab,
}),
),
);
const WindowTab = lazy(() =>
import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({
default: module.WindowTab,
@@ -61,6 +69,9 @@ export const SettingsContent = () => {
<Tabs.Tab value="hotkeys">
{t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
<Tabs.Tab value="integrations">
{t('page.setting.integrationsTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
{isElectron() && (
<Tabs.Tab value="window">
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
@@ -85,6 +96,11 @@ export const SettingsContent = () => {
<HotkeysTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="integrations">
<Suspense fallback={null}>
<IntegrationsTab />
</Suspense>
</Tabs.Panel>
{isElectron() && (
<Tabs.Panel value="window">
<Suspense fallback={null}>
@@ -32,6 +32,7 @@ const LibraryHeaderBarComponent = ({ children, ignoreMaxWidth }: LibraryHeaderBa
interface HeaderPlayButtonProps {
className?: string;
disabled?: boolean;
ids?: string[];
itemType: LibraryItem;
listQuery?: Record<string, any>;
@@ -46,6 +47,7 @@ interface TitleProps {
const HeaderPlayButton = ({
className,
disabled,
ids,
itemType,
listQuery,
@@ -58,6 +60,8 @@ const HeaderPlayButton = ({
const handlePlay = useCallback(
(playType: Play) => {
if (disabled) return;
if (listQuery) {
player.addToQueueByListQuery(serverId, listQuery, itemType, playType);
} else if (ids) {
@@ -68,7 +72,7 @@ const HeaderPlayButton = ({
closeAllModals();
},
[listQuery, ids, songs, player, serverId, itemType],
[disabled, listQuery, ids, songs, player, serverId, itemType],
);
const isPlayerFetching = useIsPlayerFetching();
@@ -80,6 +84,7 @@ const HeaderPlayButton = ({
<div className={styles.playButtonContainer}>
<DefaultPlayButton
className={className}
disabled={disabled}
loading={isPlayerFetching}
onClick={() => setIsOpen((prev) => !prev)}
ref={buttonRef}
@@ -49,12 +49,14 @@ interface LibraryHeaderProps {
export const LibraryHeader = forwardRef(
(
{ children, containerClassName, imageUrl, item, title }: LibraryHeaderProps,
{ children, containerClassName, imageUrl: imageUrlProp, item, title }: LibraryHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { t } = useTranslation();
const [isImageError, setIsImageError] = useState<boolean | null>(false);
const effectiveImageUrl = imageUrlProp ?? item.imageUrl ?? undefined;
const onImageError = () => {
setIsImageError(true);
};
@@ -77,20 +79,18 @@ export const LibraryHeader = forwardRef(
};
const openImage = useCallback(() => {
const imageId = item.imageId;
const itemType = item.type as LibraryItem;
if (!imageId || !itemType) {
return;
let modalImageUrl = effectiveImageUrl;
if (!modalImageUrl && item.imageId && itemType) {
modalImageUrl = getItemImageUrl({
id: item.imageId,
itemType,
});
}
const imageUrl = getItemImageUrl({
id: imageId,
itemType,
});
if (!imageUrl) {
console.error('No image URL found');
if (!modalImageUrl) {
return;
}
@@ -110,7 +110,7 @@ export const LibraryHeader = forwardRef(
enableViewport={false}
fetchPriority="high"
isExplicit={item.explicitStatus === ExplicitStatus.EXPLICIT}
src={imageUrl}
src={modalImageUrl}
style={{
maxHeight: '100%',
maxWidth: '100%',
@@ -122,7 +122,7 @@ export const LibraryHeader = forwardRef(
),
fullScreen: true,
});
}, [item.explicitStatus, item.imageId, item.type]);
}, [effectiveImageUrl, item.explicitStatus, item.imageId, item.type]);
return (
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
@@ -149,7 +149,7 @@ export const LibraryHeader = forwardRef(
id={item.imageId}
itemType={item.type as LibraryItem}
onError={onImageError}
src={imageUrl || ''}
src={effectiveImageUrl ?? ''}
type="header"
/>
)}
@@ -263,6 +263,7 @@ export const calculateTitleSize = (title: string) => {
};
interface LibraryHeaderMenuProps {
disabled?: boolean;
favorite?: boolean;
onArtistRadio?: () => void;
onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;
@@ -274,6 +275,7 @@ interface LibraryHeaderMenuProps {
}
export const LibraryHeaderMenu = ({
disabled,
favorite,
onArtistRadio,
onFavorite,
@@ -319,15 +321,30 @@ export const LibraryHeaderMenu = ({
return (
<div className={styles.libraryHeaderMenu}>
<Group wrap="nowrap">
{onPlay && <PlayTextButton {...handlePlayNow.handlers} {...handlePlayNow.props} />}
{onPlay && (
<PlayNextTextButton {...handlePlayNext.handlers} {...handlePlayNext.props} />
<PlayTextButton
{...handlePlayNow.handlers}
{...handlePlayNow.props}
disabled={disabled}
/>
)}
{onPlay && (
<PlayLastTextButton {...handlePlayLast.handlers} {...handlePlayLast.props} />
<PlayNextTextButton
{...handlePlayNext.handlers}
{...handlePlayNext.props}
disabled={disabled}
/>
)}
{onPlay && (
<PlayLastTextButton
{...handlePlayLast.handlers}
{...handlePlayLast.props}
disabled={disabled}
/>
)}
{onArtistRadio && (
<Button
disabled={disabled}
leftSection={
isPlayerFetching ? (
<Spinner color="white" />
@@ -344,17 +361,17 @@ export const LibraryHeaderMenu = ({
)}
</Group>
<Group gap="sm" wrap="nowrap">
{onRating && (
{onRating && !disabled && (
<Rating
onChange={onRating}
readOnly={isMutatingRating}
readOnly={isMutatingRating || disabled}
size="lg"
value={rating || 0}
/>
)}
{onFavorite && (
{onFavorite && !disabled && (
<ActionIcon
disabled={isMutatingFavorite}
disabled={isMutatingFavorite || disabled}
icon="favorite"
iconProps={{
fill: favorite ? 'primary' : undefined,
@@ -364,8 +381,9 @@ export const LibraryHeaderMenu = ({
variant="transparent"
/>
)}
{onMore && (
{onMore && !disabled && (
<ActionIcon
disabled={disabled}
icon="ellipsisHorizontal"
onClick={onMore}
size="lg"
@@ -45,6 +45,7 @@ interface TextPlayButtonProps extends ButtonProps {
export const PlayTextButton = ({
className,
disabled,
showTooltip = true,
variant = 'default',
...props
@@ -58,6 +59,7 @@ export const PlayTextButton = ({
label: styles.wideTextButtonLabel,
root: styles.wideTextButton,
}}
disabled={disabled}
variant="subtle"
{...props}
>
@@ -170,6 +170,7 @@ const SidebarImage = () => {
const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
serverId: currentSong?._serverId,
type: 'sidebar',
+20
View File
@@ -613,6 +613,14 @@ const QueryBuilderSettingsSchema = z.object({
tag: z.array(QueryBuilderCustomFieldSchema),
});
const IntegrationsSettingsSchema = z.object({
musicBrainz: z.boolean(),
musicbrainzAutoCountryPriority: z.boolean(),
musicBrainzExcludeReleaseTypes: z.array(z.string()),
musicBrainzPrioritizeCountries: z.array(z.string()),
youtube: z.boolean(),
});
const AutoDJSettingsSchema = z.object({
enabled: z.boolean(),
itemCount: z.number(),
@@ -629,6 +637,7 @@ export const ValidationSettingsStateSchema = z.object({
font: FontSettingsSchema,
general: GeneralSettingsSchema,
hotkeys: HotkeysSettingsSchema,
integrations: IntegrationsSettingsSchema,
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
lyrics: LyricsSettingsSchema,
lyricsDisplay: z.record(z.string(), LyricsDisplaySettingsSchema),
@@ -638,6 +647,7 @@ export const ValidationSettingsStateSchema = z.object({
tab: z.union([
z.literal('general'),
z.literal('hotkeys'),
z.literal('integrations'),
z.literal('playback'),
z.literal('window'),
z.string(),
@@ -1095,6 +1105,13 @@ const initialState: SettingsState = {
},
globalMediaHotkeys: true,
},
integrations: {
musicBrainz: true,
musicbrainzAutoCountryPriority: false,
musicBrainzExcludeReleaseTypes: [],
musicBrainzPrioritizeCountries: [],
youtube: true,
},
lists: {
['albumDetail']: {
display: ListDisplayType.TABLE,
@@ -2248,6 +2265,9 @@ export const useAlbumBackground = () =>
shallow,
);
export const useIntegrationsSettings = () =>
useSettingsStore((state) => state.integrations, shallow);
export const useExternalLinks = () =>
useSettingsStore(
(state) => ({
+6 -5
View File
@@ -34,17 +34,18 @@ export const idbStateStorage: StateStorage = {
const settingsKeys = [
'store_settings_autoDJ',
'store_settings_general',
'store_settings_lists',
'store_settings_hotkeys',
'store_settings_playback',
'store_settings_integrations',
'store_settings_lists',
'store_settings_lyrics',
'store_settings_playback',
'store_settings_queryBuilder',
'store_settings_remote',
'store_settings_tab',
'store_settings_window',
'store_settings_discord',
'store_settings_font',
'store_settings_css',
'store_settings_remote',
'store_settings_queryBuilder',
'store_settings_tab',
];
export const splitSettingsStorage: StateStorage = {
@@ -11,9 +11,10 @@ import {
MusicFolder,
Playlist,
RelatedArtist,
ServerType,
Song,
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
import { ServerListItem } from '/@/shared/types/types';
const TICKS_PER_MS = 10000;
@@ -1,5 +1,7 @@
import z from 'zod';
import { ServerType } from '../../types/domain-types';
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import { replacePathPrefix } from '/@/shared/api/utils';
@@ -14,7 +16,7 @@ import {
Song,
User,
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
import { ServerListItem } from '/@/shared/types/types';
const getImageUrl = (args: { url: null | string }) => {
const { url } = args;
@@ -0,0 +1,9 @@
.root {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.with-space {
margin-right: var(--theme-spacing-xs);
}
@@ -0,0 +1,35 @@
import clsx from 'clsx';
import { ComponentPropsWithoutRef } from 'react';
import styles from './external-song-indicator.module.css';
import { Icon } from '/@/shared/components/icon/icon';
export interface ExternalSongIndicatorProps extends ComponentPropsWithoutRef<'span'> {
isExternal: boolean | null | undefined;
size?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
withSpace?: boolean;
}
export const ExternalSongIndicator = ({
className,
isExternal,
size = 'lg',
withSpace = true,
...rest
}: ExternalSongIndicatorProps) => {
if (!isExternal) {
return null;
}
return (
<span
className={clsx(styles.root, className, {
[styles.withSpace]: withSpace,
})}
{...rest}
>
<Icon icon="externalSong" size={size} />
</span>
);
};
+2
View File
@@ -115,6 +115,7 @@ import {
LuWifi,
LuWifiOff,
LuX,
LuYoutube,
} from 'react-icons/lu';
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
@@ -173,6 +174,7 @@ export const AppIcon = {
error: LuShieldAlert,
expand: LuExpand,
externalLink: LuExternalLink,
externalSong: LuYoutube,
favorite: LuHeart,
fileJson: LuFileJson,
filter: LuListFilter,
+1
View File
@@ -34,6 +34,7 @@ export enum LibraryItem {
}
export enum ServerType {
EXTERNAL = 'external', // This is not an actual server type. This is used when fetching from external sources (e.g. musicbrainz)
JELLYFIN = 'jellyfin',
NAVIDROME = 'navidrome',
SUBSONIC = 'subsonic',
+1 -6
View File
@@ -9,6 +9,7 @@ import {
LibraryItem,
Playlist,
QueueSong,
ServerType,
Song,
} from '/@/shared/types/domain-types';
import { ServerFeatures } from '/@/shared/types/features-types';
@@ -51,12 +52,6 @@ export enum Platform {
WINDOWS = 'windows',
}
export enum ServerType {
JELLYFIN = 'jellyfin',
NAVIDROME = 'navidrome',
SUBSONIC = 'subsonic',
}
export type CardRoute = {
route: AppRoute | string;
slugs?: RouteSlug[];