Compare commits

..

6 Commits

Author SHA1 Message Date
jeffvli e3d7e8e856 add default values for optional docker env (#1500) 2026-01-04 15:36:32 -08:00
jeffvli 5ec8f1a904 add fallback to year for navidrome release display (#1498) 2026-01-04 15:22:37 -08:00
jeffvli 83b20d9086 fix card container height when inconsistent row count 2026-01-04 15:07:34 -08:00
jeffvli 211f09fe19 fix presets delete (#1497) 2026-01-04 14:42:44 -08:00
jeffvli 03c1fb0ff2 add missing table body to hotkeys manager 2026-01-04 14:31:18 -08:00
jeffvli 834412ad31 fix release notes not displayed on version change 2026-01-04 14:24:34 -08:00
11 changed files with 314 additions and 154 deletions
+8 -1
View File
@@ -18,8 +18,15 @@ FROM nginx:alpine-slim
COPY --chown=nginx:nginx --from=builder /app/out/web /usr/share/nginx/html
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
COPY ng.conf.template /etc/nginx/templates/default.conf.template
COPY ./ng.conf.template /etc/nginx/templates/default.conf.template
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENV PUBLIC_PATH="/"
ENV LEGACY_AUTHENTICATION="false"
ENV ANALYTICS_DISABLED="false"
EXPOSE 9180
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+3 -2
View File
@@ -1,7 +1,7 @@
services:
feishin:
container_name: feishin
image: 'ghcr.io/jeffvli/feishin:latest'
image: ghcr.io/jeffvli/feishin:latest
restart: unless-stopped
environment:
- SERVER_NAME=jellyfin # pre-defined server name
@@ -9,6 +9,7 @@ services:
- SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
- SERVER_URL= # http://address:port or https://address:port
- LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacyauth flag for server authentication (true or false)
- ANALYTICS_DISABLED=false # When true, disables analytics
ports:
- 9180:9180
# Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190
# Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Set default values for environment variables if not already set
export LEGACY_AUTHENTICATION=${LEGACY_AUTHENTICATION:-false}
export ANALYTICS_DISABLED=${ANALYTICS_DISABLED:-false}
# Execute the original nginx command
exec "$@"
@@ -3,6 +3,7 @@
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: var(--theme-spacing-md);
overflow: hidden;
user-select: none;
@@ -100,27 +100,55 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const originalDifferentFromRelease =
album?.originalDate && album?.originalDate !== album?.releaseDate;
const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear;
const playCount = album?.playCount;
const releasePrefix = originalDifferentFromRelease
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
: '♫';
if (originalDifferentFromRelease && album.originalDate) {
items.push({
id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`,
});
const releaseYearPrefix = originalYearDifferentFromRelease
? t('page.albumDetail.released', { postProcess: 'sentenceCase' })
: '';
if (album.originalDate) {
if (originalDifferentFromRelease) {
items.push({
id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`,
});
}
if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
});
}
} else if (album.originalYear) {
if (originalYearDifferentFromRelease) {
items.push({
id: 'originalYear',
value: `${album.originalYear}`,
});
}
if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
});
} else if (releaseYear) {
items.push({
id: 'releaseYear',
value: `${releaseYearPrefix} ${releaseYear}`,
});
}
}
items.push(
...[
{
id: 'releaseDate',
value: releaseDate
? `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`
: releaseYear,
},
{
id: 'songCount',
value: t('entity.trackWithCount', { count: detailQuery?.data?.songCount || 0 }),
@@ -266,97 +266,102 @@ export const HotkeyManagerSettings = memo(() => {
/>
<div className={styles.container}>
<Table withColumnBorders withRowBorders>
{filteredBindings.map((binding) => (
<Table.Tr key={`hotkey-${binding}`}>
<Table.Td style={{ userSelect: 'none' }}>
{BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}
</Table.Td>
<Table.Td>
<TextInput
id={`hotkey-${binding}`}
leftSection={<Icon icon="keyboard" />}
onBlur={() => setSelected(null)}
onChange={() => {}}
onKeyDownCapture={(e) => {
if (selected !== (binding as BindingActions))
return;
handleSetHotkey(binding as BindingActions, e);
}}
readOnly
rightSection={
<ActionIcon
icon="edit"
onClick={() => {
setSelected(binding as BindingActions);
document
.getElementById(`hotkey-${binding}`)
?.focus();
<Table.Tbody>
{filteredBindings.map((binding) => (
<Table.Tr key={`hotkey-${binding}`}>
<Table.Td style={{ userSelect: 'none' }}>
{BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}
</Table.Td>
<Table.Td>
<TextInput
id={`hotkey-${binding}`}
leftSection={<Icon icon="keyboard" />}
onBlur={() => setSelected(null)}
onChange={() => {}}
onKeyDownCapture={(e) => {
if (selected !== (binding as BindingActions))
return;
handleSetHotkey(binding as BindingActions, e);
}}
readOnly
rightSection={
<ActionIcon
icon="edit"
onClick={() => {
setSelected(binding as BindingActions);
document
.getElementById(`hotkey-${binding}`)
?.focus();
}}
variant="transparent"
/>
}
style={{
opacity:
selected === (binding as BindingActions)
? 0.8
: 1,
outline: duplicateHotkeyMap.includes(
bindings[
binding as keyof typeof BINDINGS_MAP
].hotkey!,
)
? '1px dashed red'
: undefined,
}}
value={
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey
}
/>
</Table.Td>
{isElectron() && (
<Table.Td>
<Checkbox
checked={
bindings[
binding as keyof typeof BINDINGS_MAP
].isGlobal
}
disabled={
bindings[
binding as keyof typeof BINDINGS_MAP
].hotkey === ''
}
onChange={(e) =>
handleSetGlobalHotkey(
binding as BindingActions,
e,
)
}
size="md"
style={{
opacity: bindings[
binding as keyof typeof BINDINGS_MAP
].allowGlobal
? 1
: 0,
}}
/>
</Table.Td>
)}
{bindings[binding as keyof typeof BINDINGS_MAP].hotkey && (
<Table.Td>
<ActionIcon
icon="x"
iconProps={{
color: 'error',
}}
onClick={() =>
handleClearHotkey(binding as BindingActions)
}
variant="transparent"
/>
}
style={{
opacity:
selected === (binding as BindingActions)
? 0.8
: 1,
outline: duplicateHotkeyMap.includes(
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey!,
)
? '1px dashed red'
: undefined,
}}
value={
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey
}
/>
</Table.Td>
{isElectron() && (
<Table.Td>
<Checkbox
checked={
bindings[binding as keyof typeof BINDINGS_MAP]
.isGlobal
}
disabled={
bindings[binding as keyof typeof BINDINGS_MAP]
.hotkey === ''
}
onChange={(e) =>
handleSetGlobalHotkey(
binding as BindingActions,
e,
)
}
size="md"
style={{
opacity: bindings[
binding as keyof typeof BINDINGS_MAP
].allowGlobal
? 1
: 0,
}}
/>
</Table.Td>
)}
{bindings[binding as keyof typeof BINDINGS_MAP].hotkey && (
<Table.Td>
<ActionIcon
icon="x"
iconProps={{
color: 'error',
}}
onClick={() =>
handleClearHotkey(binding as BindingActions)
}
variant="transparent"
/>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
</>
@@ -1,5 +1,8 @@
import { nanoid } from 'nanoid';
export const audiomotionanalyzerPresets = [
{
id: nanoid(),
name: 'Preset 1',
value: {
alphaBars: false,
@@ -52,6 +55,7 @@ export const audiomotionanalyzerPresets = [
},
},
{
id: nanoid(),
name: 'Preset 2',
value: {
alphaBars: false,
@@ -104,6 +108,7 @@ export const audiomotionanalyzerPresets = [
},
},
{
id: nanoid(),
name: 'Preset 3',
value: {
alphaBars: false,
@@ -158,6 +163,7 @@ export const audiomotionanalyzerPresets = [
},
},
{
id: nanoid(),
name: 'Preset 4',
value: {
alphaBars: false,
@@ -1,4 +1,5 @@
import butterchurnPresets from 'butterchurn-presets';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -390,10 +391,10 @@ const PresetSettings = () => {
const [isPasting, setIsPasting] = useState(false);
const [pasteValue, setPasteValue] = useState('');
const applyPreset = (presetName: null | string) => {
if (!presetName) return;
const applyPreset = (presetId: null | string) => {
if (!presetId) return;
const preset = visualizer.audiomotionanalyzer.presets.find((p) => p.name === presetName);
const preset = visualizer.audiomotionanalyzer.presets.find((p) => p.id === presetId);
if (!preset) return;
@@ -484,7 +485,7 @@ const PresetSettings = () => {
if (existingPreset) {
// Update existing preset
const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>
p.name === newPresetName.trim()
p.id === existingPreset.id
? {
...p,
value: getCurrentSettingsAsPresetValue(),
@@ -499,9 +500,12 @@ const PresetSettings = () => {
},
},
});
setSelectedPreset(existingPreset.id);
} else {
// Add new preset
const newPreset = {
id: nanoid(),
name: newPresetName.trim(),
value: getCurrentSettingsAsPresetValue(),
};
@@ -513,11 +517,12 @@ const PresetSettings = () => {
},
},
});
setSelectedPreset(newPreset.id);
}
setNewPresetName('');
setIsSaving(false);
setSelectedPreset(newPresetName.trim());
};
const getCurrentSettingsAsPresetValue = () => {
@@ -579,12 +584,17 @@ const PresetSettings = () => {
const handleUpdatePreset = () => {
if (!selectedPreset || !newPresetName.trim()) return;
const selectedPresetObj = visualizer.audiomotionanalyzer.presets.find(
(p) => p.id === selectedPreset,
);
if (!selectedPresetObj) return;
let trimmedName = newPresetName.trim();
const isRenaming = trimmedName !== selectedPreset;
const isRenaming = trimmedName !== selectedPresetObj.name;
if (isRenaming) {
const existingNames = visualizer.audiomotionanalyzer.presets
.filter((p) => p.name !== selectedPreset)
.filter((p) => p.id !== selectedPreset)
.map((p) => p.name);
if (existingNames.includes(trimmedName)) {
@@ -600,7 +610,7 @@ const PresetSettings = () => {
}
const updatedPresets = visualizer.audiomotionanalyzer.presets.map((p) =>
p.name === selectedPreset
p.id === selectedPreset
? {
...p,
name: trimmedName,
@@ -621,14 +631,13 @@ const PresetSettings = () => {
setNewPresetName('');
setIsRenaming(false);
setSelectedPreset(trimmedName);
};
const handleDeletePreset = () => {
if (!selectedPreset) return;
const updatedPresets = visualizer.audiomotionanalyzer.presets.filter(
(p) => p.name !== selectedPreset,
(p) => p.id !== selectedPreset,
);
setSettings({
@@ -797,7 +806,7 @@ const PresetSettings = () => {
const presetOptions = useMemo(() => {
return visualizer.audiomotionanalyzer.presets.map((preset) => ({
label: preset.name,
value: preset.name,
value: preset.id,
}));
}, [visualizer.audiomotionanalyzer.presets]);
@@ -907,8 +916,13 @@ const PresetSettings = () => {
<>
<Button
onClick={() => {
setNewPresetName(selectedPreset);
setIsRenaming(true);
const preset = visualizer.audiomotionanalyzer.presets.find(
(p) => p.id === selectedPreset,
);
if (preset) {
setNewPresetName(preset.name);
setIsRenaming(true);
}
}}
variant="default"
>
+30 -20
View File
@@ -130,7 +130,12 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
height: '400px',
}}
>
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
<Text
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
fw={400}
lh="1.5"
size="md"
/>
</ScrollArea>
<Group justify="flex-end">
<Button
@@ -151,11 +156,13 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
);
};
const WAIT_FOR_LOCAL_STORAGE = 1000 * 2;
export const ReleaseNotesModal = () => {
const { version } = packageJson;
const { t } = useTranslation();
const [value, setValue] = useLocalStorage({ key: 'version' });
const [, setValue] = useLocalStorage({ key: 'version' });
const handleDismiss = useCallback(() => {
setValue(version);
@@ -163,25 +170,28 @@ export const ReleaseNotesModal = () => {
}, [setValue, version]);
useEffect(() => {
// If value is undefined, set it to current version but don't show modal
if (value === undefined) {
setValue(version);
return;
}
const timeoutId = setTimeout(() => {
const valueFromLocalStorage = localStorage.getItem('version');
const versionString = `"${version}"`;
// Only show modal if the stored version is different from current version
if (value !== version) {
openModal({
children: <ReleaseNotesContent onDismiss={handleDismiss} version={version} />,
onClose: handleDismiss,
size: 'xl',
title: t('common.newVersion', {
postProcess: 'sentenceCase',
version,
}) as string,
});
}
}, [handleDismiss, value, version, t, setValue]);
// Only show modal if the stored version is different from current version
if (valueFromLocalStorage !== versionString) {
openModal({
children: <ReleaseNotesContent onDismiss={handleDismiss} version={version} />,
onClose: handleDismiss,
size: 'xl',
title: t('common.newVersion', {
postProcess: 'sentenceCase',
version,
}) as string,
});
}
}, WAIT_FOR_LOCAL_STORAGE);
return () => {
clearTimeout(timeoutId);
};
}, [handleDismiss, t, version]);
return null;
};
+33 -3
View File
@@ -1,5 +1,6 @@
import isElectron from 'is-electron';
import merge from 'lodash/merge';
import mergeWith from 'lodash/mergeWith';
import { nanoid } from 'nanoid';
import { generatePath } from 'react-router';
import { z } from 'zod';
import { devtools, persist } from 'zustand/middleware';
@@ -51,7 +52,16 @@ const deepMergeIntoState = <T extends Record<string, any>>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { actions, ...updatesWithoutActions } = updates as any;
merge(state, updatesWithoutActions);
// Use mergeWith to replace arrays instead of merging them by index
mergeWith(state, updatesWithoutActions, (_objValue, srcValue) => {
// If source value is an array, replace the entire array instead of merging
if (Array.isArray(srcValue)) {
return srcValue;
}
// Default merge behavior
return undefined;
});
};
const HomeItemSchema = z.enum([
@@ -331,6 +341,7 @@ const AudioMotionAnalyzerSettingsSchema = z.object({
peakLine: z.boolean(),
presets: z.array(
z.object({
id: z.string(),
name: z.string(),
value: z.any(),
}),
@@ -1928,10 +1939,29 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
}
}
if (version <= 19) {
// Add IDs to presets that don't have them
if (
state.visualizer?.audiomotionanalyzer?.presets &&
Array.isArray(state.visualizer.audiomotionanalyzer.presets)
) {
state.visualizer.audiomotionanalyzer.presets =
state.visualizer.audiomotionanalyzer.presets.map((preset) => {
if (!preset.id) {
return {
...preset,
id: nanoid(),
};
}
return preset;
});
}
}
return persistedState;
},
name: 'store_settings',
version: 19,
version: 20,
},
),
);
+67 -16
View File
@@ -37,36 +37,89 @@ const matchesFullDate = (date: string) => {
return Boolean(date.match(/^\d{4}-\d{2}-\d{2}$/));
};
const normalizeReleaseDate = (item: { date?: string; releaseDate?: string }) => {
const matchesYearOnly = (date: string) => {
return Boolean(date.match(/^\d{4}$/));
};
const normalizeReleaseDate = (item: {
date?: string;
releaseDate?: string;
}): { date: null | string; year: null | number } => {
if (item.releaseDate && matchesFullDate(item.releaseDate)) {
return item.releaseDate;
return {
date: item.releaseDate,
year: parseInt(item.releaseDate.split('-')[0]),
};
} else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {
return {
date: null,
year: parseInt(item.releaseDate),
};
}
if (item.date && matchesFullDate(item.date)) {
return item.date;
return {
date: item.date,
year: parseInt(item.date.split('-')[0]),
};
} else if (item.date && matchesYearOnly(item.date)) {
return {
date: null,
year: parseInt(item.date),
};
}
return null;
return {
date: null,
year: null,
};
};
const normalizeOriginalDate = (item: {
date?: string;
originalDate?: string;
releaseDate?: string;
}) => {
}): { date: null | string; year: null | number } => {
if (item.originalDate && matchesFullDate(item.originalDate)) {
return item.originalDate;
return {
date: item.originalDate,
year: parseInt(item.originalDate.split('-')[0]),
};
} else if (item.originalDate && matchesYearOnly(item.originalDate)) {
return {
date: null,
year: parseInt(item.originalDate),
};
}
if (item.releaseDate && matchesFullDate(item.releaseDate)) {
return item.releaseDate;
return {
date: item.releaseDate,
year: parseInt(item.releaseDate.split('-')[0]),
};
} else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {
return {
date: null,
year: parseInt(item.releaseDate),
};
}
if (item.date && matchesFullDate(item.date)) {
return item.date;
return {
date: item.date,
year: parseInt(item.date.split('-')[0]),
};
} else if (item.date && matchesYearOnly(item.date)) {
return {
date: null,
year: parseInt(item.date),
};
}
return null;
return {
date: null,
year: null,
};
};
const getArtists = (
@@ -240,7 +293,7 @@ const normalizeSong = (
: null,
playCount: item.playCount || 0,
playlistItemId,
releaseDate: normalizeReleaseDate(item),
releaseDate: normalizeReleaseDate(item).date,
releaseYear: item.year || null,
sampleRate: item.sampleRate || null,
size: item.size,
@@ -303,9 +356,7 @@ const normalizeAlbum = (
pathReplaceWith?: string,
): Album => {
const releaseDate = normalizeReleaseDate(item);
const releaseYear = releaseDate ? parseInt(releaseDate.split('-')[0]) : null;
const originalDate = normalizeOriginalDate(item);
const originalYear = originalDate ? parseInt(originalDate.split('-')[0]) : null;
return {
...parseAlbumTags(item),
@@ -341,12 +392,12 @@ const normalizeAlbum = (
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
name: item.name,
originalDate,
originalYear,
originalDate: originalDate.date,
originalYear: originalDate.year,
playCount: item.playCount || 0,
releaseDate,
releaseDate: releaseDate.date,
releaseType: item.mbzAlbumType || null,
releaseYear,
releaseYear: releaseDate.year,
size: item.size,
songCount: item.songCount,
songs: item.songs