1 Commits

Author SHA1 Message Date
Цвылев Александр Вадимович 57044dabba feat(settings): wire Playback + Scrobbling panels to the backend
Adds src/api/endpoints/settings.ts (getSettings/updateSettings,
getScrobbling/updateScrobbling) plus AppSettings/ScrobblingConfig types
and a Settings cache tag. Replaces the "coming soon" scaffolds:

- Playback: stream-quality preference (SegmentedControl), server-synced.
- Scrobbling: enable + provider (Last.fm/ListenBrainz) + username +
  write-only session key, with a linked/not-linked badge and save states.

Built with modern-sk (Window/SegmentedControl/TextField/Button/Callout/
Badge) + the shared Loading/Error states; en + ru strings added. Server
state flows through RTK Query hooks only (no fetch in components).

Also drops a pre-existing unused TextField import in LibraryPage that was
failing `tsc --noEmit`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:16:19 +03:00
8 changed files with 419 additions and 11 deletions
+90
View File
@@ -0,0 +1,90 @@
import { api } from '../index';
import type {
AppSettings,
ScrobbleProvider,
ScrobblingConfig,
StreamQuality,
ThemePref,
} from '../types';
// Wire shapes (snake_case) from the backend, mapped to the app's camelCase types.
interface RawSettings {
theme: ThemePref;
stream_quality: StreamQuality;
}
interface RawScrobbling {
enabled: boolean;
provider: ScrobbleProvider | null;
username: string | null;
configured: boolean;
}
const toSettings = (r: RawSettings): AppSettings => ({
theme: r.theme,
streamQuality: r.stream_quality,
});
const toScrobbling = (r: RawScrobbling): ScrobblingConfig => ({
enabled: r.enabled,
provider: r.provider,
username: r.username,
configured: r.configured,
});
export const settingsApi = api.injectEndpoints({
endpoints: (build) => ({
getSettings: build.query<AppSettings, void>({
query: () => '/settings',
transformResponse: (raw: RawSettings) => toSettings(raw),
providesTags: ['Settings'],
}),
updateSettings: build.mutation<
AppSettings,
{ theme?: ThemePref; streamQuality?: StreamQuality }
>({
query: ({ theme, streamQuality }) => ({
url: '/settings',
method: 'PATCH',
body: { theme, stream_quality: streamQuality },
}),
transformResponse: (raw: RawSettings) => toSettings(raw),
invalidatesTags: ['Settings'],
}),
getScrobbling: build.query<ScrobblingConfig, void>({
query: () => '/settings/scrobbling',
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
providesTags: ['Settings'],
}),
updateScrobbling: build.mutation<
ScrobblingConfig,
{
enabled: boolean;
provider: ScrobbleProvider | null;
username: string | null;
// Write-only: send only when the user typed a new key; omit to keep the
// stored one (the server never returns it).
sessionKey?: string;
}
>({
query: ({ enabled, provider, username, sessionKey }) => ({
url: '/settings/scrobbling',
method: 'PUT',
body: {
enabled,
provider,
username,
session_key: sessionKey,
},
}),
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
invalidatesTags: ['Settings'],
}),
}),
overrideExisting: false,
});
export const {
useGetSettingsQuery,
useUpdateSettingsMutation,
useGetScrobblingQuery,
useUpdateScrobblingMutation,
} = settingsApi;
+1
View File
@@ -23,6 +23,7 @@ export const api = createApi({
'Like', 'Like',
'User', 'User',
'Storage', 'Storage',
'Settings',
], ],
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at // Tier 2 offline: seed the cache from the persisted snapshot dispatched at
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations // startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
+21
View File
@@ -258,3 +258,24 @@ export interface MetadataEdit {
genre?: string; genre?: string;
trackNumber?: number; trackNumber?: number;
} }
// -- user settings (`/settings`) ---------------------------------------------
export type ThemePref = 'system' | 'light' | 'dark';
export type StreamQuality = 'original' | 'high' | 'medium' | 'low';
/** General per-user preferences from `GET /settings`. */
export interface AppSettings {
theme: ThemePref;
streamQuality: StreamQuality;
}
export type ScrobbleProvider = 'lastfm' | 'listenbrainz';
/** Scrobbling config from `GET /settings/scrobbling`. The session key is
* write-only server-side, so it never appears here — only `configured`. */
export interface ScrobblingConfig {
enabled: boolean;
provider: ScrobbleProvider | null;
username: string | null;
configured: boolean;
}
-1
View File
@@ -7,7 +7,6 @@ import {
TabsContent, TabsContent,
ScrollArea, ScrollArea,
Card, Card,
TextField,
Callout, Callout,
SearchField, SearchField,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
+236 -10
View File
@@ -1,7 +1,27 @@
import type { ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk'; import {
Window,
SegmentedControl,
TextField,
Button,
Callout,
Badge,
useTheme,
} from '@olly/modern-sk';
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n'; import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
import {
useGetSettingsQuery,
useUpdateSettingsMutation,
useGetScrobblingQuery,
useUpdateScrobblingMutation,
} from '../../api/endpoints/settings';
import type {
ScrobbleProvider,
StreamQuality,
} from '../../api/types';
/** Labelled settings row: caption on the left, control on the right. */ /** Labelled settings row: caption on the left, control on the right. */
function SettingRow({ function SettingRow({
@@ -27,6 +47,14 @@ function SettingRow({
); );
} }
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
/** `/settings/profile` — profile + app language + theme (all wired). */ /** `/settings/profile` — profile + app language + theme (all wired). */
export function ProfileSettings() { export function ProfileSettings() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -66,26 +94,224 @@ export function ProfileSettings() {
); );
} }
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */ const STREAM_QUALITIES: StreamQuality[] = ['original', 'high', 'medium', 'low'];
/** `/settings/playback` — default stream quality (server-synced). */
export function PlaybackSettings() { export function PlaybackSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetSettingsQuery();
const [updateSettings, { isLoading: isSaving }] = useUpdateSettingsMutation();
return ( return (
<Window title={t('settings.tabs.playback')}> <Window title={t('settings.tabs.playback')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}> {isLoading && <LoadingSkeleton rows={1} height={40} />}
{t('common.comingSoon')} {isError && (
</p> <ErrorState message={t('common.error')} onRetry={() => refetch()} />
)}
{data && (
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<SettingRow label={t('settings.playback.streamQuality')}>
<SegmentedControl
value={data.streamQuality}
onValueChange={(v) =>
void updateSettings({ streamQuality: v as StreamQuality })
}
items={STREAM_QUALITIES.map((q) => ({
value: q,
label: t(`settings.playback.quality.${q}`),
}))}
/>
</SettingRow>
<p
style={{
margin: 0,
fontSize: '0.8rem',
color: 'var(--color-text-3)',
}}
>
{isSaving
? t('settings.common.saving')
: t('settings.playback.streamQualityHint')}
</p>
</div>
)}
</Window> </Window>
); );
} }
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */ const PROVIDERS: ScrobbleProvider[] = ['lastfm', 'listenbrainz'];
/** `/settings/scrobbling` — Last.fm / ListenBrainz linking (server-synced). */
export function ScrobblingSettings() { export function ScrobblingSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetScrobblingQuery();
const [updateScrobbling, { isLoading: isSaving }] =
useUpdateScrobblingMutation();
const [enabled, setEnabled] = useState(false);
const [provider, setProvider] = useState<ScrobbleProvider>('lastfm');
const [username, setUsername] = useState('');
const [sessionKey, setSessionKey] = useState('');
const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
// Seed the form from the server once it loads (and on refetch).
useEffect(() => {
if (data) {
setEnabled(data.enabled);
setProvider(data.provider ?? 'lastfm');
setUsername(data.username ?? '');
}
}, [data]);
const handleSave = async () => {
setStatus('idle');
try {
await updateScrobbling({
enabled,
provider: enabled ? provider : null,
username: username.trim() || null,
sessionKey: sessionKey || undefined,
}).unwrap();
setSessionKey(''); // the key is write-only — don't keep it around
setStatus('saved');
} catch {
setStatus('error');
}
};
return ( return (
<Window title={t('settings.tabs.scrobbling')}> <Window title={t('settings.tabs.scrobbling')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}> {isLoading && <LoadingSkeleton rows={3} height={40} />}
{t('common.comingSoon')} {isError && (
</p> <ErrorState message={t('common.error')} onRetry={() => refetch()} />
)}
{data && (
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<p
style={{
margin: 0,
fontSize: '0.875rem',
color: 'var(--color-text-2)',
}}
>
{t('settings.scrobbling.description')}
</p>
<Badge variant={data.configured ? 'lime' : 'neutral'}>
{data.configured
? t('settings.scrobbling.configured')
: t('settings.scrobbling.notConfigured')}
</Badge>
</div>
<SettingRow label={t('settings.scrobbling.enabled')}>
<SegmentedControl
value={enabled ? 'on' : 'off'}
onValueChange={(v) => setEnabled(v === 'on')}
items={[
{ value: 'on', label: t('settings.scrobbling.on') },
{ value: 'off', label: t('settings.scrobbling.off') },
]}
/>
</SettingRow>
{enabled && (
<>
<SettingRow label={t('settings.scrobbling.provider')}>
<SegmentedControl
value={provider}
onValueChange={(v) => setProvider(v as ScrobbleProvider)}
items={PROVIDERS.map((p) => ({
value: p,
label: t(`settings.scrobbling.${p}`),
}))}
/>
</SettingRow>
<div>
<label style={labelStyle}>
{t('settings.scrobbling.username')}
</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('settings.scrobbling.usernamePlaceholder')}
autoComplete="off"
/>
</div>
<div>
<label style={labelStyle}>
{t('settings.scrobbling.sessionKey')}
</label>
<TextField
type="password"
value={sessionKey}
onChange={(e) => setSessionKey(e.target.value)}
placeholder={
data.configured
? t('settings.scrobbling.sessionKeyKeep')
: t('settings.scrobbling.sessionKeyPlaceholder')
}
autoComplete="off"
/>
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('settings.scrobbling.sessionKeyHint')}
</span>
</div>
</>
)}
{status === 'saved' && (
<Callout variant="success">
{t('settings.scrobbling.saved')}
</Callout>
)}
{status === 'error' && (
<Callout variant="danger">{t('settings.scrobbling.error')}</Callout>
)}
<div>
<Button
variant="primary"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving
? t('settings.common.saving')
: t('settings.common.save')}
</Button>
</div>
</div>
)}
</Window> </Window>
); );
} }
+35
View File
@@ -357,6 +357,41 @@ const en = {
scrobbling: 'Scrobbling', scrobbling: 'Scrobbling',
instance: 'Instance', instance: 'Instance',
}, },
common: {
save: 'Save',
saving: 'Saving…',
},
playback: {
streamQuality: 'Streaming quality',
streamQualityHint:
'Audio quality when streaming from the server. "Original" streams the file as-is.',
quality: {
original: 'Original',
high: 'High',
medium: 'Medium',
low: 'Low',
},
},
scrobbling: {
description: 'Send your listens to Last.fm or ListenBrainz.',
configured: 'Linked',
notConfigured: 'Not linked',
enabled: 'Scrobbling',
on: 'On',
off: 'Off',
provider: 'Service',
lastfm: 'Last.fm',
listenbrainz: 'ListenBrainz',
username: 'Username',
usernamePlaceholder: 'your account name',
sessionKey: 'Session key / token',
sessionKeyPlaceholder: 'paste your token',
sessionKeyKeep: 'leave blank to keep the current token',
sessionKeyHint:
'Stored encrypted on the server and never shown again.',
saved: 'Scrobbling settings saved.',
error: 'Could not save. Please try again.',
},
}, },
admin: { admin: {
userDetail: 'User', userDetail: 'User',
+35
View File
@@ -358,6 +358,41 @@ const ru: Translations = {
scrobbling: 'Скробблинг', scrobbling: 'Скробблинг',
instance: 'Сервер', instance: 'Сервер',
}, },
common: {
save: 'Сохранить',
saving: 'Сохранение…',
},
playback: {
streamQuality: 'Качество потока',
streamQualityHint:
'Качество звука при стриминге с сервера. «Оригинал» отдаёт файл как есть.',
quality: {
original: 'Оригинал',
high: 'Высокое',
medium: 'Среднее',
low: 'Низкое',
},
},
scrobbling: {
description: 'Отправляйте свои прослушивания в Last.fm или ListenBrainz.',
configured: 'Подключено',
notConfigured: 'Не подключено',
enabled: 'Скробблинг',
on: 'Вкл',
off: 'Выкл',
provider: 'Сервис',
lastfm: 'Last.fm',
listenbrainz: 'ListenBrainz',
username: 'Имя пользователя',
usernamePlaceholder: 'ваш логин',
sessionKey: 'Ключ сессии / токен',
sessionKeyPlaceholder: 'вставьте токен',
sessionKeyKeep: 'оставьте пустым, чтобы сохранить текущий токен',
sessionKeyHint:
'Хранится на сервере в зашифрованном виде и больше не показывается.',
saved: 'Настройки скробблинга сохранены.',
error: 'Не удалось сохранить. Попробуйте ещё раз.',
},
}, },
admin: { admin: {
userDetail: 'Пользователь', userDetail: 'Пользователь',
+1
View File
@@ -21,6 +21,7 @@ import './api/endpoints/search';
import './api/endpoints/likes'; import './api/endpoints/likes';
import './api/endpoints/storage'; import './api/endpoints/storage';
import './api/endpoints/admin'; import './api/endpoints/admin';
import './api/endpoints/settings';
import './api/endpoints/upload'; import './api/endpoints/upload';
// Tier 3 offline: register the audio-caching service worker (no-op if the // Tier 3 offline: register the audio-caching service worker (no-op if the