diff --git a/CLAUDE.md b/CLAUDE.md index 9e881de..8a9e960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,8 +123,8 @@ Rust/Tauri 2. Entry point: `src-tauri/src/main.rs` → `lib.rs`. - **Smart crossfade (album-aware)**: A `SharedPlayback::smart_crossfade_enabled` toggle (default OFF, opt-in, `profile_setting['audio.smart_crossfade']`) suppresses the fade between two tracks of the same album so concept records / live sets aren't smeared by the equal-power mix. Implementation is one atomic + one DB round trip in the analytics `PrefetchNext` handler: it looks up `current.album_id` + `next.album_id`, writes the bool to `SharedPlayback::pending_next_same_album` right before dispatching `SetNextTrack`. The decoder checks both atomics at mix-decision time; when smart-skip applies, it bypasses the mix branch and falls through to the existing gapless EOF swap. The hint is naturally one-shot — each new prefetch overwrites it, and manual `LoadAndPlay` paths never consult it. - **Dynamic crossfade (tempo-aware)**: A `SharedPlayback::dynamic_crossfade_enabled` toggle (default OFF, opt-in, `profile_setting['audio.dynamic_crossfade']`) scales each fade by the BPM gap between two tracks. Analytics `PrefetchNext` reads `track_analysis.bpm` for current + next; when both are known, it picks a factor (≤8 BPM → 100%, ≤20 → 75%, ≤40 → 50%, else 30%), applies a 1500 ms floor (clamped against the base) and writes the result to `SharedPlayback::pending_next_crossfade_ms` right before `SetNextTrack`. The decoder reads the override as the effective `cf_ms` when non-zero and clears it the instant the mix actually starts. Either BPM unknown → no override, decoder falls back to the static `crossfade_ms`. Smart crossfade still wins for same-album hand-offs. - **Spectrum visualizer (FFT)**: [`audio/spectrum.rs`](src-tauri/src/audio/spectrum.rs) lives on the decoder thread, fed with post-EQ samples right before they hit the SPSC ring (cpal callback is never touched). 2048-pt windowed real FFT via `realfft`, 50% overlap, 48 log-spaced bands from 30 Hz to 16 kHz, throttled to ~30 Hz. Gated behind `SharedPlayback::visualizer_enabled` (atomic `bool`) so when the toggle is off, `SpectrumAnalyzer::feed` short-circuits at the first atomic load — zero allocations, zero FFT cost. Frames are emitted as `player:spectrum` Tauri events (`Vec` in 0..1, peaks may briefly overshoot). Frontend [`SpectrumVisualizer`](src/components/player/SpectrumVisualizer.tsx) is a Canvas with `requestAnimationFrame` decay (jump up fast, fall slow) so the visual feels fluid even though the source cadence is sub-screen. Persisted in `profile_setting['ui.visualizer']`, default OFF; mounted inside [`FullscreenNowPlaying`](src/components/player/FullscreenNowPlaying.tsx) above the progress bar. -- **Playback speed (0.5×–2×)**: resampler-shift — the decoder feeds rubato a fake source rate of `actual_rate * speed` so each cpal output sample represents `speed` source samples of audio. Pitch follows speed (no time-stretching). State on `SharedPlayback`: `playback_speed_bits` (`AtomicU32` f32 bits, clamped) + `speed_dirty` (one-shot flag). The decoder polls dirty once per `'pkt` loop and calls `ActiveStream::rebuild_resampler` on the primary + crossfade prefetched secondary, then clears the local already-resampled buffers and runs `drain_silent` so the audible transition is < 20 ms. `set_playback_speed` rebases `samples_played`/`base_offset_ms` against the position **at the old speed** before flipping the atomic, so the progress bar stays continuous across the change. Both `current_position_ms` and `session_listened_ms` multiply by speed, so analytics credit fires on track-time covered, not wall-clock — listening to a 6 min track at 2× counts as 6 min for the heatmap. Persisted in `profile_setting['audio.playback_speed']`. UI is a compact text pill [`SpeedControl`](src/components/player/SpeedControl.tsx) next to the volume — same footprint as the `80%` label. -- **Player-bar overflow menu**: [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) is the "⋯" popover that absorbs Fullscreen + Mini-player so the right side of the bar stops growing every time we ship a feature. Primary slot stays: Lyrics, Queue, Device picker, Speed pill, Volume. Opt-in (visibility-toggled): A-B loop, Sleep timer. When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage / feedback warrants it. +- **Playback speed (0.5×–2×)**: resampler-shift — the decoder feeds rubato a fake source rate of `actual_rate * speed` so each cpal output sample represents `speed` source samples of audio. Pitch follows speed (no time-stretching). State on `SharedPlayback`: `playback_speed_bits` (`AtomicU32` f32 bits, clamped) + `speed_dirty` (one-shot flag). The decoder polls dirty once per `'pkt` loop and calls `ActiveStream::rebuild_resampler` on the primary + crossfade prefetched secondary, then clears the local already-resampled buffers and runs `drain_silent` so the audible transition is < 20 ms. `set_playback_speed` rebases `samples_played`/`base_offset_ms` against the position **at the old speed** before flipping the atomic, so the progress bar stays continuous across the change. Both `current_position_ms` and `session_listened_ms` multiply by speed, so analytics credit fires on track-time covered, not wall-clock — listening to a 6 min track at 2× counts as 6 min for the heatmap. Persisted in `profile_setting['audio.playback_speed']`. UI lives inside the player-bar overflow ("⋯") menu (range slider + 5 presets); the "⋯" trigger surfaces a `1.25×` emerald badge when speed ≠ 1× so the user keeps live feedback without opening the menu. +- **Player-bar overflow menu**: [`MoreActionsMenu`](src/components/player/MoreActionsMenu.tsx) is the "⋯" popover that hosts every secondary player action so the right side of the bar stops growing every time we ship a feature. Default contents: playback speed (range slider + 5 presets), A-B loop (a labelled row wrapping the standalone [`AbLoopButton`](src/components/player/AbLoopButton.tsx)) and the full sleep-timer panel (presets grid + end-of-track + custom-minutes form — duplicated inline rather than mounting a nested popover, since a popover-in-popover breaks outside-click detection). Primary slot: Lyrics, Queue, Device picker, "⋯", Volume + the Spotify-style right cluster (Mini-player, Fullscreen). Users can **pin** Sleep timer / A-B loop to the bar from Settings (`profile_setting['ui.show_sleep_timer' | 'ui.show_ab_loop']`, default OFF) — when pinned, the entry is omitted from the overflow menu so it isn't duplicated. The "⋯" trigger surfaces a single badge that prioritises the sleep-timer countdown when armed, otherwise the current playback speed if ≠ 1× — so the user keeps live feedback without opening the menu. The trigger auto-hides only when nothing inside would render (Spotify mode + both pinnable entries pinned). When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage / feedback warrants it, and add a pin toggle if both modes make sense. - **Auto-backup**: [`backup.rs`](src-tauri/src/backup.rs) is a single tokio task spawned at boot that reads `app_setting['backup.enabled' | 'backup.interval_days' | 'backup.folder' | 'backup.retention' | 'backup.last_run_at']` and either parks on a `tokio::sync::Notify` (disabled) or sleeps until `last_run_at + interval_days * 86_400_000`. On wakeup, [`run_one_backup`](src-tauri/src/backup.rs) iterates every row in `profile`, calls the shared `profile_io::write_archive` (pub-crate-ified to keep auto + manual exports bit-compatible) into `-.waveflow`, then prunes per-profile archives beyond `retention` by mtime. Per-profile errors are logged but don't abort the pass. `set_backup_config` signals the same `Notify` so the loop reconfigures within seconds, not at the next deadline. Default folder is `/waveflow/backups/`. UI: [`BackupCard`](src/components/views/settings/BackupCard.tsx) in Settings → Stockage right after the manual export/import block. - **Stats JSON export**: [`commands/stats.rs::export_stats_json(range, target_path)`](src-tauri/src/commands/stats.rs) bundles overview + top 100 tracks / artists / albums + listening-by-day + listening-by-hour into a versioned (`schema_version: 1`) pretty-printed JSON file. Writes the file from Rust via `spawn_blocking` so we don't depend on `tauri-plugin-fs` just to round-trip a string between frontend and disk. UI: Download button next to the range selector in StatisticsView. - **WaveFlow Wrapped (year-in-review)**: [`commands/wrapped.rs::get_wrapped(year)`](src-tauri/src/commands/wrapped.rs) bundles every aggregate the Spotify-Wrapped clone needs into a single payload — overview (plays / minutes / unique tracks / artists / albums), top 10 tracks + artists + top 5 albums (sharing the existing stats row shapes so the frontend's artwork resolver works unchanged), per-month + per-hour histograms, most active day, mood profile (listening-weighted average BPM + LUFS, with a 5-tier energy label derived from BPM buckets), first listen of the year, and longest consecutive-day listening streak. Year bounds are computed in **local time** (Jan 1 00:00 → Dec 31 23:59:59, exclusive upper) so a play at 11:59 PM on Dec 31 lands in the right year regardless of UTC offset. `available_wrapped_years` enumerates every year that has at least one `play_event` row, sorted DESC, so the year picker doesn't list empty years. Frontend: [`WrappedView`](src/components/views/WrappedView.tsx) is a story-style overlay (`fixed inset-0 z-100`, similar pattern to `FullscreenNowPlaying`) with 10–12 auto-advancing slides (intro → minutes → top tracks → top artists → top albums → most active day → mood → listening clock → streak → first listen → months → outro). Slides without data are filtered out of the rotation (no analysed track → mood slide skipped; no streak ≥ 2 days → streak slide skipped) so the experience tightens to what the profile actually has. Top-of-screen progress segments + space-to-pause + arrow-key navigation match Instagram / Snapchat story conventions. Entry point: a banner on [`HomeView`](src/components/views/HomeView.tsx) that only renders when `available_wrapped_years` returns ≥ 1 year, gradient-styled to match the overlay's accent so the transition feels continuous. **Shareable PNG**: the overlay's top-bar Share button opens a Save / Copy menu. [`lib/wrappedCard.ts`](src/lib/wrappedCard.ts) builds a 1080×1920 portrait PNG on an offscreen Canvas (radial-gradient backdrop sampled from the year accent, big year + minutes number, top 5 tracks with cover thumbnails, mood + streak strip), then the Save path ships the bytes to [`save_share_image`](src-tauri/src/commands/share.rs) which writes via `spawn_blocking`, while Copy uses `navigator.clipboard.write` with `ClipboardItem("image/png")`. No font file in the bundle — text uses the WebView's native stack. diff --git a/docs/features/playback.md b/docs/features/playback.md index 5cb3904..4999599 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -95,7 +95,7 @@ Both `current_position_ms()` and `session_listened_ms()` multiply the wall-clock ### UI -[`SpeedControl`](../../src/components/player/SpeedControl.tsx) is a compact text pill (`1.0×` / `1.25×`) next to the volume slider — same footprint as the volume's percentage label. Click opens a popover with a range slider (step 0.05) and five preset buttons (0.75 / 1 / 1.25 / 1.5 / 2). Emerald accent when speed ≠ 1×. Hidden in Spotify mode (the Web Playback SDK has no speed control). No new icon — the text is the affordance. +Speed lives inside the player-bar overflow ("⋯") menu — range slider (step 0.05) + five preset buttons (0.75 / 1 / 1.25 / 1.5 / 2) — rather than a dedicated pill, since most users never touch it. When speed ≠ 1×, the "⋯" trigger surfaces a compact `1.25×` badge in emerald so the user keeps a live indicator without opening the menu. Hidden entirely in Spotify mode (the Web Playback SDK has no speed control). ## A-B repeat @@ -103,7 +103,7 @@ Musicolet-style intra-track loop. Two `AtomicU64` endpoints on `SharedPlayback` Three commands cover the lifecycle: `player_set_ab_loop` (set one or both endpoints), `player_clear_ab_loop`, `player_get_ab_loop`. Each one emits `player:ab-loop` so the UI button + ProgressBar markers stay in sync across views without polling. -UI is a tri-state click cycle in [`AbLoopButton`](../../src/components/player/AbLoopButton.tsx) — idle → A captured (amber) → A+B armed (emerald) → clear — with an "A" / "AB" badge over the icon. The PlayerBar's [`ProgressBar`](../../src/components/player/ProgressBar.tsx) renders the endpoints as coloured pin markers (amber A, rose B) with a tinted region between them so the loop is legible at a glance. Hidden by default — enable from Settings → Lecture → "Afficher la boucle A-B" (`profile_setting['ui.show_ab_loop']`). +UI is a tri-state click cycle in [`AbLoopButton`](../../src/components/player/AbLoopButton.tsx) — idle → A captured (amber) → A+B armed (emerald) → clear — with an "A" / "AB" badge over the icon. The PlayerBar's [`ProgressBar`](../../src/components/player/ProgressBar.tsx) renders the endpoints as coloured pin markers (amber A, rose B) with a tinted region between them so the loop is legible at a glance. By default the button lives in the player-bar overflow ("⋯") menu wrapped as a labelled row; pinning it to a primary slot is a one-click toggle in Settings → Lecture (`profile_setting['ui.show_ab_loop']`). ## Queue diff --git a/docs/features/ui.md b/docs/features/ui.md index 634fdd8..a8b29eb 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -104,26 +104,26 @@ Track tables themselves are **borderless** — no `rounded-2xl border bg-white` Right side of [`PlayerBar`](../../src/components/player/PlayerBar.tsx) is the highest-pressure real estate in the UI — every new feature wants an icon there. To keep the bar from running out of width on narrow windows, controls cluster by frequency: -| Tier | Controls | Where | -| ------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------- | -| **Primary** | Lyrics, Queue, Device picker, Speed pill, Volume | Always visible | -| **Overflow** | Fullscreen, Mini-player | [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) — "⋯" popover | -| **Opt-in** | A-B loop, Sleep timer | Visibility-toggle pattern (see below) | +| Tier | Controls | Where | +| ------------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **Primary** | Lyrics, Queue, Device picker, "⋯", Volume, **Mini-player**, **Fullscreen** | Always visible. Spotify-style right cluster (mini-player + fullscreen) sits after volume | +| **Overflow** | Playback speed (slider + presets), A-B loop, Sleep timer (panel) | [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) — "⋯" popover; the trigger itself is hidden when nothing inside is left | +| **Pinnable** | A-B loop, Sleep timer (promote to primary) | Toggle in Settings → Lecture (see below) | -When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage data or user feedback warrants it. +When adding a new player-bar action: default it into the overflow menu first — promote to primary only when usage data or user feedback warrants it. If both placements make sense, expose a pin toggle. The "⋯" trigger auto-hides when its menu would be empty. -The **Speed pill** ([`SpeedControl`](../../src/components/player/SpeedControl.tsx)) is intentionally text-only (`1.0×` / `1.25×`) instead of an icon — it doubles as the live value display, so the user sees the current speed without opening the popover. Same footprint as the volume's `80%` label. See [playback / Playback speed](playback.md#playback-speed-05--2) for the backend side. +**Playback speed** lives inside [`MoreActionsMenu`](../../src/components/player/MoreActionsMenu.tsx) (range slider + five presets) rather than a dedicated bar button — it's used too rarely to deserve a permanent slot. When speed ≠ 1×, the "⋯" trigger surfaces a compact `1.25×` badge in emerald (same corner as the sleep-timer countdown — the countdown wins when both are active). See [playback / Playback speed](playback.md#playback-speed-05--2) for the backend side. -### Visibility toggles +### Pin toggles -A handful of niche playback features live behind a per-profile visibility toggle so the player bar stays uncluttered for typical users. Both default to **off** and opt in from Settings → Lecture: +A-B loop and Sleep timer are **always available** — they live in the "⋯" overflow menu by default. The pin toggles let frequent users promote them to a primary slot on the bar so they're one click away. Both default to **off**: -| Setting key | Button | Default | -| --------------------- | ---------------------------- | ------- | -| `ui.show_sleep_timer` | Moon icon (sleep timer menu) | off | -| `ui.show_ab_loop` | Repeat icon (A-B loop) | off | +| Setting key | Pinned button rendered in primary slot | Default | +| --------------------- | -------------------------------------- | ------- | +| `ui.show_sleep_timer` | Moon icon (sleep timer menu) | off | +| `ui.show_ab_loop` | Repeat icon (A-B loop) | off | -The PlayerBar listens to `waveflow:sleep-timer-visibility` / `waveflow:ab-loop-visibility` window events dispatched by the Settings toggle so the icons appear / disappear without a polling loop. +When a pin is OFF, the entry stays in the overflow menu and the sleep-timer countdown badge surfaces on the "⋯" trigger itself so the user keeps live feedback while the timer is armed. The PlayerBar listens to `waveflow:sleep-timer-visibility` / `waveflow:ab-loop-visibility` window events dispatched by the Settings toggle so the layout re-renders without a polling loop. ## Keyboard shortcuts diff --git a/src/components/player/MoreActionsMenu.tsx b/src/components/player/MoreActionsMenu.tsx index e3560c5..d64a722 100644 --- a/src/components/player/MoreActionsMenu.tsx +++ b/src/components/player/MoreActionsMenu.tsx @@ -1,29 +1,52 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Maximize2, MoreHorizontal, PictureInPicture2 } from "lucide-react"; +import { MoreHorizontal, Moon, X } from "lucide-react"; + +import type { SleepTimerStatus } from "../../hooks/useSleepTimer"; +import { usePlayer } from "../../hooks/usePlayer"; +import { AbLoopButton } from "./AbLoopButton"; + +const SLEEP_PRESETS_MIN = [5, 15, 30, 45, 60, 90]; +const SPEED_PRESETS = [0.75, 1.0, 1.25, 1.5, 2.0]; +const SPEED_MIN = 0.5; +const SPEED_MAX = 2.0; interface MoreActionsMenuProps { - /** When `false`, the mini-player entry is hidden — used in Spotify - * mode where the WebPlayer SDK can't drive a second webview. */ - miniPlayerAvailable: boolean; - onOpenFullscreen: () => void; - onOpenMiniPlayer: () => void; + /** When `true`, A-B loop is pinned as a primary button in the bar + * and the overflow menu doesn't duplicate it. */ + pinAbLoop: boolean; + /** When `true`, sleep timer is pinned as a primary button in the + * bar and the overflow menu doesn't duplicate it. */ + pinSleepTimer: boolean; + /** When `true`, render the playback-speed section inside the menu. + * Hidden in Spotify mode (Web Playback SDK has no speed control). */ + showSpeed: boolean; + sleepTimer: { + status: SleepTimerStatus; + onSetDuration: (minutes: number) => void; + onSetEndOfTrack: () => void; + onCancel: () => void; + }; } /** - * Overflow popover that absorbs the player bar's secondary actions - * (Fullscreen, Mini-player). Trigger is a single "⋯" icon so the - * crowded bottom-right cluster doesn't keep growing each time we - * add a feature. Lyrics / Queue / Device / Volume stay first-class - * because they're the most-used controls. + * Overflow popover for the player bar's secondary actions. Hosts + * Sleep timer, A-B loop and playback speed. Sleep timer / A-B loop + * can be pinned to the bar via Settings; speed has no pin (used too + * rarely to deserve a permanent slot). The caller is expected to + * skip rendering this component entirely when nothing would go + * inside (both pinned + Spotify mode hides speed too). */ export function MoreActionsMenu({ - miniPlayerAvailable, - onOpenFullscreen, - onOpenMiniPlayer, + pinAbLoop, + pinSleepTimer, + showSpeed, + sleepTimer, }: MoreActionsMenuProps) { const { t } = useTranslation(); + const { playbackSpeed, setPlaybackSpeed } = usePlayer(); const [isOpen, setIsOpen] = useState(false); + const [customMinutes, setCustomMinutes] = useState(""); const containerRef = useRef(null); useEffect(() => { @@ -47,9 +70,39 @@ export function MoreActionsMenu({ }; }, [isOpen]); - const handle = (cb: () => void) => () => { - setIsOpen(false); - cb(); + const showSleepInMenu = !pinSleepTimer; + const showAbInMenu = !pinAbLoop; + + const sleepArmed = sleepTimer.status.kind !== "off"; + const sleepBadge = + sleepTimer.status.kind === "duration" + ? formatRemaining(sleepTimer.status.remainingMs) + : sleepTimer.status.kind === "end-of-track" + ? t("sleepTimer.endOfTrackBadge") + : null; + + // Trigger badge priority: sleep-timer countdown > non-default speed. + // Both are mutually exclusive in the same corner so the user always + // sees the most time-sensitive signal first. `isOffSpeed` is gated + // on `showSpeed` because the speed UI is hidden in Spotify mode — + // tinting the trigger green for a value the user can't even see + // from this menu would be misleading. + const isOffSpeed = showSpeed && Math.abs(playbackSpeed - 1.0) > 0.001; + const speedBadge = isOffSpeed ? formatSpeed(playbackSpeed) : null; + const triggerBadge = sleepBadge && showSleepInMenu ? sleepBadge : speedBadge; + const triggerBadgeTone = + sleepBadge && showSleepInMenu + ? "bg-emerald-500 text-white" + : "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/40"; + + const handleCustomSleep = (event: React.FormEvent) => { + event.preventDefault(); + const minutes = parseInt(customMinutes, 10); + if (Number.isFinite(minutes) && minutes > 0 && minutes <= 720) { + sleepTimer.onSetDuration(minutes); + setCustomMinutes(""); + setIsOpen(false); + } }; return ( @@ -58,46 +111,187 @@ export function MoreActionsMenu({ type="button" onClick={() => setIsOpen((open) => !open)} aria-label={t("playerBar.moreActions")} - aria-haspopup="menu" + aria-haspopup="dialog" aria-expanded={isOpen} title={t("playerBar.moreActions")} - className={`p-2 rounded-lg transition-colors ${ + className={`relative p-2 rounded-lg transition-colors ${ isOpen ? "text-emerald-500" - : "text-zinc-400 hover:text-zinc-800 dark:hover:text-white" + : (sleepArmed && showSleepInMenu) || isOffSpeed + ? "text-emerald-500 hover:text-emerald-400" + : "text-zinc-400 hover:text-zinc-800 dark:hover:text-white" }`} > + {triggerBadge && ( + + {triggerBadge} + + )} {isOpen && (
- - {miniPlayerAvailable && ( - + {showSpeed && ( +
+
+
+ {t("player.speed.title")} +
+ + {formatSpeed(playbackSpeed)} + +
+ + setPlaybackSpeed(parseFloat(e.target.value))} + aria-label={t("player.speed.slider")} + className="w-full accent-emerald-500" + /> + +
+ {SPEED_PRESETS.map((preset) => { + const active = Math.abs(playbackSpeed - preset) < 0.001; + return ( + + ); + })} +
+
+ )} + + {showSpeed && (showAbInMenu || showSleepInMenu) && ( +
+ )} + + {showAbInMenu && ( +
+ + {t("playerBar.abLoop")} + + +
+ )} + + {showSleepInMenu && showAbInMenu && ( +
+ )} + + {showSleepInMenu && ( +
+
+
+ + {t("sleepTimer.title")} +
+ {sleepArmed && ( + + )} +
+ +
+ {SLEEP_PRESETS_MIN.map((m) => ( + + ))} +
+ + + +
+ setCustomMinutes(e.target.value)} + placeholder={t("sleepTimer.customPlaceholder")} + aria-label={t("sleepTimer.customAriaLabel")} + className="flex-1 px-2 py-1.5 rounded-lg text-xs bg-white border border-zinc-200 text-zinc-800 placeholder-zinc-400 focus:outline-none focus:border-emerald-500 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + /> + +
+
)}
)}
); } + +function formatRemaining(ms: number): string { + // totalSec uses ceil so the final second of countdown still reads + // "1s" rather than "0s". h/m branches use floor so "1h 1m" shows + // as "1h" instead of misleadingly rounding up to "2h". + const totalSec = Math.max(0, Math.ceil(ms / 1000)); + if (totalSec >= 3600) { + const h = Math.floor(totalSec / 3600); + return `${h}h`; + } + if (totalSec >= 60) { + const m = Math.floor(totalSec / 60); + return `${m}m`; + } + return `${totalSec}s`; +} + +function formatSpeed(value: number): string { + const trimmed = Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2); + return `${trimmed.replace(/(\.\d)0$/, "$1")}×`; +} diff --git a/src/components/player/PlayerBar.tsx b/src/components/player/PlayerBar.tsx index 12b73df..f51eca0 100644 --- a/src/components/player/PlayerBar.tsx +++ b/src/components/player/PlayerBar.tsx @@ -1,6 +1,13 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Menu, MonitorSpeaker, Heart, Mic2 } from "lucide-react"; +import { + Menu, + MonitorSpeaker, + Heart, + Mic2, + Maximize2, + PictureInPicture2, +} from "lucide-react"; import { usePlayer } from "../../hooks/usePlayer"; import { useSleepTimer } from "../../hooks/useSleepTimer"; import { Artwork } from "../common/Artwork"; @@ -10,7 +17,6 @@ import { ProgressBar } from "./ProgressBar"; import { SleepTimerMenu } from "./SleepTimerMenu"; import { AbLoopButton } from "./AbLoopButton"; import { VolumeControl } from "./VolumeControl"; -import { SpeedControl } from "./SpeedControl"; import { MoreActionsMenu } from "./MoreActionsMenu"; import { AudioQualityFooter } from "./AudioQualityFooter"; import { FullscreenNowPlaying } from "./FullscreenNowPlaying"; @@ -38,27 +44,28 @@ export function PlayerBar({ onNavigateToArtist }: PlayerBarProps) { const sleepTimer = useSleepTimer({ currentVolume: volume, setVolume }); - // Per-profile preference: hide the sleep-timer / A-B loop icons. - // Default: hidden — both are niche features that mostly clutter - // the player bar for typical users; opt-in via Settings. + // Per-profile preference: pin sleep-timer / A-B loop as primary + // buttons in the bar. Default: OFF — both features live in the + // overflow ("...") menu by default so the bar stays calm, and + // users opt in to surface them when they use them often. // SettingsView dispatches `waveflow:sleep-timer-visibility` / // `waveflow:ab-loop-visibility` window events after toggling so // we re-read without polling. - const [showSleepTimer, setShowSleepTimer] = useState(false); - const [showAbLoop, setShowAbLoop] = useState(false); + const [pinSleepTimer, setPinSleepTimer] = useState(false); + const [pinAbLoop, setPinAbLoop] = useState(false); useEffect(() => { const refreshSleep = () => { getProfileSetting("ui.show_sleep_timer") .then((v) => { - // Missing key → treat as "false" (off by default). - setShowSleepTimer(v == null ? false : v === "1" || v === "true"); + // Missing key → treat as "false" (in overflow menu by default). + setPinSleepTimer(v == null ? false : v === "1" || v === "true"); }) .catch(() => {}); }; const refreshAb = () => { getProfileSetting("ui.show_ab_loop") .then((v) => { - setShowAbLoop(v == null ? false : v === "1" || v === "true"); + setPinAbLoop(v == null ? false : v === "1" || v === "true"); }) .catch(() => {}); }; @@ -199,12 +206,14 @@ export function PlayerBar({ onNavigateToArtist }: PlayerBarProps) { {/* Right: Extra Controls */}
- {/* A-B repeat — sits left of the sleep timer. */} - {showAbLoop && } + {/* A-B repeat (primary slot — opt-in pin via Settings). + When unpinned, the entry lives in the "..." menu so the + bar stays calm by default. */} + {pinAbLoop && } - {/* Sleep timer (sits left of Lyrics; user-hideable from - Settings via `ui.show_sleep_timer`). */} - {showSleepTimer && ( + {/* Sleep timer (primary slot — opt-in pin via Settings). + Same overflow-by-default rule as A-B loop. */} + {pinSleepTimer && ( )} - {/* Overflow menu — absorbs Fullscreen + Mini-player so the - bar stops growing every time we ship a feature. Lyrics / - Queue / Device stay first-class because they're the - most-used. The Spotify mode hides Mini-player inside - the menu via the `miniPlayerAvailable` prop. */} - setIsFullscreenOpen(true)} - onOpenMiniPlayer={() => { - import("../../lib/miniPlayer").then((m) => - m.openMiniPlayer().catch((err) => { - console.error("[PlayerBar] open mini-player failed", err); - }), - ); - }} - /> - - {/* Compact speed pill — sits just before volume so the two - "playback shape" controls cluster together. Hidden in - Spotify mode (Web Playback SDK has no speed control). */} - {!isSpotify && } + {/* Overflow menu — hosts playback speed, Sleep timer and + A-B loop. Hidden when nothing would go inside (Spotify + mode + both features pinned to the bar). */} + {(!isSpotify || !pinSleepTimer || !pinAbLoop) && ( + + )} + + {/* Spotify-style right cluster: mini-player + fullscreen as + primary icon buttons after volume. Mini-player is hidden + in Spotify mode (Web Playback SDK can't drive a second + webview). */} + {!isSpotify && ( + + )} + +
diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx deleted file mode 100644 index 2600e3d..0000000 --- a/src/components/player/SpeedControl.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { usePlayer } from "../../hooks/usePlayer"; - -const PRESETS = [0.75, 1.0, 1.25, 1.5, 2.0]; -const MIN = 0.5; -const MAX = 2.0; - -function formatSpeed(value: number): string { - // Drop the trailing zero on integer multipliers so "1×" doesn't - // read as "1.0×" alongside "1.25×". The × sign is kept literal - // because it's recognisable across every locale (Pocket Casts, - // YouTube and Audible all use it as-is, not translated). - const trimmed = Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2); - // Strip a trailing zero on .X0 values ("1.50" → "1.5") for - // visual compactness — the pill is meant to stay narrow. - return `${trimmed.replace(/(\.\d)0$/, "$1")}×`; -} - -/** - * Compact text pill that opens a popover with playback-speed - * controls. Trigger is a plain text button (no icon) so it stays as - * small as the volume's "80%" label and slots next to it in the - * crowded player bar. - */ -export function SpeedControl() { - const { t } = useTranslation(); - const { playbackSpeed, setPlaybackSpeed } = usePlayer(); - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - if (!isOpen) return; - const onPointer = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setIsOpen(false); - }; - document.addEventListener("mousedown", onPointer); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onPointer); - document.removeEventListener("keydown", onKey); - }; - }, [isOpen]); - - const isCustomSpeed = !PRESETS.includes(playbackSpeed); - const isOffPreset = playbackSpeed !== 1.0; - - return ( -
- - - {isOpen && ( -
-
- - {t("player.speed.title")} - - - {formatSpeed(playbackSpeed)} - -
- - setPlaybackSpeed(parseFloat(e.target.value))} - aria-label={t("player.speed.slider")} - className="w-full accent-emerald-500" - /> - -
- {PRESETS.map((preset) => { - const active = Math.abs(playbackSpeed - preset) < 0.001; - return ( - - ); - })} -
- - {isCustomSpeed && ( -

- {t("player.speed.custom")} -

- )} -

- {t("player.speed.pitchHint")} -

-
- )} -
- ); -} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index dff12a0..73f0a1d 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -856,7 +856,8 @@ "previous": "المقطع السابق", "next": "المقطع التالي", "devices": "أجهزة الإخراج", - "moreActions": "إجراءات إضافية" + "moreActions": "إجراءات إضافية", + "abLoop": "حلقة A-B" }, "miniPlayer": { "idle": "لا يوجد مقطع قيد التشغيل", @@ -872,6 +873,7 @@ "cancel": "إلغاء", "start": "موافق", "customPlaceholder": "دقيقة", + "customAriaLabel": "مدة مخصصة بالدقائق", "minutes_one": "{{count}} دقيقة", "minutes_other": "{{count}} دقيقة" }, @@ -1124,12 +1126,12 @@ "subtitle": "انقر على مقطع لبدء التشغيل (أو انقر نقرًا مزدوجًا)" }, "showSleepTimer": { - "title": "إظهار مؤقت السكون", - "subtitle": "إظهار أيقونة القمر في شريط المشغل" + "title": "تثبيت مؤقت السكون في الشريط", + "subtitle": "عند إيقافه، يظل المؤقت متاحاً من قائمة « … »" }, "showAbLoop": { - "title": "إظهار تكرار A-B", - "subtitle": "يعرض زر حلقة A-B في شريط التشغيل" + "title": "تثبيت حلقة A-B في الشريط", + "subtitle": "عند إيقافها، تظل حلقة A-B متاحة من قائمة « … »" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 7d4ac39..1e564c6 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -856,7 +856,8 @@ "previous": "Vorheriger Titel", "next": "Nächster Titel", "devices": "Ausgabegeräte", - "moreActions": "Weitere Aktionen" + "moreActions": "Weitere Aktionen", + "abLoop": "A-B-Schleife" }, "miniPlayer": { "idle": "Kein Titel wird abgespielt", @@ -872,6 +873,7 @@ "cancel": "Abbrechen", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Benutzerdefinierte Dauer in Minuten", "minutes_one": "{{count}} Min.", "minutes_other": "{{count}} Min." }, @@ -1124,12 +1126,12 @@ "subtitle": "Klicken Sie auf einen Titel, um die Wiedergabe zu starten (oder doppelklicken Sie darauf)." }, "showSleepTimer": { - "title": "Sleep-Timer anzeigen", - "subtitle": "Mond-Symbol in der Wiedergabeleiste anzeigen" + "title": "Sleep-Timer in der Leiste anheften", + "subtitle": "Wenn aus, bleibt der Timer über das Menü „…“ erreichbar" }, "showAbLoop": { - "title": "A-B-Wiederholung anzeigen", - "subtitle": "Zeigt den A-B-Schleifen-Button in der Wiedergabeleiste an" + "title": "A-B-Schleife in der Leiste anheften", + "subtitle": "Wenn aus, bleibt die A-B-Schleife über das Menü „…“ erreichbar" }, "duplicates": { "title": "Duplikate erkennen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 588f19a..60ce1c6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -865,7 +865,8 @@ "next": "Next track", "openFullscreen": "Immersive view", "devices": "Output devices", - "moreActions": "More actions" + "moreActions": "More actions", + "abLoop": "A-B Loop" }, "miniPlayer": { "idle": "No track playing", @@ -881,6 +882,7 @@ "cancel": "Cancel", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Custom duration in minutes", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1158,12 +1160,12 @@ "subtitle": "Click a track to start playback (otherwise double-click)" }, "showSleepTimer": { - "title": "Show sleep timer", - "subtitle": "Show the moon icon in the player bar" + "title": "Pin sleep timer in the bar", + "subtitle": "When off, the timer stays available from the « … » menu" }, "showAbLoop": { - "title": "Show A-B repeat", - "subtitle": "Show the A-B loop button in the player bar" + "title": "Pin A-B loop in the bar", + "subtitle": "When off, A-B loop stays available from the « … » menu" }, "showSpotify": { "title": "Show Spotify entry", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index da70820..2c28c72 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -856,7 +856,8 @@ "previous": "Pista anterior", "next": "Pista siguiente", "devices": "Dispositivos de salida", - "moreActions": "Más acciones" + "moreActions": "Más acciones", + "abLoop": "Bucle A-B" }, "miniPlayer": { "idle": "Sin pista en reproducción", @@ -872,6 +873,7 @@ "cancel": "Cancelar", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Duración personalizada en minutos", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1124,12 +1126,12 @@ "subtitle": "Haz clic en una pista para iniciar la reproducción (o haz doble clic)." }, "showSleepTimer": { - "title": "Mostrar temporizador de apagado", - "subtitle": "Muestra el icono de luna en la barra de reproducción" + "title": "Anclar el temporizador en la barra", + "subtitle": "Desactivado, el temporizador sigue disponible desde el menú « … »" }, "showAbLoop": { - "title": "Mostrar repetición A-B", - "subtitle": "Muestra el botón de bucle A-B en la barra de reproducción" + "title": "Anclar el bucle A-B en la barra", + "subtitle": "Desactivado, el bucle A-B sigue disponible desde el menú « … »" }, "duplicates": { "title": "Detectar duplicados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 95c08c1..713ed69 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -865,7 +865,8 @@ "next": "Piste suivante", "openFullscreen": "Vue immersive", "devices": "Périphériques de sortie", - "moreActions": "Plus d'actions" + "moreActions": "Plus d'actions", + "abLoop": "Boucle A-B" }, "miniPlayer": { "idle": "Aucun titre en cours", @@ -881,6 +882,7 @@ "cancel": "Annuler", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Durée personnalisée en minutes", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1158,12 +1160,12 @@ "subtitle": "Un clic sur une piste lance la lecture (sinon double-clic)" }, "showSleepTimer": { - "title": "Afficher le minuteur de sommeil", - "subtitle": "Affiche l'icône lune dans la barre de lecture" + "title": "Épingler le minuteur dans la barre", + "subtitle": "Désactivé, le minuteur reste accessible depuis le menu « … »" }, "showAbLoop": { - "title": "Afficher la boucle A-B", - "subtitle": "Affiche le bouton de boucle A-B dans la barre de lecture" + "title": "Épingler la boucle A-B dans la barre", + "subtitle": "Désactivé, la boucle A-B reste accessible depuis le menu « … »" }, "showSpotify": { "title": "Afficher l'entrée Spotify", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 5a98301..3b89c6f 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -856,7 +856,8 @@ "previous": "पिछला ट्रैक", "next": "अगला ट्रैक", "devices": "आउटपुट डिवाइस", - "moreActions": "अधिक क्रियाएँ" + "moreActions": "अधिक क्रियाएँ", + "abLoop": "A-B लूप" }, "miniPlayer": { "idle": "कोई ट्रैक नहीं चल रहा", @@ -872,6 +873,7 @@ "cancel": "रद्द करें", "start": "ठीक", "customPlaceholder": "मिनट", + "customAriaLabel": "मिनट में कस्टम अवधि", "minutes_one": "{{count}} मिनट", "minutes_other": "{{count}} मिनट" }, @@ -1124,12 +1126,12 @@ "subtitle": "प्लेबैक शुरू करने के लिए किसी ट्रैक पर क्लिक करें (अन्यथा डबल-क्लिक करें)" }, "showSleepTimer": { - "title": "स्लीप टाइमर दिखाएँ", - "subtitle": "प्लेयर बार में चाँद का चिह्न दिखाएँ" + "title": "स्लीप टाइमर को बार में पिन करें", + "subtitle": "बंद होने पर भी टाइमर « … » मेनू से उपलब्ध रहता है" }, "showAbLoop": { - "title": "A-B रिपीट दिखाएं", - "subtitle": "प्लेयर बार में A-B लूप बटन दिखाता है" + "title": "A-B लूप को बार में पिन करें", + "subtitle": "बंद होने पर भी A-B लूप « … » मेनू से उपलब्ध रहता है" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 643a59d..b8b7c67 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -856,7 +856,8 @@ "previous": "Trek sebelumnya", "next": "Trek berikutnya", "devices": "Perangkat keluaran", - "moreActions": "Tindakan lainnya" + "moreActions": "Tindakan lainnya", + "abLoop": "Loop A-B" }, "miniPlayer": { "idle": "Tidak ada trek diputar", @@ -872,6 +873,7 @@ "cancel": "Batal", "start": "OK", "customPlaceholder": "Mnt", + "customAriaLabel": "Durasi khusus dalam menit", "minutes_one": "{{count}} mnt", "minutes_other": "{{count}} mnt" }, @@ -1124,12 +1126,12 @@ "subtitle": "Klik sebuah trek untuk memulai pemutaran (atau klik dua kali)" }, "showSleepTimer": { - "title": "Tampilkan timer tidur", - "subtitle": "Tampilkan ikon bulan di bilah pemutar" + "title": "Sematkan timer tidur di bilah", + "subtitle": "Saat nonaktif, timer tetap tersedia dari menu « … »" }, "showAbLoop": { - "title": "Tampilkan ulang A-B", - "subtitle": "Menampilkan tombol loop A-B di bilah pemutar" + "title": "Sematkan loop A-B di bilah", + "subtitle": "Saat nonaktif, loop A-B tetap tersedia dari menu « … »" }, "analyze": { "title": "Menganalisis perpustakaan", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 48c63ce..a8938c9 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -856,7 +856,8 @@ "previous": "Brano precedente", "next": "Brano successivo", "devices": "Dispositivi di uscita", - "moreActions": "Altre azioni" + "moreActions": "Altre azioni", + "abLoop": "Loop A-B" }, "miniPlayer": { "idle": "Nessun brano in riproduzione", @@ -872,6 +873,7 @@ "cancel": "Annulla", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Durata personalizzata in minuti", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1124,12 +1126,12 @@ "subtitle": "Fai clic su un brano per avviare la riproduzione (oppure fai doppio clic)" }, "showSleepTimer": { - "title": "Mostra timer di spegnimento", - "subtitle": "Mostra l'icona della luna nella barra di riproduzione" + "title": "Fissa il timer nella barra", + "subtitle": "Disattivato, il timer resta accessibile dal menu « … »" }, "showAbLoop": { - "title": "Mostra ripetizione A-B", - "subtitle": "Mostra il pulsante del loop A-B nella barra di riproduzione" + "title": "Fissa il loop A-B nella barra", + "subtitle": "Disattivato, il loop A-B resta accessibile dal menu « … »" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 10e0274..a42cd44 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -856,7 +856,8 @@ "previous": "前のトラック", "next": "次のトラック", "devices": "出力デバイス", - "moreActions": "その他の操作" + "moreActions": "その他の操作", + "abLoop": "A-Bループ" }, "miniPlayer": { "idle": "再生中のトラックなし", @@ -872,6 +873,7 @@ "cancel": "キャンセル", "start": "OK", "customPlaceholder": "分", + "customAriaLabel": "カスタム時間(分)", "minutes_one": "{{count}} 分", "minutes_other": "{{count}} 分" }, @@ -1124,12 +1126,12 @@ "subtitle": "トラックをクリックして再生を開始します(それ以外の場合はダブルクリックしてください)" }, "showSleepTimer": { - "title": "スリープタイマーを表示", - "subtitle": "プレーヤーバーに月のアイコンを表示" + "title": "スリープタイマーをバーに固定", + "subtitle": "オフでもタイマーは「…」メニューから利用できます" }, "showAbLoop": { - "title": "A-Bリピートを表示", - "subtitle": "再生バーにA-Bループボタンを表示します" + "title": "A-Bループをバーに固定", + "subtitle": "オフでもA-Bループは「…」メニューから利用できます" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/kr.json b/src/i18n/locales/kr.json index 1eb34de..54461de 100644 --- a/src/i18n/locales/kr.json +++ b/src/i18n/locales/kr.json @@ -856,7 +856,8 @@ "previous": "이전 트랙", "next": "다음 트랙", "devices": "출력 장치", - "moreActions": "추가 작업" + "moreActions": "추가 작업", + "abLoop": "A-B 루프" }, "miniPlayer": { "idle": "재생 중인 트랙 없음", @@ -872,6 +873,7 @@ "cancel": "취소", "start": "확인", "customPlaceholder": "분", + "customAriaLabel": "사용자 지정 시간 (분)", "minutes_one": "{{count}}분", "minutes_other": "{{count}}분" }, @@ -1124,12 +1126,12 @@ "subtitle": "트랙을 클릭하여 재생을 시작합니다(또는 더블 클릭)." }, "showSleepTimer": { - "title": "수면 타이머 표시", - "subtitle": "플레이어 바에 달 아이콘 표시" + "title": "수면 타이머를 바에 고정", + "subtitle": "꺼져 있어도 타이머는 「…」 메뉴에서 사용할 수 있습니다" }, "showAbLoop": { - "title": "A-B 반복 표시", - "subtitle": "재생 바에 A-B 루프 버튼을 표시합니다" + "title": "A-B 루프를 바에 고정", + "subtitle": "꺼져 있어도 A-B 루프는 「…」 메뉴에서 사용할 수 있습니다" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 57daf30..29f623c 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -856,7 +856,8 @@ "previous": "Vorig nummer", "next": "Volgend nummer", "devices": "Uitvoerapparaten", - "moreActions": "Meer acties" + "moreActions": "Meer acties", + "abLoop": "A-B-lus" }, "miniPlayer": { "idle": "Geen nummer afgespeeld", @@ -872,6 +873,7 @@ "cancel": "Annuleren", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Aangepaste duur in minuten", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1124,12 +1126,12 @@ "subtitle": "Klik op een track om het afspelen te starten (anders dubbelklikken)" }, "showSleepTimer": { - "title": "Slaaptimer weergeven", - "subtitle": "Toon het maan-icoon in de afspeelbalk" + "title": "Slaaptimer aan de balk vastmaken", + "subtitle": "Uitgeschakeld blijft de timer beschikbaar via het menu „…“" }, "showAbLoop": { - "title": "Toon A-B-herhaling", - "subtitle": "Toont de A-B-loopknop in de afspeelbalk" + "title": "A-B-lus aan de balk vastmaken", + "subtitle": "Uitgeschakeld blijft de A-B-lus beschikbaar via het menu „…“" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index ca6dfa1..7f8e226 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -856,7 +856,8 @@ "previous": "Faixa anterior", "next": "Próxima faixa", "devices": "Dispositivos de saída", - "moreActions": "Mais ações" + "moreActions": "Mais ações", + "abLoop": "Loop A-B" }, "miniPlayer": { "idle": "Nenhuma faixa em reprodução", @@ -872,6 +873,7 @@ "cancel": "Cancelar", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Duração personalizada em minutos", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1124,12 +1126,12 @@ "subtitle": "Clique em uma faixa para iniciar a reprodução (ou clique duas vezes)" }, "showSleepTimer": { - "title": "Mostrar timer de sono", - "subtitle": "Mostra o ícone da lua na barra de reprodução" + "title": "Fixar o timer na barra", + "subtitle": "Desativado, o timer continua disponível no menu « … »" }, "showAbLoop": { - "title": "Mostrar repetição A-B", - "subtitle": "Mostra o botão de loop A-B na barra de reprodução" + "title": "Fixar o loop A-B na barra", + "subtitle": "Desativado, o loop A-B continua disponível no menu « … »" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 56dc939..0c18f2f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -856,7 +856,8 @@ "previous": "Faixa anterior", "next": "Faixa seguinte", "devices": "Dispositivos de saída", - "moreActions": "Mais ações" + "moreActions": "Mais ações", + "abLoop": "Ciclo A-B" }, "miniPlayer": { "idle": "Nenhuma faixa em reprodução", @@ -872,6 +873,7 @@ "cancel": "Cancelar", "start": "OK", "customPlaceholder": "Min.", + "customAriaLabel": "Duração personalizada em minutos", "minutes_one": "{{count}} min", "minutes_other": "{{count}} min" }, @@ -1124,12 +1126,12 @@ "subtitle": "Clique numa faixa para iniciar a reprodução (ou clique duas vezes)" }, "showSleepTimer": { - "title": "Mostrar temporizador de suspensão", - "subtitle": "Mostra o ícone da lua na barra do leitor" + "title": "Fixar o temporizador na barra", + "subtitle": "Desativado, o temporizador continua disponível no menu « … »" }, "showAbLoop": { - "title": "Mostrar repetição A-B", - "subtitle": "Mostra o botão de loop A-B na barra de reprodução" + "title": "Fixar o ciclo A-B na barra", + "subtitle": "Desativado, o ciclo A-B continua disponível no menu « … »" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 239c234..e94774e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -856,7 +856,8 @@ "previous": "Предыдущий трек", "next": "Следующий трек", "devices": "Устройства вывода", - "moreActions": "Дополнительные действия" + "moreActions": "Дополнительные действия", + "abLoop": "Цикл A-B" }, "miniPlayer": { "idle": "Трек не воспроизводится", @@ -872,6 +873,7 @@ "cancel": "Отмена", "start": "OK", "customPlaceholder": "Мин.", + "customAriaLabel": "Произвольная длительность в минутах", "minutes_one": "{{count}} мин", "minutes_other": "{{count}} мин" }, @@ -1124,12 +1126,12 @@ "subtitle": "Нажмите на трек, чтобы начать воспроизведение (или дважды щелкните по нему)." }, "showSleepTimer": { - "title": "Показывать таймер сна", - "subtitle": "Отображать значок луны в панели воспроизведения" + "title": "Закрепить таймер на панели", + "subtitle": "Если отключено, таймер остаётся доступным из меню «…»" }, "showAbLoop": { - "title": "Показывать повтор A-B", - "subtitle": "Показывает кнопку цикла A-B на панели воспроизведения" + "title": "Закрепить цикл A-B на панели", + "subtitle": "Если отключено, цикл A-B остаётся доступным из меню «…»" }, "analyze": { "title": "Анализ библиотеки", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index a3ad18a..3af474b 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -856,7 +856,8 @@ "previous": "Önceki parça", "next": "Sonraki parça", "devices": "Çıkış aygıtları", - "moreActions": "Daha fazla işlem" + "moreActions": "Daha fazla işlem", + "abLoop": "A-B Döngüsü" }, "miniPlayer": { "idle": "Çalan parça yok", @@ -872,6 +873,7 @@ "cancel": "İptal", "start": "Tamam", "customPlaceholder": "Dak.", + "customAriaLabel": "Dakika cinsinden özel süre", "minutes_one": "{{count}} dk", "minutes_other": "{{count}} dk" }, @@ -1124,12 +1126,12 @@ "subtitle": "Çalmayı başlatmak için bir parçaya tıklayın (aksi takdirde çift tıklayın)" }, "showSleepTimer": { - "title": "Uyku zamanlayıcısını göster", - "subtitle": "Oynatıcı çubuğunda ay simgesini göster" + "title": "Uyku zamanlayıcısını çubuğa sabitle", + "subtitle": "Kapalıyken zamanlayıcıya \"…\" menüsünden erişilebilir" }, "showAbLoop": { - "title": "A-B tekrarını göster", - "subtitle": "A-B döngü düğmesini oynatma çubuğunda gösterir" + "title": "A-B döngüsünü çubuğa sabitle", + "subtitle": "Kapalıyken A-B döngüsüne \"…\" menüsünden erişilebilir" }, "analyze": { "title": "Kütüphaneyi analiz et", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3eef23a..b98d847 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -856,7 +856,8 @@ "previous": "上一曲", "next": "下一曲", "devices": "输出设备", - "moreActions": "更多操作" + "moreActions": "更多操作", + "abLoop": "A-B 循环" }, "miniPlayer": { "idle": "未播放任何曲目", @@ -872,6 +873,7 @@ "cancel": "取消", "start": "确定", "customPlaceholder": "分钟", + "customAriaLabel": "自定义时长(分钟)", "minutes_one": "{{count}} 分钟", "minutes_other": "{{count}} 分钟" }, @@ -1124,12 +1126,12 @@ "subtitle": "点击一首曲目开始播放(否则请双击)" }, "showSleepTimer": { - "title": "显示睡眠定时器", - "subtitle": "在播放栏显示月亮图标" + "title": "将睡眠定时器固定到播放栏", + "subtitle": "关闭后仍可从「…」菜单访问定时器" }, "showAbLoop": { - "title": "显示 A-B 循环", - "subtitle": "在播放栏中显示 A-B 循环按钮" + "title": "将 A-B 循环固定到播放栏", + "subtitle": "关闭后仍可从「…」菜单访问 A-B 循环" }, "duplicates": { "title": "Detect duplicates", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d84527c..025d85f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -856,7 +856,8 @@ "previous": "上一首", "next": "下一首", "devices": "輸出裝置", - "moreActions": "更多動作" + "moreActions": "更多動作", + "abLoop": "A-B 循環" }, "miniPlayer": { "idle": "沒有播放中的曲目", @@ -872,6 +873,7 @@ "cancel": "取消", "start": "確定", "customPlaceholder": "分鐘", + "customAriaLabel": "自訂時長(分鐘)", "minutes_one": "{{count}} 分鐘", "minutes_other": "{{count}} 分鐘" }, @@ -1124,12 +1126,12 @@ "subtitle": "點擊一首曲目即可開始播放(否則請雙擊)" }, "showSleepTimer": { - "title": "顯示睡眠計時器", - "subtitle": "在播放列顯示月亮圖示" + "title": "將睡眠計時器釘選到播放列", + "subtitle": "關閉後仍可從「…」選單存取計時器" }, "showAbLoop": { - "title": "顯示 A-B 循環", - "subtitle": "在播放列中顯示 A-B 循環按鈕" + "title": "將 A-B 循環釘選到播放列", + "subtitle": "關閉後仍可從「…」選單存取 A-B 循環" }, "duplicates": { "title": "Detect duplicates",