mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 21:10: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-log": "^5.4.3",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
"fast-xml-parser": "^5.3.2",
|
"fast-xml-parser": "^5.3.2",
|
||||||
"format-duration": "^3.0.2",
|
"format-duration": "^3.0.2",
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mpris-service": "^2.1.2",
|
"mpris-service": "^2.1.2",
|
||||||
|
"musicbrainz-api": "^0.27.1",
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||||
"nuqs": "^2.7.1",
|
"nuqs": "^2.7.1",
|
||||||
@@ -134,6 +136,7 @@
|
|||||||
"string-to-color": "^2.2.2",
|
"string-to-color": "^2.2.2",
|
||||||
"wavesurfer.js": "^7.11.1",
|
"wavesurfer.js": "^7.11.1",
|
||||||
"ws": "^8.18.2",
|
"ws": "^8.18.2",
|
||||||
|
"ytmusic-api": "^5.3.0",
|
||||||
"zod": "^3.22.3",
|
"zod": "^3.22.3",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+652
-3
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,7 @@
|
|||||||
"dismiss": "dismiss",
|
"dismiss": "dismiss",
|
||||||
"doNotShowAgain": "do not show this again",
|
"doNotShowAgain": "do not show this again",
|
||||||
"duration": "duration",
|
"duration": "duration",
|
||||||
|
"external": "external",
|
||||||
"view": "view",
|
"view": "view",
|
||||||
"edit": "edit",
|
"edit": "edit",
|
||||||
"enable": "enable",
|
"enable": "enable",
|
||||||
@@ -152,6 +153,7 @@
|
|||||||
"trackPeak": "track peak",
|
"trackPeak": "track peak",
|
||||||
"translation": "translation",
|
"translation": "translation",
|
||||||
"unknown": "unknown",
|
"unknown": "unknown",
|
||||||
|
"unavailable": "unavailable",
|
||||||
"version": "version",
|
"version": "version",
|
||||||
"year": "year",
|
"year": "year",
|
||||||
"yes": "yes",
|
"yes": "yes",
|
||||||
@@ -582,6 +584,7 @@
|
|||||||
"analytics": "analytics",
|
"analytics": "analytics",
|
||||||
"generalTab": "general",
|
"generalTab": "general",
|
||||||
"hotkeysTab": "hotkeys",
|
"hotkeysTab": "hotkeys",
|
||||||
|
"integrationsTab": "integrations",
|
||||||
"playbackTab": "playback",
|
"playbackTab": "playback",
|
||||||
"windowTab": "window",
|
"windowTab": "window",
|
||||||
"updates": "update",
|
"updates": "update",
|
||||||
@@ -892,6 +895,16 @@
|
|||||||
"mpvExtraParameters_help": "one per line",
|
"mpvExtraParameters_help": "one per line",
|
||||||
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
|
||||||
"musicbrainz": "show MusicBrainz links",
|
"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_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
|
||||||
"neteaseTranslation": "Enable NetEase translations",
|
"neteaseTranslation": "Enable NetEase translations",
|
||||||
"notify": "enable song notifications",
|
"notify": "enable song notifications",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createSocket } from 'dgram';
|
import { createSocket } from 'dgram';
|
||||||
import { ipcMain } from 'electron';
|
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 = {
|
type JellyfinResponse = {
|
||||||
Address: string;
|
Address: string;
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ import './player';
|
|||||||
import './remote';
|
import './remote';
|
||||||
import './settings';
|
import './settings';
|
||||||
import './discord-rpc';
|
import './discord-rpc';
|
||||||
|
import './youtube';
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const store = new Store<any>({
|
|||||||
lyrics: ['NetEase', 'lrclib.net'],
|
lyrics: ['NetEase', 'lrclib.net'],
|
||||||
mediaSession: false,
|
mediaSession: false,
|
||||||
playbackType: 'web',
|
playbackType: 'web',
|
||||||
|
renderer_server_port: 38472,
|
||||||
should_prompt_accessibility: true,
|
should_prompt_accessibility: true,
|
||||||
shown_accessibility_warning: false,
|
shown_accessibility_warning: false,
|
||||||
window_enable_tray: true,
|
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 electronLocalShortcut from 'electron-localshortcut';
|
||||||
import log from 'electron-log/main';
|
import log from 'electron-log/main';
|
||||||
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
||||||
|
import express from 'express';
|
||||||
import { access, constants } from 'fs';
|
import { access, constants } from 'fs';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
|
|
||||||
@@ -218,6 +219,37 @@ const getAssetPath = (...paths: string[]): string => {
|
|||||||
return path.join(RESOURCES_PATH, ...paths);
|
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 = () => {
|
export const getMainWindow = () => {
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
};
|
};
|
||||||
@@ -580,12 +612,11 @@ async function createWindow(first = true): Promise<void> {
|
|||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// HMR for renderer base on electron-vite cli.
|
// HMR for renderer: use Vite dev server URL in development, otherwise the local HTTP server.
|
||||||
// Load the remote URL for development or the local html file for production.
|
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||||
} else {
|
} 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 = [
|
const FONT_HEADERS = [
|
||||||
'font/collection',
|
'font/collection',
|
||||||
'font/otf',
|
'font/otf',
|
||||||
@@ -766,7 +805,7 @@ if (!singleInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady()
|
app.whenReady()
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
protocol.handle('feishin', async (request) => {
|
protocol.handle('feishin', async (request) => {
|
||||||
const filePath = `file://${request.url.slice('feishin://'.length)}`;
|
const filePath = `file://${request.url.slice('feishin://'.length)}`;
|
||||||
const response = await net.fetch(filePath);
|
const response = await net.fetch(filePath);
|
||||||
@@ -784,6 +823,10 @@ if (!singleInstance) {
|
|||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!(is.dev && process.env['ELECTRON_RENDERER_URL'])) {
|
||||||
|
await startRendererServer();
|
||||||
|
}
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
if (store.get('window_enable_tray', true)) {
|
if (store.get('window_enable_tray', true)) {
|
||||||
createTray();
|
createTray();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { mpris } from './mpris';
|
|||||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||||
import { remote } from './remote';
|
import { remote } from './remote';
|
||||||
import { utils } from './utils';
|
import { utils } from './utils';
|
||||||
|
import { youtube } from './youtube';
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {
|
const api = {
|
||||||
@@ -25,6 +26,7 @@ const api = {
|
|||||||
mpvPlayerListener,
|
mpvPlayerListener,
|
||||||
remote,
|
remote,
|
||||||
utils,
|
utils,
|
||||||
|
youtube,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PreloadApi = typeof api;
|
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,
|
userId: apiClientProps.server?.userId,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
Fields: JF_FIELDS.ALBUM_ARTIST_DETAIL,
|
Fields: 'Genres, Overview, SortName, ProviderIds',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
jfApiClient(apiClientProps).getSimilarArtistList({
|
jfApiClient(apiClientProps).getSimilarArtistList({
|
||||||
@@ -269,7 +269,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||||
query: {
|
query: {
|
||||||
Fields: JF_FIELDS.ALBUM_ARTIST_LIST,
|
Fields: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
||||||
ImageTypeLimit: 1,
|
ImageTypeLimit: 1,
|
||||||
Limit: query.limit,
|
Limit: query.limit,
|
||||||
ParentId: getLibraryId(query.musicFolderId),
|
ParentId: getLibraryId(query.musicFolderId),
|
||||||
@@ -321,7 +321,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
userId: apiClientProps.server.userId,
|
userId: apiClientProps.server.userId,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
Fields: JF_FIELDS.SONG,
|
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags, SortName, ProviderIds',
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
ParentId: query.id,
|
ParentId: query.id,
|
||||||
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||||
|
|||||||
@@ -270,6 +270,32 @@ export const queryKeys: Record<
|
|||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
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: {
|
musicFolders: {
|
||||||
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
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 itemName = firstItem ? getItemName(firstItem) : 'Item';
|
||||||
|
|
||||||
const itemImage = useItemImageUrl({
|
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,
|
itemType: data.itemType || LibraryItem.SONG,
|
||||||
|
serverId: (firstItem as { _serverId?: string })?._serverId,
|
||||||
type: 'table',
|
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 {
|
.image-container.is-round {
|
||||||
&::before {
|
&::before {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-container.no-hover-overlay {
|
||||||
|
&:hover {
|
||||||
|
&::before {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.favorite-badge {
|
.favorite-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -50px;
|
top: -50px;
|
||||||
@@ -100,9 +121,19 @@
|
|||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-container:hover .favorite-badge,
|
.external-badge {
|
||||||
.image-container:hover .rating-badge {
|
position: absolute;
|
||||||
opacity: 0;
|
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 {
|
.image {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ItemControls } from '/@/renderer/components/item-list/types';
|
|||||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { useShowRatings } from '/@/renderer/store';
|
import { useIntegrationsSettings, useShowRatings } from '/@/renderer/store';
|
||||||
import {
|
import {
|
||||||
formatDateAbsolute,
|
formatDateAbsolute,
|
||||||
formatDateAbsoluteUTC,
|
formatDateAbsoluteUTC,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '/@/renderer/utils/format';
|
} from '/@/renderer/utils/format';
|
||||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
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 { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Separator } from '/@/shared/components/separator/separator';
|
import { Separator } from '/@/shared/components/separator/separator';
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
Genre,
|
Genre,
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
||||||
@@ -178,6 +180,7 @@ const CompactItemCard = ({
|
|||||||
showRating,
|
showRating,
|
||||||
withControls,
|
withControls,
|
||||||
}: ItemCardDerivativeProps) => {
|
}: ItemCardDerivativeProps) => {
|
||||||
|
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
|
||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
const itemRowId =
|
const itemRowId =
|
||||||
data && internalState && typeof data === 'object' && 'id' in data
|
data && internalState && typeof data === 'object' && 'id' in data
|
||||||
@@ -339,9 +342,15 @@ const CompactItemCard = ({
|
|||||||
? (data as { userRating: null | number }).userRating
|
? (data as { userRating: null | number }).userRating
|
||||||
: null;
|
: null;
|
||||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
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, {
|
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||||
|
[styles.external]: isExternal,
|
||||||
[styles.isRound]: isRound,
|
[styles.isRound]: isRound,
|
||||||
|
[styles.noHoverOverlay]: isExternal && !showItemCardControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageContainerContent = (
|
const imageContainerContent = (
|
||||||
@@ -373,8 +382,13 @@ const CompactItemCard = ({
|
|||||||
)}
|
)}
|
||||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
{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>
|
<AnimatePresence>
|
||||||
{withControls && showControls && data && (
|
{showItemCardControls && (
|
||||||
<ItemCardControls
|
<ItemCardControls
|
||||||
controls={controls}
|
controls={controls}
|
||||||
enableExpansion={enableExpansion}
|
enableExpansion={enableExpansion}
|
||||||
@@ -409,6 +423,7 @@ const CompactItemCard = ({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.container, styles.compact, {
|
className={clsx(styles.container, styles.compact, {
|
||||||
[styles.dragging]: isDragging,
|
[styles.dragging]: isDragging,
|
||||||
|
[styles.external]: isExternal,
|
||||||
[styles.selected]: isSelected,
|
[styles.selected]: isSelected,
|
||||||
})}
|
})}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -482,6 +497,7 @@ const DefaultItemCard = ({
|
|||||||
showRating,
|
showRating,
|
||||||
withControls,
|
withControls,
|
||||||
}: ItemCardDerivativeProps) => {
|
}: ItemCardDerivativeProps) => {
|
||||||
|
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
|
||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
const itemRowId =
|
const itemRowId =
|
||||||
data && internalState && typeof data === 'object' && 'id' in data
|
data && internalState && typeof data === 'object' && 'id' in data
|
||||||
@@ -570,10 +586,6 @@ const DefaultItemCard = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
|
||||||
[styles.isRound]: isRound,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isFavorite =
|
const isFavorite =
|
||||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||||
const userRating =
|
const userRating =
|
||||||
@@ -582,6 +594,16 @@ const DefaultItemCard = ({
|
|||||||
? (data as { userRating: null | number }).userRating
|
? (data as { userRating: null | number }).userRating
|
||||||
: null;
|
: null;
|
||||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
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 = (
|
const imageContainerContent = (
|
||||||
<>
|
<>
|
||||||
@@ -610,8 +632,13 @@ const DefaultItemCard = ({
|
|||||||
)}
|
)}
|
||||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
{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>
|
<AnimatePresence>
|
||||||
{withControls && showControls && (
|
{showItemCardControls && (
|
||||||
<ItemCardControls
|
<ItemCardControls
|
||||||
controls={controls}
|
controls={controls}
|
||||||
enableExpansion={enableExpansion}
|
enableExpansion={enableExpansion}
|
||||||
@@ -628,6 +655,7 @@ const DefaultItemCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.container, {
|
className={clsx(styles.container, {
|
||||||
|
[styles.external]: isExternal,
|
||||||
[styles.selected]: isSelected,
|
[styles.selected]: isSelected,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -717,6 +745,7 @@ const PosterItemCard = ({
|
|||||||
showRating,
|
showRating,
|
||||||
withControls,
|
withControls,
|
||||||
}: ItemCardDerivativeProps) => {
|
}: ItemCardDerivativeProps) => {
|
||||||
|
const { youtube: youtubeIntegrationEnabled } = useIntegrationsSettings();
|
||||||
const [showControls, setShowControls] = useState(false);
|
const [showControls, setShowControls] = useState(false);
|
||||||
const itemRowId =
|
const itemRowId =
|
||||||
data && internalState && typeof data === 'object' && 'id' in data
|
data && internalState && typeof data === 'object' && 'id' in data
|
||||||
@@ -870,10 +899,6 @@ const PosterItemCard = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
|
||||||
[styles.isRound]: isRound,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isFavorite =
|
const isFavorite =
|
||||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||||
const userRating =
|
const userRating =
|
||||||
@@ -882,6 +907,16 @@ const PosterItemCard = ({
|
|||||||
? (data as { userRating: null | number }).userRating
|
? (data as { userRating: null | number }).userRating
|
||||||
: null;
|
: null;
|
||||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
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 = (
|
const imageContainerContent = (
|
||||||
<>
|
<>
|
||||||
@@ -910,8 +945,13 @@ const PosterItemCard = ({
|
|||||||
)}
|
)}
|
||||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||||
|
{isExternal && (
|
||||||
|
<div className={styles.externalBadge}>
|
||||||
|
<ExternalSongIndicator isExternal size="xl" withSpace={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{withControls && showControls && data && (
|
{showItemCardControls && (
|
||||||
<ItemCardControls
|
<ItemCardControls
|
||||||
controls={controls}
|
controls={controls}
|
||||||
enableExpansion={enableExpansion}
|
enableExpansion={enableExpansion}
|
||||||
@@ -930,6 +970,7 @@ const PosterItemCard = ({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.container, styles.poster, {
|
className={clsx(styles.container, styles.poster, {
|
||||||
[styles.dragging]: isDragging,
|
[styles.dragging]: isDragging,
|
||||||
|
[styles.external]: isExternal,
|
||||||
[styles.selected]: isSelected,
|
[styles.selected]: isSelected,
|
||||||
})}
|
})}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -1025,18 +1066,20 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
|||||||
if ('id' in data && data.id) {
|
if ('id' in data && data.id) {
|
||||||
if ('_itemType' in data) {
|
if ('_itemType' in data) {
|
||||||
switch (data._itemType) {
|
switch (data._itemType) {
|
||||||
case LibraryItem.ALBUM:
|
case LibraryItem.ALBUM: {
|
||||||
return (
|
const albumPath = getTitlePath(LibraryItem.ALBUM, data.id);
|
||||||
<Link
|
return albumPath ? (
|
||||||
state={{ item: data }}
|
<Link state={{ item: data }} to={albumPath}>
|
||||||
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
|
||||||
albumId: data.id,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ExplicitIndicator explicitStatus={explicitStatus} />
|
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||||
{data.name}
|
{data.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ExplicitIndicator explicitStatus={explicitStatus} />
|
||||||
|
{data.name}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case LibraryItem.ALBUM_ARTIST:
|
case LibraryItem.ALBUM_ARTIST:
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -1333,7 +1376,6 @@ const getItemNavigationPath = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;
|
const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;
|
||||||
|
|
||||||
return getTitlePath(effectiveItemType, data.id);
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
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';
|
import { Play, TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface UseDefaultItemListControlsArgs {
|
interface UseDefaultItemListControlsArgs {
|
||||||
@@ -384,6 +384,15 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
return;
|
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);
|
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -417,9 +426,9 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
enableMultiSelect,
|
enableMultiSelect,
|
||||||
overrides,
|
|
||||||
onColumnReordered,
|
onColumnReordered,
|
||||||
onColumnResized,
|
onColumnResized,
|
||||||
|
overrides,
|
||||||
player,
|
player,
|
||||||
setFavorite,
|
setFavorite,
|
||||||
setRating,
|
setRating,
|
||||||
|
|||||||
+1
-3
@@ -5,8 +5,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.text-container {
|
.text-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
@@ -43,7 +41,6 @@ a.title {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.artists {
|
.artists {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -65,3 +62,4 @@ a.title {
|
|||||||
.active {
|
.active {
|
||||||
color: var(--theme-colors-primary);
|
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 { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
|
||||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
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 { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
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) => {
|
export const DefaultTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
||||||
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
|
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}>
|
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
||||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||||
|
<ExternalSongIndicator
|
||||||
|
isExternal={item?._serverType === ServerType.EXTERNAL}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
{item.name as string}
|
{item.name as string}
|
||||||
</Text>
|
</Text>
|
||||||
<div className={styles.artists}>
|
<div className={styles.artists}>
|
||||||
@@ -123,6 +128,10 @@ export const QueueSongTitleArtistColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
{...titleLinkProps}
|
{...titleLinkProps}
|
||||||
>
|
>
|
||||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||||
|
<ExternalSongIndicator
|
||||||
|
isExternal={song?._serverType === ServerType.EXTERNAL}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
{row.name as string}
|
{row.name as string}
|
||||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ a.name-container {
|
|||||||
.active {
|
.active {
|
||||||
color: var(--theme-colors-primary);
|
color: var(--theme-colors-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
} 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 { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
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 { 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 TitleColumnBase = (props: ItemTableListInnerColumn) => {
|
||||||
const { itemType } = props;
|
const { itemType } = props;
|
||||||
@@ -60,6 +61,10 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
|
|||||||
{...titleLinkProps}
|
{...titleLinkProps}
|
||||||
>
|
>
|
||||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||||
|
<ExternalSongIndicator
|
||||||
|
isExternal={item?._serverType === ServerType.EXTERNAL}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
{row}
|
{row}
|
||||||
</Text>
|
</Text>
|
||||||
</TableColumnContainer>
|
</TableColumnContainer>
|
||||||
@@ -106,6 +111,7 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
|
|||||||
{...titleLinkProps}
|
{...titleLinkProps}
|
||||||
>
|
>
|
||||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||||
|
<ExternalSongIndicator isExternal={song?._serverType === ServerType.EXTERNAL} />
|
||||||
{row}
|
{row}
|
||||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,6 @@ a.title {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.artists {
|
.artists {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -94,3 +93,4 @@ a.title {
|
|||||||
.active {
|
.active {
|
||||||
color: var(--theme-colors-primary);
|
color: var(--theme-colors-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -21,9 +21,10 @@ import {
|
|||||||
} from '/@/renderer/features/shared/components/play-button-group';
|
} from '/@/renderer/features/shared/components/play-button-group';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
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 { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
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';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
||||||
@@ -143,6 +144,10 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
>
|
>
|
||||||
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
<Text className={styles.title} isNoSelect size="md" {...titleLinkProps}>
|
||||||
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
<ExplicitIndicator explicitStatus={item?.explicitStatus} />
|
||||||
|
<ExternalSongIndicator
|
||||||
|
isExternal={item?._serverType === ServerType.EXTERNAL}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
{item.name as string}
|
{item.name as string}
|
||||||
</Text>
|
</Text>
|
||||||
<div className={styles.artists}>
|
<div className={styles.artists}>
|
||||||
@@ -294,6 +299,10 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
|||||||
{...titleLinkProps}
|
{...titleLinkProps}
|
||||||
>
|
>
|
||||||
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
<ExplicitIndicator explicitStatus={song?.explicitStatus} />
|
||||||
|
<ExternalSongIndicator
|
||||||
|
isExternal={song?._serverType === ServerType.EXTERNAL}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
{row.name as string}
|
{row.name as string}
|
||||||
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
{song?.trackSubtitle && props.itemType !== LibraryItem.QUEUE_SONG && (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ import { api } from '/@/renderer/api';
|
|||||||
import { controller } from '/@/renderer/api/controller';
|
import { controller } from '/@/renderer/api/controller';
|
||||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { getOptimizedListCount } from '/@/renderer/api/utils-list-count';
|
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 { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import { AlbumDetailQuery, AlbumListQuery, ListCountQuery } from '/@/shared/types/domain-types';
|
import { AlbumDetailQuery, AlbumListQuery, ListCountQuery } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const albumQueries = {
|
export const albumQueries = {
|
||||||
detail: (args: QueryHookArgs<AlbumDetailQuery>) => {
|
detail: (args: QueryHookArgs<AlbumDetailQuery>) => {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryFn: ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
|
const mbzReleaseId = getMbzReleaseIdFromAlbumId(args.query.id);
|
||||||
|
|
||||||
|
if (mbzReleaseId !== null) {
|
||||||
|
return fetchMbzReleaseAsAlbum(mbzReleaseId);
|
||||||
|
}
|
||||||
|
|
||||||
return api.controller.getAlbumDetail({
|
return api.controller.getAlbumDetail({
|
||||||
apiClientProps: { serverId: args.serverId, signal },
|
apiClientProps: { serverId: args.serverId, signal },
|
||||||
query: args.query,
|
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 { ItemControls } from '/@/renderer/components/item-list/types';
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
import { ListSortOrderToggleButtonControlled } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
|
||||||
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
|
import { FILTER_KEYS, searchLibraryItems } from '/@/renderer/features/shared/utils';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
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 { useExternalLinks, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||||
import { sentenceCase, titleCase } from '/@/renderer/utils';
|
import { sentenceCase, titleCase } from '/@/renderer/utils';
|
||||||
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||||
@@ -119,6 +120,13 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
|
|||||||
|
|
||||||
const items: Array<{ id: string; value: ReactNode | string | undefined }> = [];
|
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(
|
items.push(
|
||||||
...releaseTypes,
|
...releaseTypes,
|
||||||
{
|
{
|
||||||
@@ -362,9 +370,14 @@ const AlbumMetadataExternalLinks = ({
|
|||||||
|
|
||||||
export const AlbumDetailContent = () => {
|
export const AlbumDetailContent = () => {
|
||||||
const { albumId } = useParams() as { albumId: string };
|
const { albumId } = useParams() as { albumId: string };
|
||||||
const server = useCurrentServer();
|
const serverId = useCurrentServerId();
|
||||||
|
const isMbz = isMbzAlbumId(albumId);
|
||||||
|
|
||||||
const detailQuery = useSuspenseQuery(
|
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();
|
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 { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import {
|
import {
|
||||||
LibraryHeader,
|
LibraryHeader,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
|
||||||
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
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 { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
|
||||||
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
|
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) => {
|
export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||||
const { albumId } = useParams() as { albumId: string };
|
const { albumId } = useParams() as { albumId: string };
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const server = useCurrentServer();
|
const serverId = useCurrentServerId();
|
||||||
const showRatings = useShowRatings();
|
const showRatings = useShowRatings();
|
||||||
|
|
||||||
|
const isMbz = isMbzAlbumId(albumId);
|
||||||
const detailQuery = useQuery(
|
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 =
|
const showRating =
|
||||||
|
!isExternal &&
|
||||||
showRatings &&
|
showRatings &&
|
||||||
(detailQuery?.data?._serverType === ServerType.NAVIDROME ||
|
(detailQuery?.data?._serverType === ServerType.NAVIDROME ||
|
||||||
detailQuery?.data?._serverType === ServerType.SUBSONIC);
|
detailQuery?.data?._serverType === ServerType.SUBSONIC);
|
||||||
@@ -80,8 +90,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handlePlay = (type?: Play) => {
|
const handlePlay = (type?: Play) => {
|
||||||
if (!server?.id || !albumId) return;
|
if (isExternal || !serverId || !albumId) return;
|
||||||
addToQueueByFetch(server.id, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
|
addToQueueByFetch(serverId, [albumId], LibraryItem.ALBUM, type || playButtonBehavior);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleMoreOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
@@ -248,6 +258,7 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<LibraryHeaderMenu
|
<LibraryHeaderMenu
|
||||||
|
disabled={isExternal && !youtubeEnabled}
|
||||||
favorite={detailQuery?.data?.userFavorite}
|
favorite={detailQuery?.data?.userFavorite}
|
||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
onMore={handleMoreOptions}
|
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 { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
|
import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content';
|
||||||
import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header';
|
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 { AnimatedPage } from '/@/renderer/features/shared/components/animated-page';
|
||||||
import {
|
import {
|
||||||
LibraryBackgroundImage,
|
LibraryBackgroundImage,
|
||||||
@@ -16,9 +17,9 @@ import { LibraryContainer } from '/@/renderer/features/shared/components/library
|
|||||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
||||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
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 { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
const AlbumDetailRoute = () => {
|
const AlbumDetailRoute = () => {
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -26,18 +27,24 @@ const AlbumDetailRoute = () => {
|
|||||||
const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
|
const { albumBackground, albumBackgroundBlur } = useAlbumBackground();
|
||||||
|
|
||||||
const { albumId } = useParams() as { albumId: string };
|
const { albumId } = useParams() as { albumId: string };
|
||||||
const server = useCurrentServer();
|
const serverId = useCurrentServerId();
|
||||||
|
const { youtube: youtubeEnabled } = useIntegrationsSettings();
|
||||||
|
const isMbz = isMbzAlbumId(albumId);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
...albumQueries.detail({ query: { id: albumId }, serverId: server?.id }),
|
...albumQueries.detail({
|
||||||
|
query: { id: albumId },
|
||||||
|
serverId: isMbz ? 'musicbrainz' : serverId,
|
||||||
|
}),
|
||||||
placeholderData: location.state?.item,
|
placeholderData: location.state?.item,
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageUrl =
|
const imageUrl =
|
||||||
useItemImageUrl({
|
useItemImageUrl({
|
||||||
id: detailQuery?.data?.imageId || undefined,
|
id: detailQuery?.data?.imageId || undefined,
|
||||||
|
imageUrl: detailQuery?.data?.imageUrl || undefined,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: LibraryItem.ALBUM,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
}) || '';
|
}) || '';
|
||||||
@@ -52,10 +59,12 @@ const AlbumDetailRoute = () => {
|
|||||||
|
|
||||||
const showBlurredImage = albumBackground;
|
const showBlurredImage = albumBackground;
|
||||||
|
|
||||||
if (isColorLoading) {
|
if (isColorLoading || (detailQuery.isLoading && !detailQuery.data)) {
|
||||||
return <Spinner container />;
|
return <Spinner container />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isExternal = detailQuery?.data?._serverType === ServerType.EXTERNAL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||||
<NativeScrollArea
|
<NativeScrollArea
|
||||||
@@ -64,6 +73,7 @@ const AlbumDetailRoute = () => {
|
|||||||
children: (
|
children: (
|
||||||
<LibraryHeaderBar>
|
<LibraryHeaderBar>
|
||||||
<LibraryHeaderBar.PlayButton
|
<LibraryHeaderBar.PlayButton
|
||||||
|
disabled={isExternal && !youtubeEnabled}
|
||||||
ids={[albumId]}
|
ids={[albumId]}
|
||||||
itemType={LibraryItem.ALBUM}
|
itemType={LibraryItem.ALBUM}
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table
|
|||||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||||
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
|
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 { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import {
|
import {
|
||||||
@@ -1069,21 +1070,46 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const itemsPerRow = getItemsPerRow(cq);
|
const itemsPerRow = getItemsPerRow(cq);
|
||||||
const albumCount = albums.length;
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const serverId = useCurrentServerId();
|
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 displayedAlbums = showAll ? albums : albums.slice(0, MAX_SECTION_CARDS);
|
||||||
const hasMoreAlbums = albums.length > MAX_SECTION_CARDS;
|
const hasMoreAlbums = albums.length > MAX_SECTION_CARDS;
|
||||||
|
|
||||||
const handlePlay = useCallback(
|
const handlePlay = useCallback(
|
||||||
(playType: Play) => {
|
(playType: Play) => {
|
||||||
if (albums.length === 0) return;
|
if (nonExternalAlbums.length === 0) return;
|
||||||
const albumIds = albums.map((album) => album.id);
|
const albumIds = nonExternalAlbums.map((album) => album.id);
|
||||||
player.addToQueueByFetch(serverId, albumIds, LibraryItem.ALBUM, playType);
|
player.addToQueueByFetch(serverId, albumIds, LibraryItem.ALBUM, playType);
|
||||||
},
|
},
|
||||||
[albums, player, serverId],
|
[nonExternalAlbums, player, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePlayNext = usePlayButtonClick({
|
const handlePlayNext = usePlayButtonClick({
|
||||||
@@ -1120,11 +1146,11 @@ const AlbumSection = ({ albums, controls, cq, releaseType, rows, title }: AlbumS
|
|||||||
<TextTitle fw={700} order={3}>
|
<TextTitle fw={700} order={3}>
|
||||||
{title}
|
{title}
|
||||||
</TextTitle>
|
</TextTitle>
|
||||||
<Badge variant="default">{albumCount}</Badge>
|
<Badge variant="default">{albumCountBadge}</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<div className={styles.albumSectionDividerContainer}>
|
<div className={styles.albumSectionDividerContainer}>
|
||||||
<div className={styles.albumSectionDivider} />
|
<div className={styles.albumSectionDivider} />
|
||||||
{albumCount > 0 && (
|
{nonExternalAlbums.length > 0 && (
|
||||||
<ActionIconGroup>
|
<ActionIconGroup>
|
||||||
<PlayTooltip type={Play.NOW}>
|
<PlayTooltip type={Play.NOW}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -1352,6 +1378,15 @@ interface ArtistAlbumsProps {
|
|||||||
const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const artistReleaseTypeItems = useArtistReleaseTypeItems();
|
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 [searchTerm, setSearchTerm] = useState('');
|
||||||
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
|
||||||
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
||||||
@@ -1364,16 +1399,55 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
|
|||||||
albumArtistId?: string;
|
albumArtistId?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serverId = useCurrentServerId();
|
||||||
const routeId = (artistId || albumArtistId) as string;
|
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 rows = useGridRows(LibraryItem.ALBUM, ItemListKey.ALBUM);
|
||||||
const controls = useDefaultItemListControls();
|
const controls = useDefaultItemListControls();
|
||||||
|
|
||||||
const filteredAndSortedAlbums = useMemo(() => {
|
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);
|
const searched = searchLibraryItems(albums, debouncedSearchTerm, LibraryItem.ALBUM);
|
||||||
return sortAlbumList(searched, sortBy, sortOrder);
|
return sortAlbumList(searched, sortBy, sortOrder);
|
||||||
}, [albumsQuery.data?.items, debouncedSearchTerm, sortBy, sortOrder]);
|
}, [albumsQuery.data?.items, debouncedSearchTerm, musicbrainzAlbums, sortBy, sortOrder]);
|
||||||
|
|
||||||
const albumsByReleaseType = useMemo(() => {
|
const albumsByReleaseType = useMemo(() => {
|
||||||
return groupAlbumsByReleaseType(filteredAndSortedAlbums, routeId, groupingType);
|
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 { useCurrentServer, useCurrentServerId, useShowRatings } from '/@/renderer/store';
|
||||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||||
import { Rating } from '/@/shared/components/rating/rating';
|
import { Rating } from '/@/shared/components/rating/rating';
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||||
import { ServerType } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
interface SetRatingActionProps {
|
interface SetRatingActionProps {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
|
|||||||
@@ -24,29 +24,16 @@ const getItemName = (item: unknown): string => {
|
|||||||
return 'Item';
|
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) => {
|
export const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const itemCount = items.length;
|
const itemCount = items.length;
|
||||||
const firstItem = items[0];
|
const firstItem = items[0];
|
||||||
const itemName = firstItem ? getItemName(firstItem) : 'Item';
|
const itemName = firstItem ? getItemName(firstItem) : 'Item';
|
||||||
const itemImage = firstItem ? getItemImage(firstItem) : null;
|
|
||||||
const isMultiple = itemCount > 1;
|
const isMultiple = itemCount > 1;
|
||||||
|
|
||||||
const imageUrl = useItemImageUrl({
|
const imageUrl = useItemImageUrl({
|
||||||
id: (firstItem as { imageId?: string })?.imageId,
|
id: (firstItem as { imageId?: string })?.imageId,
|
||||||
|
imageUrl: (firstItem as { imageUrl?: string })?.imageUrl,
|
||||||
itemType: itemType || LibraryItem.SONG,
|
itemType: itemType || LibraryItem.SONG,
|
||||||
serverId: (firstItem as { _serverId?: string })?._serverId,
|
serverId: (firstItem as { _serverId?: string })?._serverId,
|
||||||
type: 'table',
|
type: 'table',
|
||||||
@@ -61,7 +48,7 @@ export const ContextMenuPreview = ({ items, itemType }: ContextMenuPreviewProps)
|
|||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{itemImage ? (
|
{imageUrl ? (
|
||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
<img alt={itemName} className={styles.image} src={imageUrl} />
|
<img alt={itemName} className={styles.image} src={imageUrl} />
|
||||||
<div className={styles.imageOverlay} />
|
<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 { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { useForm } from '/@/shared/hooks/use-form';
|
import { useForm } from '/@/shared/hooks/use-form';
|
||||||
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
import {
|
||||||
import { ServerType, toServerType } from '/@/shared/types/types';
|
AuthenticationResponse,
|
||||||
|
ServerListItemWithCredential,
|
||||||
|
ServerType,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
import { toServerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
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.JELLYFIN]: JellyfinIcon,
|
||||||
[ServerType.NAVIDROME]: NavidromeIcon,
|
[ServerType.NAVIDROME]: NavidromeIcon,
|
||||||
[ServerType.SUBSONIC]: SubsonicIcon,
|
[ServerType.SUBSONIC]: SubsonicIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_NAMES: Record<ServerType, string> = {
|
const SERVER_NAMES: Record<Exclude<ServerType, ServerType.EXTERNAL>, string> = {
|
||||||
[ServerType.JELLYFIN]: 'Jellyfin',
|
[ServerType.JELLYFIN]: 'Jellyfin',
|
||||||
[ServerType.NAVIDROME]: 'Navidrome',
|
[ServerType.NAVIDROME]: 'Navidrome',
|
||||||
[ServerType.SUBSONIC]: 'OpenSubsonic',
|
[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 = (
|
const sourceServerId = (
|
||||||
args.source.item?.[0] as unknown as { _serverId: string }
|
args.source.item?.[0] as unknown as { _serverId: string }
|
||||||
)?._serverId;
|
)?._serverId;
|
||||||
|
|
||||||
const sourceItemType = args.source.itemType as LibraryItem;
|
const sourceItemType = args.source.itemType as LibraryItem;
|
||||||
|
|
||||||
switch (args.source.type) {
|
switch (args.source.type) {
|
||||||
@@ -297,7 +296,7 @@ const EmptyQueueDropZone = () => {
|
|||||||
const folderIds = folders.map((folder) => folder.id);
|
const folderIds = folders.map((folder) => folder.id);
|
||||||
|
|
||||||
// Handle folders: fetch and add to queue
|
// Handle folders: fetch and add to queue
|
||||||
if (folderIds.length > 0) {
|
if (folderIds.length > 0 && sourceServerId) {
|
||||||
playerContext.addToQueueByFetch(
|
playerContext.addToQueueByFetch(
|
||||||
sourceServerId,
|
sourceServerId,
|
||||||
folderIds,
|
folderIds,
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import { PlayerStatus } from '/@/shared/types/types';
|
|||||||
export interface MpvPlayerEngineHandle extends AudioPlayer {}
|
export interface MpvPlayerEngineHandle extends AudioPlayer {}
|
||||||
|
|
||||||
interface MpvPlayerEngineProps {
|
interface MpvPlayerEngineProps {
|
||||||
|
currentSongUrl: string | undefined;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isTransitioning: boolean;
|
isTransitioning: boolean;
|
||||||
|
nextSongUrl: string | undefined;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress: (e: PlayerOnProgressProps) => void;
|
onProgress: (e: PlayerOnProgressProps) => void;
|
||||||
playerRef: RefObject<MpvPlayerEngineHandle | null>;
|
playerRef: RefObject<MpvPlayerEngineHandle | null>;
|
||||||
@@ -39,8 +41,10 @@ const PROGRESS_UPDATE_INTERVAL = 250;
|
|||||||
|
|
||||||
export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||||
const {
|
const {
|
||||||
|
currentSongUrl: currentSongUrlProp,
|
||||||
isMuted,
|
isMuted,
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
|
nextSongUrl: nextSongUrlProp,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
playerRef,
|
playerRef,
|
||||||
@@ -56,6 +60,11 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
const isInitializedRef = useRef<boolean>(false);
|
const isInitializedRef = useRef<boolean>(false);
|
||||||
const hasPopulatedQueueRef = useRef<boolean>(false);
|
const hasPopulatedQueueRef = useRef<boolean>(false);
|
||||||
const isMountedRef = useRef<boolean>(true);
|
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 { mpvAudioDeviceId, transcode } = usePlaybackSettings();
|
||||||
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
|
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
|
||||||
@@ -124,15 +133,17 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
if (!radioState.currentStreamUrl) {
|
if (!radioState.currentStreamUrl) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentResolved =
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
currentSongUrlProp ??
|
||||||
: undefined;
|
(playerData.currentSong
|
||||||
const nextSongUrl = playerData.nextSong
|
? getSongUrl(playerData.currentSong, transcode)
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
: undefined);
|
||||||
: undefined;
|
const nextResolved =
|
||||||
|
nextSongUrlProp ??
|
||||||
|
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||||
|
|
||||||
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
|
if (currentResolved && !hasPopulatedQueueRef.current && mpvPlayer) {
|
||||||
mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true);
|
mpvPlayer.setQueue(currentResolved, nextResolved ?? currentResolved, true);
|
||||||
hasPopulatedQueueRef.current = true;
|
hasPopulatedQueueRef.current = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +168,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mpvExtraParameters, mpvProperties, mpvAudioDeviceId, reloadTrigger]);
|
}, [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
|
// Update volume
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mpvPlayer) {
|
if (!mpvPlayer) {
|
||||||
@@ -257,7 +292,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
const handleOnAutoNext = () => {
|
const handleOnAutoNext = () => {
|
||||||
mediaAutoNext();
|
mediaAutoNext();
|
||||||
handleMpvAutoNext(transcode);
|
handleMpvAutoNext(transcode, nextSongUrlRef.current);
|
||||||
};
|
};
|
||||||
|
|
||||||
mpvPlayerListener.rendererAutoNext(handleOnAutoNext);
|
mpvPlayerListener.rendererAutoNext(handleOnAutoNext);
|
||||||
@@ -270,10 +305,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
usePlayerEvents(
|
usePlayerEvents(
|
||||||
{
|
{
|
||||||
onMediaNext: () => {
|
onMediaNext: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
|
||||||
},
|
},
|
||||||
onMediaPrev: () => {
|
onMediaPrev: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
|
||||||
},
|
},
|
||||||
onNextSongInsertion: (song) => {
|
onNextSongInsertion: (song) => {
|
||||||
const radioState = useRadioStore.getState();
|
const radioState = useRadioStore.getState();
|
||||||
@@ -282,11 +317,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
|
const nextSongUrl =
|
||||||
|
nextSongUrlRef.current ?? (song ? getSongUrl(song, transcode) : undefined);
|
||||||
mpvPlayer?.setQueueNext(nextSongUrl);
|
mpvPlayer?.setQueueNext(nextSongUrl);
|
||||||
},
|
},
|
||||||
onPlayerPlay: () => {
|
onPlayerPlay: () => {
|
||||||
replaceMpvQueue(transcode);
|
replaceMpvQueue(transcode, currentSongUrlRef.current, nextSongUrlRef.current);
|
||||||
},
|
},
|
||||||
onQueueCleared: () => {},
|
onQueueCleared: () => {},
|
||||||
},
|
},
|
||||||
@@ -337,24 +373,30 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
|||||||
|
|
||||||
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
|
||||||
|
|
||||||
function handleMpvAutoNext(transcode: {
|
function handleMpvAutoNext(
|
||||||
bitrate?: number | undefined;
|
transcode: {
|
||||||
enabled: boolean;
|
bitrate?: number | undefined;
|
||||||
format?: string | undefined;
|
enabled: boolean;
|
||||||
}) {
|
format?: string | undefined;
|
||||||
|
},
|
||||||
|
nextUrlOverride?: string,
|
||||||
|
) {
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl =
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
nextUrlOverride ??
|
||||||
: undefined;
|
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||||
mpvPlayer?.autoNext(nextSongUrl);
|
mpvPlayer?.autoNext(nextSongUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceMpvQueue(transcode: {
|
function replaceMpvQueue(
|
||||||
bitrate?: number | undefined;
|
transcode: {
|
||||||
enabled: boolean;
|
bitrate?: number | undefined;
|
||||||
format?: string | undefined;
|
enabled: boolean;
|
||||||
}) {
|
format?: string | undefined;
|
||||||
// Don't override queue if radio is active
|
},
|
||||||
|
currentUrlOverride?: string,
|
||||||
|
nextUrlOverride?: string,
|
||||||
|
) {
|
||||||
const radioState = useRadioStore.getState();
|
const radioState = useRadioStore.getState();
|
||||||
|
|
||||||
if (radioState.currentStreamUrl) {
|
if (radioState.currentStreamUrl) {
|
||||||
@@ -362,11 +404,14 @@ function replaceMpvQueue(transcode: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playerData = usePlayerStore.getState().getPlayerData();
|
const playerData = usePlayerStore.getState().getPlayerData();
|
||||||
const currentSongUrl = playerData.currentSong
|
const currentSongUrl =
|
||||||
? getSongUrl(playerData.currentSong, transcode)
|
currentUrlOverride ??
|
||||||
: undefined;
|
(playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) : undefined);
|
||||||
const nextSongUrl = playerData.nextSong
|
const nextSongUrl =
|
||||||
? getSongUrl(playerData.nextSong, transcode)
|
nextUrlOverride ??
|
||||||
: undefined;
|
(playerData.nextSong ? getSongUrl(playerData.nextSong, transcode) : undefined);
|
||||||
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
|
|
||||||
|
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';
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
export interface WebPlayerEngineHandle extends AudioPlayer {
|
export interface WebPlayerEngineHandle extends AudioPlayer {
|
||||||
player1(): {
|
player1(): WebPlayerEnginePlayerHandle;
|
||||||
ref: null | ReactPlayer;
|
player2(): WebPlayerEnginePlayerHandle;
|
||||||
setVolume: (volume: number) => void;
|
}
|
||||||
};
|
|
||||||
player2(): {
|
export interface WebPlayerEnginePlayerHandle {
|
||||||
ref: null | ReactPlayer;
|
getCurrentTime: () => number;
|
||||||
setVolume: (volume: number) => void;
|
getDuration: () => number;
|
||||||
};
|
pause: () => void;
|
||||||
|
play: () => void;
|
||||||
|
ref: null | ReactPlayer;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebPlayerEngineProps {
|
interface WebPlayerEngineProps {
|
||||||
@@ -39,6 +42,70 @@ interface WebPlayerEngineProps {
|
|||||||
volume: number;
|
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
|
// 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
|
// 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
|
// 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));
|
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
|
||||||
},
|
},
|
||||||
pause() {
|
pause() {
|
||||||
player1Ref.current?.getInternalPlayer()?.pause();
|
pauseInternalPlayer(player1Ref.current);
|
||||||
player2Ref.current?.getInternalPlayer()?.pause();
|
pauseInternalPlayer(player2Ref.current);
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
if (playerNum === 1) {
|
if (playerNum === 1) {
|
||||||
player1Ref.current?.getInternalPlayer()?.play();
|
playInternalPlayer(player1Ref.current);
|
||||||
} else {
|
} else {
|
||||||
player2Ref.current?.getInternalPlayer()?.play();
|
playInternalPlayer(player2Ref.current);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
player1() {
|
player1(): WebPlayerEnginePlayerHandle {
|
||||||
return {
|
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),
|
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
player2() {
|
player2(): WebPlayerEnginePlayerHandle {
|
||||||
return {
|
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),
|
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { TranscodingConfig } from '/@/renderer/store';
|
import { youtubeQueries } from '/@/renderer/features/musicbrainz/api/youtube-api';
|
||||||
import { QueueSong } from '/@/shared/types/domain-types';
|
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(
|
export function useSongUrl(
|
||||||
song: QueueSong | undefined,
|
song: QueueSong | undefined,
|
||||||
@@ -11,10 +15,38 @@ export function useSongUrl(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
const prior = useRef(['', '']);
|
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(() => {
|
return useMemo(() => {
|
||||||
if (song?._serverId) {
|
if (!song) {
|
||||||
// If we are the current track, we do not want a transcoding
|
prior.current = ['', ''];
|
||||||
// reconfiguration to force a restart.
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
return externalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (song._serverId) {
|
||||||
if (current && prior.current[0] === song._uniqueId) {
|
if (current && prior.current[0] === song._uniqueId) {
|
||||||
return prior.current[1];
|
return prior.current[1];
|
||||||
}
|
}
|
||||||
@@ -29,18 +61,16 @@ export function useSongUrl(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// transcoding enabled; save the updated result
|
|
||||||
prior.current = [song._uniqueId, url];
|
prior.current = [song._uniqueId, url];
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no track; clear result
|
|
||||||
prior.current = ['', ''];
|
prior.current = ['', ''];
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [
|
}, [
|
||||||
song?._serverId,
|
song,
|
||||||
song?._uniqueId,
|
isExternal,
|
||||||
song?.id,
|
externalUrl,
|
||||||
current,
|
current,
|
||||||
transcode.bitrate,
|
transcode.bitrate,
|
||||||
transcode.format,
|
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({
|
return api.controller.getStreamUrl({
|
||||||
apiClientProps: { serverId: song._serverId },
|
apiClientProps: { serverId: song._serverId },
|
||||||
query: {
|
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 { MpvPlayerEngine, MpvPlayerEngineHandle } from './engine/mpv-player-engine';
|
||||||
|
|
||||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import {
|
import {
|
||||||
usePlaybackSettings,
|
usePlaybackSettings,
|
||||||
@@ -23,12 +24,15 @@ const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
|||||||
|
|
||||||
export function MpvPlayer() {
|
export function MpvPlayer() {
|
||||||
const playerRef = useRef<MpvPlayerEngineHandle>(null);
|
const playerRef = useRef<MpvPlayerEngineHandle>(null);
|
||||||
const { currentSong, status } = usePlayerData();
|
const { currentSong, nextSong, status } = usePlayerData();
|
||||||
const { mediaAutoNext, setTimestamp } = usePlayerActions();
|
const { mediaAutoNext, setTimestamp } = usePlayerActions();
|
||||||
const { speed } = usePlayerProperties();
|
const { speed } = usePlayerProperties();
|
||||||
const isMuted = usePlayerMuted();
|
const isMuted = usePlayerMuted();
|
||||||
const volume = usePlayerVolume();
|
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 [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
@@ -174,8 +178,10 @@ export function MpvPlayer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MpvPlayerEngine
|
<MpvPlayerEngine
|
||||||
|
currentSongUrl={currentSongUrl}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isTransitioning={isTransitioning}
|
isTransitioning={isTransitioning}
|
||||||
|
nextSongUrl={nextSongUrl}
|
||||||
onEnded={handleOnEnded}
|
onEnded={handleOnEnded}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
playerRef={playerRef}
|
playerRef={playerRef}
|
||||||
|
|||||||
@@ -46,6 +46,26 @@ export function WebPlayer() {
|
|||||||
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);
|
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);
|
||||||
const [player2Source, setPlayer2Source] = 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(
|
const fadeAndSetStatus = useCallback(
|
||||||
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
|
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
|
||||||
// Cancel any in-progress fade
|
// Cancel any in-progress fade
|
||||||
@@ -106,7 +126,7 @@ export function WebPlayer() {
|
|||||||
currentPlayer: playerRef.current.player1(),
|
currentPlayer: playerRef.current.player1(),
|
||||||
currentPlayerNum: num,
|
currentPlayerNum: num,
|
||||||
currentTime: e.playedSeconds,
|
currentTime: e.playedSeconds,
|
||||||
duration: getDuration(playerRef.current.player1().ref),
|
duration: getDuration(playerRef.current.player1()),
|
||||||
hasNextSong: Boolean(player2),
|
hasNextSong: Boolean(player2),
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
nextPlayer: playerRef.current.player2(),
|
nextPlayer: playerRef.current.player2(),
|
||||||
@@ -118,7 +138,7 @@ export function WebPlayer() {
|
|||||||
case PlayerStyle.GAPLESS:
|
case PlayerStyle.GAPLESS:
|
||||||
gaplessHandler({
|
gaplessHandler({
|
||||||
currentTime: e.playedSeconds,
|
currentTime: e.playedSeconds,
|
||||||
duration: getDuration(playerRef.current.player1().ref),
|
duration: getDuration(playerRef.current.player1()),
|
||||||
isFlac: false,
|
isFlac: false,
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
nextPlayer: playerRef.current.player2(),
|
nextPlayer: playerRef.current.player2(),
|
||||||
@@ -144,7 +164,7 @@ export function WebPlayer() {
|
|||||||
currentPlayer: playerRef.current.player2(),
|
currentPlayer: playerRef.current.player2(),
|
||||||
currentPlayerNum: num,
|
currentPlayerNum: num,
|
||||||
currentTime: e.playedSeconds,
|
currentTime: e.playedSeconds,
|
||||||
duration: getDuration(playerRef.current.player2().ref),
|
duration: getDuration(playerRef.current.player2()),
|
||||||
hasNextSong: Boolean(player1),
|
hasNextSong: Boolean(player1),
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
nextPlayer: playerRef.current.player1(),
|
nextPlayer: playerRef.current.player1(),
|
||||||
@@ -156,7 +176,7 @@ export function WebPlayer() {
|
|||||||
case PlayerStyle.GAPLESS:
|
case PlayerStyle.GAPLESS:
|
||||||
gaplessHandler({
|
gaplessHandler({
|
||||||
currentTime: e.playedSeconds,
|
currentTime: e.playedSeconds,
|
||||||
duration: getDuration(playerRef.current.player2().ref),
|
duration: getDuration(playerRef.current.player2()),
|
||||||
isFlac: false,
|
isFlac: false,
|
||||||
isTransitioning,
|
isTransitioning,
|
||||||
nextPlayer: playerRef.current.player1(),
|
nextPlayer: playerRef.current.player1(),
|
||||||
@@ -175,7 +195,7 @@ export function WebPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
promise.then(() => {
|
promise.then(() => {
|
||||||
playerRef.current?.player1()?.ref?.getInternalPlayer().pause();
|
playerRef.current?.player1()?.pause();
|
||||||
playerRef.current?.setVolume(volume);
|
playerRef.current?.setVolume(volume);
|
||||||
setIsTransitioning(false);
|
setIsTransitioning(false);
|
||||||
});
|
});
|
||||||
@@ -188,7 +208,7 @@ export function WebPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
promise.then(() => {
|
promise.then(() => {
|
||||||
playerRef.current?.player2()?.ref?.getInternalPlayer().pause();
|
playerRef.current?.player2()?.pause();
|
||||||
playerRef.current?.setVolume(volume);
|
playerRef.current?.setVolume(volume);
|
||||||
setIsTransitioning(false);
|
setIsTransitioning(false);
|
||||||
});
|
});
|
||||||
@@ -213,11 +233,11 @@ export function WebPlayer() {
|
|||||||
if (num === 1) {
|
if (num === 1) {
|
||||||
playerRef.current?.player1()?.setVolume(volume);
|
playerRef.current?.player1()?.setVolume(volume);
|
||||||
playerRef.current?.player2()?.setVolume(0);
|
playerRef.current?.player2()?.setVolume(0);
|
||||||
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
|
playerRef.current?.player2()?.pause();
|
||||||
} else {
|
} else {
|
||||||
playerRef.current?.player2()?.setVolume(volume);
|
playerRef.current?.player2()?.setVolume(volume);
|
||||||
playerRef.current?.player1()?.setVolume(0);
|
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) {
|
if (num === 1) {
|
||||||
playerRef.current?.player1()?.setVolume(volume);
|
playerRef.current?.player1()?.setVolume(volume);
|
||||||
playerRef.current?.player2()?.setVolume(0);
|
playerRef.current?.player2()?.setVolume(0);
|
||||||
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
|
playerRef.current?.player2()?.pause();
|
||||||
} else {
|
} else {
|
||||||
playerRef.current?.player2()?.setVolume(volume);
|
playerRef.current?.player2()?.setVolume(volume);
|
||||||
playerRef.current?.player1()?.setVolume(0);
|
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 interval = setInterval(() => {
|
||||||
const activePlayer =
|
const activePlayer =
|
||||||
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
|
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
|
||||||
const internalPlayer =
|
|
||||||
activePlayer?.ref?.getInternalPlayer() as HTMLAudioElement | null;
|
|
||||||
|
|
||||||
if (!internalPlayer) {
|
if (!activePlayer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = internalPlayer.currentTime;
|
const currentTime = activePlayer.getCurrentTime();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
transitionType === PlayerStyle.CROSSFADE ||
|
transitionType === PlayerStyle.CROSSFADE ||
|
||||||
@@ -400,46 +418,110 @@ export function WebPlayer() {
|
|||||||
const player1Url = useSongUrl(player1, num === 1, transcode);
|
const player1Url = useSongUrl(player1, num === 1, transcode);
|
||||||
const player2Url = useSongUrl(player2, num === 2, transcode);
|
const player2Url = useSongUrl(player2, num === 2, transcode);
|
||||||
|
|
||||||
const handlePlayer1Start = useCallback(
|
const disconnectPlayerSource = useCallback(
|
||||||
async (player: ReactPlayer) => {
|
(playerNum: 1 | 2) => {
|
||||||
if (!webAudio || player1Source) return;
|
const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef;
|
||||||
if (player1Url) {
|
const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source;
|
||||||
// This should fire once, only if the source is real (meaning we
|
const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef;
|
||||||
// saw the dummy source) and the context is not ready
|
|
||||||
if (webAudio.context.state !== 'running') {
|
if (sourceRef.current) {
|
||||||
await webAudio.context.resume();
|
try {
|
||||||
|
sourceRef.current.disconnect();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
|
sourceRef.current = null;
|
||||||
if (internal) {
|
internalRef.current = null;
|
||||||
const { context, gains } = webAudio;
|
setSource(null);
|
||||||
const source = context.createMediaElementSource(internal);
|
|
||||||
source.connect(gains[0]);
|
|
||||||
setPlayer1Source(source);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[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(
|
const handlePlayer2Start = useCallback(
|
||||||
async (player: ReactPlayer) => {
|
async (player: ReactPlayer) => {
|
||||||
if (!webAudio || player2Source) return;
|
await connectPlayerToWebAudio(2, player);
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[player2Source, player2Url, webAudio],
|
[connectPlayerToWebAudio],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -468,6 +550,7 @@ function crossfadeHandler(args: {
|
|||||||
crossfadeDuration: number;
|
crossfadeDuration: number;
|
||||||
crossfadeStyle: CrossfadeStyle;
|
crossfadeStyle: CrossfadeStyle;
|
||||||
currentPlayer: {
|
currentPlayer: {
|
||||||
|
pause: () => void;
|
||||||
ref: null | ReactPlayer;
|
ref: null | ReactPlayer;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
};
|
};
|
||||||
@@ -477,6 +560,8 @@ function crossfadeHandler(args: {
|
|||||||
hasNextSong: boolean;
|
hasNextSong: boolean;
|
||||||
isTransitioning: boolean | string;
|
isTransitioning: boolean | string;
|
||||||
nextPlayer: {
|
nextPlayer: {
|
||||||
|
pause: () => void;
|
||||||
|
play: () => void;
|
||||||
ref: null | ReactPlayer;
|
ref: null | ReactPlayer;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
};
|
};
|
||||||
@@ -504,7 +589,7 @@ function crossfadeHandler(args: {
|
|||||||
if (!hasNextSong) {
|
if (!hasNextSong) {
|
||||||
currentPlayer.setVolume(volume);
|
currentPlayer.setVolume(volume);
|
||||||
nextPlayer.setVolume(0);
|
nextPlayer.setVolume(0);
|
||||||
nextPlayer.ref?.getInternalPlayer()?.pause();
|
nextPlayer.pause();
|
||||||
|
|
||||||
if (isTransitioning) {
|
if (isTransitioning) {
|
||||||
setIsTransitioning(false);
|
setIsTransitioning(false);
|
||||||
@@ -516,7 +601,7 @@ function crossfadeHandler(args: {
|
|||||||
if (!isTransitioning) {
|
if (!isTransitioning) {
|
||||||
if (duration > 0 && currentTime > duration - crossfadeDuration) {
|
if (duration > 0 && currentTime > duration - crossfadeDuration) {
|
||||||
nextPlayer.setVolume(0);
|
nextPlayer.setVolume(0);
|
||||||
nextPlayer.ref?.getInternalPlayer().play();
|
nextPlayer.play();
|
||||||
return setIsTransitioning(player);
|
return setIsTransitioning(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +671,7 @@ function gaplessHandler(args: {
|
|||||||
isFlac: boolean;
|
isFlac: boolean;
|
||||||
isTransitioning: boolean | string;
|
isTransitioning: boolean | string;
|
||||||
nextPlayer: {
|
nextPlayer: {
|
||||||
|
play: () => void;
|
||||||
ref: null | ReactPlayer;
|
ref: null | ReactPlayer;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
};
|
};
|
||||||
@@ -604,10 +690,8 @@ function gaplessHandler(args: {
|
|||||||
const durationPadding = getDurationPadding(isFlac);
|
const durationPadding = getDurationPadding(isFlac);
|
||||||
|
|
||||||
if (currentTime + durationPadding >= duration) {
|
if (currentTime + durationPadding >= duration) {
|
||||||
return nextPlayer.ref
|
nextPlayer.play();
|
||||||
?.getInternalPlayer()
|
return;
|
||||||
?.play()
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -647,8 +731,14 @@ function getCrossfadeEasing(style: CrossfadeStyle): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDuration(ref: null | ReactPlayer | undefined) {
|
function getDuration(
|
||||||
return ref?.getInternalPlayer()?.duration || 0;
|
player:
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
getDuration: () => number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return player?.getDuration?.() ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDurationPadding(isFlac: boolean) {
|
function getDurationPadding(isFlac: boolean) {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const FullScreenPlayerImage = () => {
|
|||||||
|
|
||||||
const currentImageUrl = useItemImageUrl({
|
const currentImageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
serverId: currentSong?._serverId,
|
serverId: currentSong?._serverId,
|
||||||
type: 'fullScreenPlayer',
|
type: 'fullScreenPlayer',
|
||||||
@@ -101,6 +102,7 @@ export const FullScreenPlayerImage = () => {
|
|||||||
|
|
||||||
const nextImageUrl = useItemImageUrl({
|
const nextImageUrl = useItemImageUrl({
|
||||||
id: nextSong?.imageId || undefined,
|
id: nextSong?.imageId || undefined,
|
||||||
|
imageUrl: nextSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
serverId: nextSong?._serverId,
|
serverId: nextSong?._serverId,
|
||||||
type: 'fullScreenPlayer',
|
type: 'fullScreenPlayer',
|
||||||
|
|||||||
@@ -83,13 +83,17 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
|||||||
|
|
||||||
const currentImageUrl = useItemImageUrl({
|
const currentImageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: currentSong?._serverId,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextImageUrl = useItemImageUrl({
|
const nextImageUrl = useItemImageUrl({
|
||||||
id: nextSong?.imageId || undefined,
|
id: nextSong?.imageId || undefined,
|
||||||
|
imageUrl: nextSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: nextSong?._serverId,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export const LeftControls = () => {
|
|||||||
id={currentSong?.imageId}
|
id={currentSong?.imageId}
|
||||||
itemType={LibraryItem.SONG}
|
itemType={LibraryItem.SONG}
|
||||||
serverId={currentSong?._serverId}
|
serverId={currentSong?._serverId}
|
||||||
|
src={currentSong?.imageUrl}
|
||||||
type="table"
|
type="table"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -102,14 +102,18 @@ export const MobileFullscreenPlayerAlbumArt = () => {
|
|||||||
|
|
||||||
const currentImageUrl = useItemImageUrl({
|
const currentImageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: currentSong?._serverId,
|
||||||
size: mainImageDimensions.idealSize,
|
size: mainImageDimensions.idealSize,
|
||||||
type: 'fullScreenPlayer',
|
type: 'fullScreenPlayer',
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextImageUrl = useItemImageUrl({
|
const nextImageUrl = useItemImageUrl({
|
||||||
id: nextSong?.imageId || undefined,
|
id: nextSong?.imageId || undefined,
|
||||||
|
imageUrl: nextSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: nextSong?._serverId,
|
||||||
size: mainImageDimensions.idealSize,
|
size: mainImageDimensions.idealSize,
|
||||||
type: 'fullScreenPlayer',
|
type: 'fullScreenPlayer',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,13 +81,17 @@ const BackgroundImage = memo(({ dynamicBackground, dynamicIsImage }: BackgroundI
|
|||||||
|
|
||||||
const currentImageUrl = useItemImageUrl({
|
const currentImageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: currentSong?._serverId,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextImageUrl = useItemImageUrl({
|
const nextImageUrl = useItemImageUrl({
|
||||||
id: nextSong?.imageId || undefined,
|
id: nextSong?.imageId || undefined,
|
||||||
|
imageUrl: nextSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: nextSong?._serverId,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,6 +314,7 @@ const MobilePlayerContainer = memo(
|
|||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
imageUrl: currentSong?.imageUrl,
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
|
serverId: currentSong?._serverId,
|
||||||
type: 'itemCard',
|
type: 'itemCard',
|
||||||
});
|
});
|
||||||
const { background } = useFastAverageColor({
|
const { background } = useFastAverageColor({
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const MobilePlayerbar = () => {
|
|||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
id={currentSong.imageId}
|
id={currentSong.imageId}
|
||||||
itemType={LibraryItem.SONG}
|
itemType={LibraryItem.SONG}
|
||||||
|
serverId={currentSong?._serverId}
|
||||||
|
src={currentSong?.imageUrl}
|
||||||
type="table"
|
type="table"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -890,6 +890,9 @@ export const usePlayer = () => {
|
|||||||
* @param args - The arguments to use to fetch the data
|
* @param args - The arguments to use to fetch the data
|
||||||
* @returns The songs to add to the queue
|
* @returns The songs to add to the queue
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const EXTERNAL_SERVER_ID = 'musicbrainz';
|
||||||
|
|
||||||
export async function fetchSongsByItemType(
|
export async function fetchSongsByItemType(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
@@ -901,6 +904,23 @@ export async function fetchSongsByItemType(
|
|||||||
) {
|
) {
|
||||||
const songs: Song[] = [];
|
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) {
|
switch (args.itemType) {
|
||||||
case LibraryItem.ALBUM: {
|
case LibraryItem.ALBUM: {
|
||||||
const albumSongsResponse = await getAlbumSongsById({
|
const albumSongsResponse = await getAlbumSongsById({
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
useRadioPlayer,
|
useRadioPlayer,
|
||||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||||
import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
|
import { usePlayerSong, usePlayerStore } from '/@/renderer/store';
|
||||||
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
|
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||||
import { PlayerShuffle, ServerType } from '/@/shared/types/types';
|
import { PlayerShuffle } from '/@/shared/types/types';
|
||||||
|
|
||||||
const ipc = isElectron() ? window.api.ipc : null;
|
const ipc = isElectron() ? window.api.ipc : null;
|
||||||
const utils = isElectron() ? window.api.utils : 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 { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
import { updateQueueSong } from '/@/renderer/store/player.store';
|
import { updateQueueSong } from '/@/renderer/store/player.store';
|
||||||
import { LogCategory, logFn } from '/@/renderer/utils/logger';
|
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 = () => {
|
export const useUpdateCurrentSong = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -16,7 +16,11 @@ export const useUpdateCurrentSong = () => {
|
|||||||
async (properties: { index: number; song: QueueSong | undefined }) => {
|
async (properties: { index: number; song: QueueSong | undefined }) => {
|
||||||
const currentSong = properties.song;
|
const currentSong = properties.song;
|
||||||
|
|
||||||
if (!currentSong?.id || !currentSong?._serverId) {
|
if (
|
||||||
|
!currentSong?.id ||
|
||||||
|
!currentSong?._serverId ||
|
||||||
|
currentSong?._serverType === ServerType.EXTERNAL
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ import { Text } from '/@/shared/components/text/text';
|
|||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { useFocusTrap } from '/@/shared/hooks/use-focus-trap';
|
import { useFocusTrap } from '/@/shared/hooks/use-focus-trap';
|
||||||
import { useForm } from '/@/shared/hooks/use-form';
|
import { useForm } from '/@/shared/hooks/use-form';
|
||||||
import { AuthenticationResponse, ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
import {
|
||||||
import { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';
|
AuthenticationResponse,
|
||||||
|
ServerListItemWithCredential,
|
||||||
|
ServerType,
|
||||||
|
} from '/@/shared/types/domain-types';
|
||||||
|
import { DiscoveredServerItem, toServerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
const autodiscover = isElectron() ? window.api.autodiscover : null;
|
const autodiscover = isElectron() ? window.api.autodiscover : null;
|
||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||||
@@ -70,7 +74,7 @@ function useAutodiscovery() {
|
|||||||
return { isDone, servers };
|
return { isDone, servers };
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_TYPES: Record<ServerType, ServerDetails> = {
|
const SERVER_TYPES: Record<Exclude<ServerType, ServerType.EXTERNAL>, ServerDetails> = {
|
||||||
[ServerType.JELLYFIN]: {
|
[ServerType.JELLYFIN]: {
|
||||||
icon: JellyfinIcon,
|
icon: JellyfinIcon,
|
||||||
name: 'Jellyfin',
|
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(() =>
|
const WindowTab = lazy(() =>
|
||||||
import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({
|
import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({
|
||||||
default: module.WindowTab,
|
default: module.WindowTab,
|
||||||
@@ -61,6 +69,9 @@ export const SettingsContent = () => {
|
|||||||
<Tabs.Tab value="hotkeys">
|
<Tabs.Tab value="hotkeys">
|
||||||
{t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}
|
{t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="integrations">
|
||||||
|
{t('page.setting.integrationsTab', { postProcess: 'sentenceCase' })}
|
||||||
|
</Tabs.Tab>
|
||||||
{isElectron() && (
|
{isElectron() && (
|
||||||
<Tabs.Tab value="window">
|
<Tabs.Tab value="window">
|
||||||
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
|
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
|
||||||
@@ -85,6 +96,11 @@ export const SettingsContent = () => {
|
|||||||
<HotkeysTab />
|
<HotkeysTab />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="integrations">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<IntegrationsTab />
|
||||||
|
</Suspense>
|
||||||
|
</Tabs.Panel>
|
||||||
{isElectron() && (
|
{isElectron() && (
|
||||||
<Tabs.Panel value="window">
|
<Tabs.Panel value="window">
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const LibraryHeaderBarComponent = ({ children, ignoreMaxWidth }: LibraryHeaderBa
|
|||||||
|
|
||||||
interface HeaderPlayButtonProps {
|
interface HeaderPlayButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
listQuery?: Record<string, any>;
|
listQuery?: Record<string, any>;
|
||||||
@@ -46,6 +47,7 @@ interface TitleProps {
|
|||||||
|
|
||||||
const HeaderPlayButton = ({
|
const HeaderPlayButton = ({
|
||||||
className,
|
className,
|
||||||
|
disabled,
|
||||||
ids,
|
ids,
|
||||||
itemType,
|
itemType,
|
||||||
listQuery,
|
listQuery,
|
||||||
@@ -58,6 +60,8 @@ const HeaderPlayButton = ({
|
|||||||
|
|
||||||
const handlePlay = useCallback(
|
const handlePlay = useCallback(
|
||||||
(playType: Play) => {
|
(playType: Play) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
if (listQuery) {
|
if (listQuery) {
|
||||||
player.addToQueueByListQuery(serverId, listQuery, itemType, playType);
|
player.addToQueueByListQuery(serverId, listQuery, itemType, playType);
|
||||||
} else if (ids) {
|
} else if (ids) {
|
||||||
@@ -68,7 +72,7 @@ const HeaderPlayButton = ({
|
|||||||
|
|
||||||
closeAllModals();
|
closeAllModals();
|
||||||
},
|
},
|
||||||
[listQuery, ids, songs, player, serverId, itemType],
|
[disabled, listQuery, ids, songs, player, serverId, itemType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPlayerFetching = useIsPlayerFetching();
|
const isPlayerFetching = useIsPlayerFetching();
|
||||||
@@ -80,6 +84,7 @@ const HeaderPlayButton = ({
|
|||||||
<div className={styles.playButtonContainer}>
|
<div className={styles.playButtonContainer}>
|
||||||
<DefaultPlayButton
|
<DefaultPlayButton
|
||||||
className={className}
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
loading={isPlayerFetching}
|
loading={isPlayerFetching}
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ interface LibraryHeaderProps {
|
|||||||
|
|
||||||
export const LibraryHeader = forwardRef(
|
export const LibraryHeader = forwardRef(
|
||||||
(
|
(
|
||||||
{ children, containerClassName, imageUrl, item, title }: LibraryHeaderProps,
|
{ children, containerClassName, imageUrl: imageUrlProp, item, title }: LibraryHeaderProps,
|
||||||
ref: Ref<HTMLDivElement>,
|
ref: Ref<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isImageError, setIsImageError] = useState<boolean | null>(false);
|
const [isImageError, setIsImageError] = useState<boolean | null>(false);
|
||||||
|
|
||||||
|
const effectiveImageUrl = imageUrlProp ?? item.imageUrl ?? undefined;
|
||||||
|
|
||||||
const onImageError = () => {
|
const onImageError = () => {
|
||||||
setIsImageError(true);
|
setIsImageError(true);
|
||||||
};
|
};
|
||||||
@@ -77,20 +79,18 @@ export const LibraryHeader = forwardRef(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openImage = useCallback(() => {
|
const openImage = useCallback(() => {
|
||||||
const imageId = item.imageId;
|
|
||||||
const itemType = item.type as LibraryItem;
|
const itemType = item.type as LibraryItem;
|
||||||
|
|
||||||
if (!imageId || !itemType) {
|
let modalImageUrl = effectiveImageUrl;
|
||||||
return;
|
|
||||||
|
if (!modalImageUrl && item.imageId && itemType) {
|
||||||
|
modalImageUrl = getItemImageUrl({
|
||||||
|
id: item.imageId,
|
||||||
|
itemType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = getItemImageUrl({
|
if (!modalImageUrl) {
|
||||||
id: imageId,
|
|
||||||
itemType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!imageUrl) {
|
|
||||||
console.error('No image URL found');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
enableViewport={false}
|
enableViewport={false}
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
isExplicit={item.explicitStatus === ExplicitStatus.EXPLICIT}
|
isExplicit={item.explicitStatus === ExplicitStatus.EXPLICIT}
|
||||||
src={imageUrl}
|
src={modalImageUrl}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -122,7 +122,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
),
|
),
|
||||||
fullScreen: true,
|
fullScreen: true,
|
||||||
});
|
});
|
||||||
}, [item.explicitStatus, item.imageId, item.type]);
|
}, [effectiveImageUrl, item.explicitStatus, item.imageId, item.type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
|
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
|
||||||
@@ -149,7 +149,7 @@ export const LibraryHeader = forwardRef(
|
|||||||
id={item.imageId}
|
id={item.imageId}
|
||||||
itemType={item.type as LibraryItem}
|
itemType={item.type as LibraryItem}
|
||||||
onError={onImageError}
|
onError={onImageError}
|
||||||
src={imageUrl || ''}
|
src={effectiveImageUrl ?? ''}
|
||||||
type="header"
|
type="header"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -263,6 +263,7 @@ export const calculateTitleSize = (title: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface LibraryHeaderMenuProps {
|
interface LibraryHeaderMenuProps {
|
||||||
|
disabled?: boolean;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
onArtistRadio?: () => void;
|
onArtistRadio?: () => void;
|
||||||
onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
@@ -274,6 +275,7 @@ interface LibraryHeaderMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LibraryHeaderMenu = ({
|
export const LibraryHeaderMenu = ({
|
||||||
|
disabled,
|
||||||
favorite,
|
favorite,
|
||||||
onArtistRadio,
|
onArtistRadio,
|
||||||
onFavorite,
|
onFavorite,
|
||||||
@@ -319,15 +321,30 @@ export const LibraryHeaderMenu = ({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.libraryHeaderMenu}>
|
<div className={styles.libraryHeaderMenu}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{onPlay && <PlayTextButton {...handlePlayNow.handlers} {...handlePlayNow.props} />}
|
|
||||||
{onPlay && (
|
{onPlay && (
|
||||||
<PlayNextTextButton {...handlePlayNext.handlers} {...handlePlayNext.props} />
|
<PlayTextButton
|
||||||
|
{...handlePlayNow.handlers}
|
||||||
|
{...handlePlayNow.props}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{onPlay && (
|
{onPlay && (
|
||||||
<PlayLastTextButton {...handlePlayLast.handlers} {...handlePlayLast.props} />
|
<PlayNextTextButton
|
||||||
|
{...handlePlayNext.handlers}
|
||||||
|
{...handlePlayNext.props}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onPlay && (
|
||||||
|
<PlayLastTextButton
|
||||||
|
{...handlePlayLast.handlers}
|
||||||
|
{...handlePlayLast.props}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{onArtistRadio && (
|
{onArtistRadio && (
|
||||||
<Button
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
leftSection={
|
leftSection={
|
||||||
isPlayerFetching ? (
|
isPlayerFetching ? (
|
||||||
<Spinner color="white" />
|
<Spinner color="white" />
|
||||||
@@ -344,17 +361,17 @@ export const LibraryHeaderMenu = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{onRating && (
|
{onRating && !disabled && (
|
||||||
<Rating
|
<Rating
|
||||||
onChange={onRating}
|
onChange={onRating}
|
||||||
readOnly={isMutatingRating}
|
readOnly={isMutatingRating || disabled}
|
||||||
size="lg"
|
size="lg"
|
||||||
value={rating || 0}
|
value={rating || 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onFavorite && (
|
{onFavorite && !disabled && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
disabled={isMutatingFavorite}
|
disabled={isMutatingFavorite || disabled}
|
||||||
icon="favorite"
|
icon="favorite"
|
||||||
iconProps={{
|
iconProps={{
|
||||||
fill: favorite ? 'primary' : undefined,
|
fill: favorite ? 'primary' : undefined,
|
||||||
@@ -364,8 +381,9 @@ export const LibraryHeaderMenu = ({
|
|||||||
variant="transparent"
|
variant="transparent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onMore && (
|
{onMore && !disabled && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
disabled={disabled}
|
||||||
icon="ellipsisHorizontal"
|
icon="ellipsisHorizontal"
|
||||||
onClick={onMore}
|
onClick={onMore}
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface TextPlayButtonProps extends ButtonProps {
|
|||||||
|
|
||||||
export const PlayTextButton = ({
|
export const PlayTextButton = ({
|
||||||
className,
|
className,
|
||||||
|
disabled,
|
||||||
showTooltip = true,
|
showTooltip = true,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
...props
|
...props
|
||||||
@@ -58,6 +59,7 @@ export const PlayTextButton = ({
|
|||||||
label: styles.wideTextButtonLabel,
|
label: styles.wideTextButtonLabel,
|
||||||
root: styles.wideTextButton,
|
root: styles.wideTextButton,
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ const SidebarImage = () => {
|
|||||||
|
|
||||||
const imageUrl = useItemImageUrl({
|
const imageUrl = useItemImageUrl({
|
||||||
id: currentSong?.imageId || undefined,
|
id: currentSong?.imageId || undefined,
|
||||||
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
serverId: currentSong?._serverId,
|
serverId: currentSong?._serverId,
|
||||||
type: 'sidebar',
|
type: 'sidebar',
|
||||||
|
|||||||
@@ -613,6 +613,14 @@ const QueryBuilderSettingsSchema = z.object({
|
|||||||
tag: z.array(QueryBuilderCustomFieldSchema),
|
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({
|
const AutoDJSettingsSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
itemCount: z.number(),
|
itemCount: z.number(),
|
||||||
@@ -629,6 +637,7 @@ export const ValidationSettingsStateSchema = z.object({
|
|||||||
font: FontSettingsSchema,
|
font: FontSettingsSchema,
|
||||||
general: GeneralSettingsSchema,
|
general: GeneralSettingsSchema,
|
||||||
hotkeys: HotkeysSettingsSchema,
|
hotkeys: HotkeysSettingsSchema,
|
||||||
|
integrations: IntegrationsSettingsSchema,
|
||||||
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
|
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
|
||||||
lyrics: LyricsSettingsSchema,
|
lyrics: LyricsSettingsSchema,
|
||||||
lyricsDisplay: z.record(z.string(), LyricsDisplaySettingsSchema),
|
lyricsDisplay: z.record(z.string(), LyricsDisplaySettingsSchema),
|
||||||
@@ -638,6 +647,7 @@ export const ValidationSettingsStateSchema = z.object({
|
|||||||
tab: z.union([
|
tab: z.union([
|
||||||
z.literal('general'),
|
z.literal('general'),
|
||||||
z.literal('hotkeys'),
|
z.literal('hotkeys'),
|
||||||
|
z.literal('integrations'),
|
||||||
z.literal('playback'),
|
z.literal('playback'),
|
||||||
z.literal('window'),
|
z.literal('window'),
|
||||||
z.string(),
|
z.string(),
|
||||||
@@ -1095,6 +1105,13 @@ const initialState: SettingsState = {
|
|||||||
},
|
},
|
||||||
globalMediaHotkeys: true,
|
globalMediaHotkeys: true,
|
||||||
},
|
},
|
||||||
|
integrations: {
|
||||||
|
musicBrainz: true,
|
||||||
|
musicbrainzAutoCountryPriority: false,
|
||||||
|
musicBrainzExcludeReleaseTypes: [],
|
||||||
|
musicBrainzPrioritizeCountries: [],
|
||||||
|
youtube: true,
|
||||||
|
},
|
||||||
lists: {
|
lists: {
|
||||||
['albumDetail']: {
|
['albumDetail']: {
|
||||||
display: ListDisplayType.TABLE,
|
display: ListDisplayType.TABLE,
|
||||||
@@ -2248,6 +2265,9 @@ export const useAlbumBackground = () =>
|
|||||||
shallow,
|
shallow,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const useIntegrationsSettings = () =>
|
||||||
|
useSettingsStore((state) => state.integrations, shallow);
|
||||||
|
|
||||||
export const useExternalLinks = () =>
|
export const useExternalLinks = () =>
|
||||||
useSettingsStore(
|
useSettingsStore(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
|
|||||||
@@ -34,17 +34,18 @@ export const idbStateStorage: StateStorage = {
|
|||||||
const settingsKeys = [
|
const settingsKeys = [
|
||||||
'store_settings_autoDJ',
|
'store_settings_autoDJ',
|
||||||
'store_settings_general',
|
'store_settings_general',
|
||||||
'store_settings_lists',
|
|
||||||
'store_settings_hotkeys',
|
'store_settings_hotkeys',
|
||||||
'store_settings_playback',
|
'store_settings_integrations',
|
||||||
|
'store_settings_lists',
|
||||||
'store_settings_lyrics',
|
'store_settings_lyrics',
|
||||||
|
'store_settings_playback',
|
||||||
|
'store_settings_queryBuilder',
|
||||||
|
'store_settings_remote',
|
||||||
|
'store_settings_tab',
|
||||||
'store_settings_window',
|
'store_settings_window',
|
||||||
'store_settings_discord',
|
'store_settings_discord',
|
||||||
'store_settings_font',
|
'store_settings_font',
|
||||||
'store_settings_css',
|
'store_settings_css',
|
||||||
'store_settings_remote',
|
|
||||||
'store_settings_queryBuilder',
|
|
||||||
'store_settings_tab',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const splitSettingsStorage: StateStorage = {
|
export const splitSettingsStorage: StateStorage = {
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
MusicFolder,
|
MusicFolder,
|
||||||
Playlist,
|
Playlist,
|
||||||
RelatedArtist,
|
RelatedArtist,
|
||||||
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerListItem, ServerType } from '/@/shared/types/types';
|
import { ServerListItem } from '/@/shared/types/types';
|
||||||
|
|
||||||
const TICKS_PER_MS = 10000;
|
const TICKS_PER_MS = 10000;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { ServerType } from '../../types/domain-types';
|
||||||
|
|
||||||
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
|
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
|
||||||
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
||||||
import { replacePathPrefix } from '/@/shared/api/utils';
|
import { replacePathPrefix } from '/@/shared/api/utils';
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
Song,
|
Song,
|
||||||
User,
|
User,
|
||||||
} from '/@/shared/types/domain-types';
|
} 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 getImageUrl = (args: { url: null | string }) => {
|
||||||
const { url } = args;
|
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,
|
LuWifi,
|
||||||
LuWifiOff,
|
LuWifiOff,
|
||||||
LuX,
|
LuX,
|
||||||
|
LuYoutube,
|
||||||
} from 'react-icons/lu';
|
} from 'react-icons/lu';
|
||||||
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
||||||
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
|
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
|
||||||
@@ -173,6 +174,7 @@ export const AppIcon = {
|
|||||||
error: LuShieldAlert,
|
error: LuShieldAlert,
|
||||||
expand: LuExpand,
|
expand: LuExpand,
|
||||||
externalLink: LuExternalLink,
|
externalLink: LuExternalLink,
|
||||||
|
externalSong: LuYoutube,
|
||||||
favorite: LuHeart,
|
favorite: LuHeart,
|
||||||
fileJson: LuFileJson,
|
fileJson: LuFileJson,
|
||||||
filter: LuListFilter,
|
filter: LuListFilter,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export enum LibraryItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerType {
|
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',
|
JELLYFIN = 'jellyfin',
|
||||||
NAVIDROME = 'navidrome',
|
NAVIDROME = 'navidrome',
|
||||||
SUBSONIC = 'subsonic',
|
SUBSONIC = 'subsonic',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
Playlist,
|
Playlist,
|
||||||
QueueSong,
|
QueueSong,
|
||||||
|
ServerType,
|
||||||
Song,
|
Song,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeatures } from '/@/shared/types/features-types';
|
import { ServerFeatures } from '/@/shared/types/features-types';
|
||||||
@@ -51,12 +52,6 @@ export enum Platform {
|
|||||||
WINDOWS = 'windows',
|
WINDOWS = 'windows',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerType {
|
|
||||||
JELLYFIN = 'jellyfin',
|
|
||||||
NAVIDROME = 'navidrome',
|
|
||||||
SUBSONIC = 'subsonic',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CardRoute = {
|
export type CardRoute = {
|
||||||
route: AppRoute | string;
|
route: AppRoute | string;
|
||||||
slugs?: RouteSlug[];
|
slugs?: RouteSlug[];
|
||||||
|
|||||||
Reference in New Issue
Block a user