mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-03 19:03:31 +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 |
@@ -155,17 +155,6 @@ jobs:
|
|||||||
pnpm run publish:win:alpha
|
pnpm run publish:win:alpha
|
||||||
on_retry_command: pnpm cache delete
|
on_retry_command: pnpm cache delete
|
||||||
|
|
||||||
- name: Build and Publish to R2 (Windows ARM64)
|
|
||||||
if: matrix.os == 'windows-latest'
|
|
||||||
uses: nick-invision/retry@v2.8.2
|
|
||||||
with:
|
|
||||||
timeout_minutes: 30
|
|
||||||
max_attempts: 3
|
|
||||||
retry_on: error
|
|
||||||
command: |
|
|
||||||
pnpm run publish:win-arm64:alpha
|
|
||||||
on_retry_command: pnpm cache delete
|
|
||||||
|
|
||||||
- name: Build and Publish to R2 (macOS)
|
- name: Build and Publish to R2 (macOS)
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v2.8.2
|
||||||
|
|||||||
@@ -155,19 +155,6 @@ jobs:
|
|||||||
pnpm run publish:win:beta
|
pnpm run publish:win:beta
|
||||||
on_retry_command: pnpm cache delete
|
on_retry_command: pnpm cache delete
|
||||||
|
|
||||||
- name: Build and Publish releases (Windows ARM64)
|
|
||||||
if: matrix.os == 'windows-latest'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
uses: nick-invision/retry@v2.8.2
|
|
||||||
with:
|
|
||||||
timeout_minutes: 30
|
|
||||||
max_attempts: 3
|
|
||||||
retry_on: error
|
|
||||||
command: |
|
|
||||||
pnpm run publish:win-arm64:beta
|
|
||||||
on_retry_command: pnpm cache delete
|
|
||||||
|
|
||||||
- name: Build and Publish releases (macOS)
|
- name: Build and Publish releases (macOS)
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -50,16 +50,6 @@ jobs:
|
|||||||
command: |
|
command: |
|
||||||
pnpm run package:win:pr
|
pnpm run package:win:pr
|
||||||
|
|
||||||
- name: Build for Windows (ARM64)
|
|
||||||
if: ${{ matrix.os == 'windows-latest' }}
|
|
||||||
uses: nick-invision/retry@v2.8.2
|
|
||||||
with:
|
|
||||||
timeout_minutes: 30
|
|
||||||
max_attempts: 3
|
|
||||||
retry_on: error
|
|
||||||
command: |
|
|
||||||
pnpm run package:win-arm64:pr
|
|
||||||
|
|
||||||
- name: Build for Linux
|
- name: Build for Linux
|
||||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||||
uses: nick-invision/retry@v2.8.2
|
uses: nick-invision/retry@v2.8.2
|
||||||
|
|||||||
@@ -33,15 +33,3 @@ jobs:
|
|||||||
command: |
|
command: |
|
||||||
pnpm run publish:win
|
pnpm run publish:win
|
||||||
on_retry_command: pnpm cache delete
|
on_retry_command: pnpm cache delete
|
||||||
|
|
||||||
- name: Build and Publish releases (ARM64)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
uses: nick-invision/retry@v2.8.2
|
|
||||||
with:
|
|
||||||
timeout_minutes: 30
|
|
||||||
max_attempts: 3
|
|
||||||
retry_on: error
|
|
||||||
command: |
|
|
||||||
pnpm run publish:win-arm64
|
|
||||||
on_retry_command: pnpm cache delete
|
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ jobs:
|
|||||||
- uses: vedantmgoyal9/winget-releaser@main
|
- uses: vedantmgoyal9/winget-releaser@main
|
||||||
with:
|
with:
|
||||||
identifier: jeffvli.Feishin
|
identifier: jeffvli.Feishin
|
||||||
installers-regex: 'Feishin-*-win-(x64|arm64)\.exe'
|
installers-regex: 'Feishin-*-win-x64\.exe'
|
||||||
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -35,19 +35,6 @@ jobs:
|
|||||||
pnpm run publish:win
|
pnpm run publish:win
|
||||||
on_retry_command: pnpm cache delete
|
on_retry_command: pnpm cache delete
|
||||||
|
|
||||||
- name: Build and Publish releases (Windows ARM64)
|
|
||||||
if: matrix.os == 'windows-latest'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
uses: nick-invision/retry@v2.8.2
|
|
||||||
with:
|
|
||||||
timeout_minutes: 30
|
|
||||||
max_attempts: 3
|
|
||||||
retry_on: error
|
|
||||||
command: |
|
|
||||||
pnpm run publish:win-arm64
|
|
||||||
on_retry_command: pnpm cache delete
|
|
||||||
|
|
||||||
- name: Build and Publish releases (macOS)
|
- name: Build and Publish releases (macOS)
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
env:
|
env:
|
||||||
|
|||||||
+3
-4
@@ -44,7 +44,6 @@
|
|||||||
"package:mac": "pnpm run build && electron-builder --mac",
|
"package:mac": "pnpm run build && electron-builder --mac",
|
||||||
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
|
"package:mac:pr": "pnpm run build && electron-builder --mac --publish never",
|
||||||
"package:win": "pnpm run build && electron-builder --win",
|
"package:win": "pnpm run build && electron-builder --win",
|
||||||
"package:win-arm64:pr": "pnpm run build && electron-builder --win --arm64 --publish never",
|
|
||||||
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
|
"package:win:pr": "pnpm run build && electron-builder --win --publish never",
|
||||||
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
|
"publish:linux": "pnpm run build && electron-builder --publish always --linux",
|
||||||
"publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64",
|
"publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64",
|
||||||
@@ -56,9 +55,6 @@
|
|||||||
"publish:mac:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac",
|
"publish:mac:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac",
|
||||||
"publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac",
|
"publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac",
|
||||||
"publish:win": "pnpm run build && electron-builder --publish always --win",
|
"publish:win": "pnpm run build && electron-builder --publish always --win",
|
||||||
"publish:win-arm64": "pnpm run build && electron-builder --publish always --win --arm64",
|
|
||||||
"publish:win-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win --arm64",
|
|
||||||
"publish:win-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win --arm64",
|
|
||||||
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
|
"publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win",
|
||||||
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
|
"publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
@@ -102,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",
|
||||||
@@ -115,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",
|
||||||
@@ -138,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",
|
||||||
@@ -1145,7 +1158,6 @@
|
|||||||
"year": "$t(common.year)"
|
"year": "$t(common.year)"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"detail": "detail",
|
|
||||||
"grid": "grid",
|
"grid": "grid",
|
||||||
"list": "list",
|
"list": "list",
|
||||||
"table": "table"
|
"table": "table"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+2
-18
@@ -21,14 +21,6 @@ export default class MenuBuilder {
|
|||||||
selector: 'orderFrontStandardAboutPanel:',
|
selector: 'orderFrontStandardAboutPanel:',
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
|
||||||
accelerator: 'Command+,',
|
|
||||||
click: () => {
|
|
||||||
this.mainWindow.webContents.send('renderer-open-settings');
|
|
||||||
},
|
|
||||||
label: 'Settings',
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ label: 'Services', submenu: [] },
|
{ label: 'Services', submenu: [] },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
@@ -159,8 +151,8 @@ export default class MenuBuilder {
|
|||||||
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
|
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDefaultTemplate(): MenuItemConstructorOptions[] {
|
buildDefaultTemplate() {
|
||||||
const templateDefault: MenuItemConstructorOptions[] = [
|
const templateDefault = [
|
||||||
{
|
{
|
||||||
label: '&File',
|
label: '&File',
|
||||||
submenu: [
|
submenu: [
|
||||||
@@ -168,14 +160,6 @@ export default class MenuBuilder {
|
|||||||
accelerator: 'Ctrl+O',
|
accelerator: 'Ctrl+O',
|
||||||
label: '&Open',
|
label: '&Open',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accelerator: 'Ctrl+,',
|
|
||||||
click: () => {
|
|
||||||
this.mainWindow.webContents.send('renderer-open-settings');
|
|
||||||
},
|
|
||||||
label: '&Settings...',
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
{
|
||||||
accelerator: 'Ctrl+W',
|
accelerator: 'Ctrl+W',
|
||||||
click: () => {
|
click: () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ const forceGarbageCollection = (): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rendererOpenSettings = (cb: (event: IpcRendererEvent) => void) => {
|
|
||||||
ipcRenderer.on('renderer-open-settings', cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
disableAutoUpdates,
|
disableAutoUpdates,
|
||||||
@@ -78,7 +74,6 @@ export const utils = {
|
|||||||
openApplicationDirectory,
|
openApplicationDirectory,
|
||||||
openItem,
|
openItem,
|
||||||
playerErrorListener,
|
playerErrorListener,
|
||||||
rendererOpenSettings,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Utils = typeof utils;
|
export type Utils = typeof utils;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -57,7 +57,7 @@ const JF_FIELDS = {
|
|||||||
ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds',
|
ALBUM_ARTIST_DETAIL: 'Genres, Overview, SortName, ProviderIds',
|
||||||
ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
ALBUM_ARTIST_LIST: 'Genres, DateCreated, ExternalUrls, Overview, SortName, ProviderIds',
|
||||||
ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds',
|
ALBUM_DETAIL: 'Genres, DateCreated, ChildCount, People, Tags, ProviderIds',
|
||||||
ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds, ChildCount',
|
ALBUM_LIST: 'People, Tags, Studios, SortName, UserData, ProviderIds',
|
||||||
FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
FOLDER: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||||
GENRE: 'ItemCounts',
|
GENRE: 'ItemCounts',
|
||||||
PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
PLAYLIST_DETAIL: 'Genres, DateCreated, MediaSources, ChildCount, ParentId, SortName',
|
||||||
@@ -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',
|
||||||
@@ -1112,7 +1112,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
GenreIds: query.genreIds?.join(','),
|
GenreIds: query.genreIds?.join(','),
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
IsFavorite: query.favorite,
|
IsFavorite: query.favorite,
|
||||||
Limit: query.limit === -1 ? undefined : query.limit,
|
Limit: query.limit,
|
||||||
ParentId: getLibraryId(query.musicFolderId),
|
ParentId: getLibraryId(query.musicFolderId),
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
SearchTerm: query.searchTerm,
|
SearchTerm: query.searchTerm,
|
||||||
@@ -1147,7 +1147,7 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
GenreIds: query.genreIds?.join(','),
|
GenreIds: query.genreIds?.join(','),
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
IsFavorite: query.favorite,
|
IsFavorite: query.favorite,
|
||||||
Limit: query.limit === -1 ? undefined : query.limit,
|
Limit: query.limit,
|
||||||
ParentId: getLibraryId(query.musicFolderId),
|
ParentId: getLibraryId(query.musicFolderId),
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
SearchTerm: query.searchTerm,
|
SearchTerm: query.searchTerm,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import isElectron from 'is-electron';
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
|
|
||||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||||
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
|
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
|
||||||
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
|
||||||
@@ -80,19 +79,6 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isElectron()) {
|
|
||||||
window.api.utils.rendererOpenSettings(() => {
|
|
||||||
openSettingsModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ipc?.removeAllListeners('renderer-open-settings');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const notificationStyles = useMemo(
|
const notificationStyles = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
root: {
|
root: {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: var(--theme-spacing-sm);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--theme-spacing-sm);
|
||||||
|
container-type: inline-size;
|
||||||
|
background: var(--theme-colors-surface);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
|
@container (min-width: 500px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
position: relative;
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
content: '';
|
||||||
|
background-color: rgb(0 0 0);
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::before {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 500px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--theme-spacing-sm);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--theme-spacing-xs) 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-container .header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-container .header .title {
|
||||||
|
max-width: 70%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-container .content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--theme-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-container .content .tags {
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// import { AnimatePresence } from 'motion/react';
|
||||||
|
// import { MouseEvent, useMemo, useState } from 'react';
|
||||||
|
// import { Link } from 'react-router';
|
||||||
|
|
||||||
|
// import styles from './item-detail.module.css';
|
||||||
|
|
||||||
|
// import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||||
|
// import { useFastAverageColor } from '/@/renderer/hooks';
|
||||||
|
// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
|
// import { Badge } from '/@/shared/components/badge/badge';
|
||||||
|
// import { Divider } from '/@/shared/components/divider/divider';
|
||||||
|
// import { Group } from '/@/shared/components/group/group';
|
||||||
|
// import { Image } from '/@/shared/components/image/image';
|
||||||
|
// import { Rating } from '/@/shared/components/rating/rating';
|
||||||
|
// import { Text } from '/@/shared/components/text/text';
|
||||||
|
// import {
|
||||||
|
// Album,
|
||||||
|
// AlbumArtist,
|
||||||
|
// Artist,
|
||||||
|
// LibraryItem,
|
||||||
|
// Playlist,
|
||||||
|
// Song,
|
||||||
|
// } from '/@/shared/types/domain-types';
|
||||||
|
// import { stringToColor } from '/@/shared/utils/string-to-color';
|
||||||
|
|
||||||
|
// interface ItemDetailProps {
|
||||||
|
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
|
||||||
|
// itemHeight: number;
|
||||||
|
// itemType: LibraryItem;
|
||||||
|
// onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
|
||||||
|
// withControls?: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
|
||||||
|
// const imageUrl = getImageUrl(data);
|
||||||
|
|
||||||
|
// const [showControls, setShowControls] = useState(false);
|
||||||
|
|
||||||
|
// const { background } = useFastAverageColor({
|
||||||
|
// algorithm: 'simple',
|
||||||
|
// src: imageUrl,
|
||||||
|
// srcLoaded: false,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // const tags = [...(data?.genres ?? [])];
|
||||||
|
|
||||||
|
// const tags = useMemo(() => {
|
||||||
|
// if (!data) {
|
||||||
|
// return [];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const items: {
|
||||||
|
// color?: string;
|
||||||
|
// id: string;
|
||||||
|
// isLight?: boolean;
|
||||||
|
// itemType: LibraryItem;
|
||||||
|
// name: string;
|
||||||
|
// }[] = [];
|
||||||
|
|
||||||
|
// if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
|
||||||
|
// data.albumArtists?.forEach((tag: { id: string; name: string }) => {
|
||||||
|
// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if ('genres' in data && Array.isArray(data.genres)) {
|
||||||
|
// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
|
||||||
|
// const { color, isLight } = stringToColor(tag.name);
|
||||||
|
// items.push({ ...tag, color, isLight });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // if ('tags' in data && typeof data.tags === 'object') {
|
||||||
|
// // console.log('data.tags :>> ', data.tags);
|
||||||
|
// // Object.entries(data.tags).forEach(([key, value]) => {
|
||||||
|
// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
|
||||||
|
// // });
|
||||||
|
// // }
|
||||||
|
|
||||||
|
// return items;
|
||||||
|
// }, [data]);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div
|
||||||
|
// className={styles.container}
|
||||||
|
// onClick={(e) => onClick?.(e, data, itemType)}
|
||||||
|
// style={{ backgroundColor: background }}
|
||||||
|
// >
|
||||||
|
// <div
|
||||||
|
// className={styles.imageContainer}
|
||||||
|
// onMouseEnter={() => withControls && setShowControls(true)}
|
||||||
|
// onMouseLeave={() => withControls && setShowControls(false)}
|
||||||
|
// >
|
||||||
|
// <Image alt={data?.name} src={imageUrl} />
|
||||||
|
// <AnimatePresence>
|
||||||
|
// {withControls && showControls && <ItemCardControls type="compact" />}
|
||||||
|
// </AnimatePresence>
|
||||||
|
// </div>
|
||||||
|
// <div className={styles.metadataContainer}>
|
||||||
|
// <div className={styles.header}>
|
||||||
|
// <Text className={styles.title} component={Link} isLink size="lg" weight={500}>
|
||||||
|
// {data?.name}
|
||||||
|
// </Text>
|
||||||
|
// <Group>
|
||||||
|
// {data && 'userRating' in data && (
|
||||||
|
// <Rating size="xs" value={data?.userRating ?? 0} />
|
||||||
|
// )}
|
||||||
|
// {data && 'userFavorite' in data && (
|
||||||
|
// <ActionIcon
|
||||||
|
// icon="favorite"
|
||||||
|
// iconProps={{
|
||||||
|
// fill: data?.userFavorite ? 'primary' : 'default',
|
||||||
|
// }}
|
||||||
|
// size="xs"
|
||||||
|
// />
|
||||||
|
// )}
|
||||||
|
// </Group>
|
||||||
|
// </div>
|
||||||
|
// <Divider />
|
||||||
|
// <div className={styles.content}>
|
||||||
|
// <Group className={styles.tags} gap="xs">
|
||||||
|
// {tags.map((tag) => (
|
||||||
|
// <Badge
|
||||||
|
// key={tag.id}
|
||||||
|
// style={{
|
||||||
|
// backgroundColor: tag.color,
|
||||||
|
// color: tag.isLight ? 'black' : 'white',
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {tag.name}
|
||||||
|
// </Badge>
|
||||||
|
// ))}
|
||||||
|
// </Group>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
|
||||||
|
// if (data && 'imageUrl' in data) {
|
||||||
|
// return data.imageUrl || undefined;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return undefined;
|
||||||
|
// };
|
||||||
@@ -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 {
|
||||||
@@ -192,10 +192,9 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
|
onColumnReordered?.(columnIdFrom, columnIdTo, edge);
|
||||||
},
|
},
|
||||||
|
|
||||||
onColumnResized: onColumnResized
|
onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => {
|
||||||
? ({ columnId, width }: { columnId: TableColumn; width: number }) =>
|
onColumnResized?.(columnId, width);
|
||||||
onColumnResized(columnId, width)
|
},
|
||||||
: undefined,
|
|
||||||
|
|
||||||
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
|
onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => {
|
||||||
if (!item || !internalState) {
|
if (!item || !internalState) {
|
||||||
@@ -242,13 +241,11 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playType = (meta?.playType as Play) || Play.NOW;
|
const playType = (meta?.playType as Play) || Play.NOW;
|
||||||
const singleSongOnly = meta?.singleSongOnly === true;
|
|
||||||
|
|
||||||
// For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song
|
// For NEXT, LAST, NEXT_SHUFFLE, and LAST_SHUFFLE, only add the clicked song
|
||||||
// For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song
|
// For NOW and SHUFFLE, add a range of songs around the clicked song
|
||||||
let songsToAdd: Song[];
|
let songsToAdd: Song[];
|
||||||
if (
|
if (
|
||||||
singleSongOnly ||
|
|
||||||
playType === Play.NEXT ||
|
playType === Play.NEXT ||
|
||||||
playType === Play.LAST ||
|
playType === Play.LAST ||
|
||||||
playType === Play.NEXT_SHUFFLE ||
|
playType === Play.NEXT_SHUFFLE ||
|
||||||
@@ -387,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);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -420,9 +426,9 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
enableMultiSelect,
|
enableMultiSelect,
|
||||||
overrides,
|
|
||||||
onColumnReordered,
|
onColumnReordered,
|
||||||
onColumnResized,
|
onColumnResized,
|
||||||
|
overrides,
|
||||||
player,
|
player,
|
||||||
setFavorite,
|
setFavorite,
|
||||||
setRating,
|
setRating,
|
||||||
|
|||||||
@@ -7,19 +7,14 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
|||||||
|
|
||||||
interface UseItemListColumnReorderProps {
|
interface UseItemListColumnReorderProps {
|
||||||
itemListKey: ItemListKey;
|
itemListKey: ItemListKey;
|
||||||
tableKey?: 'detail' | 'main';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListColumnReorder = ({
|
export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReorderProps) => {
|
||||||
itemListKey,
|
|
||||||
tableKey = 'main',
|
|
||||||
}: UseItemListColumnReorderProps) => {
|
|
||||||
const { setList } = useSettingsStoreActions();
|
const { setList } = useSettingsStoreActions();
|
||||||
|
|
||||||
const handleColumnReordered = useCallback(
|
const handleColumnReordered = useCallback(
|
||||||
(columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {
|
(columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => {
|
||||||
const list = useSettingsStore.getState().lists[itemListKey];
|
const columns = useSettingsStore.getState().lists[itemListKey]?.table.columns;
|
||||||
const columns = tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
|
|
||||||
|
|
||||||
if (!columns) {
|
if (!columns) {
|
||||||
return;
|
return;
|
||||||
@@ -88,20 +83,13 @@ export const useItemListColumnReorder = ({
|
|||||||
// Insert the column at the new position
|
// Insert the column at the new position
|
||||||
newColumns.splice(newIndex, 0, updatedMovedColumn);
|
newColumns.splice(newIndex, 0, updatedMovedColumn);
|
||||||
|
|
||||||
if (tableKey === 'detail') {
|
|
||||||
type SetListData = Parameters<
|
|
||||||
ReturnType<typeof useSettingsStoreActions>['setList']
|
|
||||||
>[1];
|
|
||||||
setList(itemListKey, { detail: { columns: newColumns } } as SetListData);
|
|
||||||
} else {
|
|
||||||
setList(itemListKey, {
|
setList(itemListKey, {
|
||||||
table: {
|
table: {
|
||||||
columns: newColumns,
|
columns: newColumns,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[itemListKey, setList, tableKey],
|
[itemListKey, setList],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleColumnReordered };
|
return { handleColumnReordered };
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
|||||||
|
|
||||||
interface UseItemListColumnResizeProps {
|
interface UseItemListColumnResizeProps {
|
||||||
itemListKey: ItemListKey;
|
itemListKey: ItemListKey;
|
||||||
tableKey?: 'detail' | 'main';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListColumnResize = ({
|
export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResizeProps) => {
|
||||||
itemListKey,
|
|
||||||
tableKey = 'main',
|
|
||||||
}: UseItemListColumnResizeProps) => {
|
|
||||||
const { setList } = useSettingsStoreActions();
|
const { setList } = useSettingsStoreActions();
|
||||||
const columns = useSettingsStore((state) => {
|
const columns = useSettingsStore((state) => state.lists[itemListKey]?.table.columns);
|
||||||
const list = state.lists[itemListKey];
|
|
||||||
return tableKey === 'detail' ? list?.detail?.columns : list?.table?.columns;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleColumnResized = useCallback(
|
const handleColumnResized = useCallback(
|
||||||
(columnId: TableColumn, width: number) => {
|
(columnId: TableColumn, width: number) => {
|
||||||
@@ -26,20 +19,13 @@ export const useItemListColumnResize = ({
|
|||||||
column.id === columnId ? { ...column, width } : column,
|
column.id === columnId ? { ...column, width } : column,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tableKey === 'detail') {
|
|
||||||
type SetListData = Parameters<
|
|
||||||
ReturnType<typeof useSettingsStoreActions>['setList']
|
|
||||||
>[1];
|
|
||||||
setList(itemListKey, { detail: { columns: updatedColumns } } as SetListData);
|
|
||||||
} else {
|
|
||||||
setList(itemListKey, {
|
setList(itemListKey, {
|
||||||
table: {
|
table: {
|
||||||
columns: updatedColumns,
|
columns: updatedColumns,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[columns, itemListKey, setList, tableKey],
|
[columns, itemListKey, setList],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleColumnResized };
|
return { handleColumnResized };
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useLocation, useNavigationType } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useScrollStore } from '/@/renderer/store/scroll.store';
|
import { parseIntParam, setSearchParam } from '/@/renderer/utils/query-params';
|
||||||
|
|
||||||
interface UseItemListScrollPersistProps {
|
interface UseItemListScrollPersistProps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
export const useItemListScrollPersist = ({ enabled }: UseItemListScrollPersistProps) => {
|
||||||
const location = useLocation();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const navigationType = useNavigationType();
|
|
||||||
const setOffset = useScrollStore((s) => s.setOffset);
|
|
||||||
const getOffset = useScrollStore((s) => s.getOffset);
|
|
||||||
|
|
||||||
const scrollOffset = useMemo(() => {
|
const scrollOffset = useMemo(() => parseIntParam(searchParams, 'scrollOffset'), [searchParams]);
|
||||||
if (navigationType !== 'POP') return undefined;
|
|
||||||
return getOffset(location.key);
|
|
||||||
}, [getOffset, location.key, navigationType]);
|
|
||||||
|
|
||||||
const handleOnScrollEnd = useCallback(
|
const handleOnScrollEnd = useCallback(
|
||||||
(offset: number) => {
|
(offset: number) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
setOffset(location.key, offset);
|
|
||||||
|
setSearchParams((prev) => setSearchParam(prev, 'scrollOffset', offset), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[enabled, location.key, setOffset],
|
[enabled, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleOnScrollEnd, scrollOffset };
|
return { handleOnScrollEnd, scrollOffset };
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export const ActionsColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
|
|
||||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
const index = internalState?.findItemIndex(song.id) ?? -1;
|
|
||||||
controls?.onMore?.({
|
|
||||||
event,
|
|
||||||
index,
|
|
||||||
internalState: internalState ?? undefined,
|
|
||||||
item: song,
|
|
||||||
itemType: LibraryItem.SONG,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDoubleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionIcon
|
|
||||||
icon="ellipsisHorizontal"
|
|
||||||
iconProps={{
|
|
||||||
color: 'muted',
|
|
||||||
size: 'xs',
|
|
||||||
}}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
JOINED_ARTISTS_MUTED_PROPS,
|
|
||||||
JoinedArtists,
|
|
||||||
} from '/@/renderer/features/albums/components/joined-artists';
|
|
||||||
|
|
||||||
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
|
||||||
const name = song.albumArtistName?.trim() ?? '';
|
|
||||||
const hasArtists = name.length > 0 || (song.albumArtists?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
if (!hasArtists) return <> </>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JoinedArtists
|
|
||||||
artistName={song.albumArtistName ?? ''}
|
|
||||||
artists={song.albumArtists ?? []}
|
|
||||||
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
|
||||||
readOnly={!isRowHovered}
|
|
||||||
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const AlbumColumn = ({ song }: ItemDetailListCellProps) => song.album ?? <> </>;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
JOINED_ARTISTS_MUTED_PROPS,
|
|
||||||
JoinedArtists,
|
|
||||||
} from '/@/renderer/features/albums/components/joined-artists';
|
|
||||||
|
|
||||||
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
|
||||||
const name = song.artistName?.trim() ?? '';
|
|
||||||
const hasArtists = name.length > 0 || (song.artists?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
if (!hasArtists) return <> </>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JoinedArtists
|
|
||||||
artistName={song.artistName ?? ''}
|
|
||||||
artists={song.artists ?? []}
|
|
||||||
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
|
||||||
readOnly={!isRowHovered}
|
|
||||||
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const BitDepthColumn = ({ song }: ItemDetailListCellProps) => song.bitDepth;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const BitRateColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.bitRate != null ? `${song.bitRate} kbps` : <> </>;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const BpmColumn = ({ song }: ItemDetailListCellProps) => song.bpm ?? <> </>;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const ChannelsColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.channels != null ? String(song.channels) : <> </>;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const CodecColumn = ({ song }: ItemDetailListCellProps) => song.container ?? <> </>;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const CommentColumn = ({ song }: ItemDetailListCellProps) => song.comment ?? <> </>;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const ComposerColumn = ({ song }: ItemDetailListCellProps) => {
|
|
||||||
const composers = song.participants?.composer;
|
|
||||||
if (!composers?.length) return <> </>;
|
|
||||||
return composers.map((a) => a.name).join(', ');
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { formatDateAbsolute } from '/@/renderer/utils/format';
|
|
||||||
|
|
||||||
export const DateAddedColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.createdAt ? formatDateAbsolute(song.createdAt) : <> </>;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
interface DefaultColumnProps extends ItemDetailListCellProps {
|
|
||||||
columnId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultColumn = ({ columnId, song }: DefaultColumnProps) => {
|
|
||||||
const raw = (song as Record<string, unknown>)[columnId];
|
|
||||||
if (raw === undefined || raw === null || typeof raw === 'object') return <> </>;
|
|
||||||
return String(raw);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const DiscNumberColumn = ({ song }: ItemDetailListCellProps) => String(song.discNumber ?? 1);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import formatDuration from 'format-duration';
|
|
||||||
|
|
||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const DurationColumn = ({ song }: ItemDetailListCellProps) => formatDuration(song.duration);
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
|
||||||
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export const FavoriteColumn = ({
|
|
||||||
controls,
|
|
||||||
internalState,
|
|
||||||
isMutatingFavorite,
|
|
||||||
onFavoriteClick,
|
|
||||||
song,
|
|
||||||
}: ItemDetailListCellProps) => {
|
|
||||||
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
|
|
||||||
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
|
|
||||||
const isMutating = isMutatingFavorite ?? (isMutatingCreateFavorite || isMutatingDeleteFavorite);
|
|
||||||
const isFavorite = song.userFavorite ?? false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionIcon
|
|
||||||
disabled={isMutating}
|
|
||||||
icon="favorite"
|
|
||||||
iconProps={{
|
|
||||||
color: isFavorite ? 'primary' : 'muted',
|
|
||||||
fill: isFavorite ? 'primary' : undefined,
|
|
||||||
size: 'xs',
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
const index = internalState?.findItemIndex(song.id) ?? -1;
|
|
||||||
if (controls?.onFavorite) {
|
|
||||||
controls.onFavorite({
|
|
||||||
event,
|
|
||||||
favorite: !isFavorite,
|
|
||||||
index,
|
|
||||||
internalState: internalState ?? undefined,
|
|
||||||
item: song,
|
|
||||||
itemType: LibraryItem.SONG,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onFavoriteClick?.(song);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDoubleClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
.group {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: var(--theme-spacing-sm) var(--theme-spacing-xs);
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--theme-spacing-xs) 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group a {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { generatePath, Link } from 'react-router';
|
|
||||||
|
|
||||||
import styles from './genre-badge-column.module.css';
|
|
||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
|
||||||
import { Badge } from '/@/shared/components/badge/badge';
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
|
||||||
import { stringToColor } from '/@/shared/utils/string-to-color';
|
|
||||||
|
|
||||||
const MAX_GENRES = 4;
|
|
||||||
|
|
||||||
export const GenreBadgeColumn = ({ song }: ItemDetailListCellProps) => {
|
|
||||||
const genres = song.genres;
|
|
||||||
|
|
||||||
const genresWithStyle = useMemo(() => {
|
|
||||||
if (!genres) return [];
|
|
||||||
return genres.slice(0, MAX_GENRES).map((genre) => {
|
|
||||||
const { color, isLight } = stringToColor(genre.name);
|
|
||||||
const path = generatePath(AppRoute.LIBRARY_GENRES_DETAIL, { genreId: genre.id });
|
|
||||||
return { ...genre, color, isLight, path };
|
|
||||||
});
|
|
||||||
}, [genres]);
|
|
||||||
|
|
||||||
if (!genresWithStyle.length) return <> </>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group className={styles.group} wrap="nowrap">
|
|
||||||
{genresWithStyle.map((genre) => (
|
|
||||||
<Badge
|
|
||||||
component={Link}
|
|
||||||
key={genre.id}
|
|
||||||
state={{ item: genre }}
|
|
||||||
style={{
|
|
||||||
backgroundColor: genre.color,
|
|
||||||
color: genre.isLight ? 'black' : 'white',
|
|
||||||
}}
|
|
||||||
to={genre.path}
|
|
||||||
>
|
|
||||||
{genre.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Fragment } from 'react';
|
|
||||||
import { generatePath, Link } from 'react-router';
|
|
||||||
|
|
||||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
|
||||||
import { Text } from '/@/shared/components/text/text';
|
|
||||||
|
|
||||||
const TEXT_PROPS = { isMuted: true, isNoSelect: true, size: 'sm' as const } as const;
|
|
||||||
|
|
||||||
export const GenreColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
|
||||||
const genres = song.genres ?? [];
|
|
||||||
if (!genres.length) return <> </>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{genres.map((genre, index) => (
|
|
||||||
<Fragment key={genre.id}>
|
|
||||||
{isRowHovered ? (
|
|
||||||
<Text
|
|
||||||
component={Link}
|
|
||||||
isLink
|
|
||||||
state={{ item: genre }}
|
|
||||||
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
|
|
||||||
genreId: genre.id,
|
|
||||||
})}
|
|
||||||
{...TEXT_PROPS}
|
|
||||||
>
|
|
||||||
{genre.name}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text component="span" {...TEXT_PROPS}>
|
|
||||||
{genre.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{index < genres.length - 1 && ', '}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
.image-container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-container {
|
|
||||||
flex: 1 1 0;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
max-height: 100%;
|
|
||||||
aspect-ratio: unset;
|
|
||||||
padding-top: var(--theme-spacing-xs);
|
|
||||||
padding-bottom: var(--theme-spacing-xs);
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
z-index: 10;
|
|
||||||
opacity: 0.6;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button-overlay:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button-overlay button {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-image {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center;
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import styles from './image-column.module.css';
|
|
||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
|
||||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
|
||||||
import {
|
|
||||||
LONG_PRESS_PLAY_BEHAVIOR,
|
|
||||||
PlayTooltip,
|
|
||||||
} from '/@/renderer/features/shared/components/play-button-group';
|
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
|
||||||
import { Play } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
export const ImageColumn = ({
|
|
||||||
controls,
|
|
||||||
internalState,
|
|
||||||
rowIndex = 0,
|
|
||||||
song,
|
|
||||||
}: ItemDetailListCellProps) => {
|
|
||||||
const playButtonBehavior = usePlayButtonBehavior();
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
|
|
||||||
const handlePlay = (playType: Play) => {
|
|
||||||
if (!song || !controls?.onDoubleClick) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.onDoubleClick({
|
|
||||||
event: null,
|
|
||||||
index: rowIndex,
|
|
||||||
internalState,
|
|
||||||
item: song,
|
|
||||||
itemType: LibraryItem.SONG,
|
|
||||||
meta: { playType, singleSongOnly: true },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.imageContainer}
|
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
className={styles.compactImage}
|
|
||||||
containerClassName={styles.compactContainer}
|
|
||||||
explicitStatus={song.explicitStatus}
|
|
||||||
id={song.imageId}
|
|
||||||
itemType={LibraryItem.SONG}
|
|
||||||
serverId={song._serverId}
|
|
||||||
type="table"
|
|
||||||
/>
|
|
||||||
{isHovered && (
|
|
||||||
<div className={clsx(styles.playButtonOverlay)}>
|
|
||||||
<PlayTooltip disabled={false} type={playButtonBehavior}>
|
|
||||||
<PlayButton
|
|
||||||
fill
|
|
||||||
onClick={() => handlePlay(playButtonBehavior)}
|
|
||||||
onLongPress={() =>
|
|
||||||
handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PlayTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import React, { type ReactNode } from 'react';
|
|
||||||
|
|
||||||
import type { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { ActionsColumn } from './actions-column';
|
|
||||||
import { AlbumArtistColumn } from './album-artist-column';
|
|
||||||
import { AlbumColumn } from './album-column';
|
|
||||||
import { ArtistColumn } from './artist-column';
|
|
||||||
import { BitDepthColumn } from './bit-depth-column';
|
|
||||||
import { BitRateColumn } from './bit-rate-column';
|
|
||||||
import { BpmColumn } from './bpm-column';
|
|
||||||
import { ChannelsColumn } from './channels-column';
|
|
||||||
import { CodecColumn } from './codec-column';
|
|
||||||
import { CommentColumn } from './comment-column';
|
|
||||||
import { ComposerColumn } from './composer-column';
|
|
||||||
import { DateAddedColumn } from './date-added-column';
|
|
||||||
import { DefaultColumn } from './default-column';
|
|
||||||
import { DiscNumberColumn } from './disc-number-column';
|
|
||||||
import { DurationColumn } from './duration-column';
|
|
||||||
import { FavoriteColumn } from './favorite-column';
|
|
||||||
import { GenreBadgeColumn } from './genre-badge-column';
|
|
||||||
import { GenreColumn } from './genre-column';
|
|
||||||
import { ImageColumn } from './image-column';
|
|
||||||
import { LastPlayedColumn } from './last-played-column';
|
|
||||||
import { PathColumn } from './path-column';
|
|
||||||
import { PlayCountColumn } from './play-count-column';
|
|
||||||
import { RatingColumn } from './rating-column';
|
|
||||||
import { ReleaseDateColumn } from './release-date-column';
|
|
||||||
import { RowIndexColumn } from './row-index-column';
|
|
||||||
import { SampleRateColumn } from './sample-rate-column';
|
|
||||||
import { SizeColumn } from './size-column';
|
|
||||||
import { TitleArtistColumn } from './title-artist-column';
|
|
||||||
import { TitleColumn } from './title-column';
|
|
||||||
import { TitleCombinedColumn } from './title-combined-column';
|
|
||||||
import { TrackNumberColumn } from './track-number-column';
|
|
||||||
import { YearColumn } from './year-column';
|
|
||||||
|
|
||||||
import { TableColumn } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
type CellComponent = (props: ItemDetailListCellProps) => ReactNode;
|
|
||||||
|
|
||||||
const COLUMN_MAP: Partial<Record<TableColumn, CellComponent>> = {
|
|
||||||
[TableColumn.ACTIONS]: ActionsColumn,
|
|
||||||
[TableColumn.ALBUM]: AlbumColumn,
|
|
||||||
[TableColumn.ALBUM_ARTIST]: AlbumArtistColumn,
|
|
||||||
[TableColumn.ARTIST]: ArtistColumn,
|
|
||||||
[TableColumn.BIT_DEPTH]: BitDepthColumn,
|
|
||||||
[TableColumn.BIT_RATE]: BitRateColumn,
|
|
||||||
[TableColumn.BPM]: BpmColumn,
|
|
||||||
[TableColumn.CHANNELS]: ChannelsColumn,
|
|
||||||
[TableColumn.CODEC]: CodecColumn,
|
|
||||||
[TableColumn.COMMENT]: CommentColumn,
|
|
||||||
[TableColumn.COMPOSER]: ComposerColumn,
|
|
||||||
[TableColumn.DATE_ADDED]: DateAddedColumn,
|
|
||||||
[TableColumn.DISC_NUMBER]: DiscNumberColumn,
|
|
||||||
[TableColumn.DURATION]: DurationColumn,
|
|
||||||
[TableColumn.GENRE]: GenreColumn,
|
|
||||||
[TableColumn.GENRE_BADGE]: GenreBadgeColumn,
|
|
||||||
[TableColumn.IMAGE]: ImageColumn,
|
|
||||||
[TableColumn.LAST_PLAYED]: LastPlayedColumn,
|
|
||||||
[TableColumn.PATH]: PathColumn,
|
|
||||||
[TableColumn.PLAY_COUNT]: PlayCountColumn,
|
|
||||||
[TableColumn.RELEASE_DATE]: ReleaseDateColumn,
|
|
||||||
[TableColumn.ROW_INDEX]: RowIndexColumn,
|
|
||||||
[TableColumn.SAMPLE_RATE]: SampleRateColumn,
|
|
||||||
[TableColumn.SIZE]: SizeColumn,
|
|
||||||
[TableColumn.TITLE]: TitleColumn,
|
|
||||||
[TableColumn.TITLE_ARTIST]: TitleArtistColumn,
|
|
||||||
[TableColumn.TITLE_COMBINED]: TitleCombinedColumn,
|
|
||||||
[TableColumn.TRACK_NUMBER]: TrackNumberColumn,
|
|
||||||
[TableColumn.USER_FAVORITE]: FavoriteColumn,
|
|
||||||
[TableColumn.USER_RATING]: RatingColumn,
|
|
||||||
[TableColumn.YEAR]: YearColumn,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DetailListCellComponentProps = ItemDetailListCellProps & { columnId?: string };
|
|
||||||
|
|
||||||
export function getDetailListCellComponent(
|
|
||||||
columnId: string | TableColumn,
|
|
||||||
): (props: DetailListCellComponentProps) => ReactNode {
|
|
||||||
const Component = COLUMN_MAP[columnId as TableColumn];
|
|
||||||
if (Component) {
|
|
||||||
return Component as (props: DetailListCellComponentProps) => ReactNode;
|
|
||||||
}
|
|
||||||
return (props: DetailListCellComponentProps) =>
|
|
||||||
React.createElement(DefaultColumn, {
|
|
||||||
columnId: props.columnId ?? (columnId as string),
|
|
||||||
song: props.song,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export {
|
|
||||||
ActionsColumn,
|
|
||||||
AlbumArtistColumn,
|
|
||||||
AlbumColumn,
|
|
||||||
ArtistColumn,
|
|
||||||
BitDepthColumn,
|
|
||||||
BitRateColumn,
|
|
||||||
BpmColumn,
|
|
||||||
ChannelsColumn,
|
|
||||||
CodecColumn,
|
|
||||||
CommentColumn,
|
|
||||||
ComposerColumn,
|
|
||||||
DateAddedColumn,
|
|
||||||
DefaultColumn,
|
|
||||||
DiscNumberColumn,
|
|
||||||
DurationColumn,
|
|
||||||
FavoriteColumn,
|
|
||||||
GenreBadgeColumn,
|
|
||||||
GenreColumn,
|
|
||||||
ImageColumn,
|
|
||||||
LastPlayedColumn,
|
|
||||||
PathColumn,
|
|
||||||
PlayCountColumn,
|
|
||||||
RatingColumn,
|
|
||||||
ReleaseDateColumn,
|
|
||||||
RowIndexColumn,
|
|
||||||
SampleRateColumn,
|
|
||||||
SizeColumn,
|
|
||||||
TitleArtistColumn,
|
|
||||||
TitleColumn,
|
|
||||||
TitleCombinedColumn,
|
|
||||||
TrackNumberColumn,
|
|
||||||
YearColumn,
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { formatDateRelative } from '/@/renderer/utils/format';
|
|
||||||
|
|
||||||
export const LastPlayedColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.lastPlayedAt ? formatDateRelative(song.lastPlayedAt) : <> </>;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const PathColumn = ({ song }: ItemDetailListCellProps) => song.path ?? <> </>;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const PlayCountColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.playCount ? String(song.playCount) : <> </>;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
|
||||||
import { Rating } from '/@/shared/components/rating/rating';
|
|
||||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export const RatingColumn = ({ controls, internalState, song }: ItemDetailListCellProps) => {
|
|
||||||
const isMutatingRating = useIsMutatingRating();
|
|
||||||
const value = song.userRating ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Rating
|
|
||||||
onChange={(rating) => {
|
|
||||||
const index = internalState?.findItemIndex(song.id) ?? -1;
|
|
||||||
controls?.onRating?.({
|
|
||||||
event: null,
|
|
||||||
index,
|
|
||||||
internalState: internalState ?? undefined,
|
|
||||||
item: song,
|
|
||||||
itemType: LibraryItem.SONG,
|
|
||||||
rating,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
readOnly={isMutatingRating}
|
|
||||||
size="xs"
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
|
|
||||||
|
|
||||||
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <> </>;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.icon-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import styles from './row-index-column.module.css';
|
|
||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
|
||||||
import { usePlayerStatus } from '/@/renderer/store';
|
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
|
|
||||||
const status = usePlayerStatus();
|
|
||||||
const { isActive } = useIsCurrentSong(song);
|
|
||||||
const isPlaying = isActive && status === PlayerStatus.PLAYING;
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
return (
|
|
||||||
<div className={styles.iconWrapper}>
|
|
||||||
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{String((rowIndex ?? 0) + 1)}</>;
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const SampleRateColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.sampleRate ? `${song.sampleRate} Hz` : <> </>;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
import { formatSizeString } from '/@/renderer/utils/format';
|
|
||||||
|
|
||||||
export const SizeColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.size ? formatSizeString(song.size) : <> </>;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import styles from './title-column.module.css';
|
|
||||||
|
|
||||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
|
||||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
|
||||||
|
|
||||||
export const TitleArtistColumn = ({ song }: ItemDetailListCellProps) => {
|
|
||||||
const { isActive } = useIsCurrentSong(song);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={clsx({ [styles.active]: isActive })}>
|
|
||||||
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
|
||||||
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> </>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.active {
|
|
||||||
color: var(--theme-colors-primary);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import styles from './title-column.module.css';
|
|
||||||
|
|
||||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
|
||||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
|
||||||
|
|
||||||
export const TitleColumn = ({ song }: ItemDetailListCellProps) => {
|
|
||||||
const { isActive } = useIsCurrentSong(song);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={clsx({ [styles.active]: isActive })}>
|
|
||||||
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
|
||||||
{song.name ?? <> </>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import styles from './title-column.module.css';
|
|
||||||
|
|
||||||
import { ItemDetailListCellProps } from '/@/renderer/components/item-list/item-detail-list/columns/types';
|
|
||||||
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
|
|
||||||
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
|
||||||
|
|
||||||
export const TitleCombinedColumn = ({ song }: ItemDetailListCellProps) => {
|
|
||||||
const { isActive } = useIsCurrentSong(song);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={clsx({ [styles.active]: isActive })}>
|
|
||||||
<ExplicitIndicator explicitStatus={song.explicitStatus} />
|
|
||||||
{[song.name, song.artistName].filter(Boolean).join(' — ') ?? <> </>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
|
|
||||||
const disc = song.discNumber ?? 1;
|
|
||||||
const track = song.trackNumber.toString().padStart(2, '0');
|
|
||||||
return `${disc}-${track}`;
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
|
||||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
|
||||||
import { Song } from '/@/shared/types/domain-types';
|
|
||||||
|
|
||||||
export interface ItemDetailListCellProps {
|
|
||||||
controls?: ItemControls;
|
|
||||||
internalState?: ItemListStateActions;
|
|
||||||
isMutatingFavorite?: boolean;
|
|
||||||
isRowHovered?: boolean;
|
|
||||||
onFavoriteClick?: (song: Song) => void;
|
|
||||||
rowIndex?: number;
|
|
||||||
size?: 'compact' | 'default' | 'large';
|
|
||||||
song: Song;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
|
||||||
|
|
||||||
export const YearColumn = ({ song }: ItemDetailListCellProps) =>
|
|
||||||
song.releaseYear ? String(song.releaseYear) : <> </>;
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
.container {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-list-header {
|
|
||||||
display: grid;
|
|
||||||
flex-shrink: 0;
|
|
||||||
grid-template-columns: 240px 1fr;
|
|
||||||
gap: var(--theme-spacing-md);
|
|
||||||
padding: 0 var(--theme-spacing-md);
|
|
||||||
font-size: var(--theme-font-size-sm);
|
|
||||||
user-select: none;
|
|
||||||
background-color: var(--theme-colors-background);
|
|
||||||
border-bottom: 1px solid var(--theme-colors-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left-album-name {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: var(--theme-font-size-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--theme-colors-foreground);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracks-table-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracks-table-header-size-compact {
|
|
||||||
height: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracks-table-header-size-default {
|
|
||||||
height: 40px;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracks-table-header-size-large {
|
|
||||||
height: 48px;
|
|
||||||
min-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 60%;
|
|
||||||
padding-right: var(--theme-spacing-sm);
|
|
||||||
padding-left: var(--theme-spacing-sm);
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell-no-h-padding {
|
|
||||||
padding-right: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell-with-vertical-border {
|
|
||||||
border-right: 1px solid var(--theme-colors-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell-dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell-dragged-over-left::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 3px;
|
|
||||||
content: '';
|
|
||||||
background-color: var(--theme-colors-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell-dragged-over-right::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 3px;
|
|
||||||
content: '';
|
|
||||||
background-color: var(--theme-colors-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell:hover .resize-handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-header-cell:hover .resize-handle::before {
|
|
||||||
background-color: var(--theme-colors-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 2px;
|
|
||||||
margin-right: -4px;
|
|
||||||
cursor: col-resize;
|
|
||||||
background: var(--theme-colors-border);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .resize-handle::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
content: '';
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.resize-handle-left {
|
|
||||||
left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
margin-left: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-left::before {
|
|
||||||
right: auto;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-right {
|
|
||||||
right: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle-dragging {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 240px 1fr;
|
|
||||||
gap: var(--theme-spacing-md);
|
|
||||||
padding: var(--theme-spacing-md);
|
|
||||||
border-bottom: 1px solid var(--theme-colors-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-column-wrapper {
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 5;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
content: '';
|
|
||||||
background-color: rgb(0 0 0);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@mixin dark {
|
|
||||||
&::before {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
&::before {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .favorite-badge,
|
|
||||||
&:hover .rating-badge {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.favorite-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -50px;
|
|
||||||
left: -50px;
|
|
||||||
z-index: 1;
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: var(--theme-colors-primary);
|
|
||||||
box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rating-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--theme-spacing-sm);
|
|
||||||
right: var(--theme-spacing-sm);
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
|
||||||
font-size: var(--theme-font-size-md);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--theme-colors-foreground);
|
|
||||||
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: var(--theme-colors-primary);
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
box-shadow: 0 2px 8px rgb(0 0 0 / 50%);
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .image {
|
|
||||||
object-fit: var(--theme-image-fit);
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .metadata {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--theme-spacing-xs);
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: var(--theme-font-size-md);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .title {
|
|
||||||
font-weight: 500;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .title:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .artist {
|
|
||||||
font-size: var(--theme-font-size-sm);
|
|
||||||
color: var(--theme-colors-foreground-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .artist-plain-text:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .metadata-link {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .metadata-link:hover {
|
|
||||||
color: var(--theme-colors-foreground);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .metadata-extra {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--theme-spacing-xs);
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
font-size: var(--theme-font-size-sm);
|
|
||||||
color: var(--theme-colors-foreground-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .metadata-line {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-wrap-style: balance;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .metadata-line-clamp-2 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .right {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .tracks-table {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
font-size: var(--theme-font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-header-cell {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-cell {
|
|
||||||
min-width: 0;
|
|
||||||
padding-right: var(--theme-spacing-sm);
|
|
||||||
padding-left: var(--theme-spacing-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-row-size-compact {
|
|
||||||
height: 32px;
|
|
||||||
min-height: 32px;
|
|
||||||
max-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-row-size-default {
|
|
||||||
height: 40px;
|
|
||||||
min-height: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-row-size-large {
|
|
||||||
height: 48px;
|
|
||||||
min-height: 48px;
|
|
||||||
max-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-cell-muted {
|
|
||||||
color: var(--theme-colors-foreground-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-cell-with-vertical-border {
|
|
||||||
border-right: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-cell-vertical-border-visible {
|
|
||||||
border-right-color: var(--theme-colors-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-cell-image {
|
|
||||||
display: flex;
|
|
||||||
align-self: stretch;
|
|
||||||
min-height: 0;
|
|
||||||
max-height: 100%;
|
|
||||||
padding-right: var(--theme-spacing-sm);
|
|
||||||
padding-left: var(--theme-spacing-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row .track-cell-no-h-padding {
|
|
||||||
padding-right: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row-dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-alternate-even {
|
|
||||||
background-color: var(--theme-colors-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-alternate-odd {
|
|
||||||
@mixin dark {
|
|
||||||
background-color: darken(var(--theme-colors-background), 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background-color: darken(var(--theme-colors-background), 2%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-selected {
|
|
||||||
@mixin dark {
|
|
||||||
background-color: lighten(var(--theme-colors-surface), 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin light {
|
|
||||||
background-color: darken(var(--theme-colors-surface), 5%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-with-horizontal-border {
|
|
||||||
border-top: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-horizontal-border-visible {
|
|
||||||
border-top-color: var(--theme-colors-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-hover-highlight-enabled {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-hover-highlight-enabled .track-cell {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-row.track-row-hover-highlight-enabled:hover::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
content: '';
|
|
||||||
background-color: var(--theme-colors-surface);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-image-container {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-image {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: var(--theme-radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-title-container {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-title {
|
|
||||||
width: 75%;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-artist-container {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-artist {
|
|
||||||
width: 50%;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-tracks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-track-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 1fr 8rem;
|
|
||||||
gap: var(--theme-spacing-sm);
|
|
||||||
align-items: center;
|
|
||||||
padding-right: var(--theme-spacing-sm);
|
|
||||||
padding-left: var(--theme-spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-tracks-size-compact .skeleton-track-row {
|
|
||||||
height: 32px;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-tracks-size-default .skeleton-track-row {
|
|
||||||
height: 40px;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-tracks-size-large .skeleton-track-row {
|
|
||||||
height: 48px;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-track-cell {
|
|
||||||
width: 100%;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-track-cell-title {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
|||||||
import { TableColumn } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
|
|
||||||
[TableColumn.ACTIONS]: 32,
|
|
||||||
[TableColumn.BIT_DEPTH]: 80,
|
|
||||||
[TableColumn.BIT_RATE]: 80,
|
|
||||||
[TableColumn.BPM]: 56,
|
|
||||||
[TableColumn.CHANNELS]: 80,
|
|
||||||
[TableColumn.CODEC]: 80,
|
|
||||||
[TableColumn.DATE_ADDED]: 128,
|
|
||||||
[TableColumn.DISC_NUMBER]: 36,
|
|
||||||
[TableColumn.DURATION]: 72,
|
|
||||||
[TableColumn.RELEASE_DATE]: 128,
|
|
||||||
[TableColumn.SAMPLE_RATE]: 90,
|
|
||||||
[TableColumn.TRACK_NUMBER]: 56,
|
|
||||||
[TableColumn.USER_FAVORITE]: 32,
|
|
||||||
[TableColumn.USER_RATING]: 64,
|
|
||||||
[TableColumn.YEAR]: 56,
|
|
||||||
};
|
|
||||||
|
|
||||||
const HOVER_ONLY_COLUMNS: TableColumn[] = [
|
|
||||||
TableColumn.ACTIONS,
|
|
||||||
TableColumn.USER_FAVORITE,
|
|
||||||
TableColumn.USER_RATING,
|
|
||||||
];
|
|
||||||
|
|
||||||
const NO_HORIZONTAL_PADDING_COLUMNS: TableColumn[] = [
|
|
||||||
TableColumn.ACTIONS,
|
|
||||||
TableColumn.USER_FAVORITE,
|
|
||||||
TableColumn.USER_RATING,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getTrackColumnFixed(columnId: TableColumn): {
|
|
||||||
fixedWidth: number;
|
|
||||||
isFixedColumn: boolean;
|
|
||||||
} {
|
|
||||||
const width = FIXED_TRACK_COLUMN_WIDTHS[columnId];
|
|
||||||
return width !== undefined
|
|
||||||
? { fixedWidth: width, isFixedColumn: true }
|
|
||||||
: { fixedWidth: 0, isFixedColumn: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNoHorizontalPaddingColumn(columnId: TableColumn): boolean {
|
|
||||||
return NO_HORIZONTAL_PADDING_COLUMNS.includes(columnId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTrackColumnHoverOnly(columnId: TableColumn): boolean {
|
|
||||||
return HOVER_ONLY_COLUMNS.includes(columnId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldShowHoverOnlyColumnContent(
|
|
||||||
columnId: TableColumn,
|
|
||||||
isRowHovered: boolean,
|
|
||||||
song: { userFavorite?: boolean | null; userRating?: null | number },
|
|
||||||
): boolean {
|
|
||||||
if (!HOVER_ONLY_COLUMNS.includes(columnId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
isRowHovered ||
|
|
||||||
(columnId === TableColumn.USER_FAVORITE && song.userFavorite !== false) ||
|
|
||||||
(columnId === TableColumn.USER_RATING && song.userRating != null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,6 @@ const ImageColumnBase = (props: ItemTableListInnerColumn) => {
|
|||||||
itemType: props.itemType,
|
itemType: props.itemType,
|
||||||
meta: {
|
meta: {
|
||||||
playType,
|
playType,
|
||||||
singleSongOnly: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
+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
-3
@@ -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) => {
|
||||||
@@ -58,7 +59,6 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
itemType: props.itemType,
|
itemType: props.itemType,
|
||||||
meta: {
|
meta: {
|
||||||
playType,
|
playType,
|
||||||
singleSongOnly: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -144,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}>
|
||||||
@@ -201,7 +205,6 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) =>
|
|||||||
itemType: props.itemType,
|
itemType: props.itemType,
|
||||||
meta: {
|
meta: {
|
||||||
playType,
|
playType,
|
||||||
singleSongOnly: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -296,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
|
||||||
|
|||||||
+5
-5
@@ -7,8 +7,8 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
|||||||
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
import { Folder, LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types';
|
||||||
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop';
|
||||||
|
|
||||||
interface DragDropState<TElement extends HTMLElement = HTMLDivElement> {
|
interface DragDropState {
|
||||||
dragRef: null | React.Ref<TElement>;
|
dragRef: null | React.Ref<HTMLDivElement>;
|
||||||
isDraggedOver: 'bottom' | 'top' | null;
|
isDraggedOver: 'bottom' | 'top' | null;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ interface UseItemDragDropStateProps {
|
|||||||
playlistId?: string;
|
playlistId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivElement>({
|
export const useItemDragDropState = ({
|
||||||
enableDrag,
|
enableDrag,
|
||||||
internalState,
|
internalState,
|
||||||
isDataRow,
|
isDataRow,
|
||||||
@@ -31,14 +31,14 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
|
|||||||
itemType,
|
itemType,
|
||||||
playerContext,
|
playerContext,
|
||||||
playlistId,
|
playlistId,
|
||||||
}: UseItemDragDropStateProps): DragDropState<TElement> => {
|
}: UseItemDragDropStateProps): DragDropState => {
|
||||||
const shouldEnableDrag = enableDrag && isDataRow && !!item;
|
const shouldEnableDrag = enableDrag && isDataRow && !!item;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDraggedOver,
|
isDraggedOver,
|
||||||
isDragging: isDraggingLocal,
|
isDragging: isDraggingLocal,
|
||||||
ref: dragRef,
|
ref: dragRef,
|
||||||
} = useDragDrop<TElement>({
|
} = useDragDrop<HTMLDivElement>({
|
||||||
drag: {
|
drag: {
|
||||||
getId: () => {
|
getId: () => {
|
||||||
if (!item || !isDataRow) {
|
if (!item || !isDataRow) {
|
||||||
|
|||||||
@@ -26,11 +26,6 @@
|
|||||||
padding: var(--theme-spacing-xs) var(--theme-spacing-xl);
|
padding: var(--theme-spacing-xs) var(--theme-spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.container.no-horizontal-padding {
|
|
||||||
padding-right: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container.center {
|
.container.center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -210,11 +205,6 @@
|
|||||||
padding: 0 var(--theme-spacing-xl);
|
padding: 0 var(--theme-spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container.no-horizontal-padding {
|
|
||||||
padding-right: 0;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-dragging {
|
.header-dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import styles from './item-table-list-column.module.css';
|
|||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state';
|
import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/item-detail-list/utils';
|
|
||||||
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
|
import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column';
|
||||||
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
|
import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column';
|
||||||
import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';
|
import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column';
|
||||||
@@ -480,7 +479,6 @@ export const TableColumnTextContainer = (
|
|||||||
[styles.dragging]: isDataRow && isDragging,
|
[styles.dragging]: isDataRow && isDragging,
|
||||||
[styles.large]: props.size === 'large',
|
[styles.large]: props.size === 'large',
|
||||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||||
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
|
||||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||||
[styles.paddingMd]: props.cellPadding === 'md',
|
[styles.paddingMd]: props.cellPadding === 'md',
|
||||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||||
@@ -634,7 +632,6 @@ export const TableColumnContainer = (
|
|||||||
[styles.dragging]: isDataRow && isDragging,
|
[styles.dragging]: isDataRow && isDragging,
|
||||||
[styles.large]: props.size === 'large',
|
[styles.large]: props.size === 'large',
|
||||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||||
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
|
||||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||||
[styles.paddingMd]: props.cellPadding === 'md',
|
[styles.paddingMd]: props.cellPadding === 'md',
|
||||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||||
@@ -853,7 +850,6 @@ export const TableColumnHeaderContainer = (
|
|||||||
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
|
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
|
||||||
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
|
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
|
||||||
[styles.headerDragging]: isDragging,
|
[styles.headerDragging]: isDragging,
|
||||||
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
|
||||||
[styles.paddingLg]: props.cellPadding === 'lg',
|
[styles.paddingLg]: props.cellPadding === 'lg',
|
||||||
[styles.paddingMd]: props.cellPadding === 'md',
|
[styles.paddingMd]: props.cellPadding === 'md',
|
||||||
[styles.paddingSm]: props.cellPadding === 'sm',
|
[styles.paddingSm]: props.cellPadding === 'sm',
|
||||||
@@ -885,7 +881,7 @@ export const TableColumnHeaderContainer = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
||||||
[TableColumn.ACTIONS]: (
|
[TableColumn.ACTIONS]: (
|
||||||
<Flex className={styles.headerIconWrapper}>
|
<Flex className={styles.headerIconWrapper}>
|
||||||
<Icon fill="default" icon="ellipsisHorizontal" />
|
<Icon fill="default" icon="ellipsisHorizontal" />
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export interface ItemListTableComponentProps<TQuery> extends ItemListComponentPr
|
|||||||
enableRowHoverHighlight?: boolean;
|
enableRowHoverHighlight?: boolean;
|
||||||
enableSelection?: boolean;
|
enableSelection?: boolean;
|
||||||
enableVerticalBorders?: boolean;
|
enableVerticalBorders?: boolean;
|
||||||
size?: 'compact' | 'default' | 'large';
|
size?: 'compact' | 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemTableListColumnConfig {
|
export interface ItemTableListColumnConfig {
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
@@ -233,8 +243,8 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
|||||||
{metadataItems.map((item, index) => (
|
{metadataItems.map((item, index) => (
|
||||||
<Fragment key={item.id}>
|
<Fragment key={item.id}>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Text isMuted isNoSelect>
|
<Text fw={400} isMuted isNoSelect>
|
||||||
<Separator />
|
•
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text fw={400}>{item.value}</Text>
|
<Text fw={400}>{item.value}</Text>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -36,18 +36,6 @@ const AlbumListPaginatedTable = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const AlbumListInfiniteDetail = lazy(() =>
|
|
||||||
import('/@/renderer/features/albums/components/album-list-infinite-detail').then((module) => ({
|
|
||||||
default: module.AlbumListInfiniteDetail,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const AlbumListPaginatedDetail = lazy(() =>
|
|
||||||
import('/@/renderer/features/albums/components/album-list-paginated-detail').then((module) => ({
|
|
||||||
default: module.AlbumListPaginatedDetail,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const AlbumListFilters = () => {
|
const AlbumListFilters = () => {
|
||||||
return (
|
return (
|
||||||
<ListWithSidebarContainer.SidebarPortal>
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
@@ -74,16 +62,13 @@ export const AlbumListContent = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AlbumListSuspenseContainer = () => {
|
const AlbumListSuspenseContainer = () => {
|
||||||
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
|
const { display, grid, itemsPerPage, pagination, table } = useListSettings(ItemListKey.ALBUM);
|
||||||
ItemListKey.ALBUM,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { customFilters } = useListContext();
|
const { customFilters } = useListContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Spinner container />}>
|
<Suspense fallback={<Spinner container />}>
|
||||||
<AlbumListView
|
<AlbumListView
|
||||||
detail={detail}
|
|
||||||
display={display}
|
display={display}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
@@ -98,17 +83,13 @@ const AlbumListSuspenseContainer = () => {
|
|||||||
export type OverrideAlbumListQuery = Omit<Partial<AlbumListQuery>, 'limit' | 'startIndex'>;
|
export type OverrideAlbumListQuery = Omit<Partial<AlbumListQuery>, 'limit' | 'startIndex'>;
|
||||||
|
|
||||||
export const AlbumListView = ({
|
export const AlbumListView = ({
|
||||||
detail,
|
|
||||||
display,
|
display,
|
||||||
grid,
|
grid,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
overrideQuery,
|
overrideQuery,
|
||||||
pagination,
|
pagination,
|
||||||
table,
|
table,
|
||||||
}: ItemListSettings & {
|
}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => {
|
||||||
detail?: ItemListSettings['detail'];
|
|
||||||
overrideQuery?: OverrideAlbumListQuery;
|
|
||||||
}) => {
|
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const { pageKey } = useListContext();
|
const { pageKey } = useListContext();
|
||||||
|
|
||||||
@@ -198,32 +179,6 @@ export const AlbumListView = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ListDisplayType.DETAIL: {
|
|
||||||
switch (pagination) {
|
|
||||||
case ListPaginationType.INFINITE: {
|
|
||||||
return (
|
|
||||||
<AlbumListInfiniteDetail
|
|
||||||
enableHeader={detail?.enableHeader}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
query={mergedQuery}
|
|
||||||
serverId={server.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case ListPaginationType.PAGINATED: {
|
|
||||||
return (
|
|
||||||
<AlbumListPaginatedDetail
|
|
||||||
enableHeader={detail?.enableHeader}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
query={mergedQuery}
|
|
||||||
serverId={server.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
ALBUM_TABLE_COLUMNS,
|
|
||||||
SONG_TABLE_COLUMNS,
|
|
||||||
} from '/@/renderer/components/item-list/item-table-list/default-columns';
|
|
||||||
import { useListContext } from '/@/renderer/context/list-context';
|
import { useListContext } from '/@/renderer/context/list-context';
|
||||||
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
@@ -95,15 +92,8 @@ export const AlbumListHeaderFilters = ({ toggleGenreTarget }: { toggleGenreTarge
|
|||||||
<ListRefreshButton listKey={pageKey as ItemListKey} />
|
<ListRefreshButton listKey={pageKey as ItemListKey} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ListDisplayTypeToggleButton enableDetail listKey={ItemListKey.ALBUM} />
|
<ListDisplayTypeToggleButton listKey={ItemListKey.ALBUM} />
|
||||||
<ListConfigMenu
|
<ListConfigMenu
|
||||||
detailConfig={{
|
|
||||||
optionsConfig: {
|
|
||||||
autoFitColumns: { hidden: true },
|
|
||||||
},
|
|
||||||
tableColumnsData: SONG_TABLE_COLUMNS,
|
|
||||||
tableKey: 'detail',
|
|
||||||
}}
|
|
||||||
listKey={ItemListKey.ALBUM}
|
listKey={ItemListKey.ALBUM}
|
||||||
tableColumnsData={ALBUM_TABLE_COLUMNS}
|
tableColumnsData={ALBUM_TABLE_COLUMNS}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
|
||||||
import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader';
|
|
||||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
|
||||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
|
||||||
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
|
||||||
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
|
||||||
import {
|
|
||||||
AlbumListQuery,
|
|
||||||
AlbumListSort,
|
|
||||||
LibraryItem,
|
|
||||||
SortOrder,
|
|
||||||
} from '/@/shared/types/domain-types';
|
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
interface AlbumListInfiniteDetailProps extends ItemListComponentProps<AlbumListQuery> {
|
|
||||||
enableHeader?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlbumListInfiniteDetail = ({
|
|
||||||
enableHeader = true,
|
|
||||||
itemsPerPage = 100,
|
|
||||||
query = {
|
|
||||||
sortBy: AlbumListSort.NAME,
|
|
||||||
sortOrder: SortOrder.ASC,
|
|
||||||
},
|
|
||||||
serverId,
|
|
||||||
}: AlbumListInfiniteDetailProps) => {
|
|
||||||
const listCountQuery = albumQueries.listCount({
|
|
||||||
query: { ...query },
|
|
||||||
serverId: serverId,
|
|
||||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
|
||||||
|
|
||||||
const listQueryFn = api.controller.getAlbumList;
|
|
||||||
|
|
||||||
const { handleColumnReordered } = useItemListColumnReorder({
|
|
||||||
itemListKey: ItemListKey.ALBUM,
|
|
||||||
tableKey: 'detail',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleColumnResized } = useItemListColumnResize({
|
|
||||||
itemListKey: ItemListKey.ALBUM,
|
|
||||||
tableKey: 'detail',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({
|
|
||||||
eventKey: ItemListKey.ALBUM,
|
|
||||||
itemsPerPage,
|
|
||||||
itemType: LibraryItem.ALBUM,
|
|
||||||
listCountQuery,
|
|
||||||
listQueryFn,
|
|
||||||
query,
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ItemDetailList
|
|
||||||
data={loadedItems}
|
|
||||||
enableHeader={enableHeader}
|
|
||||||
getItem={getItem}
|
|
||||||
itemCount={itemCount}
|
|
||||||
onColumnReordered={handleColumnReordered}
|
|
||||||
onColumnResized={handleColumnResized}
|
|
||||||
onRangeChanged={onRangeChanged}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { UseSuspenseQueryOptions } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
|
||||||
import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader';
|
|
||||||
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
|
|
||||||
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
|
|
||||||
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
|
|
||||||
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
|
|
||||||
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
|
|
||||||
import { ItemListComponentProps } from '/@/renderer/components/item-list/types';
|
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
|
||||||
import {
|
|
||||||
AlbumListQuery,
|
|
||||||
AlbumListSort,
|
|
||||||
LibraryItem,
|
|
||||||
SortOrder,
|
|
||||||
} from '/@/shared/types/domain-types';
|
|
||||||
import { ItemListKey } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
interface AlbumListPaginatedDetailProps extends ItemListComponentProps<AlbumListQuery> {
|
|
||||||
enableHeader?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlbumListPaginatedDetail = ({
|
|
||||||
enableHeader = true,
|
|
||||||
itemsPerPage = 100,
|
|
||||||
query = {
|
|
||||||
sortBy: AlbumListSort.NAME,
|
|
||||||
sortOrder: SortOrder.ASC,
|
|
||||||
},
|
|
||||||
serverId,
|
|
||||||
}: AlbumListPaginatedDetailProps) => {
|
|
||||||
const listCountQuery = albumQueries.listCount({
|
|
||||||
query: { ...query },
|
|
||||||
serverId: serverId,
|
|
||||||
}) as UseSuspenseQueryOptions<number, Error, number, readonly unknown[]>;
|
|
||||||
|
|
||||||
const listQueryFn = api.controller.getAlbumList;
|
|
||||||
|
|
||||||
const { handleColumnReordered } = useItemListColumnReorder({
|
|
||||||
itemListKey: ItemListKey.ALBUM,
|
|
||||||
tableKey: 'detail',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleColumnResized } = useItemListColumnResize({
|
|
||||||
itemListKey: ItemListKey.ALBUM,
|
|
||||||
tableKey: 'detail',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { currentPage, onChange } = useItemListPagination();
|
|
||||||
|
|
||||||
const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({
|
|
||||||
currentPage,
|
|
||||||
eventKey: ItemListKey.ALBUM,
|
|
||||||
itemsPerPage,
|
|
||||||
itemType: LibraryItem.ALBUM,
|
|
||||||
listCountQuery,
|
|
||||||
listQueryFn,
|
|
||||||
query,
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ItemListWithPagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
onChange={onChange}
|
|
||||||
pageCount={pageCount}
|
|
||||||
totalItemCount={totalItemCount}
|
|
||||||
>
|
|
||||||
<ItemDetailList
|
|
||||||
currentPage={currentPage}
|
|
||||||
enableHeader={enableHeader}
|
|
||||||
items={data || []}
|
|
||||||
onColumnReordered={handleColumnReordered}
|
|
||||||
onColumnResized={handleColumnResized}
|
|
||||||
/>
|
|
||||||
</ItemListWithPagination>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +1,21 @@
|
|||||||
import { Fragment, memo } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { generatePath, Link } from 'react-router';
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { Text, TextProps } from '/@/shared/components/text/text';
|
import { Text, TextProps } from '/@/shared/components/text/text';
|
||||||
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
|
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const JOINED_ARTISTS_MUTED_PROPS = {
|
|
||||||
linkProps: { fw: 400, isMuted: true },
|
|
||||||
rootTextProps: { fw: 400, isMuted: true, size: 'sm' as const },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface JoinedArtistsProps {
|
interface JoinedArtistsProps {
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
||||||
linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;
|
linkProps?: Partial<Omit<TextProps, 'children' | 'component' | 'to'>>;
|
||||||
readOnly?: boolean;
|
|
||||||
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JoinedArtistsComponent = ({
|
export const JoinedArtists = ({
|
||||||
artistName,
|
artistName,
|
||||||
artists,
|
artists,
|
||||||
linkProps,
|
linkProps,
|
||||||
readOnly = false,
|
|
||||||
rootTextProps,
|
rootTextProps,
|
||||||
}: JoinedArtistsProps) => {
|
}: JoinedArtistsProps) => {
|
||||||
const parts: (
|
const parts: (
|
||||||
@@ -118,7 +111,7 @@ const JoinedArtistsComponent = ({
|
|||||||
{artists.map((artist, index) => (
|
{artists.map((artist, index) => (
|
||||||
<Fragment key={artist.id || `artist-${index}`}>
|
<Fragment key={artist.id || `artist-${index}`}>
|
||||||
{index > 0 && ', '}
|
{index > 0 && ', '}
|
||||||
{artist.id && !readOnly ? (
|
{artist.id ? (
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -131,7 +124,7 @@ const JoinedArtistsComponent = ({
|
|||||||
{artist.name}
|
{artist.name}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text component="span" fw={500} {...linkProps}>
|
<Text fw={500} {...linkProps}>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -159,7 +152,7 @@ const JoinedArtistsComponent = ({
|
|||||||
|
|
||||||
const { artist, text } = part;
|
const { artist, text } = part;
|
||||||
|
|
||||||
if (artist.id && !readOnly) {
|
if (artist.id) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
@@ -176,7 +169,7 @@ const JoinedArtistsComponent = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Text component="span" fw={500} key={`${artist.name}-${index}`} {...linkProps}>
|
<Text fw={500} key={`${artist.name}-${index}`} {...linkProps}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -187,7 +180,7 @@ const JoinedArtistsComponent = ({
|
|||||||
{unmatchedArtists.map((artist, index) => (
|
{unmatchedArtists.map((artist, index) => (
|
||||||
<Fragment key={artist.id}>
|
<Fragment key={artist.id}>
|
||||||
{index > 0 && ', '}
|
{index > 0 && ', '}
|
||||||
{artist.id && !readOnly ? (
|
{artist.id ? (
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
@@ -199,10 +192,6 @@ const JoinedArtistsComponent = ({
|
|||||||
>
|
>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</Text>
|
</Text>
|
||||||
) : artist.id ? (
|
|
||||||
<Text component="span" fw={500} {...linkProps}>
|
|
||||||
{artist.name}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Text component="span" isMuted>
|
<Text component="span" isMuted>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
@@ -216,8 +205,6 @@ const JoinedArtistsComponent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JoinedArtists = memo(JoinedArtistsComponent);
|
|
||||||
|
|
||||||
function escapeRegex(str: string): string {
|
function escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { AppRoute } from '/@/renderer/router/routes';
|
|||||||
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
import { formatDurationString } from '/@/renderer/utils';
|
import { formatDurationString } from '/@/renderer/utils';
|
||||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
@@ -161,11 +160,7 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
|
|||||||
.filter((i) => i.enabled)
|
.filter((i) => i.enabled)
|
||||||
.map((item, index) => (
|
.map((item, index) => (
|
||||||
<Fragment key={`item-${item.id}-${index}`}>
|
<Fragment key={`item-${item.id}-${index}`}>
|
||||||
{index > 0 && (
|
{index > 0 && <Text isNoSelect>•</Text>}
|
||||||
<Text isMuted isNoSelect>
|
|
||||||
{SEPARATOR_STRING}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text isMuted={item.secondary}>{item.value}</Text>
|
<Text isMuted={item.secondary}>{item.value}</Text>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user