Compare commits

...

11 Commits

Author SHA1 Message Date
jeffvli db79d1a71e add album mode for autodj
- add selection modes: similar, random
- add autodj settings in playerbar popover
2026-05-25 19:20:30 -07:00
muckymucky 22d37135ae fix: strip playback accelerators whenever any text input is focused (#2059)
The command-palette-specific IPC approach didn't cover other modals
with inputs (settings search, playlist creation, etc.). Replace it
with document-level focusin/focusout listeners that signal the main
process whenever any input/textarea/contenteditable gains or loses
focus, so the menu rebuild is triggered automatically for all current
and future input surfaces.

Co-authored-by: muckymucky <muckymucky@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:35:45 -07:00
jeffvli 61c6036d41 cap scrobble status at max values 2026-05-25 13:26:12 -07:00
jeffvli 95ae474cc6 fix playerbar slider styles
- left / right values should be consistent width
2026-05-25 13:26:12 -07:00
jeffvli 504bbeed91 handle positional scrobbles for OS 2026-05-25 13:26:11 -07:00
jeffvli 0e5b3450dd set ignoreScrobble, playbackRate as optional params 2026-05-25 13:26:11 -07:00
jeffvli 650ae0b320 add reportPlayback as default Jellyfin feature 2026-05-25 13:26:11 -07:00
jeffvli 66cfab3b57 add playbackReport handler to Subsonic controller 2026-05-25 13:26:10 -07:00
jeffvli 0455d5bfb8 add reportPlayback server feature 2026-05-25 13:26:09 -07:00
jeffvli 112449576e add OS reportPlayback endpoint and types 2026-05-25 13:26:09 -07:00
muckymucky 3122f4121e fix: strip playback menu accelerators while command palette is open (#2055)
On macOS, menu item accelerators (e.g. Space for Play/Pause) fire even
when an input has focus, bypassing the renderer-side useHotkeys guard
added in #1925. Typing a space character in the command palette search
toggled play/pause instead of inserting a space.

Track command palette open state in the main process via IPC. When the
palette is open, rebuild the application menu with empty playback
accelerators so single-key shortcuts no longer intercept keystrokes
intended for the search input. Restore accelerators when the palette
closes.

Co-authored-by: Jack McCrea <jack@MacBookPro.lan>
2026-05-25 11:24:15 -07:00
26 changed files with 1162 additions and 244 deletions
+3
View File
@@ -114,8 +114,11 @@ These variables override app settings **on first run** when no persisted setting
| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `autoDJ.albumStrategy` | `similar` | `FS_AUTO_DJ_ALBUM_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. |
| `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. |
| `autoDJ.mode` | `songs` | `FS_AUTO_DJ_MODE` | `songs` / `albums`. |
| `autoDJ.songStrategy` | `similar` | `FS_AUTO_DJ_SONG_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). |
---
+3
View File
@@ -88,8 +88,11 @@ window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}";
window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}";
window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}";
window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}";
window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}";
window.FS_AUTO_DJ_ITEM_COUNT = "${FS_AUTO_DJ_ITEM_COUNT}";
window.FS_AUTO_DJ_MODE = "${FS_AUTO_DJ_MODE}";
window.FS_AUTO_DJ_SONG_STRATEGY = "${FS_AUTO_DJ_SONG_STRATEGY}";
window.FS_AUTO_DJ_TIMING = "${FS_AUTO_DJ_TIMING}";
window.FS_CSS_CONTENT = "${FS_CSS_CONTENT}";
+15 -2
View File
@@ -415,6 +415,11 @@
},
"shuffleAll": {
"title": "Play random",
"input_kind_albums": "Albums",
"input_kind_songs": "Songs",
"input_kind": "Random picks",
"input_limit_albums": "How many albums?",
"input_limit_songs": "How many songs?",
"input_genre": "$t(entity.genre, {\"count\": 1})",
"input_limit": "How many songs?",
"input_minYear": "From year",
@@ -731,11 +736,19 @@
},
"setting": {
"autoDJ": "Auto DJ",
"autoDJ_description": "Automatically add similar songs to the queue",
"autoDJ_itemCount": "Item count",
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue when auto DJ is enabled",
"autoDJ_itemCount_description": "The number of items attempted to be added to the queue",
"autoDJ_timing": "Timing",
"autoDJ_timing_description": "The number of songs remaining in the queue before auto DJ is triggered",
"autoDJ_mode": "Mode",
"autoDJ_mode_albums": "Albums",
"autoDJ_mode_description": "Choose to add either songs or entire albums to the queue",
"autoDJ_mode_songs": "Songs",
"autoDJ_enabled": "Enable Auto DJ",
"autoDJ_albumStrategy": "Album selection mode",
"autoDJ_songStrategy": "Song selection mode",
"autoDJ_strategy_option_library_random": "Random",
"autoDJ_strategy_option_similar": "Similar",
"autosave": "Automatically save play queue",
"autosave_description": "Enable automatically saving the play queue to your server. This is only possible when using Navidrome/Subsonic, and you cannot have a mixed play queue.",
"autosaveCount": "Automatic play queue save frequency",
+11 -1
View File
@@ -280,6 +280,16 @@ let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
let currentSidebarCollapsed = false;
let currentShuffleEnabled = false;
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
let inputFocused = false;
ipcMain.on('input-focus-state', (_event, focused: boolean) => {
const next = !!focused;
if (inputFocused === next) return;
inputFocused = next;
if (isMacOS()) {
rebuildMainMenu();
}
});
if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => {
@@ -340,7 +350,7 @@ const rebuildMainMenu = () => {
if (!menuBuilder || !mainWindow) return;
menuBuilder.buildMenu({
accelerators: playbackMenuAccelerators,
accelerators: inputFocused ? {} : playbackMenuAccelerators,
playbackStatus: currentPlaybackStatus,
privateMode: currentPrivateMode,
repeatMode: currentRepeatMode,
+5
View File
@@ -54,6 +54,10 @@ const forceGarbageCollection = (): boolean => {
}
};
const setInputFocused = (focused: boolean) => {
ipcRenderer.send('input-focus-state', focused);
};
const rendererOpenSettings = (cb: () => void) => {
ipcRenderer.on('renderer-open-settings', () => cb());
};
@@ -101,6 +105,7 @@ export const utils = {
rendererTogglePrivateMode,
rendererToggleSidebar,
rendererUpdateAvailable,
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
};
@@ -1062,7 +1062,9 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to get server info');
}
const defaultFeatures = {};
const defaultFeatures = {
[ServerFeature.REPORT_PLAYBACK]: [1],
};
const features = {
...defaultFeatures,
@@ -289,6 +289,14 @@ export const contract = c.router({
200: ssType._response.removeFavorite,
},
},
reportPlayback: {
method: 'GET',
path: 'reportPlayback.view',
query: ssType._parameters.reportPlayback,
responses: {
200: ssType._response.reportPlayback,
},
},
savePlayQueue: {
method: 'GET',
path: 'savePlayQueue.view',
@@ -1460,6 +1460,10 @@ export const SubsonicController: InternalControllerEndpoint = {
features.serverPlayQueue = [1];
}
if (subsonicFeatures[SubsonicExtensions.PLAYBACK_REPORT]) {
features.reportPlayback = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
},
getSimilarSongs: async (args) => {
@@ -2298,6 +2302,57 @@ export const SubsonicController: InternalControllerEndpoint = {
scrobble: async (args) => {
const { apiClientProps, query } = args;
if (hasFeature(apiClientProps.server, ServerFeature.REPORT_PLAYBACK)) {
if (query.submission) {
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
submission: query.submission,
},
});
if (res.status !== 200) {
throw new Error('Failed to scrobble');
}
return null;
}
let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing';
switch (query.event) {
case 'pause':
state = 'paused';
break;
case 'start':
state = 'starting';
break;
case 'timeupdate':
case 'unpause':
state = 'playing';
break;
default:
state = 'playing';
}
const res = await ssApiClient(apiClientProps).reportPlayback({
query: {
ignoreScrobble: true,
mediaId: query.id,
mediaType: query.mediaType,
playbackRate: query.playbackRate,
positionMs: query.position ?? 0,
state,
},
});
if (res.status !== 200) {
throw new Error('Failed to report playback');
}
return null;
}
const res = await ssApiClient(apiClientProps).scrobble({
query: {
id: query.id,
+40
View File
@@ -93,6 +93,7 @@ const AppEffects = () => (
<GlobalShortcutsEffect />
<LanguageEffect />
<NativeMenuSyncEffect />
<InputFocusEffect />
</>
);
@@ -170,3 +171,42 @@ const NativeMenuSyncEffect = () => {
return null;
};
const InputFocusEffect = () => {
useEffect(() => {
if (!isElectron()) return;
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as Element | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
window.api?.utils?.setInputFocused?.(true);
}
};
const handleFocusOut = (e: FocusEvent) => {
const related = e.relatedTarget as Element | null;
if (
related instanceof HTMLInputElement ||
related instanceof HTMLTextAreaElement ||
(related instanceof HTMLElement && related.isContentEditable)
) {
return;
}
window.api?.utils?.setInputFocused?.(false);
};
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
return () => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
};
}, []);
return null;
};
@@ -0,0 +1,202 @@
import type { QueryClient } from '@tanstack/react-query';
import { autoDjGenreIdsForSongGenre, autoDjPushUniqueAlbumIds } from './auto-dj-utils';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
import { shuffle } from '/@/renderer/utils/shuffle';
import {
AlbumListSort,
type QueueSong,
type ServerListItem,
SortOrder,
} from '/@/shared/types/domain-types';
export type AutoDjAlbumCollectArgs = {
albumStrategy: AutoDJStrategy;
currentSong: QueueSong;
itemCount: number;
musicFolderId: string | string[] | undefined;
queryClient: QueryClient;
queueAlbumIdSet: Set<string>;
server: null | ServerListItem | undefined;
serverId: string;
trySimilarSongs: boolean;
};
export const runAutoDjAlbumIds = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
switch (args.albumStrategy) {
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
return collectAlbumsLibraryRandom(args);
}
default: {
return collectAlbumsSimilar(args);
}
}
};
const collectAlbumsLibraryRandom = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
const page = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: Math.max(args.itemCount, 1),
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ autoDjAlbumLibraryRandom: args.currentSong?.id }),
});
const ids = page.items.map((a) => a.id).filter((id) => id && !args.queueAlbumIdSet.has(id));
return shuffle(ids).slice(0, args.itemCount);
};
const collectAlbumsSimilar = async (args: AutoDjAlbumCollectArgs): Promise<string[]> => {
const targetAlbumCount = args.itemCount;
const candidateAlbumIds: string[] = [];
const seenAlbumCandidates = new Set<string>();
if (args.trySimilarSongs && args.currentSong?.id) {
const similarSongsFromSimilarApi = await args.queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: args.itemCount * 4,
songId: args.currentSong.id,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
similarSongAlbumDj: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...similarSongsFromSimilarApi.map((s) => s.albumId),
);
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong && args.server) {
const genre = args.currentSong.genres?.[0];
if (genre) {
const genreIds = autoDjGenreIdsForSongGenre(genre, args.server.type);
const genreAlbums = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
genreIds,
limit: 50,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genreAlbumDj: genreIds,
song: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...genreAlbums.items.map((album) => album.id),
);
if (!args.trySimilarSongs) {
const randomAlbumMixCount = Math.max(1, Math.ceil(50 * 0.2));
const randomAlbumsMix = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: randomAlbumMixCount,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genreAlbumDjMixRandom: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...randomAlbumsMix.items.map((album) => album.id),
);
}
}
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
const albumArtist = args.currentSong.albumArtists?.[0];
if (albumArtist) {
const albumsByArtist = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
artistIds: [albumArtist.id],
limit: 50,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
artistAlbumDj: albumArtist.id,
song: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...albumsByArtist.items.map((album) => album.id),
);
}
}
if (candidateAlbumIds.length < targetAlbumCount && args.currentSong) {
const randomAlbumsFallback = await args.queryClient.fetchQuery({
...albumQueries.list({
query: {
limit: 80,
musicFolderId: args.musicFolderId,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
fallbackAlbumDj: args.currentSong.id,
}),
});
autoDjPushUniqueAlbumIds(
candidateAlbumIds,
seenAlbumCandidates,
args.queueAlbumIdSet,
...randomAlbumsFallback.items.map((album) => album.id),
);
}
const shuffledAlbums = shuffle(candidateAlbumIds);
return shuffledAlbums.slice(0, targetAlbumCount);
};
@@ -0,0 +1,164 @@
import type { QueryClient } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AUTO_DJ_STRATEGY, type AutoDJStrategy } from '/@/renderer/store/settings.store';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import {
Played,
type QueueSong,
type ServerListItem,
Song,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
export type AutoDjSongCollectArgs = {
currentSong: QueueSong;
itemCount: number;
musicFolderId: string | string[] | undefined;
queryClient: QueryClient;
queueSongIdSet: Set<string>;
server: null | ServerListItem | undefined;
serverId: string;
songStrategy: AutoDJStrategy;
trySimilarSongs: boolean;
};
export const runAutoDjSongs = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
switch (args.songStrategy) {
case AUTO_DJ_STRATEGY.LIBRARY_RANDOM: {
return collectSongsLibraryRandom(args);
}
default: {
return collectSongsSimilar(args);
}
}
};
const collectSongsLibraryRandom = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: {
limit: Math.max(args.itemCount * 3, 50),
played: Played.All,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ autoDjLibraryRandomSongs: args.currentSong.id }),
});
const pool = randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id));
const shuffled = shuffleInPlace(pool);
return shuffled.slice(0, args.itemCount);
};
const collectSongsSimilar = async (args: AutoDjSongCollectArgs): Promise<Song[]> => {
let uniqueSimilarSongs: Song[] = [];
if (args.trySimilarSongs) {
const similarSongs = await args.queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: args.itemCount,
songId: args.currentSong?.id,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: args.currentSong?.id }),
});
uniqueSimilarSongs = similarSongs.filter((song) => !args.queueSongIdSet.has(song.id));
}
if (uniqueSimilarSongs.length < args.itemCount) {
const genre = args.currentSong?.genres?.[0];
if (genre) {
const genreLimit = 50;
const genreSimilarSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: {
genre: genre.id,
limit: genreLimit,
played: Played.All,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
genre,
similarSongs: args.currentSong?.id,
}),
});
const genreSongs = genreSimilarSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
);
if (!args.trySimilarSongs) {
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: { limit: randomSongCount, played: Played.All },
serverId: args.serverId,
}),
});
const uniqueRandomSongs = randomSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
);
const randomSongsToAdd = uniqueRandomSongs.slice(0, randomSongCount);
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
} else {
uniqueSimilarSongs.push(...genreSongs);
}
}
}
if (uniqueSimilarSongs.length < args.itemCount) {
const albumArtist = args.currentSong?.albumArtists?.[0];
if (albumArtist) {
const albumArtistSimilarSongs = await args.queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [albumArtist.id],
limit: 50,
sortBy: SongListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: args.serverId,
}),
queryKey: queryKeys.player.fetch({
albumArtist,
similarSongs: args.currentSong?.id,
}),
});
uniqueSimilarSongs.push(
...albumArtistSimilarSongs.items.filter(
(song) => !args.queueSongIdSet.has(song.id),
),
);
}
}
if (uniqueSimilarSongs.length < args.itemCount) {
const randomSongs = await args.queryClient.fetchQuery({
...songsQueries.random({
query: { limit: 50, played: Played.All },
serverId: args.serverId,
}),
});
uniqueSimilarSongs.push(
...randomSongs.items.filter((song) => !args.queueSongIdSet.has(song.id)),
);
}
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
return shuffledSongs.slice(0, args.itemCount);
};
@@ -0,0 +1,28 @@
import type { Genre } from '/@/shared/types/domain-types';
import { ServerType } from '/@/shared/types/domain-types';
export const autoDjPushUniqueAlbumIds = (
accumulator: string[],
seenAlbums: Set<string>,
queueAlbumIdSet: Set<string>,
...ids: (string | undefined)[]
) => {
for (const id of ids) {
if (!id || queueAlbumIdSet.has(id) || seenAlbums.has(id)) continue;
seenAlbums.add(id);
accumulator.push(id);
}
};
export const autoDjGenreIdsForSongGenre = (genre: Genre, serverType: ServerType): string[] => {
if (serverType === ServerType.JELLYFIN) {
return [genre.id];
}
if (serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC) {
return [genre.name];
}
return [genre.id];
};
@@ -14,6 +14,8 @@
}
.root {
width: 100%;
&:hover {
.bar {
background-color: var(--theme-colors-primary-filled);
@@ -44,48 +46,45 @@
transition: opacity 0.2s ease-in-out;
}
.track {
&::before {
right: calc(0.1rem * -1);
}
}
.slider-container {
display: flex;
--slider-value-width: 4.75rem;
--slider-value-gap: var(--theme-spacing-sm);
display: grid;
grid-template-columns: var(--slider-value-width) minmax(0, 1fr) var(--slider-value-width);
gap: var(--slider-value-gap);
align-items: center;
width: 95%;
height: 20px;
}
.slider-value-wrapper {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
max-width: 50px;
min-width: 0;
min-height: 0;
&:first-child {
justify-self: end;
}
&:last-child {
justify-self: start;
}
@media (width < 768px) {
display: none;
}
}
.slider-value-wrapper-elapsed {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 4.75rem;
min-height: 0;
@media (width < 768px) {
display: none;
@media (width < 768px) {
.slider-container {
grid-template-columns: minmax(0, 1fr);
}
}
.slider-wrapper {
display: flex;
flex: 6;
align-items: center;
width: 100%;
min-width: 0;
height: 100%;
}
@@ -42,7 +42,7 @@ export const PlayerbarSlider = () => {
return (
<>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapperElapsed}>
<div className={styles.sliderValueWrapper}>
<ScrobbleStatus formattedTime={formattedTime} />
</div>
<div className={styles.sliderWrapper}>
@@ -81,7 +81,6 @@ export const CustomPlayerbarSlider = ({ ...props }: SliderProps) => {
label: styles.label,
root: styles.root,
thumb: styles.thumb,
track: styles.track,
}}
{...props}
size={6}
@@ -1,5 +1,5 @@
import { t } from 'i18next';
import { useCallback, useEffect, useState, WheelEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, WheelEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
@@ -12,6 +12,9 @@ import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useHotkeys } from '/@/renderer/hooks/use-hotkeys';
import {
AUTO_DJ_MODE,
AUTO_DJ_STRATEGY,
type AutoDJStrategy,
useAppStoreActions,
useAutoDJSettings,
useCurrentServer,
@@ -34,7 +37,15 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Paper } from '/@/shared/components/paper/paper';
import { Popover } from '/@/shared/components/popover/popover';
import { Rating } from '/@/shared/components/rating/rating';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
@@ -90,28 +101,148 @@ const AutoDJButton = () => {
const settings = useAutoDJSettings();
const { setSettings } = useSettingsStoreActions();
const toggleAutoDJ = () => {
setSettings({
autoDJ: {
...settings,
enabled: !settings.enabled,
const itemLabels = useMemo(() => {
return {
description: t('setting.autoDJ_itemCount_description'),
title: t('setting.autoDJ_itemCount'),
};
}, [t]);
const strategySelectData = useMemo(
() => [
{
label: t('setting.autoDJ_strategy_option_similar'),
value: AUTO_DJ_STRATEGY.SIMILAR,
},
});
};
{
label: t('setting.autoDJ_strategy_option_library_random'),
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
},
],
[t],
);
const strategyLabels =
settings.mode === AUTO_DJ_MODE.ALBUMS
? {
description: '',
title: t('setting.autoDJ_albumStrategy'),
}
: {
description: '',
title: t('setting.autoDJ_songStrategy'),
};
const strategyValue =
settings.mode === AUTO_DJ_MODE.ALBUMS
? (settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR)
: (settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR);
return (
<Button
onClick={(e) => {
e.stopPropagation();
toggleAutoDJ();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
<Popover position="top-end" withArrow>
<Popover.Target>
<Button
onClick={(e) => {
e.stopPropagation();
}}
size="compact-xs"
style={{ color: settings.enabled ? 'var(--theme-colors-primary)' : undefined }}
uppercase
variant="transparent"
>
{t('setting.autoDJ')}
</Button>
</Popover.Target>
<Popover.Dropdown maw={320} miw={260} onClick={(e) => e.stopPropagation()} p="sm">
<Stack gap="sm">
<Paper p="md" radius="md">
<Group align="center" gap="xs" justify="space-between" wrap="nowrap">
<Text fw={600} isNoSelect size="sm">
{t('setting.autoDJ_enabled')}
</Text>
<Switch
checked={settings.enabled}
onChange={(e) =>
setSettings({
autoDJ: { enabled: e.currentTarget.checked },
})
}
/>
</Group>
</Paper>
<SegmentedControl
data={[
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
{
label: t('setting.autoDJ_mode_albums'),
value: AUTO_DJ_MODE.ALBUMS,
},
]}
onChange={(value) =>
setSettings({
autoDJ: {
mode: value as 'albums' | 'songs',
},
})
}
value={settings.mode}
w="100%"
/>
<Select
comboboxProps={{ withinPortal: false }}
data={strategySelectData}
description={strategyLabels.description}
label={strategyLabels.title}
onChange={(value) => {
if (!value) return;
setSettings({
autoDJ:
settings.mode === AUTO_DJ_MODE.ALBUMS
? { albumStrategy: value as AutoDJStrategy }
: { songStrategy: value as AutoDJStrategy },
});
}}
size="md"
value={strategyValue}
w="100%"
/>
<NumberInput
aria-label={itemLabels.title}
description={itemLabels.description}
hideControls={false}
label={itemLabels.title}
max={50}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
itemCount: Number(e),
},
})
}
size="md"
value={Number(settings.itemCount)}
/>
<NumberInput
aria-label={t('setting.autoDJ_timing')}
description={t('setting.autoDJ_timing_description')}
hideControls={false}
label={t('setting.autoDJ_timing')}
max={5}
min={1}
onChange={(e) =>
setSettings({
autoDJ: {
timing: Number(e),
},
})
}
size="md"
value={Number(settings.timing)}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -22,6 +22,12 @@ const scrobbleProgressProps = {
const clampPct = (n: number) => Math.min(100, Math.max(0, n));
const capForDisplay = (value: number, limit: number) =>
limit > 0 ? Math.min(value, limit) : value;
const progressTowardLimit = (current: number, limit: number) =>
limit > 0 ? clampPct((current / limit) * 100) : 0;
const ScrobbleConditionProgress = ({ value }: { value: number }) => (
<Progress {...scrobbleProgressProps} value={value} w="100%" />
);
@@ -34,18 +40,25 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
const hookInactive = !scrobbleEnabled || privateMode;
const listenedSec = (snapshot.listenedMs / 1000).toFixed(1);
const listenPercentOfTrack =
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
const listenedSecRaw = snapshot.listenedMs / 1000;
const listenedSecDisplay = snapshot.submitted
? snapshot.targetDurationSec
: capForDisplay(listenedSecRaw, snapshot.targetDurationSec);
const durationConditionPct =
snapshot.targetDurationSec > 0
? clampPct((snapshot.listenedMs / 1000 / snapshot.targetDurationSec) * 100)
: 0;
const percentConditionPct =
snapshot.targetPercentage > 0 && snapshot.trackDurationMs > 0
? clampPct((listenPercentOfTrack / snapshot.targetPercentage) * 100)
: 0;
const listenPercentOfTrackRaw =
snapshot.trackDurationMs > 0 ? (snapshot.listenedMs / snapshot.trackDurationMs) * 100 : 0;
const listenPercentDisplay = snapshot.submitted
? snapshot.targetPercentage
: capForDisplay(listenPercentOfTrackRaw, snapshot.targetPercentage);
const durationConditionPct = progressTowardLimit(
listenedSecDisplay,
snapshot.targetDurationSec,
);
const percentConditionPct = progressTowardLimit(
listenPercentDisplay,
snapshot.targetPercentage,
);
return (
<HoverCard position="top" width={280}>
@@ -87,13 +100,13 @@ export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) =>
<>
<Stack gap="xs">
<Text size="xs">
{`${listenedSec}s / ${snapshot.targetDurationSec}s`}
{`${listenedSecDisplay.toFixed(1)}s / ${snapshot.targetDurationSec}s`}
</Text>
<ScrobbleConditionProgress value={durationConditionPct} />
</Stack>
<Stack gap="xs">
<Text size="xs">
{`${listenPercentOfTrack.toFixed(1)}% / ${snapshot.targetPercentage}%`}
{`${listenPercentDisplay.toFixed(1)}% / ${snapshot.targetPercentage}%`}
</Text>
<ScrobbleConditionProgress value={percentConditionPct} />
</Stack>
@@ -10,6 +10,7 @@ import { createWithEqualityFn } from 'zustand/traditional';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
@@ -18,9 +19,18 @@ import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Played, RandomSongListQuery, ServerType } from '/@/shared/types/domain-types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
Played,
RandomSongListQuery,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
interface ShuffleAllSlice extends RandomSongListQuery {
@@ -29,6 +39,7 @@ interface ShuffleAllSlice extends RandomSongListQuery {
};
enableMaxYear: boolean;
enableMinYear: boolean;
playbackKind: 'albums' | 'songs';
}
const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
@@ -42,16 +53,28 @@ const useShuffleAllStore = createWithEqualityFn<ShuffleAllSlice>()(
enableMaxYear: false,
enableMinYear: false,
genre: '',
limit: 100,
maxYear: 2020,
minYear: 2000,
musicFolder: '',
playbackKind: 'songs',
played: Played.All,
songCount: 100,
})),
{
merge: (persistedState, currentState) => merge(currentState, persistedState),
migrate: (persisted, version: number) => {
if (!persisted) {
return persisted;
}
if (version >= 2) {
return persisted;
}
return persisted;
},
name: 'store_shuffle_all',
version: 1,
version: 2,
},
),
);
@@ -66,13 +89,24 @@ export const useShuffleAllStoreActions = () => useShuffleAllStore((state) => sta
export const ShuffleAllContextModal = () => {
const server = useCurrentServer();
const { addToQueueByData } = usePlayer();
const { addToQueueByData, addToQueueByFetch } = usePlayer();
const { t } = useTranslation();
const { enableMaxYear, enableMinYear, genre, limit, maxYear, minYear, musicFolderId, played } =
useShuffleAllStore();
const {
enableMaxYear,
enableMinYear,
genre,
limit,
maxYear,
minYear,
musicFolderId,
playbackKind,
played,
} = useShuffleAllStore();
const { setStore } = useShuffleAllStoreActions();
const { isFetching, refetch } = useQuery({
const clampedLimit = Math.min(500, Math.max(1, limit || 100));
const { isFetching: isFetchingSongs, refetch: refetchSongs } = useQuery({
...randomFetchQuery({
query: {
genre: genre || undefined,
@@ -89,22 +123,75 @@ export const ShuffleAllContextModal = () => {
staleTime: 0,
});
const { isFetching: isFetchingAlbums, refetch: refetchAlbums } = useQuery({
...shuffleAlbumListQuery({
query: {
genreIds: genre ? [genre] : undefined,
limit: clampedLimit,
minYear: enableMinYear ? minYear || undefined : undefined,
musicFolderId: musicFolderId || undefined,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId: server.id,
}),
enabled: false,
gcTime: 0,
staleTime: 0,
});
const fetchTypeRef = useRef<Play>(null);
const handlePlay = async (playType: Play) => {
fetchTypeRef.current = playType;
const { data } = await refetch();
if (playbackKind === 'albums') {
const { data } = await refetchAlbums();
addToQueueByData(data?.items || [], playType);
addToQueueByFetch(
server.id,
data?.items.map((a) => a.id) ?? [],
LibraryItem.ALBUM,
playType,
);
} else {
const { data } = await refetchSongs();
addToQueueByData(data?.items || [], playType);
}
closeAllModals();
};
return (
<Stack gap="md">
<SegmentedControl
data={[
{
label: t('form.shuffleAll.input_kind_songs'),
value: 'songs',
},
{
label: t('form.shuffleAll.input_kind_albums'),
value: 'albums',
},
]}
onChange={(value) =>
setStore({
playbackKind: value as 'albums' | 'songs',
})
}
size="sm"
value={playbackKind}
w="100%"
/>
<NumberInput
label={t('form.shuffleAll.input_limit')}
label={
playbackKind === 'albums'
? t('form.shuffleAll.input_limit_albums')
: t('form.shuffleAll.input_limit_songs')
}
max={500}
min={1}
onChange={(e) => setStore({ limit: e ? Number(e) : 500 })}
@@ -127,6 +214,7 @@ export const ShuffleAllContextModal = () => {
value={minYear}
/>
<NumberInput
disabled={playbackKind === 'albums'}
label={t('form.shuffleAll.input_maxYear')}
max={2050}
min={1850}
@@ -134,6 +222,7 @@ export const ShuffleAllContextModal = () => {
rightSection={
<Checkbox
checked={enableMaxYear}
disabled={playbackKind === 'albums'}
onChange={(e) => setStore({ enableMaxYear: e.currentTarget.checked })}
style={{ marginRight: '0.5rem' }}
/>
@@ -144,7 +233,7 @@ export const ShuffleAllContextModal = () => {
<Suspense fallback={<Select data={[]} />}>
<GenreSelect />
</Suspense>
{server?.type === ServerType.JELLYFIN && (
{server?.type === ServerType.JELLYFIN && playbackKind === 'songs' && (
<Select
clearable
data={PLAYED_DATA}
@@ -156,10 +245,7 @@ export const ShuffleAllContextModal = () => {
/>
)}
<Divider />
<PlayButtonGroup
loading={(isFetching && fetchTypeRef.current) || false}
onPlay={handlePlay}
/>
<PlayButtonGroup loading={isFetchingSongs || isFetchingAlbums} onPlay={handlePlay} />
</Stack>
);
};
@@ -186,6 +272,13 @@ const randomFetchQuery = (args: {
});
};
const shuffleAlbumListQuery = (args: { query: AlbumListQuery; serverId: string }) => {
return albumQueries.list({
query: args.query,
serverId: args.serverId,
});
};
export const openShuffleAllModal = async () => {
openContextModal({
innerProps: {},
+66 -127
View File
@@ -1,11 +1,12 @@
import { useQueryClient } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import { queryKeys } from '/@/renderer/api/query-keys';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { runAutoDjAlbumIds } from '/@/renderer/features/player/auto-dj/auto-dj-albums';
import { runAutoDjSongs } from '/@/renderer/features/player/auto-dj/auto-dj-songs';
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import {
AUTO_DJ_STRATEGY,
isShuffleEnabled,
mapShuffledToQueueIndex,
useAutoDJSettings,
@@ -17,9 +18,8 @@ import {
} from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { hasFeature } from '/@/shared/api/utils';
import { Played, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
@@ -34,6 +34,9 @@ export const useAutoDJ = () => {
const hasSimilarSongsMusicFolder = hasFeature(server, ServerFeature.SIMILAR_SONGS_MUSIC_FOLDER);
useEffect(() => {
const albumStrategy = settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
const songStrategy = settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR;
const unsubscribe = usePlayerStoreBase.subscribe(
(state) => {
const queue = state.getQueue();
@@ -54,7 +57,6 @@ export const useAutoDJ = () => {
return;
}
// If no current song, don't autoplay
if (!properties.song?.id) {
return;
}
@@ -70,142 +72,76 @@ export const useAutoDJ = () => {
try {
const queue = usePlayerStore.getState().getQueue();
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
let uniqueSimilarSongs: Song[] = [];
const hasMusicFolder = server?.musicFolderId && server.musicFolderId.length > 0;
const musicFolderId =
hasMusicFolder && server?.musicFolderId ? server.musicFolderId : undefined;
const trySimilarSongs =
!hasMusicFolder || (hasMusicFolder && hasSimilarSongsMusicFolder);
// Skip similar songs fetch if a music folder is selected and does not support musicFolderId on similar songs
if (trySimilarSongs) {
// First, try to fetch similar songs based on the current song
const similarSongs = await queryClient.fetchQuery({
...songsQueries.similar({
query: {
count: settings.itemCount,
songId: properties.song?.id,
},
serverId,
}),
queryKey: queryKeys.player.fetch({ similarSongs: properties.song?.id }),
const runnerDepsBase = {
itemCount: settings.itemCount,
musicFolderId,
queryClient,
server,
serverId,
trySimilarSongs,
};
if (settings.mode === 'albums') {
if (!serverId) {
return;
}
const queueAlbumIdSet = new Set(
queue.items
.map((item) => item.albumId)
.filter((id): id is string => Boolean(id)),
);
const albumsToAdd = await runAutoDjAlbumIds({
...runnerDepsBase,
albumStrategy,
currentSong: properties.song,
queueAlbumIdSet,
});
uniqueSimilarSongs = similarSongs.filter(
(song) => !queueSongIdSet.has(song.id),
);
}
// If not enough songs, try to fetch more similar songs based on the genre of the current song
if (uniqueSimilarSongs.length < settings.itemCount) {
const genre = properties.song?.genres?.[0];
if (genre) {
const genreLimit = 50;
const genreSimilarSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: {
genre: genre.id,
limit: genreLimit,
played: Played.All,
},
serverId,
}),
queryKey: queryKeys.player.fetch({
genre,
similarSongs: properties.song?.id,
}),
});
const genreSongs = genreSimilarSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
);
// If trySimilarSongs is false, add variation by mixing in random songs
if (!trySimilarSongs) {
// Calculate how many random songs we need: 20% or at least 1
const randomSongCount = Math.max(1, Math.ceil(genreLimit * 0.2));
const randomSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: { limit: randomSongCount, played: Played.All },
serverId,
}),
});
const uniqueRandomSongs = randomSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
);
// Add minimum required random songs for variation
const randomSongsToAdd = uniqueRandomSongs.slice(
0,
randomSongCount,
);
uniqueSimilarSongs.push(...randomSongsToAdd, ...genreSongs);
} else {
uniqueSimilarSongs.push(...genreSongs);
}
}
}
// If not enough songs, try to fetch more similar songs based on the album artist of the current song
if (uniqueSimilarSongs.length < settings.itemCount) {
const albumArtist = properties.song?.albumArtists?.[0];
if (albumArtist) {
const albumArtistSimilarSongs = await queryClient.fetchQuery({
...songsQueries.list({
query: {
albumArtistIds: [albumArtist.id],
limit: 50,
sortBy: SongListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
}),
queryKey: queryKeys.player.fetch({
albumArtist,
similarSongs: properties.song?.id,
}),
});
uniqueSimilarSongs.push(
...albumArtistSimilarSongs.items.filter(
(song) => !queueSongIdSet.has(song.id),
),
);
}
}
// If not enough songs, just fetch fully random songs
if (uniqueSimilarSongs.length < settings.itemCount) {
const randomSongs = await queryClient.fetchQuery({
...songsQueries.random({
query: { limit: 50, played: Played.All },
if (albumsToAdd.length > 0) {
await player.addToQueueByFetch(
serverId,
}),
});
albumsToAdd,
LibraryItem.ALBUM,
Play.LAST,
);
uniqueSimilarSongs.push(
...randomSongs.items.filter((song) => !queueSongIdSet.has(song.id)),
);
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: albumsToAdd.length,
});
}
return;
}
// Shuffle the songs and then add to the queue
const shuffledSongs = shuffleInPlace(uniqueSimilarSongs);
if (!serverId) {
return;
}
// Splice the first itemCount songs and add to the queue
const songsToAdd = shuffledSongs.slice(0, settings.itemCount);
const queueSongIdSet = new Set(queue.items.map((item) => item.id));
// Add to the end of the queue
player.addToQueueByData(songsToAdd, Play.LAST);
// Emit event to trigger queue follow
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: songsToAdd.length,
const songsToAdd = await runAutoDjSongs({
...runnerDepsBase,
currentSong: properties.song,
queueSongIdSet,
songStrategy,
});
if (songsToAdd.length > 0) {
player.addToQueueByData(songsToAdd, Play.LAST);
eventEmitter.emit('AUTODJ_QUEUE_ADDED', {
songCount: songsToAdd.length,
});
}
} catch (error) {
logFn.error(logMsg[LogCategory.PLAYER].autoPlayFailed, {
category: LogCategory.PLAYER,
@@ -229,7 +165,10 @@ export const useAutoDJ = () => {
server,
serverId,
settings.enabled,
settings.albumStrategy,
settings.itemCount,
settings.mode,
settings.songStrategy,
settings.timing,
]);
};
@@ -4,17 +4,21 @@ import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import {
getServerById,
publishScrobbleDebug,
useAppStore,
usePlaybackSettings,
usePlayerSong,
usePlayerSpeed,
usePlayerStore,
useSettingsStore,
useTimestampStoreBase,
} from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { PlayerStatus } from '/@/shared/types/types';
type ScrobbleManualHandlers = {
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
scrobbleManualHandlers?.resetListenedState();
};
const getPositionValue = (seconds: number, useTicks: boolean) => {
if (useTicks) {
return Math.round(seconds * 1e7);
}
return seconds;
};
/*
Submission (Last.fm / etc.) eligibility uses accumulated listen time:
- If listened time meets the required percentage of track duration
@@ -99,6 +111,7 @@ export const useScrobble = () => {
const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble();
const currentSong = usePlayerSong();
const playbackRate = usePlayerSpeed();
const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined,
@@ -166,6 +179,11 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
const currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp;
const previousTime = prev.timestamp;
@@ -220,19 +238,20 @@ export const useScrobble = () => {
}
}
// Send Jellyfin progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) {
// Send progress events every 10 seconds
if (hasPlaybackReport) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) {
const position = currentTime * 1e7;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
apiClientProps: { serverId: serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'timeupdate',
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate,
position: getPositionValue(currentTime, useTicks),
submission: false,
},
},
@@ -261,20 +280,15 @@ export const useScrobble = () => {
});
if (shouldSubmitScrobble) {
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
// we just send the full duration of the song when it matches the local scrobble conditions
const position =
currentSong._serverType === ServerType.JELLYFIN
? currentSong.duration * 1e7
: undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
albumId: currentSong.albumId,
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentSong.duration ?? 0, useTicks),
submission: true,
},
},
@@ -295,7 +309,7 @@ export const useScrobble = () => {
}
}
},
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
);
const handleScrobbleFromSongChange = useCallback(
@@ -305,6 +319,7 @@ export const useScrobble = () => {
) => {
const currentSong = properties.song;
const previousSong = previousSongRef.current;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
// Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) {
@@ -365,6 +380,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0,
submission: false,
},
@@ -388,11 +405,12 @@ export const useScrobble = () => {
flushScrobbleDebug();
},
[
flushScrobbleDebug,
scrobbleSettings?.notify,
isScrobbleEnabled,
isPrivateModeEnabled,
flushScrobbleDebug,
sendScrobble,
playbackRate,
],
);
@@ -404,6 +422,11 @@ export const useScrobble = () => {
}
const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
if (!currentSong?.id) {
return;
@@ -422,7 +445,7 @@ export const useScrobble = () => {
}
// Position scrobbles are only relevant for Jellyfin
if (currentSong._serverType !== ServerType.JELLYFIN) {
if (!hasPlaybackReport) {
flushScrobbleDebug();
return;
}
@@ -436,8 +459,6 @@ export const useScrobble = () => {
return;
}
const position = properties.timestamp * 1e7;
lastProgressEventRef.current = properties.timestamp;
lastSeekEventRef.current = now;
@@ -448,7 +469,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'timeupdate',
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(properties.timestamp, useTicks),
submission: false,
},
},
@@ -465,7 +488,7 @@ export const useScrobble = () => {
);
flushScrobbleDebug();
},
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
);
const handleScrobbleFromStatus = useCallback(
@@ -475,18 +498,22 @@ export const useScrobble = () => {
}
const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
if (!currentSong?.id) {
return;
}
// Only apply to Jellyfin controller scrobble
if (currentSong._serverType !== ServerType.JELLYFIN) {
if (!hasPlaybackReport) {
return;
}
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const position = currentTimestamp * 1e7;
// Send pause event when status changes to paused
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
@@ -497,7 +524,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'pause',
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
@@ -523,7 +552,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'unpause',
id: currentSong.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false,
},
},
@@ -542,7 +573,7 @@ export const useScrobble = () => {
flushScrobbleDebug();
},
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
);
const handleScrobbleFromRepeat = useCallback(() => {
@@ -552,6 +583,7 @@ export const useScrobble = () => {
const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
return;
@@ -570,6 +602,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId,
event: 'start',
id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0,
submission: false,
},
@@ -587,7 +621,7 @@ export const useScrobble = () => {
},
);
flushScrobbleDebug();
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]);
}, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
// Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback(
@@ -607,20 +641,22 @@ export const useScrobble = () => {
}
const song = usePlayerStore.getState().getCurrentSong();
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
const useTicks = song?._serverType === ServerType.JELLYFIN;
if (!song?.id) {
return;
}
const position =
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
sendScrobble.mutate(
{
apiClientProps: { serverId: song._serverId || '' },
query: {
albumId: song.albumId,
id: song.id,
position,
mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(song.duration ?? 0, useTicks),
submission: true,
},
},
@@ -659,7 +695,7 @@ export const useScrobble = () => {
});
return () => registerScrobbleManualHandlers(null);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
usePlayerEvents(
{
@@ -1,23 +1,112 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { useAutoDJSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import {
AUTO_DJ_MODE,
AUTO_DJ_STRATEGY,
type AutoDJStrategy,
useAutoDJSettings,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
export const AutoDJSettings = memo(() => {
const { t } = useTranslation();
const settings = useAutoDJSettings();
const { setSettings } = useSettingsStoreActions();
const itemLabels = useMemo(() => {
return {
description: t('setting.autoDJ_itemCount_description'),
title: t('setting.autoDJ_itemCount'),
};
}, [t]);
const strategySelectData = useMemo(
() => [
{
label: t('setting.autoDJ_strategy_option_similar'),
value: AUTO_DJ_STRATEGY.SIMILAR,
},
{
label: t('setting.autoDJ_strategy_option_library_random'),
value: AUTO_DJ_STRATEGY.LIBRARY_RANDOM,
},
],
[t],
);
const autoDJOptions: SettingOption[] = [
{
control: (
<SegmentedControl
data={[
{ label: t('setting.autoDJ_mode_songs'), value: AUTO_DJ_MODE.SONGS },
{ label: t('setting.autoDJ_mode_albums'), value: AUTO_DJ_MODE.ALBUMS },
]}
onChange={(value) => {
setSettings({
autoDJ: {
mode: value as 'albums' | 'songs',
},
});
}}
size="sm"
value={settings.mode}
w="100%"
/>
),
description: t('setting.autoDJ_mode_description'),
title: t('setting.autoDJ_mode'),
},
{
control: (
<Select
data={strategySelectData}
onChange={(value) =>
value &&
setSettings({
autoDJ: {
songStrategy: value as AutoDJStrategy,
},
})
}
value={settings.songStrategy ?? AUTO_DJ_STRATEGY.SIMILAR}
w="100%"
/>
),
description: '',
title: t('setting.autoDJ_songStrategy'),
},
{
control: (
<Select
data={strategySelectData}
onChange={(value) =>
value &&
setSettings({
autoDJ: {
albumStrategy: value as AutoDJStrategy,
},
})
}
value={settings.albumStrategy ?? AUTO_DJ_STRATEGY.SIMILAR}
w="100%"
/>
),
description: '',
title: t('setting.autoDJ_albumStrategy'),
},
{
control: (
<NumberInput
aria-label="Auto DJ item count"
aria-label={itemLabels.title}
hideControls={false}
max={50}
min={1}
@@ -31,10 +120,8 @@ export const AutoDJSettings = memo(() => {
value={Number(settings.itemCount)}
/>
),
description: t('setting.autoDJ_itemCount', {
context: 'description',
}),
title: t('setting.autoDJ_itemCount'),
description: itemLabels.description,
title: itemLabels.title,
},
{
control: (
+3
View File
@@ -1,8 +1,11 @@
declare global {
interface Window {
ANALYTICS_DISABLED?: boolean | string;
FS_AUTO_DJ_ALBUM_STRATEGY?: string;
FS_AUTO_DJ_ENABLED?: string;
FS_AUTO_DJ_ITEM_COUNT?: string;
FS_AUTO_DJ_MODE?: string;
FS_AUTO_DJ_SONG_STRATEGY?: string;
FS_AUTO_DJ_TIMING?: string;
FS_CSS_CONTENT?: string;
FS_CSS_ENABLED?: string;
@@ -111,6 +111,8 @@ const SIDE_QUEUE_TYPES = new Set(['sideDrawerQueue', 'sideQueue']);
const SIDE_QUEUE_LAYOUTS = new Set(['horizontal', 'vertical']);
const SIDEBAR_PLAYLIST_FOLDER_VIEWS = new Set(['navigation', 'single', 'tree']);
const SIDEBAR_PLAYLIST_MODES = new Set(['compact', 'expanded']);
const AUTO_DJ_MODES = new Set(['albums', 'songs']);
const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']);
export type EnvSettingsOverrides = DeepPartial<
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'>
@@ -422,8 +424,21 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
path: ['lyrics', 'alignment'],
type: 'enum',
},
{
enumSet: AUTO_DJ_STRATEGIES,
key: 'FS_AUTO_DJ_ALBUM_STRATEGY',
path: ['autoDJ', 'albumStrategy'],
type: 'enum',
},
{ key: 'FS_AUTO_DJ_ENABLED', path: ['autoDJ', 'enabled'], type: 'bool' },
{ key: 'FS_AUTO_DJ_ITEM_COUNT', path: ['autoDJ', 'itemCount'], type: 'num' },
{ enumSet: AUTO_DJ_MODES, key: 'FS_AUTO_DJ_MODE', path: ['autoDJ', 'mode'], type: 'enum' },
{
enumSet: AUTO_DJ_STRATEGIES,
key: 'FS_AUTO_DJ_SONG_STRATEGY',
path: ['autoDJ', 'songStrategy'],
type: 'enum',
},
{ key: 'FS_AUTO_DJ_TIMING', path: ['autoDJ', 'timing'], type: 'num' },
{
key: 'FS_CSS_CONTENT',
+54 -1
View File
@@ -675,9 +675,28 @@ const QueryBuilderSettingsSchema = z.object({
tag: z.array(QueryBuilderCustomFieldSchema),
});
export const AUTO_DJ_MODE = {
ALBUMS: 'albums',
SONGS: 'songs',
} as const;
export type AutoDJMode = (typeof AUTO_DJ_MODE)[keyof typeof AUTO_DJ_MODE];
export const AUTO_DJ_STRATEGY = {
LIBRARY_RANDOM: 'library_random',
SIMILAR: 'similar',
} as const;
export type AutoDJStrategy = (typeof AUTO_DJ_STRATEGY)[keyof typeof AUTO_DJ_STRATEGY];
const autoDjStrategyEnum = z.enum(['similar', 'library_random']);
const AutoDJSettingsSchema = z.object({
albumStrategy: autoDjStrategyEnum,
enabled: z.boolean(),
itemCount: z.number(),
mode: z.enum(['songs', 'albums']),
songStrategy: autoDjStrategyEnum,
timing: z.number(),
});
@@ -1091,8 +1110,11 @@ const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle
const initialState: SettingsState = {
autoDJ: {
albumStrategy: AUTO_DJ_STRATEGY.SIMILAR,
enabled: false,
itemCount: 5,
mode: 'songs',
songStrategy: AUTO_DJ_STRATEGY.SIMILAR,
timing: 1,
},
css: {
@@ -2427,10 +2449,41 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
}
}
if (version < 28) {
if (!state.autoDJ) {
state.autoDJ = { ...initialState.autoDJ };
}
if (state.autoDJ.mode !== 'albums' && state.autoDJ.mode !== 'songs') {
state.autoDJ.mode = initialState.autoDJ.mode;
}
const normalizeAutoDjStrategy = (stored: unknown) => {
if (stored === 'library_random') {
return AUTO_DJ_STRATEGY.LIBRARY_RANDOM;
}
if (
stored === 'similar' ||
stored === 'default' ||
stored === 'similar_forward'
) {
return AUTO_DJ_STRATEGY.SIMILAR;
}
return initialState.autoDJ.songStrategy;
};
state.autoDJ.songStrategy = normalizeAutoDjStrategy(state.autoDJ.songStrategy);
state.autoDJ.albumStrategy = normalizeAutoDjStrategy(
state.autoDJ.albumStrategy,
);
}
return persistedState;
},
name: 'store_settings',
version: 27,
version: 28,
},
),
);
+14
View File
@@ -454,6 +454,7 @@ const similarSongs2 = z.object({
export enum SubsonicExtensions {
FORM_POST = 'formPost',
INDEX_BASED_QUEUE = 'indexBasedQueue',
PLAYBACK_REPORT = 'playbackReport',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
TRANSCODING = 'transcoding',
@@ -793,6 +794,17 @@ const getInternetRadioStations = z.object({
.optional(),
});
const reportPlaybackParameters = z.object({
ignoreScrobble: z.boolean().optional(),
mediaId: z.string(),
mediaType: z.enum(['song', 'podcast']),
playbackRate: z.number().optional(),
positionMs: z.number(),
state: z.enum(['starting', 'playing', 'paused', 'stopped']),
});
const reportPlayback = z.null();
export const ssType = {
_body: {
getTranscodeDecision: transcodeDecisionRequestBody,
@@ -824,6 +836,7 @@ export const ssType = {
getTranscodeStream: getTranscodeStreamParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
reportPlayback: reportPlaybackParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters,
saveQueue: saveQueueParameters,
scrobble: scrobbleParameters,
@@ -877,6 +890,7 @@ export const ssType = {
playQueueByIndex,
randomSongList,
removeFavorite,
reportPlayback,
saveQueue,
scrobble,
search3,
+2
View File
@@ -1365,6 +1365,8 @@ export type ScrobbleQuery = {
albumId?: string;
event?: 'pause' | 'start' | 'timeupdate' | 'unpause';
id: string;
mediaType: 'podcast' | 'song';
playbackRate: number;
position?: number;
submission: boolean;
};
+1
View File
@@ -13,6 +13,7 @@ export enum ServerFeature {
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
REPORT_PLAYBACK = 'reportPlayback',
SERVER_PLAY_QUEUE = 'serverPlayQueue',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder',