mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad5447871 | |||
| 3a50dee7a2 | |||
| c4ecfeedec | |||
| f43655ed5a | |||
| fddda70190 | |||
| a5992943d0 | |||
| 56e2611992 | |||
| c8ae128ac4 | |||
| ba56ab8844 | |||
| 7cb7dfb62b | |||
| 86537a8d1e | |||
| 3b3e77b672 | |||
| bec6464a44 | |||
| 812ca5302a | |||
| 1824083b99 | |||
| f46ca8cd35 | |||
| f04ea3bca0 | |||
| a547be1577 | |||
| 8ae29407ec | |||
| 8e603871b7 | |||
| 40ec16e191 | |||
| 0bb30ab0da | |||
| 9919ff9626 | |||
| f6cec17710 | |||
| 03b01472f8 | |||
| 3550177f67 | |||
| 82914c27f0 | |||
| 10d02087d0 | |||
| 4b509951a5 | |||
| 2869aab728 |
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+652
-3
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,3 +4,4 @@ import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
import './youtube';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user