Compare commits

..

6 Commits

Author SHA1 Message Date
jeffvli e62242824d update to v1.15.1 2026-07-19 16:31:07 -07:00
jeffvli dbd82d46cf use custom lyrics styling on search form instead of setting 2026-07-19 16:27:49 -07:00
jeffvli e4378d1bc9 fix mediasession handlers breaking after player stop event 2026-07-19 13:51:11 -07:00
jeffvli f7ae4faeb0 fix tag editor defaults to set sort tags to non-multi 2026-07-19 12:35:19 -07:00
jeffvli 749899cec0 fix inverted condition for progress scrobble after submission 2026-07-19 12:26:16 -07:00
jeffvli f9b9f9ab06 refactor periodic update check to use github releases instead of electron-builder (#2259)
- resolves an issue related to electron-builder continuing to download and install updates despite only calling checkForUpdates
- adds indicator to settings / app menu on new version found
2026-07-19 12:13:28 -07:00
24 changed files with 339 additions and 210 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "1.15.0",
"version": "1.15.1",
"description": "A modern self-hosted music player.",
"keywords": [
"subsonic",
-48
View File
@@ -644,54 +644,6 @@ async function createWindow(first = true): Promise<void> {
return mainWindow?.webContents.session.clearCache();
});
ipcMain.handle(
'app-check-for-updates',
async (): Promise<{ updateAvailable: boolean; version?: string }> => {
if (disableAutoUpdates()) {
console.log('Auto updates are disabled');
return { updateAvailable: false };
}
try {
console.log('Checking for updates');
const effectiveChannel = store.get('release_channel') as string;
let result: null | UpdateCheckResult;
let updater: UpdaterInstance;
if (effectiveChannel === 'alpha') {
const best = await checkAllChannelsAndGetBest();
result = best.result;
updater = best.updater;
} else {
updater = configureAndGetUpdater();
result = await updater.checkForUpdates();
}
const updateAvailable = result?.isUpdateAvailable ?? false;
console.log('Update available:', updateAvailable);
if (updateAvailable && store.get('disable_auto_updates') !== true) {
if (isMacOS()) {
getMainWindow()?.webContents.send(
'update-available',
result?.updateInfo?.version,
);
} else {
console.log('Downloading update');
updater.downloadUpdate();
}
}
return {
updateAvailable,
version: result?.updateInfo?.version,
};
} catch {
console.log('Error checking for updates');
return { updateAvailable: false };
}
},
);
ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
@@ -11,3 +11,7 @@
padding-left: var(--lyric-padding-left, 0%) !important;
}
}
.preview {
padding: 0 !important;
}
@@ -10,6 +10,7 @@ export interface LyricsScrollContentProps {
gap?: number;
paddingLeft?: number;
paddingRight?: number;
preview?: boolean;
style?: React.CSSProperties;
}
@@ -20,11 +21,19 @@ export const LyricsScrollContent = ({
gap,
paddingLeft = 0,
paddingRight = 0,
preview = false,
style,
}: LyricsScrollContentProps) => {
const contentStyle = useMemo(
() =>
({
const contentStyle = useMemo(() => {
if (preview) {
return {
gap: gap !== undefined ? `${gap}px` : undefined,
padding: 0,
...style,
} as React.CSSProperties;
}
return {
'--lyric-padding-left': `${paddingLeft}%`,
'--lyric-padding-right': `${paddingRight}%`,
gap: gap !== undefined ? `${gap}px` : undefined,
@@ -33,12 +42,14 @@ export const LyricsScrollContent = ({
paddingRight: `${paddingRight}%`,
paddingTop: '10vh',
...style,
}) as React.CSSProperties,
[bottomScrollPadding, gap, paddingLeft, paddingRight, style],
);
} as React.CSSProperties;
}, [bottomScrollPadding, gap, paddingLeft, paddingRight, preview, style]);
return (
<div className={clsx(styles.content, className)} style={contentStyle}>
<div
className={clsx(styles.content, preview && styles.preview, className)}
style={contentStyle}
>
{children}
</div>
);
@@ -33,22 +33,3 @@
}
}
}
.lyrics-preview {
:global(.synchronized-lyrics) {
height: auto !important;
padding: 1rem 0 !important;
overflow: visible !important;
transform: none !important;
}
}
.lyrics-content-wrapper {
:global(> div) {
height: auto !important;
max-height: none !important;
padding: 1rem 0 !important;
overflow: visible !important;
transform: none !important;
}
}
@@ -246,7 +246,6 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
{selectedResult && (
<Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>
<ScrollArea
className={styles['lyrics-preview']}
style={{
height: '100%',
paddingRight: '1rem',
@@ -255,14 +254,10 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
{isPreviewLoading ? (
<Spinner container />
) : previewData ? (
<div
className={styles['lyrics-content-wrapper']}
style={{ width: '100%' }}
>
{Array.isArray(previewData) ? (
Array.isArray(previewData) ? (
lyricsHasWordCues(previewData) ? (
<SynchronizedKaraokeLyrics
style={{ padding: 0 }}
preview
{...({
artist: selectedResult.artist,
lyrics: previewData,
@@ -273,7 +268,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
/>
) : (
<SynchronizedLyrics
style={{ padding: 0 }}
preview
{...({
artist: selectedResult.artist,
lyrics: previewData,
@@ -285,6 +280,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
)
) : (
<UnsynchronizedLyrics
preview
{...({
artist: selectedResult.artist,
lyrics: previewData,
@@ -293,8 +289,7 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
source: selectedResult.source,
} as UnsynchronizedLyricsProps)}
/>
)}
</div>
)
) : (
<Center>
<Text isMuted>{t('page.fullscreenPlayer.noLyrics')}</Text>
@@ -7,3 +7,11 @@
overflow: hidden auto;
word-break: break-all;
}
.preview {
height: auto;
max-height: none;
padding: 1rem 0;
overflow: visible;
transform: none;
}
@@ -34,6 +34,7 @@ export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata,
extraOverlayLyrics?: SynchronizedLyricsData[];
lyrics: SynchronizedLyricsData;
offsetMs?: number;
preview?: boolean;
pronunciationLyrics?: null | SynchronizedLyricsData;
romajiLyrics?: null | SynchronizedLyricsData;
settingsKey?: string;
@@ -44,6 +45,8 @@ export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata,
}
const SEEK_DETECT_THRESHOLD_MS = 500;
const PREVIEW_FONT_SIZE = 20;
const PREVIEW_GAP = 20;
export const SynchronizedKaraokeLyrics = ({
agents,
@@ -52,6 +55,7 @@ export const SynchronizedKaraokeLyrics = ({
lyrics,
name,
offsetMs,
preview = false,
pronunciationLyrics,
romajiLyrics,
settingsKey = 'default',
@@ -77,6 +81,11 @@ export const SynchronizedKaraokeLyrics = ({
showScrollbar,
} = useSynchronizedLyricsBase(settingsKey, offsetMs);
const effectiveFontSize = preview ? PREVIEW_FONT_SIZE : settings.fontSize;
const effectiveGap = preview ? PREVIEW_GAP : settings.gap;
const effectivePaddingLeft = preview ? 0 : settings.paddingLeft;
const effectivePaddingRight = preview ? 0 : settings.paddingRight;
const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]);
const rafRef = useRef<null | number>(null);
const statusRef = useRef(usePlayerStoreBase.getState().player.status);
@@ -97,13 +106,13 @@ export const SynchronizedKaraokeLyrics = ({
containerRef,
followRef,
followScrollAlignmentRef,
fontSize: settings.fontSize,
gap: settings.gap,
fontSize: effectiveFontSize,
gap: effectiveGap,
lineIdPrefix: 'karaoke-line',
lineLeadTimeMsRef,
lyrics: normalizedLyrics,
paddingLeft: settings.paddingLeft,
paddingRight: settings.paddingRight,
paddingLeft: effectivePaddingLeft,
paddingRight: effectivePaddingRight,
scrollContainerId: LYRICS_SCROLL_CONTAINER_ID,
});
@@ -328,7 +337,11 @@ export const SynchronizedKaraokeLyrics = ({
return (
<div
className={clsx(styles.container, 'synchronized-karaoke-lyrics overlay-scrollbar')}
className={clsx(
styles.container,
preview && styles.preview,
'synchronized-karaoke-lyrics overlay-scrollbar',
)}
id={LYRICS_SCROLL_CONTAINER_ID}
onClick={handleContainerClick}
onMouseEnter={showScrollbar}
@@ -337,15 +350,16 @@ export const SynchronizedKaraokeLyrics = ({
style={{ ...containerStyle, ...style }}
>
<LyricsScrollContent
gap={settings.gap}
paddingLeft={settings.paddingLeft}
paddingRight={settings.paddingRight}
gap={effectiveGap}
paddingLeft={effectivePaddingLeft}
paddingRight={effectivePaddingRight}
preview={preview}
>
{settings.showProvider && source && (
<LyricLine
alignment={settings.alignment}
className="lyric-credit"
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
text={`${source}`}
/>
)}
@@ -353,7 +367,7 @@ export const SynchronizedKaraokeLyrics = ({
<LyricLine
alignment={settings.alignment}
className="lyric-credit"
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
text={`${name}${artist}`}
/>
)}
@@ -396,7 +410,7 @@ export const SynchronizedKaraokeLyrics = ({
alignment={settings.alignment}
className="lyric-line synchronized"
data-lyric-time={lineStartMs}
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
id={`karaoke-line-${idx}`}
key={idx}
romajiText={pronunciationText}
@@ -414,7 +428,7 @@ export const SynchronizedKaraokeLyrics = ({
cueLines={rawLine.cueLines}
data-lyric-time={lineStartMs}
extraOverlays={extraOverlays}
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
id={`karaoke-line-${idx}`}
key={idx}
lineIndex={idx}
@@ -8,3 +8,11 @@
word-break: break-all;
transform: translateY(-2rem);
}
.preview {
height: auto;
max-height: none;
padding: 1rem 0;
overflow: visible;
transform: none;
}
@@ -29,6 +29,7 @@ export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyric
extraOverlayLyrics?: SynchronizedLyricsData[];
lyrics: SynchronizedLyricsData;
offsetMs?: number;
preview?: boolean;
pronunciationLyrics?: null | SynchronizedLyricsData;
romajiLyrics?: null | SynchronizedLyricsData;
settingsKey?: string;
@@ -38,12 +39,15 @@ export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyric
}
const SEEK_DETECT_THRESHOLD_MS = 500;
const PREVIEW_FONT_SIZE = 20;
const PREVIEW_GAP = 20;
export const SynchronizedLyrics = ({
artist,
lyrics,
name,
offsetMs,
preview = false,
pronunciationLyrics,
romajiLyrics,
settingsKey = 'default',
@@ -68,6 +72,11 @@ export const SynchronizedLyrics = ({
showScrollbar,
} = useSynchronizedLyricsBase(settingsKey, offsetMs);
const effectiveFontSize = preview ? PREVIEW_FONT_SIZE : settings.fontSize;
const effectiveGap = preview ? PREVIEW_GAP : settings.gap;
const effectivePaddingLeft = preview ? 0 : settings.paddingLeft;
const effectivePaddingRight = preview ? 0 : settings.paddingRight;
const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]);
const rafRef = useRef<null | number>(null);
const statusRef = useRef(usePlayerStoreBase.getState().player.status);
@@ -83,13 +92,13 @@ export const SynchronizedLyrics = ({
containerRef,
followRef,
followScrollAlignmentRef,
fontSize: settings.fontSize,
gap: settings.gap,
fontSize: effectiveFontSize,
gap: effectiveGap,
lineIdPrefix: 'lyric',
lineLeadTimeMsRef,
lyrics: normalizedLyrics,
paddingLeft: settings.paddingLeft,
paddingRight: settings.paddingRight,
paddingLeft: effectivePaddingLeft,
paddingRight: effectivePaddingRight,
scrollContainerId: LYRICS_SCROLL_CONTAINER_ID,
});
@@ -240,7 +249,11 @@ export const SynchronizedLyrics = ({
return (
<div
className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')}
className={clsx(
styles.container,
preview && styles.preview,
'synchronized-lyrics overlay-scrollbar',
)}
id={LYRICS_SCROLL_CONTAINER_ID}
onClick={handleContainerClick}
onMouseEnter={showScrollbar}
@@ -249,15 +262,16 @@ export const SynchronizedLyrics = ({
style={{ ...containerStyle, ...style }}
>
<LyricsScrollContent
gap={settings.gap}
paddingLeft={settings.paddingLeft}
paddingRight={settings.paddingRight}
gap={effectiveGap}
paddingLeft={effectivePaddingLeft}
paddingRight={effectivePaddingRight}
preview={preview}
>
{settings.showProvider && source && (
<LyricLine
alignment={settings.alignment}
className="lyric-credit"
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
text={`${source}`}
/>
)}
@@ -265,7 +279,7 @@ export const SynchronizedLyrics = ({
<LyricLine
alignment={settings.alignment}
className="lyric-credit"
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
text={`${name}${artist}`}
/>
)}
@@ -290,7 +304,7 @@ export const SynchronizedLyrics = ({
alignment={settings.alignment}
className="lyric-line synchronized"
data-lyric-time={lineStartMs}
fontSize={settings.fontSize}
fontSize={effectiveFontSize}
id={`lyric-${idx}`}
key={idx}
romajiText={pronunciationText}
@@ -7,3 +7,11 @@
overflow: hidden auto;
transform: translateY(-2rem);
}
.preview {
height: auto;
max-height: none;
padding: 1rem 0;
overflow: visible;
transform: none;
}
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import { useMemo } from 'react';
import styles from './unsynchronized-lyrics.module.css';
@@ -9,15 +10,20 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string;
preview?: boolean;
romajiLyrics?: null | string;
settingsKey?: string;
translatedLyrics?: null | string;
}
const PREVIEW_FONT_SIZE = 20;
const PREVIEW_GAP = 20;
export const UnsynchronizedLyrics = ({
artist,
lyrics,
name,
preview = false,
romajiLyrics,
settingsKey = 'default',
source,
@@ -27,12 +33,14 @@ export const UnsynchronizedLyrics = ({
const displaySettings = useLyricsDisplaySettings(settingsKey);
const settings = {
...lyricsSettings,
fontSizeUnsync:
displaySettings.fontSizeUnsync && displaySettings.fontSizeUnsync !== 0
fontSizeUnsync: preview
? PREVIEW_FONT_SIZE
: displaySettings.fontSizeUnsync && displaySettings.fontSizeUnsync !== 0
? displaySettings.fontSizeUnsync
: 24,
gapUnsync:
displaySettings.gapUnsync && displaySettings.gapUnsync !== 0
gapUnsync: preview
? PREVIEW_GAP
: displaySettings.gapUnsync && displaySettings.gapUnsync !== 0
? displaySettings.gapUnsync
: 24,
};
@@ -49,12 +57,13 @@ export const UnsynchronizedLyrics = ({
}, [romajiLyrics]);
return (
<div className={styles.container}>
<div className={clsx(styles.container, preview && styles.preview)}>
<LyricsScrollContent
bottomScrollPadding="6vh"
gap={settings.gapUnsync}
paddingLeft={displaySettings.paddingLeft ?? 0}
paddingRight={displaySettings.paddingRight ?? 0}
paddingLeft={preview ? 0 : (displaySettings.paddingLeft ?? 0)}
paddingRight={preview ? 0 : (displaySettings.paddingRight ?? 0)}
preview={preview}
>
{settings.showProvider && source && (
<LyricLine
@@ -185,9 +185,9 @@ export const useScrobble = () => {
// Because Jellyfin uses the stop event for submission, we need to send another
// progress update after submission so that the song continues to progress in the dashboard
const shouldSendProgress =
song._serverType !== ServerType.JELLYFIN ||
activeSong?._uniqueId !== song._uniqueId ||
status !== PlayerStatus.PLAYING;
song._serverType === ServerType.JELLYFIN &&
activeSong?._uniqueId === song._uniqueId &&
status === PlayerStatus.PLAYING;
if (!shouldSendProgress) {
return;
@@ -1,6 +1,7 @@
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button';
import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
@@ -14,9 +15,10 @@ import { Text } from '/@/shared/components/text/text';
export type SettingsHeaderProps = {
setSearch: (search: string) => void;
showUpdateAvailable?: boolean;
};
export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
export const SettingsHeader = ({ setSearch, showUpdateAvailable }: SettingsHeaderProps) => {
const { t } = useTranslation();
const { reset } = useSettingsStoreActions();
const search = useSettingSearchContext();
@@ -48,6 +50,7 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
</LibraryHeaderBar.Title>
</Group>
<Group>
{showUpdateAvailable && <UpdateAvailableButton />}
<SearchInput
defaultValue={search}
onChange={(event) => setSearch(event.target.value.toLocaleLowerCase())}
@@ -9,7 +9,7 @@ export const SettingsContextModal = () => {
return (
<SettingSearchContext.Provider value={search}>
<SettingsHeader setSearch={setSearch} />
<SettingsHeader setSearch={setSearch} showUpdateAvailable />
<SettingsContent />
</SettingSearchContext.Provider>
);
@@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next';
import { toTag } from '/@/renderer/hooks';
import { useLatestVersion } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
export const UpdateAvailableButton = () => {
const { t } = useTranslation();
const { currentVersion, isUpdateAvailable, latestVersion } = useLatestVersion();
if (!isUpdateAvailable || !latestVersion) {
return null;
}
return (
<Button
component="a"
href={`https://github.com/jeffvli/feishin/releases/tag/${toTag(latestVersion || currentVersion)}`}
size="compact-sm"
target="_blank"
variant="filled"
>
{t('common.newVersionAvailable')}: v{latestVersion}
</Button>
);
};
@@ -4,11 +4,11 @@ import { Fragment, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import packageJson from '../../../../../package.json';
import styles from './app-menu.module.css';
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import { ServerList } from '/@/renderer/features/servers/components/server-list';
import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
import { openReleaseNotesModal } from '/@/renderer/release-notes-modal';
@@ -18,10 +18,12 @@ import {
useCommandPalette,
useCurrentServer,
useGeneralSettings,
useLatestVersion,
useSettingsStoreActions,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { toast } from '/@/shared/components/toast/toast';
@@ -138,6 +140,8 @@ export const AppMenu = () => {
});
};
const { currentVersion } = useLatestVersion();
const serverHeaderMenuItems: MenuItem[] = currentServer
? [
{
@@ -270,10 +274,10 @@ export const AppMenu = () => {
{
icon: 'brandGitHub',
id: 'version',
label: t('page.appMenu.version', { version: packageJson.version }),
label: t('page.appMenu.version', { version: currentVersion }),
onClick: () =>
openReleaseNotesModal(
t('common.newVersion', { version: packageJson.version }) as string,
t('common.newVersion', { version: currentVersion }) as string,
),
type: 'item',
},
@@ -301,6 +305,15 @@ export const AppMenu = () => {
},
type: 'conditional-item',
},
{
component: (
<Flex align="center" justify="center" w="100%">
<UpdateAvailableButton />
</Flex>
),
id: 'update-available',
type: 'custom',
},
{
id: 'divider-5',
type: 'divider',
+1
View File
@@ -2,6 +2,7 @@ export * from './use-app-focus';
export * from './use-check-for-updates';
export * from './use-container-query';
export * from './use-fast-average-color';
export * from './use-github-releases';
export * from './use-hide-scrollbar';
export * from './use-hotkeys';
export * from './use-is-mounted';
+15 -20
View File
@@ -1,29 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { parseVersionFromTag, useGithubLatestRelease } from '/@/renderer/hooks/use-github-releases';
import { useAppStore } from '/@/renderer/store';
// 6 hours
const CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000;
const utils = isElectron() ? window.api?.utils : null;
export const useCheckForUpdates = () => {
const [enablePeriodicCheck, setEnablePeriodicCheck] = useState(false);
const setLatestVersion = useAppStore((state) => state.actions.setLatestVersion);
// We want to skip the first check since it's already checked in the main process when the app is started
useEffect(() => {
const timer = setTimeout(() => setEnablePeriodicCheck(true), CHECK_FOR_UPDATES_INTERVAL_MS);
return () => clearTimeout(timer);
}, []);
const isEnabled =
enablePeriodicCheck &&
Boolean(isElectron() && utils?.checkForUpdates && !utils?.disableAutoUpdates?.());
return useQuery({
enabled: isEnabled,
queryFn: () => utils?.checkForUpdates?.(),
queryKey: ['app-check-for-updates'],
const query = useGithubLatestRelease({
refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS,
refetchIntervalInBackground: true,
});
useEffect(() => {
if (query.data) {
setLatestVersion(parseVersionFromTag(query.data.tag_name));
}
}, [query.data, setLatestVersion]);
return query;
};
+49
View File
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
export const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases';
export const RELEASES_TO_FETCH = 30;
export interface GitHubRelease {
body: null | string;
name: null | string;
prerelease: boolean;
published_at: string;
tag_name: string;
}
export function parseVersionFromTag(tagName: string): string {
return tagName.startsWith('v') ? tagName.slice(1) : tagName;
}
export function toTag(version: string): string {
return version.startsWith('v') ? version : `v${version}`;
}
export const useGithubReleasesList = (perPage = RELEASES_TO_FETCH) => {
return useQuery({
queryFn: async () => {
const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, {
params: { per_page: perPage },
});
return response.data;
},
queryKey: ['github-releases-list', perPage],
retry: 2,
});
};
export const useGithubLatestRelease = (options?: {
refetchInterval?: number;
refetchIntervalInBackground?: boolean;
}) => {
return useQuery({
queryFn: async () => {
const response = await axios.get<GitHubRelease>(`${GITHUB_RELEASES_URL}/latest`);
return response.data;
},
queryKey: ['github-latest-release'],
retry: 2,
...options,
});
};
+9 -28
View File
@@ -7,6 +7,14 @@ import { useTranslation } from 'react-i18next';
import packageJson from '../../package.json';
import {
GITHUB_RELEASES_URL,
type GitHubRelease,
parseVersionFromTag,
RELEASES_TO_FETCH,
toTag,
useGithubReleasesList,
} from '/@/renderer/hooks/use-github-releases';
import { formatHrDateTime } from '/@/renderer/utils/format';
import { Button } from '/@/shared/components/button/button';
import { Center } from '/@/shared/components/center/center';
@@ -19,9 +27,7 @@ import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases';
const GITHUB_COMPARE_URL = 'https://api.github.com/repos/jeffvli/feishin/compare';
const RELEASES_TO_FETCH = 30;
interface GitHubCompareCommit {
commit: {
@@ -37,14 +43,6 @@ interface GitHubCompareResponse {
total_commits: number;
}
interface GitHubRelease {
body: null | string;
name: null | string;
prerelease: boolean;
published_at: string;
tag_name: string;
}
interface ReleaseNotesContentProps {
onDismiss: () => void;
version: string;
@@ -54,30 +52,13 @@ function isAlphaVersion(version: string): boolean {
return version.includes('-alpha');
}
function parseVersionFromTag(tagName: string): string {
return tagName.startsWith('v') ? tagName.slice(1) : tagName;
}
function toTag(version: string): string {
return version.startsWith('v') ? version : `v${version}`;
}
const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => {
const { t } = useTranslation();
const [selectedVersion, setSelectedVersion] = useState(version);
const isAlpha = isAlphaVersion(selectedVersion);
// Fetch list of recent releases for the selector
const { data: releasesList = [] } = useQuery({
queryFn: async () => {
const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, {
params: { per_page: RELEASES_TO_FETCH },
});
return response.data;
},
queryKey: ['github-releases-list'],
retry: 2,
});
const { data: releasesList = [] } = useGithubReleasesList();
const latestStableRelease = useMemo(() => {
return releasesList.find((r) => !r.prerelease);
+25 -1
View File
@@ -2,11 +2,15 @@ import type { ItemListStateItem } from '/@/renderer/components/item-list/helpers
import type { LibraryItem } from '/@/shared/types/domain-types';
import merge from 'lodash/merge';
import semverGt from 'semver/functions/gt';
import semverValid from 'semver/functions/valid';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import packageJson from '../../../package.json';
import { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { Platform } from '/@/shared/types/types';
@@ -24,6 +28,7 @@ export interface AppSlice extends AppState {
setGenreIdsMode: (mode: 'and' | 'or') => void;
setGenreSelectMode: (mode: 'multi' | 'single') => void;
setGlobalExpanded: (value: GlobalExpandedState | null) => void;
setLatestVersion: (version: null | string) => void;
setPageSidebar: (key: string, value: boolean) => void;
setPrivateMode: (enabled: boolean) => void;
setShowTimeRemaining: (enabled: boolean) => void;
@@ -52,6 +57,7 @@ export interface AppState {
genreSelectMode: 'multi' | 'single';
globalExpanded: GlobalExpandedState | null;
isReorderingQueue: boolean;
latestVersion: null | string;
pageSidebar: Record<string, boolean>;
platform: Platform;
privateMode: boolean;
@@ -157,6 +163,11 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
state.globalExpanded = value;
});
},
setLatestVersion: (version) => {
set((state) => {
state.latestVersion = version;
});
},
setPageSidebar: (key, value) => {
set((state) => {
state.pageSidebar[key] = value;
@@ -219,6 +230,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
genreSelectMode: 'multi',
globalExpanded: null,
isReorderingQueue: false,
latestVersion: null,
pageSidebar: {
album: true,
song: true,
@@ -261,7 +273,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
name: 'store_app',
partialize: (state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state
const { globalExpanded: _, ...rest } = state;
const { globalExpanded: _, latestVersion: __, ...rest } = state;
return rest;
},
version: 5,
@@ -307,6 +319,18 @@ export const useGlobalExpanded = () => useAppStore((state) => state.globalExpand
export const useSetGlobalExpanded = () => useAppStore((state) => state.actions.setGlobalExpanded);
export const useLatestVersion = () => {
const latestVersion = useAppStore((state) => state.latestVersion);
const currentVersion = packageJson.version;
const isUpdateAvailable =
!!latestVersion &&
!!semverValid(latestVersion) &&
!!semverValid(currentVersion) &&
semverGt(latestVersion, currentVersion);
return { currentVersion, isUpdateAvailable, latestVersion };
};
export const useGlobalExpandedState = () => {
const globalExpanded = useGlobalExpanded();
const setGlobalExpanded = useSetGlobalExpanded();
+17
View File
@@ -1055,6 +1055,8 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
? state.queue.shuffled.length
: queue.items.length;
const isStopped = state.player.status === PlayerStatus.STOPPED;
if (repeat === PlayerRepeat.ONE) {
// Manual next while repeat-one is active should still advance in the queue.
const nextIndex = Math.min(playbackLength - 1, currentIndex + 1);
@@ -1063,6 +1065,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
state.player.index = nextIndex;
state.player.playerNum = 1;
setTimestampStore(0);
if (isStopped) {
state.player.status = PlayerStatus.PLAYING;
}
});
eventEmitter.emit('MEDIA_NEXT', {
@@ -1112,6 +1118,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
state.player.index = nextIndex;
state.player.playerNum = 1;
setTimestampStore(0);
if (isStopped) {
state.player.status = PlayerStatus.PLAYING;
}
});
eventEmitter.emit('MEDIA_NEXT', {
@@ -1246,10 +1256,17 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
previousIndex = Math.max(0, currentIndex - 1);
}
// Same Chromium Media Session pitfall as mediaNext: a STOPPED→new-src
// transition without PLAYING drops OS media-key routing.
const resumeFromStopped = get().player.status === PlayerStatus.STOPPED;
set((state) => {
state.player.index = previousIndex;
state.player.playerNum = 1;
setTimestampStore(0);
if (resumeFromStopped) {
state.player.status = PlayerStatus.PLAYING;
}
});
eventEmitter.emit('MEDIA_PREV', {
+20 -4
View File
@@ -2052,7 +2052,7 @@ const initialState: SettingsState = {
ALBUMARTISTSSORT: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
multiValue: false,
},
artist: {
autocompleteSource: 'serverArtists',
@@ -2067,12 +2067,12 @@ const initialState: SettingsState = {
artistSort: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
multiValue: false,
},
ARTISTSSORT: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
multiValue: false,
},
genre: {
autocompleteSource: 'serverGenres',
@@ -2705,10 +2705,26 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
}
}
if (version < 32) {
const tagConfigs = state.tagEditor?.tagConfigs;
if (tagConfigs) {
for (const key of [
'albumArtistSort',
'ALBUMARTISTSSORT',
'artistSort',
'ARTISTSSORT',
] as const) {
if (tagConfigs[key]) {
tagConfigs[key].multiValue = false;
}
}
}
}
return persistedState;
},
name: 'store_settings',
version: 31,
version: 32,
},
),
);