Compare commits

..

5 Commits

Author SHA1 Message Date
Tiago Simionato 8f40894926 feat: persist player timestamp (#2043)
* feat: persist player timestamp
2026-05-21 00:16:46 -07:00
Kendall Garner 2befcb4e74 disable node integration (#2049) 2026-05-20 21:06:28 -07:00
jeffvli 2d78c32a68 prevent track radio from prepending song in queue
- this behavior relates to when using NEXT or LAST as this will add a duplicate song
2026-05-20 20:49:29 -07:00
jeffvli 3d0500980a implement row playback controls for the detail list 2026-05-20 20:40:30 -07:00
jeffvli 3551ee5077 prevent desktopCaturer from running unless visualizer explicitly requested (#1931) 2026-05-20 20:34:25 -07:00
22 changed files with 393 additions and 44 deletions
+1
View File
@@ -4,3 +4,4 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './visualizer';
@@ -0,0 +1,30 @@
import { ipcMain } from 'electron';
import { getMpvInstance } from '../player';
import { store } from '../settings';
import { PlayerType } from '/@/shared/types/types';
let isLocalVisualizerSurfaceVisible = false;
export const setLocalVisualizerSurfaceVisible = (visible: boolean) => {
isLocalVisualizerSurfaceVisible = visible;
};
export const canHandleVisualizerDisplayMedia = (): boolean => {
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
if (playbackType !== PlayerType.LOCAL) {
return false;
}
if (!isLocalVisualizerSurfaceVisible) {
return false;
}
return Boolean(getMpvInstance()?.isRunning());
};
ipcMain.on('visualizer-set-local-surface-visible', (_event, visible: boolean) => {
setLocalVisualizerSurfaceVisible(Boolean(visible));
});
+8 -2
View File
@@ -30,7 +30,9 @@ import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
import MenuBuilder, { MenuPlaybackState } from './menu';
import './features';
import {
autoUpdaterLogInterface,
createLog,
@@ -40,7 +42,6 @@ import {
isMacOS,
isWindows,
} from './utils';
import './features';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
@@ -515,7 +516,7 @@ async function createWindow(first = true): Promise<void> {
backgroundThrottling: false,
contextIsolation: true,
devTools: true,
nodeIntegration: true,
nodeIntegration: false,
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: !store.get('ignore_cors'),
@@ -734,6 +735,11 @@ async function createWindow(first = true): Promise<void> {
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!canHandleVisualizerDisplayMedia()) {
callback({});
return;
}
if (!isMacOS()) {
callback({ audio: 'loopback' });
return;
-3
View File
@@ -1,11 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import { PreloadApi } from './index';
declare global {
interface Window {
api: PreloadApi;
electron: ElectronAPI;
LEGACY_AUTHENTICATION?: boolean;
queryLocalFonts?: () => Promise<Font[]>;
REMOTE_URL?: string;
+4 -5
View File
@@ -1,5 +1,4 @@
import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge } from 'electron';
import { contextBridge, webUtils } from 'electron';
import { autodiscover } from './autodiscover';
import { browser } from './browser';
@@ -11,12 +10,14 @@ import { mpris } from './mpris';
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
import { remote } from './remote';
import { utils } from './utils';
import { visualizer } from './visualizer';
// Custom APIs for renderer
const api = {
autodiscover,
browser,
discordRpc,
getPathForFile: webUtils.getPathForFile,
ipc,
localSettings,
lyrics,
@@ -25,6 +26,7 @@ const api = {
mpvPlayerListener,
remote,
utils,
visualizer,
};
export type PreloadApi = typeof api;
@@ -34,14 +36,11 @@ export type PreloadApi = typeof api;
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api);
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.api = api;
}
+11
View File
@@ -0,0 +1,11 @@
import { ipcRenderer } from 'electron';
const setLocalSurfaceVisible = (visible: boolean) => {
ipcRenderer.send('visualizer-set-local-surface-visible', visible);
};
export const visualizer = {
setLocalSurfaceVisible,
};
export type VisualizerApi = typeof visualizer;
@@ -27,6 +27,7 @@ import { PlayCountColumn } from './play-count-column';
import { RatingColumn } from './rating-column';
import { ReleaseDateColumn } from './release-date-column';
import { RowIndexColumn } from './row-index-column';
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import { SampleRateColumn } from './sample-rate-column';
import { SizeColumn } from './size-column';
import { TitleArtistColumn } from './title-artist-column';
@@ -111,6 +112,7 @@ export {
GenreBadgeColumn,
GenreColumn,
ImageColumn,
ItemDetailRowPlayControlCell,
LastPlayedColumn,
PathColumn,
PlayCountColumn,
@@ -1,23 +1,40 @@
import styles from './row-index-column.module.css';
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import { ItemDetailListCellProps } from './types';
import { useDetailRowPlayControl } from './use-detail-row-play-control';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { usePlayerStatus } from '/@/renderer/store';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { Icon } from '/@/shared/components/icon/icon';
import { PlayerStatus } from '/@/shared/types/types';
import { TableColumn } from '/@/shared/types/types';
export const RowIndexColumn = ({ rowIndex, song }: ItemDetailListCellProps) => {
const status = usePlayerStatus();
const { isActive } = useIsCurrentSong(song);
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const DefaultRowIndexColumn = ({ rowIndex }: ItemDetailListCellProps) => {
return <>{String((rowIndex ?? 0) + 1)}</>;
};
if (isActive) {
return (
const PlayableRowIndexColumn = (props: ItemDetailListCellProps) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
const indexContent = isActive ? (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
) : (
String((props.rowIndex ?? 0) + 1)
);
return (
<ItemDetailRowPlayControlCell
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
export const RowIndexColumn = (props: ItemDetailListCellProps) => {
if (!props.columns || !isRowPlayControlColumn(TableColumn.ROW_INDEX, props.columns)) {
return <DefaultRowIndexColumn {...props} />;
}
return <>{String((rowIndex ?? 0) + 1)}</>;
return <PlayableRowIndexColumn {...props} />;
};
@@ -0,0 +1,30 @@
.cell-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 100%;
}
.play-target {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.index-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,34 @@
import { ReactNode } from 'react';
import styles from './row-play-control-cell.module.css';
import { ItemRowPlayControls } from '/@/renderer/features/shared/components/item-row-play-controls';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
import { Play } from '/@/shared/types/types';
export const ItemDetailRowPlayControlCell = ({
indexContent,
onPlay,
showPlayControls,
}: {
indexContent: ReactNode;
onPlay: (playType: Play) => void;
showPlayControls: boolean;
}) => {
if (!showPlayControls) {
return <>{indexContent}</>;
}
return (
<div className={styles.cellWrapper}>
<HoverCard openDelay={300} position="top" withArrow>
<HoverCard.Target>
<div className={styles.playTarget}>{indexContent}</div>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<ItemRowPlayControls onPlay={onPlay} />
</HoverCard.Dropdown>
</HoverCard>
</div>
);
};
@@ -1,7 +1,46 @@
import { ItemDetailRowPlayControlCell } from './row-play-control-cell';
import styles from './row-play-control-cell.module.css';
import { ItemDetailListCellProps } from './types';
import { useDetailRowPlayControl } from './use-detail-row-play-control';
export const TrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { Icon } from '/@/shared/components/icon/icon';
import { TableColumn } from '/@/shared/types/types';
const formatTrackNumber = (song: ItemDetailListCellProps['song']) => {
const disc = song.discNumber ?? 1;
const track = song.trackNumber.toString().padStart(2, '0');
return `${disc}-${track}`;
};
const DefaultTrackNumberColumn = ({ song }: ItemDetailListCellProps) => {
return <>{formatTrackNumber(song)}</>;
};
const PlayableTrackNumberColumn = (props: ItemDetailListCellProps) => {
const { handlePlay, isActive, isPlaying, showPlayControls } = useDetailRowPlayControl(props);
const indexContent = isActive ? (
<div className={styles.iconWrapper}>
<Icon fill="primary" icon={isPlaying ? 'mediaPlay' : 'mediaPause'} />
</div>
) : (
formatTrackNumber(props.song)
);
return (
<ItemDetailRowPlayControlCell
indexContent={indexContent}
onPlay={handlePlay}
showPlayControls={showPlayControls}
/>
);
};
export const TrackNumberColumn = (props: ItemDetailListCellProps) => {
if (!props.columns || !isRowPlayControlColumn(TableColumn.TRACK_NUMBER, props.columns)) {
return <DefaultTrackNumberColumn {...props} />;
}
return <PlayableTrackNumberColumn {...props} />;
};
@@ -1,8 +1,9 @@
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { Song } from '/@/shared/types/domain-types';
export interface ItemDetailListCellProps {
columns?: ItemTableListColumnConfig[];
controls?: ItemControls;
internalState?: ItemListStateActions;
isMutatingFavorite?: boolean;
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { ItemDetailListCellProps } from './types';
import { playSongFromItemListControl } from '/@/renderer/components/item-list/helpers/play-row-from-list';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsCurrentSong } from '/@/renderer/features/player/hooks/use-is-current-song';
import { usePlayerStatus } from '/@/renderer/store';
import { Song } from '/@/shared/types/domain-types';
import { Play, PlayerStatus } from '/@/shared/types/types';
export const useDetailRowPlayControl = ({
internalState,
rowIndex = 0,
song,
}: Pick<ItemDetailListCellProps, 'internalState' | 'rowIndex' | 'song'>) => {
const status = usePlayerStatus();
const player = usePlayer();
const { isActive } = useIsCurrentSong(song);
const isPlaying = isActive && status === PlayerStatus.PLAYING;
const showPlayControls = !!song?.id;
const handlePlay = useCallback(
(playType: Play) => {
if (!song) {
return;
}
playSongFromItemListControl({
index: rowIndex,
internalState,
item: song as Song,
meta: { playType, singleSongOnly: true },
player,
});
},
[internalState, player, rowIndex, song],
);
return {
handlePlay,
isActive,
isPlaying,
showPlayControls,
};
};
@@ -447,6 +447,14 @@
padding-left: 0;
}
.row .track-cell-play-control {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.track-row-dragging {
opacity: 0.5;
}
@@ -33,6 +33,7 @@ import styles from './item-detail-list.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { isRowPlayControlColumn } from '/@/renderer/components/item-list/helpers/get-row-play-control-column';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import {
ItemListStateActions,
@@ -365,6 +366,7 @@ const TrackRow = memo(
const isTitleColumn = col.id === TableColumn.TITLE;
const isImageColumn = col.id === TableColumn.IMAGE;
const isIconActionColumn = isNoHorizontalPaddingColumn(col.id);
const isPlayControlColumn = isRowPlayControlColumn(col.id, columns);
const showHoverContent = shouldShowHoverOnlyColumnContent(
col.id,
isRowHovered,
@@ -374,6 +376,7 @@ const TrackRow = memo(
const content = isSongsLoading ? null : showHoverContent ? (
<CellComponent
columnId={col.id}
columns={columns}
controls={controls}
internalState={internalState}
isMutatingFavorite={isMutatingFavorite}
@@ -393,6 +396,7 @@ const TrackRow = memo(
[styles.trackCellImage]: isImageColumn,
[styles.trackCellMuted]: !isTitleColumn,
[styles.trackCellNoHPadding]: isIconActionColumn,
[styles.trackCellPlayControl]: isPlayControlColumn,
[styles.trackCellVerticalBorderVisible]:
enableVerticalBorders && !isLastColumn,
[styles.trackCellWithVerticalBorder]: !isLastColumn,
@@ -12,10 +12,15 @@ import { Play } from '/@/shared/types/types';
interface PlayTrackRadioActionProps {
disabled?: boolean;
skipFirstSong?: boolean;
song: Song;
}
export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionProps) => {
export const PlayTrackRadioAction = ({
disabled,
skipFirstSong,
song,
}: PlayTrackRadioActionProps) => {
const { t } = useTranslation();
const player = usePlayer();
const serverId = useCurrentServerId();
@@ -38,13 +43,17 @@ export const PlayTrackRadioAction = ({ disabled, song }: PlayTrackRadioActionPro
});
if (similarSongs && similarSongs.length > 0) {
player.addToQueueByData([song, ...similarSongs], playType);
// We need to skip the first song when adding to the queue as NEXT or LAST, otherwise you will have a duplicate song
const shouldSkipFirstSong =
skipFirstSong && (playType === Play.NEXT || playType === Play.LAST);
const queueSongs = shouldSkipFirstSong ? similarSongs : [song, ...similarSongs];
player.addToQueueByData(queueSongs, playType);
}
} catch (error) {
console.error('Failed to load track radio:', error);
}
},
[player, queryClient, serverId, song],
[player, queryClient, serverId, skipFirstSong, song],
);
const handlePlayTrackRadioNow = useCallback(() => {
@@ -35,7 +35,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
<MoveQueueItemsAction items={items} />
<ShuffleItemsAction items={items} />
<ContextMenu.Divider />
<PlayTrackRadioAction disabled={items.length > 1} song={items[0]} />
<PlayTrackRadioAction disabled={items.length > 1} skipFirstSong song={items[0]} />
<ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.SONG} />
<ContextMenu.Divider />
@@ -14,7 +14,10 @@ import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-se
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
import { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';
import {
InitialTimestampRestoreHook,
QueueRestoreTimestampHook,
} from '/@/renderer/features/player/hooks/use-queue-restore';
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
@@ -134,6 +137,7 @@ export const AudioPlayers = () => {
<RemoteHook />
<AutoDJHook />
<QueueRestoreTimestampHook />
<InitialTimestampRestoreHook />
<UpdateCurrentSongHook />
<RadioAudioInstanceHook />
<RadioMetadataHook />
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { api } from '/@/renderer/api';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
@@ -9,13 +9,18 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import {
setTimestamp,
useCurrentServerId,
usePlayerActions,
usePlayerHydrated,
usePlayerSong,
usePlayerStatus,
usePlayerStore,
useTimestampStoreBase,
} from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
import { PlayerStatus } from '/@/shared/types/types';
export const useQueueRestoreTimestamp = () => {
const player = usePlayerStore();
const { mediaSeekToTimestamp } = usePlayerActions();
usePlayerEvents(
{
@@ -24,7 +29,7 @@ export const useQueueRestoreTimestamp = () => {
setTimeout(() => {
setTimestamp(position);
player.mediaSeekToTimestamp(position);
mediaSeekToTimestamp(position);
}, 100);
},
},
@@ -37,6 +42,72 @@ export const QueueRestoreTimestampHook = () => {
return null;
};
export const useInitialTimestampRestore = () => {
const { mediaSeekToTimestamp } = usePlayerActions();
const playerHydrated = usePlayerHydrated();
const currentSong = usePlayerSong();
const playerStatus = usePlayerStatus();
const timestamp = useTimestampStoreBase((state) => state.timestamp);
const startupRestoreInitializedRef = useRef(false);
const startupSeekArmedRef = useRef<null | number>(null);
const startupSeekAppliedRef = useRef(false);
const applyStartupSeek = useCallback(() => {
if (startupSeekAppliedRef.current) {
return;
}
const seekTimestamp = startupSeekArmedRef.current;
if (!seekTimestamp || seekTimestamp <= 0) {
return;
}
startupSeekAppliedRef.current = true;
startupSeekArmedRef.current = null;
setTimeout(() => {
mediaSeekToTimestamp(seekTimestamp);
}, 100);
}, [mediaSeekToTimestamp]);
useEffect(() => {
if (startupRestoreInitializedRef.current) {
return;
}
if (!playerHydrated || !currentSong) {
return;
}
startupRestoreInitializedRef.current = true;
if (timestamp > 0) {
startupSeekArmedRef.current = timestamp;
}
if (playerStatus === PlayerStatus.PLAYING) {
applyStartupSeek();
}
}, [applyStartupSeek, currentSong, playerHydrated, playerStatus, timestamp]);
usePlayerEvents(
{
onPlayerStatus: (properties) => {
if (properties.status === PlayerStatus.PLAYING) {
applyStartupSeek();
}
},
},
[applyStartupSeek],
);
};
export const InitialTimestampRestoreHook = () => {
useInitialTimestampRestore();
return null;
};
export const useSaveQueue = () => {
const serverId = useCurrentServerId();
@@ -39,7 +39,7 @@ import { FontType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const ipc = isElectron() ? window.api.ipc : null;
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
const webUtils = isElectron() ? window.electron.webUtils : null;
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
const HOME_FEATURE_STYLE_OPTIONS = [
{
@@ -295,7 +295,7 @@ export const ApplicationSettings = memo(() => {
setSettings({
font: {
...fontSettings,
custom: e ? webUtils?.getPathForFile(e) || null : null,
custom: e ? getPathForFile?.(e) || null : null,
},
})
}
@@ -21,6 +21,21 @@ type PromptState = 'loading' | { consent: boolean };
export function VisualizerSystemAudioBridgeHook() {
const playbackType = usePlaybackType();
const isVisualizerSurfaceVisible = useIsLocalVisualizerSurfaceVisible();
useEffect(() => {
if (!isElectron()) {
return;
}
const shouldReportVisible = playbackType === PlayerType.LOCAL && isVisualizerSurfaceVisible;
window.api.visualizer.setLocalSurfaceVisible(shouldReportVisible);
return () => {
window.api.visualizer.setLocalSurfaceVisible(false);
};
}, [playbackType, isVisualizerSurfaceVisible]);
if (!isElectron() || playbackType !== PlayerType.LOCAL) {
return null;
+25 -1
View File
@@ -1,4 +1,5 @@
import { subscribeWithSelector } from 'zustand/middleware';
import { del, get, set } from 'idb-keyval';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { createWithEqualityFn } from 'zustand/traditional';
interface TimestampState {
@@ -6,13 +7,36 @@ interface TimestampState {
timestamp: number;
}
const timestampStorage = {
getItem: async (name: string) => {
const value = await get(name);
if (value === undefined) {
return null;
}
return { state: { timestamp: value }, version: 1 } as const;
},
removeItem: async (name: string) => {
await del(name);
},
setItem: async (name: string, value: { state: { timestamp: number }; version?: number }) => {
await set(name, value.state.timestamp);
},
};
export const useTimestampStoreBase = createWithEqualityFn<TimestampState>()(
persist(
subscribeWithSelector((set) => ({
setTimestamp: (timestamp: number) => {
set({ timestamp });
},
timestamp: 0,
})),
{
name: 'player-timestamp',
storage: timestampStorage,
version: 1,
},
),
);
export const subscribePlayerProgress = (