Compare commits

...

6 Commits

Author SHA1 Message Date
jeffvli 34314bdf46 update to v1.12.1 2026-05-28 02:06:41 -07:00
jeffvli 9d53c53c54 fix queue end handling to prevent repeat 2026-05-28 02:06:07 -07:00
jeffvli 8acd585630 clean up discord rpc implementation with usePlayerEvents 2026-05-28 01:50:29 -07:00
jeffvli 1f5907716f disable interval-based timeupdate scrobbles 2026-05-28 00:59:30 -07:00
jeffvli 99ae0c99c6 fix restart requirement logic when switching windowbar style 2026-05-28 00:46:07 -07:00
jeffvli a56253cd3a fix queue height when using web windowbar style (#2068) 2026-05-28 00:46:06 -07:00
7 changed files with 332 additions and 328 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.12.0",
"version": "1.12.1",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '/@/renderer/api';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import {
useIsRadioActive,
useRadioPlayer,
@@ -36,6 +37,7 @@ const DiscordStatusDisplayType = {
} as const;
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
type ActivityTrigger = 'initial' | 'interval' | 'seek' | 'status_change' | 'track_change';
const MAX_FIELD_LENGTH = 127;
const MAX_URL_LENGTH = 256;
@@ -64,22 +66,24 @@ export const useDiscordRpc = () => {
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const previousActivityStateRef = useRef<ActivityState | null>(null);
const discordEnabledRef = useRef<boolean>(discordSettings.enabled);
const privateModeRef = useRef<boolean>(privateMode);
// Update imageUrl ref when it changes
useEffect(() => {
imageUrlRef.current = imageUrl;
}, [imageUrl]);
useEffect(() => {
discordEnabledRef.current = discordSettings.enabled;
}, [discordSettings.enabled]);
useEffect(() => {
privateModeRef.current = privateMode;
}, [privateMode]);
const setActivity = useCallback(
async (current: ActivityState, previous: ActivityState) => {
// Check if track changed by comparing with previous state
async (current: ActivityState, trigger: ActivityTrigger) => {
const song = current[0];
const previousSong = previous[0];
const trackChangedByState =
song && previousSong
? song._uniqueId !== previousSong._uniqueId
: song !== previousSong;
const trackChanged = song ? lastUniqueId !== song._uniqueId : false;
const isPlayingRadio = isRadioActive && isRadioPlaying;
@@ -103,6 +107,7 @@ export const useDiscordRpc = () => {
meta: {
reason,
status: current[2],
trigger,
},
});
return discordRpc?.clearActivity();
@@ -152,6 +157,7 @@ export const useDiscordRpc = () => {
showAsListening: discordSettings.showAsListening,
stationName: stationName || 'Radio',
title,
trigger,
},
});
discordRpc?.setActivity(activity);
@@ -162,20 +168,7 @@ export const useDiscordRpc = () => {
return;
}
/*
1. If the song has just started, update status
2. If we jump more then 1.2 seconds from last state, update status to match
3. If the current song id is completely different, update status
4. If the player state changed, update status
*/
if (
previous[1] === 0 ||
Math.abs(current[1] - previous[1]) > 1.2 ||
trackChangedByState ||
trackChanged ||
current[2] !== previous[2]
) {
if (trackChangedByState || trackChanged) {
if (trackChanged) {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, {
category: LogCategory.EXTERNAL,
meta: {
@@ -187,17 +180,7 @@ export const useDiscordRpc = () => {
setlastUniqueId(song._uniqueId);
}
let reason: string;
if (trackChangedByState || trackChanged) {
reason = 'track_changed';
} else if (previous[1] === 0) {
reason = 'song_started';
} else if (Math.abs(current[1] - previous[1]) > 1.2) {
reason = 'time_jump';
} else {
reason = 'player_state_changed';
}
const reason = trigger;
const start = Math.round(Date.now() - current[1] * 1000);
const end = Math.round(start + song.duration);
@@ -348,28 +331,14 @@ export const useDiscordRpc = () => {
displayType: discordSettings.displayType,
hasLargeImage: !!activity.largeImageKey,
hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp),
previousStatus: previous[2],
previousTime: previous[1],
reason,
showAsListening: discordSettings.showAsListening,
songName: song.name,
trackChanged: trackChangedByState || trackChanged,
trackChanged,
trigger,
},
});
discordRpc?.setActivity(activity);
} else {
logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcUpdateSkipped, {
category: LogCategory.EXTERNAL,
meta: {
currentStatus: current[2],
currentTime: current[1],
previousStatus: previous[2],
previousTime: previous[1],
timeDiff: Math.abs(current[1] - previous[1]),
trackChanged: trackChangedByState || trackChanged,
},
});
}
},
[
discordSettings.showAsListening,
@@ -390,7 +359,7 @@ export const useDiscordRpc = () => {
],
);
const debouncedSetActivity = useDebouncedCallback(setActivity, 500);
const debouncedSetActivity = useDebouncedCallback(setActivity, 1000);
// Quit Discord RPC if it was enabled and is now disabled
useEffect(() => {
@@ -409,95 +378,110 @@ export const useDiscordRpc = () => {
}
}, [discordSettings.clientId, privateMode, discordSettings.enabled]);
useEffect(() => {
if (!discordSettings.enabled || privateMode) {
return;
}
const getCurrentActivityState = (): ActivityState => {
const getCurrentActivityState = useCallback((): ActivityState => {
const state = usePlayerStore.getState();
const currentSong = state.getCurrentSong();
const currentTime = useTimestampStoreBase.getState().timestamp;
const status = state.player.status;
return [currentSong, currentTime, status];
};
const resetInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(() => {
const current = getCurrentActivityState();
const previous = previousActivityStateRef.current || current;
debouncedSetActivity(current, previous);
previousActivityStateRef.current = current;
}, 15000);
};
resetInterval();
const initialState = getCurrentActivityState();
let previousUniqueId = initialState[0]?._uniqueId || '';
previousActivityStateRef.current = initialState;
// Set activity immediately when Discord RPC is enabled
debouncedSetActivity(initialState, initialState);
const unsubSongChange = usePlayerStore.subscribe(
(state): ActivityState => {
const currentSong = state.getCurrentSong();
const currentTime = useTimestampStoreBase.getState().timestamp;
const status = state.player.status;
return [currentSong, currentTime, status];
},
(current, previous) => {
const currentUniqueId = current[0]?._uniqueId || '';
const trackChanged = previousUniqueId !== currentUniqueId;
if (trackChanged && current[0]) {
resetInterval();
previousUniqueId = currentUniqueId;
}
const activity: ActivityState = [
current[0] as QueueSong,
current[1] as number,
current[2] as PlayerStatus,
return [
state.getCurrentSong(),
useTimestampStoreBase.getState().timestamp,
state.player.status,
];
}, []);
// Use the ref as the source of truth for previous state
const previousActivity: ActivityState =
previousActivityStateRef.current ||
(previous
? [
previous[0] as QueueSong,
previous[1] as number,
previous[2] as PlayerStatus,
]
: activity);
debouncedSetActivity(activity, previousActivity);
previousActivityStateRef.current = activity;
},
);
return () => {
unsubSongChange();
const clearRefreshInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
const emitActivityUpdateRef = useRef<(next: ActivityState, trigger: ActivityTrigger) => void>(
() => {},
);
const resetRefreshInterval = useCallback(() => {
clearRefreshInterval();
intervalRef.current = setInterval(() => {
const current = getCurrentActivityState();
emitActivityUpdateRef.current(current, 'interval');
}, 15000);
}, [clearRefreshInterval, getCurrentActivityState]);
const emitActivityUpdate = useCallback(
(next: ActivityState, trigger: ActivityTrigger) => {
debouncedSetActivity(next, trigger);
resetRefreshInterval();
},
[debouncedSetActivity, resetRefreshInterval],
);
useEffect(() => {
emitActivityUpdateRef.current = emitActivityUpdate;
}, [emitActivityUpdate]);
useEffect(() => {
if (!discordSettings.enabled || privateMode) {
clearRefreshInterval();
return;
}
const initialState = getCurrentActivityState();
emitActivityUpdate(initialState, 'initial');
return () => {
clearRefreshInterval();
};
}, [
debouncedSetActivity,
discordSettings.clientId,
clearRefreshInterval,
discordSettings.enabled,
emitActivityUpdate,
getCurrentActivityState,
privateMode,
setActivity,
]);
usePlayerEvents(
{
onCurrentSongChange: ({ song }) => {
if (!discordEnabledRef.current || privateModeRef.current) {
return;
}
const playerState = usePlayerStore.getState();
const activityState: ActivityState = [
song,
useTimestampStoreBase.getState().timestamp,
playerState.player.status,
];
emitActivityUpdateRef.current(activityState, 'track_change');
},
onPlayerSeekToTimestamp: ({ timestamp }) => {
if (!discordEnabledRef.current || privateModeRef.current) {
return;
}
const playerState = usePlayerStore.getState();
const activityState: ActivityState = [
playerState.getCurrentSong(),
timestamp,
playerState.player.status,
];
emitActivityUpdateRef.current(activityState, 'seek');
},
onPlayerStatus: ({ status }) => {
if (!discordEnabledRef.current || privateModeRef.current) {
return;
}
const playerState = usePlayerStore.getState();
const activityState: ActivityState = [
playerState.getCurrentSong(),
useTimestampStoreBase.getState().timestamp,
status,
];
emitActivityUpdateRef.current(activityState, 'status_change');
},
},
[],
);
};
const DiscordRpcHookInner = () => {
@@ -214,7 +214,14 @@ export const SidebarPlayQueue = () => {
))}
</SplitPane>
) : (
<Stack gap={0} h="100%" w="100%">
<Stack
gap={0}
style={{
flex: 1,
minHeight: 0,
}}
w="100%"
>
<PlayQueueListControls
handleSearch={setSearch}
searchTerm={search}
@@ -153,6 +153,7 @@ export function WebPlayer() {
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player1().ref),
hasNextSong: Boolean(player2),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player2(),
@@ -206,6 +207,7 @@ export function WebPlayer() {
gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(playerRef.current.player2().ref),
hasNextSong: Boolean(player1),
isFlac: false,
isTransitioning,
nextPlayer: playerRef.current.player1(),
@@ -680,6 +682,7 @@ function exponentialEaseOut(t: number): number {
function gaplessHandler(args: {
currentTime: number;
duration: number;
hasNextSong: boolean;
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
@@ -688,7 +691,19 @@ function gaplessHandler(args: {
};
setIsTransitioning: Dispatch<boolean | string>;
}) {
const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;
const {
currentTime,
duration,
hasNextSong,
isFlac,
isTransitioning,
nextPlayer,
setIsTransitioning,
} = args;
if (!hasNextSong) {
return null;
}
if (!isTransitioning) {
if (currentTime > duration - 2) {
@@ -180,9 +180,6 @@ 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;
const currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp;
@@ -239,36 +236,36 @@ export const useScrobble = () => {
}
// Send progress events every 10 seconds
if (hasPlaybackReport) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) {
sendScrobble.mutate(
{
apiClientProps: { serverId: serverId || '' },
query: {
albumId: currentSong.albumId,
event: 'timeupdate',
id: currentSong.id,
mediaType: mediaType,
playbackRate,
position: getPositionValue(currentTime, useTicks),
submission: false,
},
},
{
onSuccess: () => {
logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
category: LogCategory.SCROBBLE,
meta: {
id: currentSong.id,
},
});
},
},
);
lastProgressEventRef.current = currentTime;
}
}
// if (hasPlaybackReport) {
// const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
// if (timeSinceLastProgress >= 10) {
// sendScrobble.mutate(
// {
// apiClientProps: { serverId: serverId || '' },
// query: {
// albumId: currentSong.albumId,
// event: 'timeupdate',
// id: currentSong.id,
// mediaType: mediaType,
// playbackRate,
// position: getPositionValue(currentTime, useTicks),
// submission: false,
// },
// },
// {
// onSuccess: () => {
// logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, {
// category: LogCategory.SCROBBLE,
// meta: {
// id: currentSong.id,
// },
// });
// },
// },
// );
// lastProgressEventRef.current = currentTime;
// }
// }
// Check if we should submit scrobble based on listened time
if (!isCurrentSongScrobbledRef.current) {
@@ -36,13 +36,12 @@ export const WindowSettings = memo(() => {
if (!e) return;
// Platform.LINUX is used as the native frame option regardless of the actual platform
const hasFrame = localSettings?.get('window_has_frame') as
| boolean
| undefined;
const isSwitchingToFrame = !hasFrame && e === Platform.LINUX;
const isSwitchingToNoFrame = hasFrame && e !== Platform.LINUX;
const requireRestart = isSwitchingToFrame || isSwitchingToNoFrame;
const previousWindowBarStyle = settings.windowBarStyle;
const isSwitchingToNative =
previousWindowBarStyle !== Platform.LINUX && e === Platform.LINUX;
const isSwitchingFromNative =
previousWindowBarStyle === Platform.LINUX && e !== Platform.LINUX;
const requireRestart = isSwitchingToNative || isSwitchingFromNative;
if (requireRestart) {
openRestartRequiredToast();
+6 -4
View File
@@ -223,7 +223,7 @@ function calculateNextIndex(
} else {
// Repeat none: move to next track, or pause if at the end
if (isLastTrack) {
return { nextIndex: 0, shouldPause: true };
return { nextIndex: currentIndex, shouldPause: true };
} else {
return { nextIndex: currentIndex + 1, shouldPause: false };
}
@@ -939,10 +939,12 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const pauseOnNext = player.pauseOnNextSongEnd;
const newStatus =
shouldPause || pauseOnNext ? PlayerStatus.PAUSED : PlayerStatus.PLAYING;
const shouldKeepCurrentPlayer = newStatus === PlayerStatus.PAUSED;
const shouldSwapPlayer = !isRepeatOneSameTrack && !shouldKeepCurrentPlayer;
set((state) => {
state.player.index = nextPlaybackIndex;
state.player.playerNum = newPlayerNum;
state.player.playerNum = shouldSwapPlayer ? newPlayerNum : player.playerNum;
setTimestampStore(0);
state.player.status = newStatus;
@@ -999,7 +1001,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
}
const { player1, player2 } = getDualPlayerSongs(
newPlayerNum,
shouldSwapPlayer ? newPlayerNum : player.playerNum,
currentSong,
nextSong,
repeat,
@@ -1009,7 +1011,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
currentSong,
index: currentQueueIndex,
nextSong,
num: newPlayerNum,
num: shouldSwapPlayer ? newPlayerNum : player.playerNum,
player1,
player2,
previousSong,