mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f395bd87 | |||
| c9cd87bae5 | |||
| 9a8cb45510 | |||
| 68b6a58ac5 | |||
| 5b5cdbfb7f | |||
| cf4e505743 | |||
| 8464ed439e | |||
| 9e49a45db9 | |||
| 8dc5f2a580 | |||
| 6bb848a675 | |||
| 8edf61f9e7 | |||
| 96d2699a2d | |||
| 614761efd7 |
@@ -30,6 +30,7 @@
|
||||
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
|
||||
"viewPlaylists": "view $t(entity.playlist_other)",
|
||||
"viewMore": "view more",
|
||||
"openApplicationDirectory": "open application directory",
|
||||
"openIn": {
|
||||
"lastfm": "Open in Last.fm",
|
||||
"musicbrainz": "Open in MusicBrainz"
|
||||
@@ -266,6 +267,12 @@
|
||||
"trackNumber": "track",
|
||||
"explicitStatus": "$t(common.explicitStatus)"
|
||||
},
|
||||
"datetime": {
|
||||
"minuteShort": "min",
|
||||
"secondShort": "sec",
|
||||
"hourShort": "hr",
|
||||
"dayShort": "day"
|
||||
},
|
||||
"filterOperator": {
|
||||
"after": "is after",
|
||||
"afterDate": "is after (date)",
|
||||
|
||||
@@ -11,6 +11,9 @@ export const store = new Store({
|
||||
'>=0.21.2': (store) => {
|
||||
store.set('window_bar_style', 'linux');
|
||||
},
|
||||
'>=1.0.0': (store) => {
|
||||
store.clear();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -702,3 +702,11 @@ if (!ipcMain.eventNames().includes('open-item')) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Register 'open-application-directory' handler globally, ensuring it is only registered once
|
||||
if (!ipcMain.eventNames().includes('open-application-directory')) {
|
||||
ipcMain.handle('open-application-directory', async () => {
|
||||
const userDataPath = app.getPath('userData');
|
||||
shell.openPath(userDataPath);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ const openItem = async (path: string) => {
|
||||
return ipcRenderer.invoke('open-item', path);
|
||||
};
|
||||
|
||||
const openApplicationDirectory = async () => {
|
||||
return ipcRenderer.invoke('open-application-directory');
|
||||
};
|
||||
|
||||
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
|
||||
ipcRenderer.on('player-error-listener', cb);
|
||||
};
|
||||
@@ -42,6 +46,7 @@ export const utils = {
|
||||
isWindows,
|
||||
logger,
|
||||
mainMessageListener,
|
||||
openApplicationDirectory,
|
||||
openItem,
|
||||
playerErrorListener,
|
||||
};
|
||||
|
||||
@@ -1202,6 +1202,9 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
name: res.body.Name,
|
||||
};
|
||||
},
|
||||
jukeboxControl: async () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
movePlaylistItem: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -582,6 +582,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
const features = {
|
||||
...subsonicArgs.features,
|
||||
...navidromeFeatures,
|
||||
jukebox: [1],
|
||||
publicPlaylist: [1],
|
||||
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
|
||||
};
|
||||
@@ -761,6 +762,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
},
|
||||
jukeboxControl: SubsonicController.jukeboxControl,
|
||||
movePlaylistItem: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ArtistListQuery,
|
||||
FolderQuery,
|
||||
GenreListQuery,
|
||||
JukeboxControlQuery,
|
||||
LyricSearchQuery,
|
||||
LyricsQuery,
|
||||
PlaylistDetailQuery,
|
||||
@@ -262,6 +263,12 @@ export const queryKeys: Record<
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
||||
},
|
||||
jukebox: {
|
||||
control: (serverId: string, query?: JukeboxControlQuery) => {
|
||||
if (query) return [serverId, 'jukebox', 'control', query] as const;
|
||||
return [serverId, 'jukebox', 'control'] as const;
|
||||
},
|
||||
},
|
||||
musicFolders: {
|
||||
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
|
||||
},
|
||||
|
||||
@@ -249,6 +249,14 @@ export const contract = c.router({
|
||||
200: ssType._response.user,
|
||||
},
|
||||
},
|
||||
jukeboxControl: {
|
||||
method: 'GET',
|
||||
path: 'jukeboxControl.view',
|
||||
query: ssType._parameters.jukeboxControl,
|
||||
responses: {
|
||||
200: ssType._response.jukeboxPlaylist,
|
||||
},
|
||||
},
|
||||
ping: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
|
||||
@@ -867,7 +867,6 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
|
||||
},
|
||||
|
||||
getPlaylistList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
@@ -1060,7 +1059,9 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to ping server');
|
||||
}
|
||||
|
||||
const features: ServerFeatures = {};
|
||||
const features: ServerFeatures = {
|
||||
jukebox: [1],
|
||||
};
|
||||
|
||||
if (!ping.body.openSubsonic || !ping.body.serverVersion) {
|
||||
return { features, version: ping.body.version };
|
||||
@@ -1579,6 +1580,30 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
name: res.body.user.username,
|
||||
};
|
||||
},
|
||||
jukeboxControl: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).jukeboxControl({
|
||||
query: query,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to control jukebox');
|
||||
}
|
||||
|
||||
const jukeboxPlaylist = res.body.jukeboxPlaylist;
|
||||
|
||||
return {
|
||||
currentIndex: jukeboxPlaylist.currentIndex,
|
||||
gain: jukeboxPlaylist.gain,
|
||||
playing: jukeboxPlaylist.playing,
|
||||
position: jukeboxPlaylist.position ?? 0,
|
||||
songs:
|
||||
jukeboxPlaylist.entry?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server),
|
||||
) || [],
|
||||
};
|
||||
},
|
||||
removeFromPlaylist: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
|
||||
@@ -87,8 +87,9 @@ export const AlbumListView = ({
|
||||
table,
|
||||
}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => {
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { query } = useAlbumListFilters();
|
||||
const { query } = useAlbumListFilters(pageKey as ItemListKey);
|
||||
|
||||
const mergedQuery = useMemo(() => {
|
||||
if (!overrideQuery) {
|
||||
|
||||
@@ -15,13 +15,15 @@ import {
|
||||
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export const useAlbumListFilters = () => {
|
||||
export const useAlbumListFilters = (listKey?: ItemListKey) => {
|
||||
const resolvedListKey = listKey ?? ItemListKey.ALBUM;
|
||||
|
||||
const { setSortBy, sortBy } = useSortByFilter<AlbumListSort>(
|
||||
AlbumListSort.NAME,
|
||||
ItemListKey.ALBUM,
|
||||
resolvedListKey,
|
||||
);
|
||||
|
||||
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.ALBUM);
|
||||
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey);
|
||||
|
||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||
|
||||
|
||||
@@ -127,15 +127,19 @@ const getPlayerProperties = (): Pick<
|
||||
const playbackSettings = useSettingsStore.getState().playback;
|
||||
|
||||
return {
|
||||
'player.mediaSession': playbackSettings.mediaSession,
|
||||
'player.mediaSession': ignoreWeb(playbackSettings.mediaSession),
|
||||
'player.queueType': player.player.queueType,
|
||||
'player.style': player.player.transitionType,
|
||||
'player.transcoding': playbackSettings.transcode.enabled,
|
||||
'player.type': playbackSettings.type,
|
||||
'player.webAudio': playbackSettings.webAudio,
|
||||
};
|
||||
'player.type': ignoreWeb(playbackSettings.type),
|
||||
'player.webAudio': ignoreWeb(playbackSettings.webAudio),
|
||||
} as any;
|
||||
};
|
||||
|
||||
function ignoreWeb<T>(value: T): T | undefined {
|
||||
return isElectron() ? value : undefined;
|
||||
}
|
||||
|
||||
const getSettingsProperties = (): SettingsProperties => {
|
||||
const settings = useSettingsStore.getState();
|
||||
|
||||
@@ -148,22 +152,30 @@ const getSettingsProperties = (): SettingsProperties => {
|
||||
'settings.autoDJItemCount': settings.autoDJ.itemCount,
|
||||
'settings.autoDJTiming': settings.autoDJ.timing,
|
||||
'settings.customCss': settings.css.enabled,
|
||||
'settings.disableAutoUpdate': settings.window.disableAutoUpdate,
|
||||
'settings.discord': settings.discord.enabled,
|
||||
'settings.exitToTray': settings.window.exitToTray,
|
||||
'settings.disableAutoUpdate': ignoreWeb(settings.window.disableAutoUpdate),
|
||||
'settings.discord': ignoreWeb(settings.discord.enabled),
|
||||
'settings.exitToTray': ignoreWeb(settings.window.exitToTray),
|
||||
'settings.followSystemTheme': settings.general.followSystemTheme,
|
||||
'settings.fontType': settings.font.type,
|
||||
'settings.globalHotkeys': settings.hotkeys.globalMediaHotkeys,
|
||||
'settings.homeFeature': settings.general.homeFeature,
|
||||
'settings.language': settings.general.language,
|
||||
// 'settings.lastFM': settings.general.lastFM,
|
||||
'settings.lyrics.enableAutoTranslation': settings.lyrics.enableAutoTranslation,
|
||||
'settings.lyrics.enableNeteaseTranslation': settings.lyrics.enableNeteaseTranslation,
|
||||
'settings.lyrics.fetch': settings.lyrics.fetch,
|
||||
'settings.lyrics.sources.genius': settings.lyrics.sources.includes(LyricSource.GENIUS),
|
||||
'settings.lyrics.sources.lrclib': settings.lyrics.sources.includes(LyricSource.LRCLIB),
|
||||
'settings.lyrics.sources.netease': settings.lyrics.sources.includes(LyricSource.NETEASE),
|
||||
'settings.minimizeToTray': settings.window.minimizeToTray,
|
||||
'settings.lyrics.enableAutoTranslation': ignoreWeb(settings.lyrics.enableAutoTranslation),
|
||||
'settings.lyrics.enableNeteaseTranslation': ignoreWeb(
|
||||
settings.lyrics.enableNeteaseTranslation,
|
||||
),
|
||||
'settings.lyrics.fetch': ignoreWeb(settings.lyrics.fetch),
|
||||
'settings.lyrics.sources.genius': ignoreWeb(
|
||||
settings.lyrics.sources.includes(LyricSource.GENIUS),
|
||||
),
|
||||
'settings.lyrics.sources.lrclib': ignoreWeb(
|
||||
settings.lyrics.sources.includes(LyricSource.LRCLIB),
|
||||
),
|
||||
'settings.lyrics.sources.netease': ignoreWeb(
|
||||
settings.lyrics.sources.includes(LyricSource.NETEASE),
|
||||
),
|
||||
'settings.minimizeToTray': ignoreWeb(settings.window.minimizeToTray),
|
||||
// 'settings.musicBrainz': settings.general.musicBrainz,
|
||||
'settings.nativeAspectRatio': settings.general.nativeAspectRatio,
|
||||
'settings.playerbarSliderType': settings.general.playerbarSlider
|
||||
@@ -172,26 +184,26 @@ const getSettingsProperties = (): SettingsProperties => {
|
||||
// 'settings.playerbarWaveformBarWidth': settings.general.playerbarSlider.barWidth,
|
||||
// 'settings.playerbarWaveformGap': settings.general.playerbarSlider.barGap,
|
||||
// 'settings.playerbarWaveformRadius': settings.general.playerbarSlider.barRadius,
|
||||
'settings.preventSleepOnPlayback': settings.window.preventSleepOnPlayback,
|
||||
'settings.releaseChannel': settings.window.releaseChannel,
|
||||
'settings.preventSleepOnPlayback': ignoreWeb(settings.window.preventSleepOnPlayback),
|
||||
'settings.releaseChannel': ignoreWeb(settings.window.releaseChannel),
|
||||
'settings.resume': settings.general.resume,
|
||||
'settings.scrobble.enabled': settings.playback.scrobble.enabled,
|
||||
'settings.scrobble.notify': settings.playback.scrobble.notify,
|
||||
'settings.scrobble.notify': ignoreWeb(settings.playback.scrobble.notify),
|
||||
'settings.showLyricsInSidebar': settings.general.showLyricsInSidebar,
|
||||
'settings.showVisualizerInSidebar': settings.general.showVisualizerInSidebar,
|
||||
'settings.sideQueueType': settings.general.sideQueueType,
|
||||
// 'settings.skipBackwardSeconds': settings.general.skipButtons.skipBackwardSeconds,
|
||||
'settings.skipButtons': settings.general.skipButtons.enabled,
|
||||
// 'settings.skipForwardSeconds': settings.general.skipButtons.skipForwardSeconds,
|
||||
'settings.startMinimized': settings.window.startMinimized,
|
||||
'settings.startMinimized': ignoreWeb(settings.window.startMinimized),
|
||||
'settings.theme': settings.general.theme,
|
||||
'settings.themeDark': settings.general.themeDark,
|
||||
'settings.themeLight': settings.general.themeLight,
|
||||
'settings.tray': settings.window.tray,
|
||||
'settings.tray': ignoreWeb(settings.window.tray),
|
||||
'settings.useThemeAccentColor': settings.general.useThemeAccentColor,
|
||||
'settings.windowBarStyle': settings.window.windowBarStyle,
|
||||
'settings.zoomFactor': settings.general.zoomFactor,
|
||||
};
|
||||
'settings.windowBarStyle': ignoreWeb(settings.window.windowBarStyle),
|
||||
'settings.zoomFactor': ignoreWeb(settings.general.zoomFactor),
|
||||
} as any;
|
||||
};
|
||||
|
||||
const getServer = (): 'unknown' | ServerType => {
|
||||
@@ -202,6 +214,7 @@ const getServer = (): 'unknown' | ServerType => {
|
||||
|
||||
export const useAppTracker = () => {
|
||||
const { mutate: trackAppMutation } = useMutation(appTrackerMutation);
|
||||
const { mutate: trackAppViewMutation } = useMutation(appViewMutation);
|
||||
const hasRunOnMountRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -246,6 +259,10 @@ export const useAppTracker = () => {
|
||||
meta: { properties, todayUTC },
|
||||
});
|
||||
|
||||
trackAppViewMutation(undefined, {
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
trackAppMutation(properties, {
|
||||
onError: () => {},
|
||||
onSettled: () => {
|
||||
@@ -275,9 +292,10 @@ export const useAppTracker = () => {
|
||||
const interval = setInterval(checkAndTrack, 1000 * 60 * 60);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [trackAppMutation]);
|
||||
}, [trackAppMutation, trackAppViewMutation]);
|
||||
};
|
||||
|
||||
// Sends the app event to the analytics server which includes usage data
|
||||
const appTrackerMutation = mutationOptions({
|
||||
mutationFn: (properties: AppTrackerProperties) => {
|
||||
try {
|
||||
@@ -296,3 +314,24 @@ const appTrackerMutation = mutationOptions({
|
||||
retry: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
// Sends a view event to the analytics server which only includes language, screen, and website
|
||||
// and triggers a page view event
|
||||
const appViewMutation = mutationOptions({
|
||||
mutationFn: () => {
|
||||
try {
|
||||
window.umami?.track((props) => ({
|
||||
language: props.language,
|
||||
screen: props.screen,
|
||||
website: props.website,
|
||||
}));
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
mutationKey: ['analytics', 'app-view'],
|
||||
onSuccess: () => {},
|
||||
retry: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
@@ -202,8 +202,12 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
}
|
||||
|
||||
if (allSongIds.length === 0) {
|
||||
toast.warn({
|
||||
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
|
||||
toast.success({
|
||||
message: t('form.addToPlaylist.success', {
|
||||
message: 0,
|
||||
numOfPlaylists: 1,
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -241,8 +245,12 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
|
||||
}
|
||||
|
||||
if (songsToAdd.length === 0) {
|
||||
toast.warn({
|
||||
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }),
|
||||
toast.success({
|
||||
message: t('form.addToPlaylist.success', {
|
||||
message: 0,
|
||||
numOfPlaylists: 1,
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { JukeboxControlQuery } from '/@/shared/types/domain-types';
|
||||
|
||||
export const jukeboxQueries = {
|
||||
jukeboxControl: (args: QueryHookArgs<JukeboxControlQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.jukeboxControl({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: args.query,
|
||||
}),
|
||||
queryKey: queryKeys.jukebox.control(args.serverId, args.query),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -94,10 +94,25 @@ export const CacheSettings = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const handleOpenApplicationDirectory = async () => {
|
||||
if (isElectron() && window.api?.utils) {
|
||||
await window.api.utils.openApplicationDirectory();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
options={options}
|
||||
title={t('page.setting.cache', { postProcess: 'sentenceCase' })}
|
||||
/>
|
||||
<>
|
||||
<SettingsSection
|
||||
options={options}
|
||||
title={t('page.setting.cache', { postProcess: 'sentenceCase' })}
|
||||
/>
|
||||
{isElectron() && (
|
||||
<Button onClick={handleOpenApplicationDirectory} variant="default">
|
||||
{t('action.openApplicationDirectory', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ActionBar = () => {
|
||||
<Group gap="sm" grow wrap="nowrap">
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button p="0.5rem">
|
||||
<Button p="0">
|
||||
<Icon icon="menu" size="lg" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
@@ -62,10 +62,10 @@ const NavigateButtons = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => navigate(-1)} p="0.5rem">
|
||||
<Button onClick={() => navigate(-1)} p="0">
|
||||
<Icon icon="arrowLeftS" size="lg" />
|
||||
</Button>
|
||||
<Button onClick={() => navigate(1)} p="0.5rem">
|
||||
<Button onClick={() => navigate(1)} p="0">
|
||||
<Icon icon="arrowRightS" size="lg" />
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -59,17 +59,43 @@
|
||||
gap: var(--theme-spacing-md);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.metadata-group-item {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--theme-spacing-xs);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metadata-group-item-no-shrink {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadata-group-item > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadata-group-item > *:last-child {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metadata-group-item-no-shrink > *:last-child {
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-b
|
||||
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store';
|
||||
import { formatDurationStringShort } from '/@/renderer/utils';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
|
||||
import { ButtonProps } from '/@/shared/components/button/button';
|
||||
@@ -185,7 +185,12 @@ const PlaylistRowButton = memo(({ item, name, onContextMenu, to }: PlaylistRowBu
|
||||
{name}
|
||||
</Text>
|
||||
<div className={styles.metadataGroup}>
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.metadataGroupItem,
|
||||
styles.metadataGroupItemNoShrink,
|
||||
)}
|
||||
>
|
||||
<Icon color="muted" icon="itemSong" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{item.songCount || 0}
|
||||
@@ -194,7 +199,7 @@ const PlaylistRowButton = memo(({ item, name, onContextMenu, to }: PlaylistRowBu
|
||||
<div className={styles.metadataGroupItem}>
|
||||
<Icon color="muted" icon="duration" size="sm" />
|
||||
<Text isMuted size="sm">
|
||||
{formatDurationStringShort(item.duration ?? 0)}
|
||||
{formatDurationString(item.duration ?? 0)}
|
||||
</Text>
|
||||
</div>
|
||||
{item.ownerId === permissions.userId && Boolean(item.public) && (
|
||||
|
||||
@@ -84,8 +84,9 @@ export const SongListView = ({
|
||||
table,
|
||||
}: ItemListSettings & { overrideQuery?: OverrideSongListQuery }) => {
|
||||
const server = useCurrentServer();
|
||||
const { pageKey } = useListContext();
|
||||
|
||||
const { query } = useSongListFilters();
|
||||
const { query } = useSongListFilters(pageKey as ItemListKey);
|
||||
|
||||
const mergedQuery = useMemo(() => {
|
||||
if (!overrideQuery) {
|
||||
|
||||
@@ -16,13 +16,12 @@ import {
|
||||
import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
export const useSongListFilters = () => {
|
||||
const { setSortBy, sortBy } = useSortByFilter<SongListSort>(
|
||||
SongListSort.NAME,
|
||||
ItemListKey.SONG,
|
||||
);
|
||||
export const useSongListFilters = (listKey?: ItemListKey) => {
|
||||
const resolvedListKey = listKey ?? ItemListKey.SONG;
|
||||
|
||||
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.SONG);
|
||||
const { setSortBy, sortBy } = useSortByFilter<SongListSort>(SongListSort.NAME, resolvedListKey);
|
||||
|
||||
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey);
|
||||
|
||||
const { searchTerm, setSearchTerm } = useSearchTermFilter('');
|
||||
|
||||
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ declare global {
|
||||
identify(unique_id: string): void;
|
||||
identify(unique_id: string, data: object): void;
|
||||
identify(data: object): void;
|
||||
track(): void;
|
||||
track(event_name: string, data: object): void;
|
||||
track(
|
||||
callback: (props: {
|
||||
|
||||
@@ -68,10 +68,12 @@ export const useSyncSettingsToMain = () => {
|
||||
mainStoreKey: 'disable_auto_updates',
|
||||
rendererValue: settings.window.disableAutoUpdate,
|
||||
},
|
||||
{
|
||||
mainStoreKey: 'release_channel',
|
||||
rendererValue: settings.window.releaseChannel,
|
||||
},
|
||||
// For some reason after the application is updated, the release channel from the
|
||||
// renderer is always set to the latest channel. This causes an infinite update loop
|
||||
// {
|
||||
// mainStoreKey: 'release_channel',
|
||||
// rendererValue: settings.window.releaseChannel,
|
||||
// },
|
||||
{
|
||||
mainStoreKey: 'window_enable_tray',
|
||||
rendererValue: settings.window.tray,
|
||||
@@ -110,13 +112,19 @@ export const useSyncSettingsToMain = () => {
|
||||
JSON.stringify(mainValueNormalized) !== JSON.stringify(rendererValueNormalized)
|
||||
) {
|
||||
hasDifferences = true;
|
||||
logFn.warn(logMsg.system.settingsSynchronized, {
|
||||
meta: {
|
||||
mainStoreKey: mapping.mainStoreKey,
|
||||
mainValue: mainValueNormalized,
|
||||
rendererValue: rendererValueNormalized,
|
||||
},
|
||||
});
|
||||
localSettings.set(mapping.mainStoreKey, rendererValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Show restart toast if there were differences
|
||||
if (hasDifferences) {
|
||||
logFn.info(logMsg.system.settingsSynchronized);
|
||||
openRestartRequiredToast(
|
||||
i18n.t('error.settingsSyncError', { postProcess: 'sentenceCase' }),
|
||||
);
|
||||
|
||||
@@ -1,54 +1,118 @@
|
||||
import type { Album, AlbumArtist, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/ar';
|
||||
import 'dayjs/locale/ca';
|
||||
import 'dayjs/locale/cs';
|
||||
import 'dayjs/locale/de';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/es';
|
||||
import 'dayjs/locale/eu';
|
||||
import 'dayjs/locale/fa';
|
||||
import 'dayjs/locale/fi';
|
||||
import 'dayjs/locale/fr';
|
||||
import 'dayjs/locale/hu';
|
||||
import 'dayjs/locale/id';
|
||||
import 'dayjs/locale/it';
|
||||
import 'dayjs/locale/ja';
|
||||
import 'dayjs/locale/ko';
|
||||
import 'dayjs/locale/nb';
|
||||
import 'dayjs/locale/nl';
|
||||
import 'dayjs/locale/pl';
|
||||
import 'dayjs/locale/pt';
|
||||
import 'dayjs/locale/pt-br';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/sl';
|
||||
import 'dayjs/locale/sr';
|
||||
import 'dayjs/locale/sv';
|
||||
import 'dayjs/locale/ta';
|
||||
import 'dayjs/locale/tr';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import 'dayjs/locale/zh-tw';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import formatDuration from 'format-duration';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
const FORMATS: Record<number, string> = Object.freeze({
|
||||
0: 'YYYY',
|
||||
1: 'MMM YYYY',
|
||||
2: 'MMM D, YYYY',
|
||||
});
|
||||
const getDayjsLocale = (i18nLang: string): string => {
|
||||
const localeMap: Record<string, string> = {
|
||||
ar: 'ar',
|
||||
ca: 'ca',
|
||||
cs: 'cs',
|
||||
de: 'de',
|
||||
en: 'en',
|
||||
es: 'es',
|
||||
eu: 'eu',
|
||||
fa: 'fa',
|
||||
fi: 'fi',
|
||||
fr: 'fr',
|
||||
hu: 'hu',
|
||||
id: 'id',
|
||||
it: 'it',
|
||||
ja: 'ja',
|
||||
ko: 'ko',
|
||||
'nb-NO': 'nb',
|
||||
nl: 'nl',
|
||||
pl: 'pl',
|
||||
pt: 'pt',
|
||||
'pt-BR': 'pt-br',
|
||||
ru: 'ru',
|
||||
sl: 'sl',
|
||||
sr: 'sr',
|
||||
sv: 'sv',
|
||||
ta: 'ta',
|
||||
tr: 'tr',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'zh-Hant': 'zh-tw',
|
||||
};
|
||||
|
||||
const getDateFormat = (key: string): string => {
|
||||
const dashes = Math.min(key.split('-').length - 1, 2);
|
||||
return FORMATS[dashes];
|
||||
return localeMap[i18nLang] || 'en';
|
||||
};
|
||||
|
||||
export const formatDateAbsolute = (key: null | string) =>
|
||||
key ? dayjs(key).format(getDateFormat(key)) : '';
|
||||
const updateDayjsLocale = () => {
|
||||
const dayjsLocale = getDayjsLocale(i18n.language);
|
||||
dayjs.locale(dayjsLocale);
|
||||
};
|
||||
|
||||
// Set initial locale
|
||||
updateDayjsLocale();
|
||||
|
||||
// Listen for i18n language changes
|
||||
i18n.on('languageChanged', updateDayjsLocale);
|
||||
|
||||
export const formatDateAbsolute = (key: null | string) => (key ? dayjs(key).format('LL') : '');
|
||||
|
||||
export const formatDateAbsoluteUTC = (key: null | string) =>
|
||||
key ? dayjs.utc(key).format(getDateFormat(key)) : '';
|
||||
key ? dayjs.utc(key).format('LL') : '';
|
||||
|
||||
export const formatHrDateTime = (key: null | string) =>
|
||||
key ? dayjs(key).format('YYYY-MM-DD HH:mm') : '';
|
||||
export const formatHrDateTime = (key: null | string) => (key ? dayjs(key).format('LLL') : '');
|
||||
|
||||
export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : '');
|
||||
|
||||
export const formatDurationString = (duration: number) => {
|
||||
const rawDuration = formatDuration(duration).split(':');
|
||||
const rawDuration = formatDuration(duration, { leading: false }).split(':');
|
||||
|
||||
let string;
|
||||
|
||||
switch (rawDuration.length) {
|
||||
case 1:
|
||||
string = `${rawDuration[0]} sec`;
|
||||
string = `${rawDuration[0]} ${i18n.t('datetime.secondShort')}`;
|
||||
break;
|
||||
case 2:
|
||||
string = `${rawDuration[0]} min ${rawDuration[1]} sec`;
|
||||
string = `${rawDuration[0]} ${i18n.t('datetime.minuteShort')} ${rawDuration[1]} ${i18n.t('datetime.secondShort')}`;
|
||||
break;
|
||||
case 3:
|
||||
string = `${rawDuration[0]} hr ${rawDuration[1]} min ${rawDuration[2]} sec`;
|
||||
string = `${rawDuration[0]} ${i18n.t('datetime.hourShort')} ${rawDuration[1]} ${i18n.t('datetime.minuteShort')} ${rawDuration[2]} ${i18n.t('datetime.secondShort')}`;
|
||||
break;
|
||||
case 4:
|
||||
string = `${rawDuration[0]} day ${rawDuration[1]} hr ${rawDuration[2]} min ${rawDuration[3]} sec`;
|
||||
string = `${rawDuration[0]} ${i18n.t('datetime.dayShort')} ${rawDuration[1]} ${i18n.t('datetime.hourShort')} ${rawDuration[2]} ${i18n.t('datetime.minuteShort')} ${rawDuration[3]} ${i18n.t('datetime.secondShort')}`;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -58,15 +122,17 @@ export const formatDurationString = (duration: number) => {
|
||||
export const formatDurationStringShort = (duration: number) => {
|
||||
const rawDuration = formatDuration(duration).split(':');
|
||||
|
||||
if (rawDuration.length === 2) {
|
||||
// Less than 1 hour: show "0h" and minutes
|
||||
return `0h ${rawDuration[0]}m`;
|
||||
} else if (rawDuration.length >= 3) {
|
||||
// 1 hour or more: show hours and minutes
|
||||
return `${rawDuration[0]}h ${rawDuration[1]}m`;
|
||||
if (rawDuration.length === 4) {
|
||||
return `${rawDuration[0]}${i18n.t('datetime.dayShort')} ${rawDuration[1]}${i18n.t('datetime.hourShort')}`;
|
||||
} else if (rawDuration.length === 3) {
|
||||
return `${rawDuration[0]}${i18n.t('datetime.hourShort')} ${rawDuration[1]}${i18n.t('datetime.minuteShort')}`;
|
||||
} else if (rawDuration.length === 2) {
|
||||
return `${rawDuration[0]}${i18n.t('datetime.minuteShort')} ${rawDuration[1]}${i18n.t('datetime.secondShort')}`;
|
||||
} else if (rawDuration.length === 1) {
|
||||
return `${rawDuration[0]}${i18n.t('datetime.secondShort')}`;
|
||||
}
|
||||
|
||||
return '0h 0m';
|
||||
return rawDuration;
|
||||
};
|
||||
|
||||
export const formatRating = (item: Album | AlbumArtist | Song) =>
|
||||
|
||||
@@ -692,6 +692,61 @@ const getInternetRadioStations = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const jukeboxStatus = z.object({
|
||||
jukeboxStatus: z.object({
|
||||
currentIndex: z.number().describe('The index of the current song in the queue'),
|
||||
gain: z.number().describe('Volume, in a range of [0.0, 1.0]'),
|
||||
playing: z.boolean().describe('Whether the jukebox is playing'),
|
||||
position: z.number().optional().describe('The position of the current song in seconds'),
|
||||
}),
|
||||
});
|
||||
|
||||
const jukeboxPlaylist = z.object({
|
||||
jukeboxPlaylist: z.object({
|
||||
currentIndex: z.number().describe('The index of the current song in the queue'),
|
||||
entry: z.array(song).optional(),
|
||||
gain: z.number().describe('Volume, in a range of [0.0, 1.0]'),
|
||||
playing: z.boolean().describe('Whether the jukebox is playing'),
|
||||
position: z.number().optional().describe('The position of the current song in seconds'),
|
||||
}),
|
||||
});
|
||||
|
||||
const jukeboxControlParameters = z.object({
|
||||
action: z.enum([
|
||||
'get',
|
||||
'status',
|
||||
'set',
|
||||
'start',
|
||||
'stop',
|
||||
'skip',
|
||||
'add',
|
||||
'clear',
|
||||
'remove',
|
||||
'shuffle',
|
||||
'setGain',
|
||||
]),
|
||||
gain: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Used by setGain to control the playback volume. A float value between 0.0 and 1.0.',
|
||||
),
|
||||
id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Used by add and set. ID of song to add to the jukebox playlist. Use multiple id parameters to add many songs in the same request. (set is similar to a clear followed by a add, but will not change the currently playing track.)',
|
||||
),
|
||||
index: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Used by skip and remove. Zero-based index of the song to skip to or remove.'),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Used by skip. Start playing this many seconds into the track.'),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumInfo: albumInfoParameters,
|
||||
@@ -716,6 +771,7 @@ export const ssType = {
|
||||
getSong: getSongParameters,
|
||||
getSongsByGenre: getSongsByGenreParameters,
|
||||
getStarred: getStarredParameters,
|
||||
jukeboxControl: jukeboxControlParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||
@@ -761,6 +817,8 @@ export const ssType = {
|
||||
getSongsByGenre,
|
||||
getStarred,
|
||||
internetRadioStation,
|
||||
jukeboxPlaylist,
|
||||
jukeboxStatus,
|
||||
musicFolderList,
|
||||
ping,
|
||||
playlist,
|
||||
|
||||
@@ -1364,6 +1364,7 @@ export type ControllerEndpoint = {
|
||||
// getArtistInfo?: (args: any) => void;
|
||||
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
jukeboxControl: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
|
||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
||||
@@ -1486,6 +1487,9 @@ export type InternalControllerEndpoint = {
|
||||
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
||||
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
|
||||
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
||||
jukeboxControl: (
|
||||
args: ReplaceApiClientProps<JukeboxControlArgs>,
|
||||
) => Promise<JukeboxControlResponse>;
|
||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||
removeFromPlaylist: (
|
||||
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
||||
@@ -1506,6 +1510,37 @@ export type InternalControllerEndpoint = {
|
||||
) => Promise<UpdatePlaylistResponse>;
|
||||
};
|
||||
|
||||
export type JukeboxControlArgs = BaseEndpointArgs & {
|
||||
query: JukeboxControlQuery;
|
||||
};
|
||||
|
||||
export type JukeboxControlQuery = {
|
||||
action:
|
||||
| 'add'
|
||||
| 'clear'
|
||||
| 'get'
|
||||
| 'remove'
|
||||
| 'set'
|
||||
| 'setGain'
|
||||
| 'shuffle'
|
||||
| 'skip'
|
||||
| 'start'
|
||||
| 'status'
|
||||
| 'stop';
|
||||
gain?: number;
|
||||
id?: string;
|
||||
index?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type JukeboxControlResponse = {
|
||||
currentIndex: number;
|
||||
gain: number;
|
||||
playing: boolean;
|
||||
position: number;
|
||||
songs: Song[];
|
||||
};
|
||||
|
||||
export type LyricGetQuery = {
|
||||
remoteSongId: string;
|
||||
remoteSource: LyricSource;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
|
||||
export enum ServerFeature {
|
||||
BFR = 'bfr',
|
||||
JUKEBOX = 'jukebox',
|
||||
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
||||
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
|
||||
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
|
||||
|
||||
Reference in New Issue
Block a user