feat: i18n

This commit is contained in:
Senko-san
2026-06-06 15:23:07 +03:00
parent bbd59cc225
commit e45bcef3a5
21 changed files with 613 additions and 163 deletions
+12 -14
View File
@@ -1,4 +1,5 @@
import { Slider } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -17,6 +18,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player);
@@ -24,17 +26,15 @@ export function PersistentPlayer() {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry && !player.currentTrackId) {
return <div className="player empty">Nothing playing</div>;
return <div className="player empty">{t('player.nothingPlaying')}</div>;
}
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
// Streaming is the web default; local playback is a mobile-client concern.
const onStream = true;
return (
<div className="player">
{/* now-playing identity */}
<div
className="pl-now"
onClick={() => dispatch(toggleNowPlaying())}
@@ -49,19 +49,18 @@ export function PersistentPlayer() {
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
{onStream ? t('player.streaming') : t('player.local')}
</div>
</div>
</div>
{/* transport + scrubber */}
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title="Shuffle"
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
@@ -69,7 +68,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playPrev}
title="Previous"
title={t('player.previous')}
>
<Icon name="skip-back" fill />
</button>
@@ -79,7 +78,7 @@ export function PersistentPlayer() {
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? 'Pause' : 'Play'}
title={player.isPlaying ? t('player.pause') : t('player.play')}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
@@ -87,7 +86,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playNext}
title="Next"
title={t('player.next')}
>
<Icon name="skip-forward" fill />
</button>
@@ -105,7 +104,7 @@ export function PersistentPlayer() {
),
)
}
title={`Repeat: ${player.repeat}`}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
@@ -121,7 +120,7 @@ export function PersistentPlayer() {
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
aria-label="Seek"
aria-label={t('player.play')}
/>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
@@ -129,13 +128,12 @@ export function PersistentPlayer() {
</div>
</div>
{/* volume + queue */}
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
onClick={() => dispatch(toggleMute())}
title={player.muted ? 'Unmute' : 'Mute'}
title={player.muted ? t('player.unmute') : t('player.mute')}
>
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button>
@@ -154,7 +152,7 @@ export function PersistentPlayer() {
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
title="Play queue"
title={t('player.queue')}
>
<Icon name="queue" />
</button>
+19 -24
View File
@@ -1,4 +1,5 @@
import { Slider, Badge } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -10,6 +11,7 @@ import {
import { toggleQueue } from '../../store/slices/player';
export function QueuePanel() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
@@ -27,13 +29,13 @@ export function QueuePanel() {
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>Play queue</h3>
<h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title="Clear queue"
title={t('queue.clear')}
>
<Icon name="trash" />
</button>
@@ -41,7 +43,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title="Close"
title={t('queue.close')}
>
<Icon name="x" />
</button>
@@ -53,10 +55,10 @@ export function QueuePanel() {
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel}
{t('queue.radio', { source: sourceLabel })}
</span>
) : (
<span>From {sourceLabel}</span>
<span>{t('queue.from', { source: sourceLabel })}</span>
)}
</div>
</div>
@@ -68,7 +70,7 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
Now playing
{t('queue.nowPlaying')}
</span>
<div className="qd-now">
<ArtTile
@@ -87,21 +89,14 @@ export function QueuePanel() {
<div className="qd-radio">
<div className="row">
<Icon name="radio" />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
Radio active
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
{t('queue.radioActive')}
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge>
<Badge variant="neutral">{t('queue.mixing')}</Badge>
</div>
{/* exploration balance — stub under the future ML contract */}
<div className="expl">
<span className="lab">Familiar</span>
<span className="lab">{t('queue.familiar')}</span>
<Slider
className="expl-slider"
min={0}
@@ -110,7 +105,7 @@ export function QueuePanel() {
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">New</span>
<span className="lab">{t('queue.new')}</span>
</div>
</div>
)}
@@ -119,17 +114,17 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
Next up
{t('queue.nextUp')}
</span>
{upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div>
<div className="qd-empty">{t('queue.nothingNext')}</div>
) : (
upNext.map(({ entry, index }) => (
<div
key={`${entry.trackId}-${index}`}
className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play"
title={t('queue.doubleClickPlay')}
>
<span className="grip">
<Icon name="dots-six-vertical" />
@@ -147,7 +142,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue"
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
@@ -156,11 +151,11 @@ export function QueuePanel() {
)}
{isRadio && (
<div className="qd-loadmore">Loading more from radio</div>
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
)}
</>
) : (
<div className="qd-empty">Queue is empty</div>
<div className="qd-empty">{t('queue.empty')}</div>
)}
</div>
</div>