mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(settings): clock format setting (Auto / 24h / 12h) (#742)
* feat(settings): clock format setting (Auto / 24h / 12h) Reported on the Psysonic Discord — the Queue side panel's ETA label and the sleep-timer preview both render via `formatClockTime`, which just calls `toLocaleTimeString` and so follows the user's system locale. On en-US that means AM/PM, with no in-app way out. Add a tri-state **Clock Format** setting under **Settings → System → App Behavior**: * `auto` (default) — keep the existing locale-driven behaviour, so bestehende installs are unaffected on first launch. * `24h` — force 24-hour wall-clock output everywhere `formatClockTime` is used. * `12h` — force AM/PM output. Wired through `authStore` (`clockFormat`, `setClockFormat`), exposed via `CustomSelect` in `SystemTab`, and threaded into the two consumers (`QueueHeader`, `PlaybackDelayModal`) so they re-render on change. `formatClockTime` itself stays a pure helper — it accepts the setting as an optional second argument and maps it to `hour12`. Locale coverage: all nine bundled locales (en, de, es, fr, nl, nb, ru, zh, ro) get the four new settings strings. Pin tests added for the `setClockFormat` setter and the `hour12` mapping in `formatClockTime`. * docs(changelog): clock format setting + contributors (PR #742)
This commit is contained in:
committed by
GitHub
parent
02e23b5755
commit
606a150e01
@@ -210,6 +210,12 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
|
|||||||
* Buttons live in a new **`.hero-nav`** flex wrapper (`inset: 0`, `justify-content: space-between`, **`pointer-events: none`**); the buttons themselves opt back into **`pointer-events: auto`** so the rest of the hero stays click-through to the album page. Wrap-around (last → first / first → last) and auto-advance timer restart use the same pattern as the previous dot handler.
|
* Buttons live in a new **`.hero-nav`** flex wrapper (`inset: 0`, `justify-content: space-between`, **`pointer-events: none`**); the buttons themselves opt back into **`pointer-events: auto`** so the rest of the hero stays click-through to the album page. Wrap-around (last → first / first → last) and auto-advance timer restart use the same pattern as the previous dot handler.
|
||||||
* The dot indicators are kept as **decorative spans** — no click handler, no hover state, **`pointer-events: none`** — so a missed click no longer navigates to the album.
|
* The dot indicators are kept as **decorative spans** — no click handler, no hover state, **`pointer-events: none`** — so a missed click no longer navigates to the album.
|
||||||
|
|
||||||
|
### Settings — Clock Format setting (Auto / 24h / 12h)
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), thanks to zunoz for the report on the Psysonic Discord, PR [#742](https://github.com/Psychotoxical/psysonic/pull/742)**
|
||||||
|
|
||||||
|
* The Queue side panel's ETA label and the sleep-timer preview both go through **`formatClockTime`**, which just delegates to **`toLocaleTimeString`** — on en-US that meant AM/PM with no in-app override. **Settings → System → App Behavior** now exposes a tri-state **Clock Format** select: **`Auto`** (default — keeps existing locale-driven behaviour, so first launch after the update is a no-op for everyone), **`24h`**, and **`12h`**, the explicit values forcing **`hour12`** everywhere `formatClockTime` is used. Wired through `authStore` (`clockFormat` / `setClockFormat`) and consumed by both surfaces; all nine bundled locales ship the four new strings.
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { X, Moon, Sunrise } from 'lucide-react';
|
import { X, Moon, Sunrise } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
@@ -153,9 +154,10 @@ export default function PlaybackDelayModal({ open, onClose, anchorRef }: Playbac
|
|||||||
|
|
||||||
// Live preview: seconds that would be applied right now if the user clicked.
|
// Live preview: seconds that would be applied right now if the user clicked.
|
||||||
// Priority: hovered chip → typed custom minutes → nothing.
|
// Priority: hovered chip → typed custom minutes → nothing.
|
||||||
|
const clockFormat = useAuthStore(s => s.clockFormat);
|
||||||
const previewSeconds = hoverSeconds ?? customSeconds;
|
const previewSeconds = hoverSeconds ?? customSeconds;
|
||||||
const previewAtMs = previewSeconds != null ? nowTick + previewSeconds * 1000 : null;
|
const previewAtMs = previewSeconds != null ? nowTick + previewSeconds * 1000 : null;
|
||||||
const previewClock = previewAtMs != null ? formatClockTime(previewAtMs) : null;
|
const previewClock = previewAtMs != null ? formatClockTime(previewAtMs, clockFormat) : null;
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import { ChevronDown, ListMusic } from 'lucide-react';
|
import { ChevronDown, ListMusic } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
import type { DurationMode } from '../../utils/componentHelpers/queuePanelHelpers';
|
||||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||||
@@ -24,6 +25,7 @@ export function QueueHeader({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
|
const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
|
const clockFormat = useAuthStore((s) => s.clockFormat);
|
||||||
|
|
||||||
const totalSecs = useMemo(() =>
|
const totalSecs = useMemo(() =>
|
||||||
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
|
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
|
||||||
@@ -40,7 +42,7 @@ export function QueueHeader({
|
|||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
|
if (durationMode === 'total') dur = formatLongDuration(Math.floor(totalSecs));
|
||||||
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
|
else if (durationMode === 'remaining') dur = `-${formatLongDuration(Math.floor(remainingSecs))}`;
|
||||||
else dur = formatClockTime(Date.now() + remainingSecs * 1000);
|
else dur = formatClockTime(Date.now() + remainingSecs * 1000, clockFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMode: DurationMode =
|
const nextMode: DurationMode =
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { AppWindow, ChevronDown, Download, ExternalLink, Globe, HardDrive, Info,
|
|||||||
import { version as appVersion } from '../../../package.json';
|
import { version as appVersion } from '../../../package.json';
|
||||||
import i18n from '../../i18n';
|
import i18n from '../../i18n';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import type { LoggingMode } from '../../store/authStoreTypes';
|
import type { ClockFormat, LoggingMode } from '../../store/authStoreTypes';
|
||||||
import { IS_LINUX } from '../../utils/platform';
|
import { IS_LINUX } from '../../utils/platform';
|
||||||
import { showToast } from '../../utils/ui/toast';
|
import { showToast } from '../../utils/ui/toast';
|
||||||
import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol';
|
import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol';
|
||||||
@@ -112,6 +112,24 @@ export function SystemTab() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
<div className="settings-toggle-row">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.clockFormatDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 160 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={auth.clockFormat}
|
||||||
|
onChange={(v) => auth.setClockFormat(v as ClockFormat)}
|
||||||
|
options={[
|
||||||
|
{ value: 'auto', label: t('settings.clockFormatAuto') },
|
||||||
|
{ value: '24h', label: t('settings.clockFormatTwentyFour') },
|
||||||
|
{ value: '12h', label: t('settings.clockFormatTwelve') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)',
|
'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)',
|
||||||
'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
|
'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
|
||||||
'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 9 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)',
|
'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 9 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)',
|
||||||
|
'Settings: tri-state Clock Format (Auto / 24h / 12h) overriding the locale default for the queue ETA and the sleep-timer preview (PR #742)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
|
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
|
||||||
minimizeToTray: 'Im Tray minimieren',
|
minimizeToTray: 'Im Tray minimieren',
|
||||||
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
|
||||||
|
clockFormat: 'Uhrzeitformat',
|
||||||
|
clockFormatDesc: 'Format für die Queue-ETA und die Vorschau des Sleep-Timers.',
|
||||||
|
clockFormatAuto: 'Automatisch (Systemsprache)',
|
||||||
|
clockFormatTwentyFour: '24-Stunden',
|
||||||
|
clockFormatTwelve: '12-Stunden (AM/PM)',
|
||||||
preloadMiniPlayer: 'Mini-Player vorladen',
|
preloadMiniPlayer: 'Mini-Player vorladen',
|
||||||
preloadMiniPlayerDesc: 'Baut das Mini-Player-Fenster beim App-Start im Hintergrund auf, damit es beim ersten Öffnen sofort Inhalt zeigt. Kostet etwas mehr RAM.',
|
preloadMiniPlayerDesc: 'Baut das Mini-Player-Fenster beim App-Start im Hintergrund auf, damit es beim ersten Öffnen sofort Inhalt zeigt. Kostet etwas mehr RAM.',
|
||||||
discordRichPresence: 'Discord Rich Presence',
|
discordRichPresence: 'Discord Rich Presence',
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
|
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
|
||||||
minimizeToTray: 'Minimize to Tray',
|
minimizeToTray: 'Minimize to Tray',
|
||||||
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
|
||||||
|
clockFormat: 'Clock Format',
|
||||||
|
clockFormatDesc: 'Wall-clock display used by the queue ETA and the sleep-timer preview.',
|
||||||
|
clockFormatAuto: 'Auto (system locale)',
|
||||||
|
clockFormatTwentyFour: '24-hour',
|
||||||
|
clockFormatTwelve: '12-hour (AM/PM)',
|
||||||
preloadMiniPlayer: 'Preload mini player',
|
preloadMiniPlayer: 'Preload mini player',
|
||||||
preloadMiniPlayerDesc: 'Build the mini player window in the background at app start so it shows content instantly on first open. Uses a little extra memory.',
|
preloadMiniPlayerDesc: 'Build the mini player window in the background at app start so it shows content instantly on first open. Uses a little extra memory.',
|
||||||
discordRichPresence: 'Discord Rich Presence',
|
discordRichPresence: 'Discord Rich Presence',
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.',
|
showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.',
|
||||||
minimizeToTray: 'Minimizar a Bandeja',
|
minimizeToTray: 'Minimizar a Bandeja',
|
||||||
minimizeToTrayDesc: 'Al cerrar la ventana, mantener Psysonic ejecutándose en la bandeja del sistema en lugar de salir.',
|
minimizeToTrayDesc: 'Al cerrar la ventana, mantener Psysonic ejecutándose en la bandeja del sistema en lugar de salir.',
|
||||||
|
clockFormat: 'Formato de hora',
|
||||||
|
clockFormatDesc: 'Formato del reloj utilizado por la ETA de la cola y la vista previa del temporizador de suspensión.',
|
||||||
|
clockFormatAuto: 'Automático (idioma del sistema)',
|
||||||
|
clockFormatTwentyFour: '24 horas',
|
||||||
|
clockFormatTwelve: '12 horas (AM/PM)',
|
||||||
preloadMiniPlayer: 'Precargar mini reproductor',
|
preloadMiniPlayer: 'Precargar mini reproductor',
|
||||||
preloadMiniPlayerDesc: 'Crea la ventana del mini reproductor en segundo plano al iniciar la aplicación para que muestre contenido al instante la primera vez que se abre. Consume un poco más de memoria.',
|
preloadMiniPlayerDesc: 'Crea la ventana del mini reproductor en segundo plano al iniciar la aplicación para que muestre contenido al instante la primera vez que se abre. Consume un poco más de memoria.',
|
||||||
discordRichPresence: 'Discord Rich Presence',
|
discordRichPresence: 'Discord Rich Presence',
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
|
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
|
||||||
minimizeToTray: 'Réduire dans la barre système',
|
minimizeToTray: 'Réduire dans la barre système',
|
||||||
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
|
||||||
|
clockFormat: 'Format de l\'heure',
|
||||||
|
clockFormatDesc: 'Format de l\'horloge utilisé par l\'ETA de la file d\'attente et l\'aperçu du minuteur de mise en veille.',
|
||||||
|
clockFormatAuto: 'Automatique (langue du système)',
|
||||||
|
clockFormatTwentyFour: '24 heures',
|
||||||
|
clockFormatTwelve: '12 heures (AM/PM)',
|
||||||
preloadMiniPlayer: 'Précharger le mini-lecteur',
|
preloadMiniPlayer: 'Précharger le mini-lecteur',
|
||||||
preloadMiniPlayerDesc: 'Construit la fenêtre du mini-lecteur en arrière-plan au démarrage de l\'application afin qu\'elle affiche son contenu instantanément à la première ouverture. Utilise un peu plus de mémoire.',
|
preloadMiniPlayerDesc: 'Construit la fenêtre du mini-lecteur en arrière-plan au démarrage de l\'application afin qu\'elle affiche son contenu instantanément à la première ouverture. Utilise un peu plus de mémoire.',
|
||||||
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
|
linuxWebkitSmoothScroll: 'Molette fluide (Linux)',
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ export const settings = {
|
|||||||
showOrbitTriggerDesc: 'Knappen i toppen for å starte eller bli med i en delt lytteøkt. Skjul den hvis du ikke bruker Orbit — du kan slå den på igjen her.',
|
showOrbitTriggerDesc: 'Knappen i toppen for å starte eller bli med i en delt lytteøkt. Skjul den hvis du ikke bruker Orbit — du kan slå den på igjen her.',
|
||||||
minimizeToTray: 'Minimer til oppgavelinjen',
|
minimizeToTray: 'Minimer til oppgavelinjen',
|
||||||
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
|
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
|
||||||
|
clockFormat: 'Klokkeformat',
|
||||||
|
clockFormatDesc: 'Klokkevisning brukt av kø-ETA og forhåndsvisningen av hviletimeren.',
|
||||||
|
clockFormatAuto: 'Automatisk (systemspråk)',
|
||||||
|
clockFormatTwentyFour: '24-timers',
|
||||||
|
clockFormatTwelve: '12-timers (AM/PM)',
|
||||||
preloadMiniPlayer: 'Forhåndslast miniavspiller',
|
preloadMiniPlayer: 'Forhåndslast miniavspiller',
|
||||||
preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.',
|
preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.',
|
||||||
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
|
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
|
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
|
||||||
minimizeToTray: 'Minimaliseren naar systeemvak',
|
minimizeToTray: 'Minimaliseren naar systeemvak',
|
||||||
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
|
||||||
|
clockFormat: 'Tijdformaat',
|
||||||
|
clockFormatDesc: 'Tijdsweergave voor de wachtrij-ETA en het voorbeeld van de slaaptimer.',
|
||||||
|
clockFormatAuto: 'Automatisch (systeemtaal)',
|
||||||
|
clockFormatTwentyFour: '24-uurs',
|
||||||
|
clockFormatTwelve: '12-uurs (AM/PM)',
|
||||||
preloadMiniPlayer: 'Mini-speler vooraf laden',
|
preloadMiniPlayer: 'Mini-speler vooraf laden',
|
||||||
preloadMiniPlayerDesc: 'Bouwt het venster van de mini-speler op de achtergrond bij het opstarten van de app, zodat het bij de eerste opening direct inhoud toont. Gebruikt iets meer geheugen.',
|
preloadMiniPlayerDesc: 'Bouwt het venster van de mini-speler op de achtergrond bij het opstarten van de app, zodat het bij de eerste opening direct inhoud toont. Gebruikt iets meer geheugen.',
|
||||||
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
|
linuxWebkitSmoothScroll: 'Vloeiend muiswiel (Linux)',
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Afișează iconița Psysonic în zona notificărilor de sistem / bara de meniu.',
|
showTrayIconDesc: 'Afișează iconița Psysonic în zona notificărilor de sistem / bara de meniu.',
|
||||||
minimizeToTray: 'Minimizează în Tavă',
|
minimizeToTray: 'Minimizează în Tavă',
|
||||||
minimizeToTrayDesc: 'La închiderea ferestrei, continuă rularea Psysonic în tava de sistem în loc de ieșire',
|
minimizeToTrayDesc: 'La închiderea ferestrei, continuă rularea Psysonic în tava de sistem în loc de ieșire',
|
||||||
|
clockFormat: 'Format Oră',
|
||||||
|
clockFormatDesc: 'Format al ceasului folosit pentru ETA-ul cozii și previzualizarea cronometrului de somn.',
|
||||||
|
clockFormatAuto: 'Automat (limba sistemului)',
|
||||||
|
clockFormatTwentyFour: '24 de ore',
|
||||||
|
clockFormatTwelve: '12 ore (AM/PM)',
|
||||||
preloadMiniPlayer: 'Preîncarcă mini player',
|
preloadMiniPlayer: 'Preîncarcă mini player',
|
||||||
preloadMiniPlayerDesc: 'Crează fereastra mini player în fundal la deschiderea aplicației pentru a arăta conținut instantaneu la prima deschidere. Folosește puțină extra memorie.',
|
preloadMiniPlayerDesc: 'Crează fereastra mini player în fundal la deschiderea aplicației pentru a arăta conținut instantaneu la prima deschidere. Folosește puțină extra memorie.',
|
||||||
discordRichPresence: 'Prezență Discord Rich',
|
discordRichPresence: 'Prezență Discord Rich',
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
|
||||||
minimizeToTray: 'Сворачивать в трей',
|
minimizeToTray: 'Сворачивать в трей',
|
||||||
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
|
||||||
|
clockFormat: 'Формат времени',
|
||||||
|
clockFormatDesc: 'Формат времени для ETA очереди и предпросмотра таймера сна.',
|
||||||
|
clockFormatAuto: 'Авто (системный)',
|
||||||
|
clockFormatTwentyFour: '24-часовой',
|
||||||
|
clockFormatTwelve: '12-часовой (AM/PM)',
|
||||||
preloadMiniPlayer: 'Предзагрузка мини-плеера',
|
preloadMiniPlayer: 'Предзагрузка мини-плеера',
|
||||||
preloadMiniPlayerDesc: 'Создаёт окно мини-плеера в фоне при запуске приложения, чтобы при первом открытии содержимое отображалось мгновенно. Использует немного больше памяти.',
|
preloadMiniPlayerDesc: 'Создаёт окно мини-плеера в фоне при запуске приложения, чтобы при первом открытии содержимое отображалось мгновенно. Использует немного больше памяти.',
|
||||||
useCustomTitlebar: 'Своя строка заголовка',
|
useCustomTitlebar: 'Своя строка заголовка',
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ export const settings = {
|
|||||||
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
|
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
|
||||||
minimizeToTray: '最小化到托盘',
|
minimizeToTray: '最小化到托盘',
|
||||||
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
|
||||||
|
clockFormat: '时间格式',
|
||||||
|
clockFormatDesc: '队列预计结束时间和睡眠定时器预览使用的时间格式。',
|
||||||
|
clockFormatAuto: '自动(跟随系统)',
|
||||||
|
clockFormatTwentyFour: '24 小时制',
|
||||||
|
clockFormatTwelve: '12 小时制(AM/PM)',
|
||||||
preloadMiniPlayer: '预加载迷你播放器',
|
preloadMiniPlayer: '预加载迷你播放器',
|
||||||
preloadMiniPlayerDesc: '在应用启动时于后台构建迷你播放器窗口,使其首次打开即可立即显示内容。会占用少量额外内存。',
|
preloadMiniPlayerDesc: '在应用启动时于后台构建迷你播放器窗口,使其首次打开即可立即显示内容。会占用少量额外内存。',
|
||||||
linuxWebkitSmoothScroll: '滚轮平滑(Linux)',
|
linuxWebkitSmoothScroll: '滚轮平滑(Linux)',
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe('trivial pass-through setters', () => {
|
|||||||
['setShowArtistImages', 'showArtistImages', true],
|
['setShowArtistImages', 'showArtistImages', true],
|
||||||
['setShowTrayIcon', 'showTrayIcon', false],
|
['setShowTrayIcon', 'showTrayIcon', false],
|
||||||
['setMinimizeToTray', 'minimizeToTray', true],
|
['setMinimizeToTray', 'minimizeToTray', true],
|
||||||
|
['setClockFormat', 'clockFormat', '24h'],
|
||||||
['setShowOrbitTrigger', 'showOrbitTrigger', false],
|
['setShowOrbitTrigger', 'showOrbitTrigger', false],
|
||||||
['setDiscordRichPresence', 'discordRichPresence', true],
|
['setDiscordRichPresence', 'discordRichPresence', true],
|
||||||
['setEnableBandsintown', 'enableBandsintown', true],
|
['setEnableBandsintown', 'enableBandsintown', true],
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||||
showTrayIcon: true,
|
showTrayIcon: true,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
|
clockFormat: 'auto',
|
||||||
showOrbitTrigger: true,
|
showOrbitTrigger: true,
|
||||||
discordRichPresence: false,
|
discordRichPresence: false,
|
||||||
discordCoverSource: 'server',
|
discordCoverSource: 'server',
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thic
|
|||||||
/** Queue header duration chip: total duration / time left / ETA finish clock. */
|
/** Queue header duration chip: total duration / time left / ETA finish clock. */
|
||||||
export type DurationMode = 'total' | 'remaining' | 'eta';
|
export type DurationMode = 'total' | 'remaining' | 'eta';
|
||||||
export type LoggingMode = 'off' | 'normal' | 'debug';
|
export type LoggingMode = 'off' | 'normal' | 'debug';
|
||||||
|
/**
|
||||||
|
* Wall-clock format for ETA / sleep-timer labels. `'auto'` follows the user's
|
||||||
|
* system locale (existing behaviour); explicit `'24h'` / `'12h'` overrides it.
|
||||||
|
*/
|
||||||
|
export type ClockFormat = 'auto' | '24h' | '12h';
|
||||||
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
|
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
|
||||||
export type DiscordCoverSource = 'none' | 'apple' | 'server';
|
export type DiscordCoverSource = 'none' | 'apple' | 'server';
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@ export interface AuthState {
|
|||||||
libraryGridMaxColumns: number;
|
libraryGridMaxColumns: number;
|
||||||
showTrayIcon: boolean;
|
showTrayIcon: boolean;
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
|
clockFormat: ClockFormat;
|
||||||
/** Whether the "Orbit" topbar trigger is rendered. Users who never
|
/** Whether the "Orbit" topbar trigger is rendered. Users who never
|
||||||
* touch Orbit can hide it so the header stays uncluttered. */
|
* touch Orbit can hide it so the header stays uncluttered. */
|
||||||
showOrbitTrigger: boolean;
|
showOrbitTrigger: boolean;
|
||||||
@@ -266,6 +272,7 @@ export interface AuthState {
|
|||||||
setLibraryGridMaxColumns: (v: number) => void;
|
setLibraryGridMaxColumns: (v: number) => void;
|
||||||
setShowTrayIcon: (v: boolean) => void;
|
setShowTrayIcon: (v: boolean) => void;
|
||||||
setMinimizeToTray: (v: boolean) => void;
|
setMinimizeToTray: (v: boolean) => void;
|
||||||
|
setClockFormat: (v: ClockFormat) => void;
|
||||||
setShowOrbitTrigger: (v: boolean) => void;
|
setShowOrbitTrigger: (v: boolean) => void;
|
||||||
setDiscordRichPresence: (v: boolean) => void;
|
setDiscordRichPresence: (v: boolean) => void;
|
||||||
setDiscordCoverSource: (v: DiscordCoverSource) => void;
|
setDiscordCoverSource: (v: DiscordCoverSource) => void;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
|||||||
| 'setLibraryGridMaxColumns'
|
| 'setLibraryGridMaxColumns'
|
||||||
| 'setShowTrayIcon'
|
| 'setShowTrayIcon'
|
||||||
| 'setMinimizeToTray'
|
| 'setMinimizeToTray'
|
||||||
|
| 'setClockFormat'
|
||||||
| 'setShowOrbitTrigger'
|
| 'setShowOrbitTrigger'
|
||||||
| 'setUseCustomTitlebar'
|
| 'setUseCustomTitlebar'
|
||||||
| 'setPreloadMiniPlayer'
|
| 'setPreloadMiniPlayer'
|
||||||
@@ -37,6 +38,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
|||||||
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
|
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
|
||||||
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
|
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
|
||||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||||
|
setClockFormat: (v) => set({ clockFormat: v }),
|
||||||
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
|
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
|
||||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||||
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
|
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* `formatClockTime` is a thin wrapper around `Date.toLocaleTimeString` whose
|
||||||
|
* only knob is `hour12`, mapped from the user's `ClockFormat` setting. The
|
||||||
|
* exact `HH:MM` output is locale-dependent and not asserted here — these
|
||||||
|
* tests pin only the `hour12` mapping, which is what the setting controls.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatClockTime } from './formatClockTime';
|
||||||
|
|
||||||
|
const SAMPLE_TS = Date.UTC(2026, 0, 1, 19, 17, 0); // 19:17 UTC, deterministic
|
||||||
|
|
||||||
|
describe('formatClockTime — clockFormat mapping', () => {
|
||||||
|
it('forces 24-hour output when clockFormat === "24h" (no AM/PM marker)', () => {
|
||||||
|
const out = formatClockTime(SAMPLE_TS, '24h');
|
||||||
|
expect(out).not.toMatch(/AM|PM/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces 12-hour output when clockFormat === "12h" (renders an AM/PM marker)', () => {
|
||||||
|
const out = formatClockTime(SAMPLE_TS, '12h');
|
||||||
|
expect(out).toMatch(/AM|PM/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the locale default when clockFormat === "auto"', () => {
|
||||||
|
// We do not assert AM/PM either way here — `'auto'` deliberately defers to
|
||||||
|
// the JS engine's locale. The contract is just "do not force `hour12`".
|
||||||
|
expect(() => formatClockTime(SAMPLE_TS, 'auto')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to the locale default when clockFormat is omitted', () => {
|
||||||
|
expect(() => formatClockTime(SAMPLE_TS)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
/** Localized wall-clock `HH:MM` for a timestamp (sleep-timer / queue-ETA labels). */
|
import type { ClockFormat } from '../../store/authStoreTypes';
|
||||||
export function formatClockTime(timestampMs: number): string {
|
|
||||||
return new Date(timestampMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
/**
|
||||||
|
* Localized wall-clock `HH:MM` for a timestamp (sleep-timer / queue-ETA labels).
|
||||||
|
* `clockFormat` overrides the system locale's `hour12` default — pass `'auto'`
|
||||||
|
* or omit to keep locale-driven behaviour.
|
||||||
|
*/
|
||||||
|
export function formatClockTime(timestampMs: number, clockFormat?: ClockFormat): string {
|
||||||
|
const hour12 = clockFormat === '24h' ? false : clockFormat === '12h' ? true : undefined;
|
||||||
|
return new Date(timestampMs).toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user