Compare commits

..

13 Commits

Author SHA1 Message Date
jeffvli 95f395bd87 add jukebox endpoint / controller 2025-12-16 18:07:09 -08:00
jeffvli c9cd87bae5 remove release_channel from settings sync 2025-12-15 23:17:47 -08:00
jeffvli 9a8cb45510 Revert "prevent autoupdater from setting release channel (#1396)"
This reverts commit 614761efd7.
2025-12-15 23:06:25 -08:00
jeffvli 68b6a58ac5 handle text overflow on sidebar playlist duration 2025-12-15 21:37:58 -08:00
jeffvli 5b5cdbfb7f prevent action bar icons from being squished on resize (fix) 2025-12-15 21:37:17 -08:00
jeffvli cf4e505743 prevent action bar icons from being squished on resize 2025-12-15 21:36:27 -08:00
jeffvli 8464ed439e use success notification instead of warn on 0 playlist entries added (#1393) 2025-12-15 21:15:20 -08:00
jeffvli 9e49a45db9 fix initial list order on artist discography (#1378) 2025-12-15 21:07:24 -08:00
jeffvli 8dc5f2a580 add page view tracker 2025-12-15 20:50:58 -08:00
jeffvli 6bb848a675 remove analytics properties for based on desktop-only settings 2025-12-15 20:31:15 -08:00
jeffvli 8edf61f9e7 localize dates (#1237) 2025-12-15 20:20:32 -08:00
jeffvli 96d2699a2d add migration to clear all original store settings (#1396) 2025-12-15 19:07:12 -08:00
jeffvli 614761efd7 prevent autoupdater from setting release channel (#1396) 2025-12-15 18:12:16 -08:00
26 changed files with 433 additions and 81 deletions
+7
View File
@@ -30,6 +30,7 @@
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor", "toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)", "viewPlaylists": "view $t(entity.playlist_other)",
"viewMore": "view more", "viewMore": "view more",
"openApplicationDirectory": "open application directory",
"openIn": { "openIn": {
"lastfm": "Open in Last.fm", "lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz" "musicbrainz": "Open in MusicBrainz"
@@ -266,6 +267,12 @@
"trackNumber": "track", "trackNumber": "track",
"explicitStatus": "$t(common.explicitStatus)" "explicitStatus": "$t(common.explicitStatus)"
}, },
"datetime": {
"minuteShort": "min",
"secondShort": "sec",
"hourShort": "hr",
"dayShort": "day"
},
"filterOperator": { "filterOperator": {
"after": "is after", "after": "is after",
"afterDate": "is after (date)", "afterDate": "is after (date)",
+3
View File
@@ -11,6 +11,9 @@ export const store = new Store({
'>=0.21.2': (store) => { '>=0.21.2': (store) => {
store.set('window_bar_style', 'linux'); store.set('window_bar_style', 'linux');
}, },
'>=1.0.0': (store) => {
store.clear();
},
}, },
}); });
+8
View File
@@ -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);
});
}
+5
View File
@@ -6,6 +6,10 @@ const openItem = async (path: string) => {
return ipcRenderer.invoke('open-item', path); return ipcRenderer.invoke('open-item', path);
}; };
const openApplicationDirectory = async () => {
return ipcRenderer.invoke('open-application-directory');
};
const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => { const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', cb); ipcRenderer.on('player-error-listener', cb);
}; };
@@ -42,6 +46,7 @@ export const utils = {
isWindows, isWindows,
logger, logger,
mainMessageListener, mainMessageListener,
openApplicationDirectory,
openItem, openItem,
playerErrorListener, playerErrorListener,
}; };
@@ -1202,6 +1202,9 @@ export const JellyfinController: InternalControllerEndpoint = {
name: res.body.Name, name: res.body.Name,
}; };
}, },
jukeboxControl: async () => {
throw new Error('Not implemented');
},
movePlaylistItem: async (args) => { movePlaylistItem: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -582,6 +582,7 @@ export const NavidromeController: InternalControllerEndpoint = {
const features = { const features = {
...subsonicArgs.features, ...subsonicArgs.features,
...navidromeFeatures, ...navidromeFeatures,
jukebox: [1],
publicPlaylist: [1], publicPlaylist: [1],
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1], [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
}; };
@@ -761,6 +762,7 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
}, },
jukeboxControl: SubsonicController.jukeboxControl,
movePlaylistItem: async (args) => { movePlaylistItem: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
+7
View File
@@ -6,6 +6,7 @@ import type {
ArtistListQuery, ArtistListQuery,
FolderQuery, FolderQuery,
GenreListQuery, GenreListQuery,
JukeboxControlQuery,
LyricSearchQuery, LyricSearchQuery,
LyricsQuery, LyricsQuery,
PlaylistDetailQuery, PlaylistDetailQuery,
@@ -262,6 +263,12 @@ export const queryKeys: Record<
}, },
root: (serverId: string) => [serverId, 'genres'] as const, 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: { musicFolders: {
list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const, list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const,
}, },
@@ -249,6 +249,14 @@ export const contract = c.router({
200: ssType._response.user, 200: ssType._response.user,
}, },
}, },
jukeboxControl: {
method: 'GET',
path: 'jukeboxControl.view',
query: ssType._parameters.jukeboxControl,
responses: {
200: ssType._response.jukeboxPlaylist,
},
},
ping: { ping: {
method: 'GET', method: 'GET',
path: 'ping.view', path: 'ping.view',
@@ -867,7 +867,6 @@ export const SubsonicController: InternalControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server); return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
}, },
getPlaylistList: async ({ apiClientProps, query }) => { getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
@@ -1060,7 +1059,9 @@ export const SubsonicController: InternalControllerEndpoint = {
throw new Error('Failed to ping server'); throw new Error('Failed to ping server');
} }
const features: ServerFeatures = {}; const features: ServerFeatures = {
jukebox: [1],
};
if (!ping.body.openSubsonic || !ping.body.serverVersion) { if (!ping.body.openSubsonic || !ping.body.serverVersion) {
return { features, version: ping.body.version }; return { features, version: ping.body.version };
@@ -1579,6 +1580,30 @@ export const SubsonicController: InternalControllerEndpoint = {
name: res.body.user.username, 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 }) => { removeFromPlaylist: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({ const res = await ssApiClient(apiClientProps).updatePlaylist({
query: { query: {
@@ -87,8 +87,9 @@ export const AlbumListView = ({
table, table,
}: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => { }: ItemListSettings & { overrideQuery?: OverrideAlbumListQuery }) => {
const server = useCurrentServer(); const server = useCurrentServer();
const { pageKey } = useListContext();
const { query } = useAlbumListFilters(); const { query } = useAlbumListFilters(pageKey as ItemListKey);
const mergedQuery = useMemo(() => { const mergedQuery = useMemo(() => {
if (!overrideQuery) { if (!overrideQuery) {
@@ -15,13 +15,15 @@ import {
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types'; import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/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>( const { setSortBy, sortBy } = useSortByFilter<AlbumListSort>(
AlbumListSort.NAME, AlbumListSort.NAME,
ItemListKey.ALBUM, resolvedListKey,
); );
const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.ALBUM); const { setSortOrder, sortOrder } = useSortOrderFilter(SortOrder.ASC, resolvedListKey);
const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const { searchTerm, setSearchTerm } = useSearchTermFilter('');
@@ -127,15 +127,19 @@ const getPlayerProperties = (): Pick<
const playbackSettings = useSettingsStore.getState().playback; const playbackSettings = useSettingsStore.getState().playback;
return { return {
'player.mediaSession': playbackSettings.mediaSession, 'player.mediaSession': ignoreWeb(playbackSettings.mediaSession),
'player.queueType': player.player.queueType, 'player.queueType': player.player.queueType,
'player.style': player.player.transitionType, 'player.style': player.player.transitionType,
'player.transcoding': playbackSettings.transcode.enabled, 'player.transcoding': playbackSettings.transcode.enabled,
'player.type': playbackSettings.type, 'player.type': ignoreWeb(playbackSettings.type),
'player.webAudio': playbackSettings.webAudio, 'player.webAudio': ignoreWeb(playbackSettings.webAudio),
}; } as any;
}; };
function ignoreWeb<T>(value: T): T | undefined {
return isElectron() ? value : undefined;
}
const getSettingsProperties = (): SettingsProperties => { const getSettingsProperties = (): SettingsProperties => {
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
@@ -148,22 +152,30 @@ const getSettingsProperties = (): SettingsProperties => {
'settings.autoDJItemCount': settings.autoDJ.itemCount, 'settings.autoDJItemCount': settings.autoDJ.itemCount,
'settings.autoDJTiming': settings.autoDJ.timing, 'settings.autoDJTiming': settings.autoDJ.timing,
'settings.customCss': settings.css.enabled, 'settings.customCss': settings.css.enabled,
'settings.disableAutoUpdate': settings.window.disableAutoUpdate, 'settings.disableAutoUpdate': ignoreWeb(settings.window.disableAutoUpdate),
'settings.discord': settings.discord.enabled, 'settings.discord': ignoreWeb(settings.discord.enabled),
'settings.exitToTray': settings.window.exitToTray, 'settings.exitToTray': ignoreWeb(settings.window.exitToTray),
'settings.followSystemTheme': settings.general.followSystemTheme, 'settings.followSystemTheme': settings.general.followSystemTheme,
'settings.fontType': settings.font.type, 'settings.fontType': settings.font.type,
'settings.globalHotkeys': settings.hotkeys.globalMediaHotkeys, 'settings.globalHotkeys': settings.hotkeys.globalMediaHotkeys,
'settings.homeFeature': settings.general.homeFeature, 'settings.homeFeature': settings.general.homeFeature,
'settings.language': settings.general.language, 'settings.language': settings.general.language,
// 'settings.lastFM': settings.general.lastFM, // 'settings.lastFM': settings.general.lastFM,
'settings.lyrics.enableAutoTranslation': settings.lyrics.enableAutoTranslation, 'settings.lyrics.enableAutoTranslation': ignoreWeb(settings.lyrics.enableAutoTranslation),
'settings.lyrics.enableNeteaseTranslation': settings.lyrics.enableNeteaseTranslation, 'settings.lyrics.enableNeteaseTranslation': ignoreWeb(
'settings.lyrics.fetch': settings.lyrics.fetch, settings.lyrics.enableNeteaseTranslation,
'settings.lyrics.sources.genius': settings.lyrics.sources.includes(LyricSource.GENIUS), ),
'settings.lyrics.sources.lrclib': settings.lyrics.sources.includes(LyricSource.LRCLIB), 'settings.lyrics.fetch': ignoreWeb(settings.lyrics.fetch),
'settings.lyrics.sources.netease': settings.lyrics.sources.includes(LyricSource.NETEASE), 'settings.lyrics.sources.genius': ignoreWeb(
'settings.minimizeToTray': settings.window.minimizeToTray, 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.musicBrainz': settings.general.musicBrainz,
'settings.nativeAspectRatio': settings.general.nativeAspectRatio, 'settings.nativeAspectRatio': settings.general.nativeAspectRatio,
'settings.playerbarSliderType': settings.general.playerbarSlider 'settings.playerbarSliderType': settings.general.playerbarSlider
@@ -172,26 +184,26 @@ const getSettingsProperties = (): SettingsProperties => {
// 'settings.playerbarWaveformBarWidth': settings.general.playerbarSlider.barWidth, // 'settings.playerbarWaveformBarWidth': settings.general.playerbarSlider.barWidth,
// 'settings.playerbarWaveformGap': settings.general.playerbarSlider.barGap, // 'settings.playerbarWaveformGap': settings.general.playerbarSlider.barGap,
// 'settings.playerbarWaveformRadius': settings.general.playerbarSlider.barRadius, // 'settings.playerbarWaveformRadius': settings.general.playerbarSlider.barRadius,
'settings.preventSleepOnPlayback': settings.window.preventSleepOnPlayback, 'settings.preventSleepOnPlayback': ignoreWeb(settings.window.preventSleepOnPlayback),
'settings.releaseChannel': settings.window.releaseChannel, 'settings.releaseChannel': ignoreWeb(settings.window.releaseChannel),
'settings.resume': settings.general.resume, 'settings.resume': settings.general.resume,
'settings.scrobble.enabled': settings.playback.scrobble.enabled, '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.showLyricsInSidebar': settings.general.showLyricsInSidebar,
'settings.showVisualizerInSidebar': settings.general.showVisualizerInSidebar, 'settings.showVisualizerInSidebar': settings.general.showVisualizerInSidebar,
'settings.sideQueueType': settings.general.sideQueueType, 'settings.sideQueueType': settings.general.sideQueueType,
// 'settings.skipBackwardSeconds': settings.general.skipButtons.skipBackwardSeconds, // 'settings.skipBackwardSeconds': settings.general.skipButtons.skipBackwardSeconds,
'settings.skipButtons': settings.general.skipButtons.enabled, 'settings.skipButtons': settings.general.skipButtons.enabled,
// 'settings.skipForwardSeconds': settings.general.skipButtons.skipForwardSeconds, // 'settings.skipForwardSeconds': settings.general.skipButtons.skipForwardSeconds,
'settings.startMinimized': settings.window.startMinimized, 'settings.startMinimized': ignoreWeb(settings.window.startMinimized),
'settings.theme': settings.general.theme, 'settings.theme': settings.general.theme,
'settings.themeDark': settings.general.themeDark, 'settings.themeDark': settings.general.themeDark,
'settings.themeLight': settings.general.themeLight, 'settings.themeLight': settings.general.themeLight,
'settings.tray': settings.window.tray, 'settings.tray': ignoreWeb(settings.window.tray),
'settings.useThemeAccentColor': settings.general.useThemeAccentColor, 'settings.useThemeAccentColor': settings.general.useThemeAccentColor,
'settings.windowBarStyle': settings.window.windowBarStyle, 'settings.windowBarStyle': ignoreWeb(settings.window.windowBarStyle),
'settings.zoomFactor': settings.general.zoomFactor, 'settings.zoomFactor': ignoreWeb(settings.general.zoomFactor),
}; } as any;
}; };
const getServer = (): 'unknown' | ServerType => { const getServer = (): 'unknown' | ServerType => {
@@ -202,6 +214,7 @@ const getServer = (): 'unknown' | ServerType => {
export const useAppTracker = () => { export const useAppTracker = () => {
const { mutate: trackAppMutation } = useMutation(appTrackerMutation); const { mutate: trackAppMutation } = useMutation(appTrackerMutation);
const { mutate: trackAppViewMutation } = useMutation(appViewMutation);
const hasRunOnMountRef = useRef(false); const hasRunOnMountRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -246,6 +259,10 @@ export const useAppTracker = () => {
meta: { properties, todayUTC }, meta: { properties, todayUTC },
}); });
trackAppViewMutation(undefined, {
onError: () => {},
});
trackAppMutation(properties, { trackAppMutation(properties, {
onError: () => {}, onError: () => {},
onSettled: () => { onSettled: () => {
@@ -275,9 +292,10 @@ export const useAppTracker = () => {
const interval = setInterval(checkAndTrack, 1000 * 60 * 60); const interval = setInterval(checkAndTrack, 1000 * 60 * 60);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [trackAppMutation]); }, [trackAppMutation, trackAppViewMutation]);
}; };
// Sends the app event to the analytics server which includes usage data
const appTrackerMutation = mutationOptions({ const appTrackerMutation = mutationOptions({
mutationFn: (properties: AppTrackerProperties) => { mutationFn: (properties: AppTrackerProperties) => {
try { try {
@@ -296,3 +314,24 @@ const appTrackerMutation = mutationOptions({
retry: false, retry: false,
throwOnError: 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) { if (allSongIds.length === 0) {
toast.warn({ toast.success({
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }), message: t('form.addToPlaylist.success', {
message: 0,
numOfPlaylists: 1,
postProcess: 'sentenceCase',
}),
}); });
return; return;
} }
@@ -241,8 +245,12 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
} }
if (songsToAdd.length === 0) { if (songsToAdd.length === 0) {
toast.warn({ toast.success({
message: t('common.noResultsFromQuery', { postProcess: 'sentenceCase' }), message: t('form.addToPlaylist.success', {
message: 0,
numOfPlaylists: 1,
postProcess: 'sentenceCase',
}),
}); });
return; 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 ( return (
<SettingsSection <>
options={options} <SettingsSection
title={t('page.setting.cache', { postProcess: 'sentenceCase' })} 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"> <Group gap="sm" grow wrap="nowrap">
<DropdownMenu position="bottom-start"> <DropdownMenu position="bottom-start">
<DropdownMenu.Target> <DropdownMenu.Target>
<Button p="0.5rem"> <Button p="0">
<Icon icon="menu" size="lg" /> <Icon icon="menu" size="lg" />
</Button> </Button>
</DropdownMenu.Target> </DropdownMenu.Target>
@@ -62,10 +62,10 @@ const NavigateButtons = () => {
return ( return (
<> <>
<Button onClick={() => navigate(-1)} p="0.5rem"> <Button onClick={() => navigate(-1)} p="0">
<Icon icon="arrowLeftS" size="lg" /> <Icon icon="arrowLeftS" size="lg" />
</Button> </Button>
<Button onClick={() => navigate(1)} p="0.5rem"> <Button onClick={() => navigate(1)} p="0">
<Icon icon="arrowRightS" size="lg" /> <Icon icon="arrowRightS" size="lg" />
</Button> </Button>
</> </>
@@ -59,17 +59,43 @@
gap: var(--theme-spacing-md); gap: var(--theme-spacing-md);
align-items: center; align-items: center;
min-width: 0; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.metadata-group-item { .metadata-group-item {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 1;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: var(--theme-spacing-xs); gap: var(--theme-spacing-xs);
align-items: center; align-items: center;
min-width: 0;
overflow: hidden;
white-space: nowrap; 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 { .name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store'; 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 { Accordion } from '/@/shared/components/accordion/accordion';
import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon';
import { ButtonProps } from '/@/shared/components/button/button'; import { ButtonProps } from '/@/shared/components/button/button';
@@ -185,7 +185,12 @@ const PlaylistRowButton = memo(({ item, name, onContextMenu, to }: PlaylistRowBu
{name} {name}
</Text> </Text>
<div className={styles.metadataGroup}> <div className={styles.metadataGroup}>
<div className={styles.metadataGroupItem}> <div
className={clsx(
styles.metadataGroupItem,
styles.metadataGroupItemNoShrink,
)}
>
<Icon color="muted" icon="itemSong" size="sm" /> <Icon color="muted" icon="itemSong" size="sm" />
<Text isMuted size="sm"> <Text isMuted size="sm">
{item.songCount || 0} {item.songCount || 0}
@@ -194,7 +199,7 @@ const PlaylistRowButton = memo(({ item, name, onContextMenu, to }: PlaylistRowBu
<div className={styles.metadataGroupItem}> <div className={styles.metadataGroupItem}>
<Icon color="muted" icon="duration" size="sm" /> <Icon color="muted" icon="duration" size="sm" />
<Text isMuted size="sm"> <Text isMuted size="sm">
{formatDurationStringShort(item.duration ?? 0)} {formatDurationString(item.duration ?? 0)}
</Text> </Text>
</div> </div>
{item.ownerId === permissions.userId && Boolean(item.public) && ( {item.ownerId === permissions.userId && Boolean(item.public) && (
@@ -84,8 +84,9 @@ export const SongListView = ({
table, table,
}: ItemListSettings & { overrideQuery?: OverrideSongListQuery }) => { }: ItemListSettings & { overrideQuery?: OverrideSongListQuery }) => {
const server = useCurrentServer(); const server = useCurrentServer();
const { pageKey } = useListContext();
const { query } = useSongListFilters(); const { query } = useSongListFilters(pageKey as ItemListKey);
const mergedQuery = useMemo(() => { const mergedQuery = useMemo(() => {
if (!overrideQuery) { if (!overrideQuery) {
@@ -16,13 +16,12 @@ import {
import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; import { SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
export const useSongListFilters = () => { export const useSongListFilters = (listKey?: ItemListKey) => {
const { setSortBy, sortBy } = useSortByFilter<SongListSort>( const resolvedListKey = listKey ?? ItemListKey.SONG;
SongListSort.NAME,
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(''); const { searchTerm, setSearchTerm } = useSearchTermFilter('');
+1
View File
@@ -4,6 +4,7 @@ declare global {
identify(unique_id: string): void; identify(unique_id: string): void;
identify(unique_id: string, data: object): void; identify(unique_id: string, data: object): void;
identify(data: object): void; identify(data: object): void;
track(): void;
track(event_name: string, data: object): void; track(event_name: string, data: object): void;
track( track(
callback: (props: { callback: (props: {
@@ -68,10 +68,12 @@ export const useSyncSettingsToMain = () => {
mainStoreKey: 'disable_auto_updates', mainStoreKey: 'disable_auto_updates',
rendererValue: settings.window.disableAutoUpdate, rendererValue: settings.window.disableAutoUpdate,
}, },
{ // For some reason after the application is updated, the release channel from the
mainStoreKey: 'release_channel', // renderer is always set to the latest channel. This causes an infinite update loop
rendererValue: settings.window.releaseChannel, // {
}, // mainStoreKey: 'release_channel',
// rendererValue: settings.window.releaseChannel,
// },
{ {
mainStoreKey: 'window_enable_tray', mainStoreKey: 'window_enable_tray',
rendererValue: settings.window.tray, rendererValue: settings.window.tray,
@@ -110,13 +112,19 @@ export const useSyncSettingsToMain = () => {
JSON.stringify(mainValueNormalized) !== JSON.stringify(rendererValueNormalized) JSON.stringify(mainValueNormalized) !== JSON.stringify(rendererValueNormalized)
) { ) {
hasDifferences = true; hasDifferences = true;
logFn.warn(logMsg.system.settingsSynchronized, {
meta: {
mainStoreKey: mapping.mainStoreKey,
mainValue: mainValueNormalized,
rendererValue: rendererValueNormalized,
},
});
localSettings.set(mapping.mainStoreKey, rendererValue); localSettings.set(mapping.mainStoreKey, rendererValue);
} }
} }
// Show restart toast if there were differences // Show restart toast if there were differences
if (hasDifferences) { if (hasDifferences) {
logFn.info(logMsg.system.settingsSynchronized);
openRestartRequiredToast( openRestartRequiredToast(
i18n.t('error.settingsSyncError', { postProcess: 'sentenceCase' }), i18n.t('error.settingsSyncError', { postProcess: 'sentenceCase' }),
); );
+91 -25
View File
@@ -1,54 +1,118 @@
import type { Album, AlbumArtist, Song } from '/@/shared/types/domain-types'; import type { Album, AlbumArtist, Song } from '/@/shared/types/domain-types';
import dayjs from 'dayjs'; 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 relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import formatDuration from 'format-duration'; import formatDuration from 'format-duration';
import i18n from '/@/i18n/i18n';
import { Rating } from '/@/shared/components/rating/rating'; import { Rating } from '/@/shared/components/rating/rating';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(localizedFormat);
const FORMATS: Record<number, string> = Object.freeze({ const getDayjsLocale = (i18nLang: string): string => {
0: 'YYYY', const localeMap: Record<string, string> = {
1: 'MMM YYYY', ar: 'ar',
2: 'MMM D, YYYY', 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 => { return localeMap[i18nLang] || 'en';
const dashes = Math.min(key.split('-').length - 1, 2);
return FORMATS[dashes];
}; };
export const formatDateAbsolute = (key: null | string) => const updateDayjsLocale = () => {
key ? dayjs(key).format(getDateFormat(key)) : ''; 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) => 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) => export const formatHrDateTime = (key: null | string) => (key ? dayjs(key).format('LLL') : '');
key ? dayjs(key).format('YYYY-MM-DD HH:mm') : '';
export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : ''); export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : '');
export const formatDurationString = (duration: number) => { export const formatDurationString = (duration: number) => {
const rawDuration = formatDuration(duration).split(':'); const rawDuration = formatDuration(duration, { leading: false }).split(':');
let string; let string;
switch (rawDuration.length) { switch (rawDuration.length) {
case 1: case 1:
string = `${rawDuration[0]} sec`; string = `${rawDuration[0]} ${i18n.t('datetime.secondShort')}`;
break; break;
case 2: case 2:
string = `${rawDuration[0]} min ${rawDuration[1]} sec`; string = `${rawDuration[0]} ${i18n.t('datetime.minuteShort')} ${rawDuration[1]} ${i18n.t('datetime.secondShort')}`;
break; break;
case 3: 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; break;
case 4: 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; break;
} }
@@ -58,15 +122,17 @@ export const formatDurationString = (duration: number) => {
export const formatDurationStringShort = (duration: number) => { export const formatDurationStringShort = (duration: number) => {
const rawDuration = formatDuration(duration).split(':'); const rawDuration = formatDuration(duration).split(':');
if (rawDuration.length === 2) { if (rawDuration.length === 4) {
// Less than 1 hour: show "0h" and minutes return `${rawDuration[0]}${i18n.t('datetime.dayShort')} ${rawDuration[1]}${i18n.t('datetime.hourShort')}`;
return `0h ${rawDuration[0]}m`; } else if (rawDuration.length === 3) {
} else if (rawDuration.length >= 3) { return `${rawDuration[0]}${i18n.t('datetime.hourShort')} ${rawDuration[1]}${i18n.t('datetime.minuteShort')}`;
// 1 hour or more: show hours and minutes } else if (rawDuration.length === 2) {
return `${rawDuration[0]}h ${rawDuration[1]}m`; 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) => export const formatRating = (item: Album | AlbumArtist | Song) =>
+58
View File
@@ -692,6 +692,61 @@ const getInternetRadioStations = z.object({
.optional(), .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 = { export const ssType = {
_parameters: { _parameters: {
albumInfo: albumInfoParameters, albumInfo: albumInfoParameters,
@@ -716,6 +771,7 @@ export const ssType = {
getSong: getSongParameters, getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters, getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters, getStarred: getStarredParameters,
jukeboxControl: jukeboxControlParameters,
randomSongList: randomSongListParameters, randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters, savePlayQueueByIndex: savePlayQueueByIndexParameters,
@@ -761,6 +817,8 @@ export const ssType = {
getSongsByGenre, getSongsByGenre,
getStarred, getStarred,
internetRadioStation, internetRadioStation,
jukeboxPlaylist,
jukeboxStatus,
musicFolderList, musicFolderList,
ping, ping,
playlist, playlist,
+35
View File
@@ -1364,6 +1364,7 @@ export type ControllerEndpoint = {
// getArtistInfo?: (args: any) => void; // getArtistInfo?: (args: any) => void;
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>; getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>; getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
jukeboxControl: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>; movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>; replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
@@ -1486,6 +1487,9 @@ export type InternalControllerEndpoint = {
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>; getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>; getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>; getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
jukeboxControl: (
args: ReplaceApiClientProps<JukeboxControlArgs>,
) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>; movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
removeFromPlaylist: ( removeFromPlaylist: (
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>, args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
@@ -1506,6 +1510,37 @@ export type InternalControllerEndpoint = {
) => Promise<UpdatePlaylistResponse>; ) => 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 = { export type LyricGetQuery = {
remoteSongId: string; remoteSongId: string;
remoteSource: LyricSource; remoteSource: LyricSource;
+1
View File
@@ -2,6 +2,7 @@
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART" // For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
export enum ServerFeature { export enum ServerFeature {
BFR = 'bfr', BFR = 'bfr',
JUKEBOX = 'jukebox',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect', MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',