From 6020bd1bd65f034da7d3e996daf15a406f2d2b7e Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:44 +0300 Subject: [PATCH 1/3] feat(player): share one sleep timer between music and video --- src/components/music/GlobalMusicAudio.tsx | 4 + src/lib/api/process.ts | 8 ++ src/lib/audio/musicAudioEngine.test.ts | 15 ++++ src/lib/audio/musicAudioEngine.ts | 31 +++++++- src/lib/settings/schema.ts | 4 + src/lib/sleepTimer.test.ts | 38 +++++++++ src/lib/sleepTimer.ts | 34 ++++++++ src/lib/useSleepTimerDriver.test.ts | 95 +++++++++++++++++++++++ src/lib/useSleepTimerDriver.ts | 91 ++++++++++++++++++++++ src/store/useSleepTimerStore.ts | 45 +++++++++++ 10 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 src/lib/api/process.ts create mode 100644 src/lib/sleepTimer.test.ts create mode 100644 src/lib/sleepTimer.ts create mode 100644 src/lib/useSleepTimerDriver.test.ts create mode 100644 src/lib/useSleepTimerDriver.ts create mode 100644 src/store/useSleepTimerStore.ts diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx index cdfa4dc4..cfafb4ba 100644 --- a/src/components/music/GlobalMusicAudio.tsx +++ b/src/components/music/GlobalMusicAudio.tsx @@ -8,6 +8,7 @@ import { useMusicMediaSession } from "../../lib/useMusicMediaSession"; import { reportMediaPosition } from "../../lib/mediaSessionArbiter"; import { musicListens, useMusicListenHistory } from "../../lib/useMusicListenHistory"; import { useMusicQueuePersistence } from "../../lib/useMusicQueuePersistence"; +import { sleepTimerTakesEnd, tickSleepTimer, useSleepTimerDriver } from "../../lib/useSleepTimerDriver"; /** * Wraps a media-element handler so only the element that is playing drives the @@ -37,6 +38,7 @@ export function GlobalMusicAudio() { useMusicListenHistory(); useMusicQueuePersistence(); + useSleepTimerDriver(); useMusicMediaSession(); const store = () => useMusicPlayerStore.getState(); @@ -73,6 +75,7 @@ export function GlobalMusicAudio() { */ store()._syncTime(el.currentTime, el.duration); reportMediaPosition("music"); + tickSleepTimer(); // Listening time is counted from here for the same reason: a frame loop // stops in the background, and listening there is the common case. @@ -87,6 +90,7 @@ export function GlobalMusicAudio() { }), onEnded: fromActive(() => { musicListens.finish(Date.now()); + if (sleepTimerTakesEnd()) return; store().handleEnded(); }), onError: (event: SyntheticEvent) => { diff --git a/src/lib/api/process.ts b/src/lib/api/process.ts new file mode 100644 index 00000000..67d705c7 --- /dev/null +++ b/src/lib/api/process.ts @@ -0,0 +1,8 @@ +import { isTauriEnv } from "./env"; + +/** Quits Flow, as the sleep timer's "Quit Flow when it ends" asks. */ +export async function quitApp(): Promise { + if (!(await isTauriEnv())) return; + const { exit } = await import("@tauri-apps/plugin-process"); + await exit(0); +} diff --git a/src/lib/audio/musicAudioEngine.test.ts b/src/lib/audio/musicAudioEngine.test.ts index 9fea2535..085178c3 100644 --- a/src/lib/audio/musicAudioEngine.test.ts +++ b/src/lib/audio/musicAudioEngine.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { volumeGain } from "./volumeCurve"; import { musicAudioEngine } from "./musicAudioEngine"; const elements = (): [HTMLAudioElement, HTMLAudioElement] => { @@ -101,3 +102,17 @@ describe("music speed", () => { musicAudioEngine.setSpeed(1); }); }); + +describe("sleep timer fade-out", () => { + it("steps the element volume down without WebAudio, and puts it back after", () => { + musicAudioEngine.setVolume(0.8); + musicAudioEngine.fadeOut(0.5, 10); + expect(pair[0].volume).toBeCloseTo(volumeGain(0.8) * 0.5); + + musicAudioEngine.setVolume(1); + expect(pair[0].volume).toBeCloseTo(0.5); + + musicAudioEngine.resetFade(); + expect(pair[0].volume).toBeCloseTo(1); + }); +}); diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index 3612dba7..34ad9921 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -62,6 +62,8 @@ class MusicAudioEngine { private volume = 1; private muted = false; + /** The sleep timer's fade-out, from 1 (none) down to 0. */ + private fadeLevel = 1; private normalizationEnabled = true; private eqSpec: EqGraphSpec = EQ_GRAPH_OFF; private eqEngaged = false; @@ -404,8 +406,35 @@ class MusicAudioEngine { return loudnessFactor(loudnessDb, this.normalizationEnabled, MUSIC_MAX_CUT_DB); } + /** + * Fades the music to silence by the time `remainingSeconds` have passed, + * from `level` now. With WebAudio the ramp runs on the audio clock, so it + * keeps going while timers are throttled in a hidden window; without it the + * element's volume steps down each time this is called again. + */ + fadeOut(level: number, remainingSeconds: number): void { + this.fadeLevel = Math.max(0, Math.min(1, level)); + if (this.graphReady && this.ctx && this.volGain) { + const gain = this.volGain.gain; + const t = this.ctx.currentTime; + gain.cancelScheduledValues(t); + gain.setValueAtTime((this.muted ? 0 : volumeGain(this.volume)) * this.fadeLevel, t); + gain.linearRampToValueAtTime(0, t + Math.max(0, remainingSeconds)); + return; + } + this.applyGain(); + } + + /** Back to full volume after a fade-out stopped or was cancelled. */ + resetFade(): void { + if (this.fadeLevel === 1) return; + this.fadeLevel = 1; + if (this.graphReady && this.ctx && this.volGain) this.volGain.gain.cancelScheduledValues(this.ctx.currentTime); + this.applyGain(); + } + private applyGain(): void { - const vol = this.muted ? 0 : volumeGain(this.volume); + const vol = (this.muted ? 0 : volumeGain(this.volume)) * this.fadeLevel; if (this.graphReady && this.ctx && this.volGain) { const t = this.ctx.currentTime; diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index 7657e971..6cdba62b 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -69,6 +69,8 @@ export const SETTINGS = { MUSIC_PLAYER_FOLLOW_LIGHT_THEME: "music_player_follow_light_theme", MUSIC_RESTORE_QUEUE: "music_restore_queue", MUSIC_RESUME_LONG_TRACKS: "music_resume_long_tracks", + MUSIC_SLEEP_TIMER_BUTTON: "music_sleep_timer_button", + SLEEP_TIMER_CLOSE_APP_ON_EXPIRY: "sleep_timer_close_app_on_expiry", LYRICS_PROVIDER_ORDER: "lyrics_provider_order", LYRICS_PROVIDER_ENABLED_STATES: "lyrics_provider_enabled_states", @@ -285,6 +287,8 @@ export const SETTING_DEFINITIONS = [ bool(SETTINGS.MUSIC_PLAYER_FOLLOW_LIGHT_THEME, "player", false, "wired"), bool(SETTINGS.MUSIC_RESTORE_QUEUE, "player", true, "wired"), bool(SETTINGS.MUSIC_RESUME_LONG_TRACKS, "player", false, "wired"), + bool(SETTINGS.MUSIC_SLEEP_TIMER_BUTTON, "player", false, "wired"), + bool(SETTINGS.SLEEP_TIMER_CLOSE_APP_ON_EXPIRY, "player", false, "wired"), str(SETTINGS.LYRICS_PROVIDER_ORDER, "player", "", "wired"), json(SETTINGS.LYRICS_PROVIDER_ENABLED_STATES, "player", "{}", "wired"), diff --git a/src/lib/sleepTimer.test.ts b/src/lib/sleepTimer.test.ts new file mode 100644 index 00000000..27e7efa2 --- /dev/null +++ b/src/lib/sleepTimer.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { SLEEP_TIMER_PRESETS, fadeLevel, formatRemaining, parseCustomMinutes } from "./sleepTimer"; + +describe("sleep timer choices", () => { + it("offers Android's presets", () => { + expect([...SLEEP_TIMER_PRESETS]).toEqual([10, 15, 30, 45, 60, 90]); + }); + + it("takes a custom time of whole minutes from one minute to a day", () => { + expect(parseCustomMinutes("25")).toBe(25); + expect(parseCustomMinutes(" 1440 ")).toBe(1440); + expect(parseCustomMinutes("0")).toBeNull(); + expect(parseCustomMinutes("1441")).toBeNull(); + expect(parseCustomMinutes("2.5")).toBeNull(); + expect(parseCustomMinutes("")).toBeNull(); + expect(parseCustomMinutes("ten")).toBeNull(); + }); +}); + +describe("fadeLevel", () => { + it("plays at full volume until the last 20 seconds, then fades to silence", () => { + expect(fadeLevel(60)).toBe(1); + expect(fadeLevel(20)).toBe(1); + expect(fadeLevel(10)).toBe(0.5); + expect(fadeLevel(0)).toBe(0); + expect(fadeLevel(-3)).toBe(0); + }); +}); + +describe("formatRemaining", () => { + it("shows minutes and seconds, and hours once there are any", () => { + expect(formatRemaining(59_100)).toBe("1:00"); + expect(formatRemaining(9 * 60_000 + 5_000)).toBe("9:05"); + expect(formatRemaining(90 * 60_000)).toBe("1:30:00"); + expect(formatRemaining(-1)).toBe("0:00"); + }); +}); diff --git a/src/lib/sleepTimer.ts b/src/lib/sleepTimer.ts new file mode 100644 index 00000000..0868d48a --- /dev/null +++ b/src/lib/sleepTimer.ts @@ -0,0 +1,34 @@ +/** The sleep timer's choices and arithmetic, shared by the music and video players (Android's SleepTimerManager). */ + +export const SLEEP_TIMER_PRESETS = [10, 15, 30, 45, 60, 90] as const; + +/** A custom time is whole minutes from one minute to a day, as on Android. */ +export const MIN_CUSTOM_MINUTES = 1; +export const MAX_CUSTOM_MINUTES = 1440; + +/** Music fades out over this many seconds before the timer stops it. */ +export const SLEEP_FADE_SECONDS = 20; + +/** Minutes from a custom entry, or null when it is not a whole number in range. */ +export function parseCustomMinutes(text: string): number | null { + const trimmed = text.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const minutes = Number(trimmed); + return minutes >= MIN_CUSTOM_MINUTES && minutes <= MAX_CUSTOM_MINUTES ? minutes : null; +} + +/** How loud the music plays with `remainingSeconds` left: full until the fade, then down to silence. */ +export function fadeLevel(remainingSeconds: number): number { + if (remainingSeconds >= SLEEP_FADE_SECONDS) return 1; + return Math.max(0, remainingSeconds / SLEEP_FADE_SECONDS); +} + +/** `h:mm:ss` or `m:ss` for the time left on the timer. */ +export function formatRemaining(remainingMs: number): string { + const total = Math.max(0, Math.ceil(remainingMs / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`; +} diff --git a/src/lib/useSleepTimerDriver.test.ts b/src/lib/useSleepTimerDriver.test.ts new file mode 100644 index 00000000..7fe6d657 --- /dev/null +++ b/src/lib/useSleepTimerDriver.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { engine, music, video, quitApp } = vi.hoisted(() => ({ + engine: { + fadeOut: vi.fn(), + resetFade: vi.fn(), + getDuration: vi.fn(() => 200), + getCurrentTime: vi.fn(() => 0), + }, + music: { isPlaying: true, duration: 200, pause: vi.fn() }, + video: { isPlaying: false, setIsPlaying: vi.fn() }, + quitApp: vi.fn(async () => {}), +})); + +vi.mock("./audio/musicAudioEngine", () => ({ musicAudioEngine: engine })); +vi.mock("../store/useMusicPlayerStore", () => ({ useMusicPlayerStore: { getState: () => music } })); +vi.mock("../store/usePlayerStore", () => ({ usePlayerStore: { getState: () => video } })); +vi.mock("./api/process", () => ({ quitApp })); +vi.mock("../store/useAppSettingsStore", () => ({ getSettingValue: () => "false", setSettingValue: vi.fn() })); + +import { useSleepTimerStore } from "../store/useSleepTimerStore"; +import { sleepTimerTakesEnd, tickSleepTimer } from "./useSleepTimerDriver"; + +const timer = () => useSleepTimerStore.getState(); + +beforeEach(() => { + vi.useFakeTimers(); + music.isPlaying = true; + video.isPlaying = false; +}); + +afterEach(() => { + timer().cancel(); + tickSleepTimer(); + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe("the shared sleep timer", () => { + it("leaves the music alone until the last 20 seconds, then fades it out on the audio clock", () => { + timer().start(10, false); + vi.advanceTimersByTime(9 * 60_000); + tickSleepTimer(); + expect(engine.fadeOut).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50_000); + tickSleepTimer(); + expect(engine.fadeOut).toHaveBeenLastCalledWith(0.5, 10); + }); + + it("pauses the music and the video when it runs out, and restores the volume", () => { + video.isPlaying = true; + timer().start(15, false); + vi.advanceTimersByTime(15 * 60_000 - 5_000); + tickSleepTimer(); + vi.advanceTimersByTime(5_000); + tickSleepTimer(); + + expect(music.pause).toHaveBeenCalled(); + expect(video.setIsPlaying).toHaveBeenCalledWith(false); + expect(engine.resetFade).toHaveBeenCalled(); + expect(timer().mode).toBe("off"); + expect(quitApp).not.toHaveBeenCalled(); + }); + + it("quits Flow after the pause when asked to", () => { + timer().start(10, true); + vi.advanceTimersByTime(10 * 60_000); + tickSleepTimer(); + expect(quitApp).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + expect(quitApp).toHaveBeenCalledTimes(1); + }); + + it("stops at the end of the song in End of song mode, fading the song's last seconds", () => { + expect(sleepTimerTakesEnd()).toBe(false); + timer().startEndOfSong(false); + engine.getCurrentTime.mockReturnValue(190); + tickSleepTimer(); + expect(engine.fadeOut).toHaveBeenLastCalledWith(0.5, 10); + + expect(sleepTimerTakesEnd()).toBe(true); + expect(timer().mode).toBe("off"); + expect(sleepTimerTakesEnd()).toBe(false); + }); + + it("puts the volume back when the timer is turned off mid-fade", () => { + timer().start(10, false); + vi.advanceTimersByTime(10 * 60_000 - 10_000); + tickSleepTimer(); + timer().cancel(); + tickSleepTimer(); + expect(engine.resetFade).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/useSleepTimerDriver.ts b/src/lib/useSleepTimerDriver.ts new file mode 100644 index 00000000..94babef2 --- /dev/null +++ b/src/lib/useSleepTimerDriver.ts @@ -0,0 +1,91 @@ +import { useEffect } from "react"; + +import { useMusicPlayerStore } from "../store/useMusicPlayerStore"; +import { usePlayerStore } from "../store/usePlayerStore"; +import { useSleepTimerStore } from "../store/useSleepTimerStore"; +import { musicAudioEngine } from "./audio/musicAudioEngine"; +import { quitApp } from "./api/process"; +import { SLEEP_FADE_SECONDS, fadeLevel } from "./sleepTimer"; + +/** Long enough for the pause to save the queue and the listen before Flow quits. */ +const QUIT_DELAY_MS = 1_000; +/** The backstop clock for when no music plays to drive the timer. */ +const FALLBACK_TICK_MS = 1_000; + +let fading = false; + +function stopFading(): void { + if (!fading) return; + fading = false; + musicAudioEngine.resetFade(); +} + +/** The timer ran out: pause whatever plays, or quit Flow when that was asked for. */ +function expire(): void { + const { quitOnExpiry, cancel } = useSleepTimerStore.getState(); + cancel(); + const music = useMusicPlayerStore.getState(); + if (music.isPlaying) music.pause(); + const video = usePlayerStore.getState(); + if (video.isPlaying) video.setIsPlaying(false); + stopFading(); + if (quitOnExpiry) setTimeout(() => void quitApp(), QUIT_DELAY_MS); +} + +/** Seconds until the timer stops playback, or null when that is not known yet. */ +function remainingSeconds(): number | null { + const { mode, endsAt } = useSleepTimerStore.getState(); + if (mode === "timer" && endsAt !== null) return (endsAt - Date.now()) / 1000; + if (mode === "endOfSong") { + const duration = musicAudioEngine.getDuration() || useMusicPlayerStore.getState().duration; + if (duration > 0 && useMusicPlayerStore.getState().isPlaying) return duration - musicAudioEngine.getCurrentTime(); + } + return null; +} + +/** + * Advances the timer. The music element's `timeupdate` calls this, which keeps + * firing in a hidden window where timers are throttled, and a one-second + * interval covers the video player and paused music. + */ +export function tickSleepTimer(): void { + const remaining = remainingSeconds(); + if (remaining === null) { + stopFading(); + return; + } + if (useSleepTimerStore.getState().mode === "timer" && remaining <= 0) { + expire(); + return; + } + if (useMusicPlayerStore.getState().isPlaying && remaining < SLEEP_FADE_SECONDS) { + fading = true; + musicAudioEngine.fadeOut(fadeLevel(remaining), remaining); + } else { + stopFading(); + } +} + +/** + * Called when a song or video plays to its end. Returns true when "End of song" + * was waiting for it, so the player stops instead of moving on. + */ +export function sleepTimerTakesEnd(): boolean { + if (useSleepTimerStore.getState().mode !== "endOfSong") return false; + expire(); + return true; +} + +/** Runs the shared sleep timer while one is set. */ +export function useSleepTimerDriver() { + const mode = useSleepTimerStore((s) => s.mode); + + useEffect(() => { + if (mode === "off") { + stopFading(); + return; + } + const interval = setInterval(tickSleepTimer, FALLBACK_TICK_MS); + return () => clearInterval(interval); + }, [mode]); +} diff --git a/src/store/useSleepTimerStore.ts b/src/store/useSleepTimerStore.ts new file mode 100644 index 00000000..050e0be5 --- /dev/null +++ b/src/store/useSleepTimerStore.ts @@ -0,0 +1,45 @@ +import { create } from "zustand"; + +import { SETTINGS } from "../lib/settings/schema"; +import { getSettingValue, setSettingValue } from "./useAppSettingsStore"; + +export type SleepTimerMode = "off" | "timer" | "endOfSong"; + +interface SleepTimerState { + mode: SleepTimerMode; + /** When a countdown stops playback, in epoch milliseconds; null otherwise. */ + endsAt: number | null; + /** Whether this timer quits Flow instead of pausing. */ + quitOnExpiry: boolean; + + start: (minutes: number, quit: boolean) => void; + startEndOfSong: (quit: boolean) => void; + /** Changes what a running timer does when it ends. */ + setQuitOnExpiry: (quit: boolean) => void; + cancel: () => void; +} + +/** The remembered "Quit Flow when it ends" choice, which new timers start from. */ +export const prefersQuitOnExpiry = (): boolean => + getSettingValue(SETTINGS.SLEEP_TIMER_CLOSE_APP_ON_EXPIRY) === "true"; + +export const setPrefersQuitOnExpiry = (quit: boolean): Promise => + setSettingValue(SETTINGS.SLEEP_TIMER_CLOSE_APP_ON_EXPIRY, String(quit)); + +/** One sleep timer for the music and video players alike; `useSleepTimerDriver` runs it. */ +export const useSleepTimerStore = create((set) => ({ + mode: "off", + endsAt: null, + quitOnExpiry: false, + + start: (minutes, quit) => { + if (!(minutes > 0)) return; + set({ mode: "timer", endsAt: Date.now() + minutes * 60_000, quitOnExpiry: quit }); + }, + + startEndOfSong: (quit) => set({ mode: "endOfSong", endsAt: null, quitOnExpiry: quit }), + + setQuitOnExpiry: (quit) => set((s) => (s.mode === "off" ? {} : { quitOnExpiry: quit })), + + cancel: () => set({ mode: "off", endsAt: null, quitOnExpiry: false }), +})); From 32aec169e0431afe5ec34546d4261aea68d62079 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:44 +0300 Subject: [PATCH 2/3] feat(player): add the sleep timer panel and an optional music player button --- .../music/MusicSleepTimerButton.tsx | 49 +++++++ .../music/player/NowPlayingColumn.tsx | 2 + src/components/player/FlowPlayerControls.tsx | 35 +---- src/components/player/Player.tsx | 19 +-- src/components/settings/tabs/PlayerTab.tsx | 4 + src/components/sleep/SleepTimerLabel.tsx | 9 ++ src/components/sleep/SleepTimerPanel.tsx | 122 ++++++++++++++++++ src/lib/i18n/strings.json | 13 ++ src/lib/useSleepTimerStatus.ts | 36 ++++++ 9 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 src/components/music/MusicSleepTimerButton.tsx create mode 100644 src/components/sleep/SleepTimerLabel.tsx create mode 100644 src/components/sleep/SleepTimerPanel.tsx create mode 100644 src/lib/useSleepTimerStatus.ts diff --git a/src/components/music/MusicSleepTimerButton.tsx b/src/components/music/MusicSleepTimerButton.tsx new file mode 100644 index 00000000..ee3929bd --- /dev/null +++ b/src/components/music/MusicSleepTimerButton.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { Moon } from "lucide-react"; + +import { getString } from "../../lib/i18n/index"; +import { SETTINGS } from "../../lib/settings/schema"; +import { useBoolPref } from "../../lib/usePreference"; +import { useSleepTimerStore } from "../../store/useSleepTimerStore"; +import { AnchoredPortalMenu, type MenuAnchor } from "../ui/AnchoredPortalMenu"; +import { SleepTimerPanel } from "../sleep/SleepTimerPanel"; +import { HapticButton } from "./HapticButton"; + +interface MusicSleepTimerButtonProps { + className: string; + /** The look of the button while a timer is set, as the player's other toggles show "on". */ + activeClassName: string; +} + +/** The music player's optional sleep timer button (a setting, off by default, as on Android). */ +export function MusicSleepTimerButton({ className, activeClassName }: MusicSleepTimerButtonProps) { + const [enabled] = useBoolPref(SETTINGS.MUSIC_SLEEP_TIMER_BUTTON, false); + const active = useSleepTimerStore((s) => s.mode !== "off"); + const [anchor, setAnchor] = useState(null); + if (!enabled) return null; + + return ( + <> + { + const rect = event.currentTarget.getBoundingClientRect(); + setAnchor({ top: rect.bottom + 6, right: rect.right }); + }} + aria-label={getString("sleep_timer")} + aria-pressed={active} + className={active ? activeClassName : className} + > + + + {anchor && ( + setAnchor(null)} + className="z-[70] w-72 rounded-xl border border-chrome-neutral-800 bg-surface-container-high p-2" + > + setAnchor(null)} /> + + )} + + ); +} diff --git a/src/components/music/player/NowPlayingColumn.tsx b/src/components/music/player/NowPlayingColumn.tsx index b84c598d..fc4f8f1c 100644 --- a/src/components/music/player/NowPlayingColumn.tsx +++ b/src/components/music/player/NowPlayingColumn.tsx @@ -14,6 +14,7 @@ import { HapticButton } from "../HapticButton"; import { MusicArtwork } from "../MusicArtwork"; import { MusicCardMenu, useMusicContextMenu } from "../MusicCardMenu"; import { MusicScrubber } from "../MusicScrubber"; +import { MusicSleepTimerButton } from "../MusicSleepTimerButton"; import { useNowPlayingMenuActions } from "../useNowPlayingMenuActions"; import { PlayerTransport } from "./PlayerTransport"; @@ -116,6 +117,7 @@ export function NowPlayingColumn({ track, compact, artworkHidden, onOpenEqualize
+ void handleLike()} aria-label={getString("music_like")} diff --git a/src/components/player/FlowPlayerControls.tsx b/src/components/player/FlowPlayerControls.tsx index 6870b0e5..8bf76826 100644 --- a/src/components/player/FlowPlayerControls.tsx +++ b/src/components/player/FlowPlayerControls.tsx @@ -49,6 +49,8 @@ import { import { SponsorBlockIcon } from "../ui/SponsorBlockIcon"; import { sponsorBlockCategoryLabel } from "../../lib/sponsorBlockCategories"; import { getString } from "../../lib/i18n/index"; +import { SleepTimerLabel } from "../sleep/SleepTimerLabel"; +import { SleepTimerPanel } from "../sleep/SleepTimerPanel"; import { Slider } from "../ui/Slider"; import { videoCodecLabel } from "../../lib/settings/playerRuntime"; @@ -80,9 +82,6 @@ export interface FlowPlayerControlsProps { bufferedPct: number; - sleepMinutes: number; - setSleepMinutes: React.Dispatch>; - muted: boolean; setMuted: React.Dispatch>; @@ -124,13 +123,6 @@ type SettingsPane = | "sleep"; const defaultSpeedOptions: PlaybackRate[] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; -const sleepOptions = [ - { label: "Off", minutes: 0 }, - { label: "15 min", minutes: 15 }, - { label: "30 min", minutes: 30 }, - { label: "45 min", minutes: 45 }, - { label: "1 hour", minutes: 60 }, -]; function cx(...classes: Array) { return classes.filter(Boolean).join(" "); @@ -174,8 +166,6 @@ export const FlowPlayerControls: React.FC = ({ selectedAudioTrackId, setSelectedAudioTrackId, bufferedPct, - sleepMinutes, - setSleepMinutes, muted, setMuted, isFullscreen, @@ -907,7 +897,7 @@ export const FlowPlayerControls: React.FC = ({ ? "Subtitle Style" : settingsPane === "audio" ? "Audio track" - : "Sleep timer"} + : getString("sleep_timer")} {settingsPane === "captions" && ( - ))} + setSettingsPane("root")} />
)} diff --git a/src/components/player/Player.tsx b/src/components/player/Player.tsx index c9dc7401..2f44983c 100644 --- a/src/components/player/Player.tsx +++ b/src/components/player/Player.tsx @@ -36,6 +36,7 @@ import { } from "../../lib/externalAudioSync"; import { useSubtitleSettingsSync } from "../../lib/useSubtitleSettingsSync"; import { openPopoutPlayer } from "../../lib/pipHandoff"; +import { sleepTimerTakesEnd } from "../../lib/useSleepTimerDriver"; import { formatPlaybackRate, normalizePlaybackRate, @@ -357,7 +358,6 @@ export const Player: React.FC = ({ const hideTimerRef = useRef | null>(null); const pointerInsideRef = useRef(false); const keyboardFocusInsideRef = useRef(false); - const sleepTimerRef = useRef | null>(null); const skippedSegmentsRef = useRef>(new Set()); const undoSkippedSegmentsRef = useRef>(new Set()); const mutedSegmentsRecordedRef = useRef>(new Set()); @@ -527,7 +527,6 @@ export const Player: React.FC = ({ setIsBuffering(false); }, []); const [bufferedPct, setBufferedPct] = useState(0); - const [sleepMinutes, setSleepMinutes] = useState(0); const [selectedCaptionId, setSelectedCaptionId] = useState("off"); const [selectedAudioTrackId, setSelectedAudioTrackId] = useState(null); const [hasStartedPlayback, setHasStartedPlayback] = useState(false); @@ -2142,16 +2141,6 @@ export const Player: React.FC = ({ }; }, []); - useEffect(() => { - if (sleepTimerRef.current) clearTimeout(sleepTimerRef.current); - if (sleepMinutes > 0) { - sleepTimerRef.current = setTimeout(() => setPlaybackDesired(false), sleepMinutes * 60_000); - } - return () => { - if (sleepTimerRef.current) clearTimeout(sleepTimerRef.current); - }; - }, [setPlaybackDesired, sleepMinutes]); - useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; @@ -2567,6 +2556,10 @@ export const Player: React.FC = ({ setIsPlaying(false); }} onEnded={() => { + if (sleepTimerTakesEnd()) { + setPlaybackDesired(false); + return; + } if (videoLoopEnabled && !isLive) { seekTo(0); setPlaybackDesired(true); @@ -2794,8 +2787,6 @@ export const Player: React.FC = ({ selectedAudioTrackId={selectedAudioTrackId} setSelectedAudioTrackId={setSelectedAudioTrackId} bufferedPct={bufferedPct} - sleepMinutes={sleepMinutes} - setSleepMinutes={setSleepMinutes} muted={muted} setMuted={setMuted} isFullscreen={isFullscreen} diff --git a/src/components/settings/tabs/PlayerTab.tsx b/src/components/settings/tabs/PlayerTab.tsx index 1cfbd179..dd4f39ba 100644 --- a/src/components/settings/tabs/PlayerTab.tsx +++ b/src/components/settings/tabs/PlayerTab.tsx @@ -37,6 +37,7 @@ export function PlayerTab() { const [followLightTheme, setFollowLightTheme] = useBoolPref(SETTINGS.MUSIC_PLAYER_FOLLOW_LIGHT_THEME); const [restoreMusicQueue, setRestoreMusicQueue] = useBoolPref(SETTINGS.MUSIC_RESTORE_QUEUE, true); const [resumeLongTracks, setResumeLongTracks] = useBoolPref(SETTINGS.MUSIC_RESUME_LONG_TRACKS, false); + const [sleepTimerButton, setSleepTimerButton] = useBoolPref(SETTINGS.MUSIC_SLEEP_TIMER_BUTTON, false); const [subtitles, setSubtitles] = useBoolPref(SETTINGS.SUBTITLES_ENABLED, false); const [subtitleLang, setSubtitleLang] = usePreference(SETTINGS.PREFERRED_SUBTITLE_LANGUAGE, 'en'); @@ -243,6 +244,9 @@ export function PlayerTab() { + + + ); diff --git a/src/components/sleep/SleepTimerLabel.tsx b/src/components/sleep/SleepTimerLabel.tsx new file mode 100644 index 00000000..c6e2bf1c --- /dev/null +++ b/src/components/sleep/SleepTimerLabel.tsx @@ -0,0 +1,9 @@ +import { useSleepTimerStatus } from "../../lib/useSleepTimerStatus"; + +/** + * The timer's short state (Off, the time left, End of song). Its own component + * so the once-a-second countdown re-renders only this text, not the player. + */ +export function SleepTimerLabel() { + return <>{useSleepTimerStatus().label}; +} diff --git a/src/components/sleep/SleepTimerPanel.tsx b/src/components/sleep/SleepTimerPanel.tsx new file mode 100644 index 00000000..e2afed74 --- /dev/null +++ b/src/components/sleep/SleepTimerPanel.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { Check, Music2 } from "lucide-react"; + +import { getString } from "../../lib/i18n/index"; +import { SLEEP_TIMER_PRESETS, parseCustomMinutes } from "../../lib/sleepTimer"; +import { useSleepTimerStatus } from "../../lib/useSleepTimerStatus"; +import { prefersQuitOnExpiry, setPrefersQuitOnExpiry, useSleepTimerStore } from "../../store/useSleepTimerStore"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; + +/** The video player's settings menu, or the music player's menu. */ +export type SleepTimerTone = "video" | "music"; + +const ROW: Record = { + video: + "flex h-10 w-full items-center justify-between gap-3 rounded-md px-3 text-left text-sm font-medium text-chrome-zinc-100 hover:bg-chrome-white/10", + music: + "flex h-10 w-full items-center justify-between gap-3 rounded-md px-3 text-left text-sm font-medium text-chrome-neutral-100 hover:bg-surface-container-highest", +}; + +const MUTED: Record = { + video: "text-chrome-zinc-300", + music: "text-chrome-neutral-400", +}; + +interface SleepTimerPanelProps { + tone: SleepTimerTone; + /** Called once a choice is made, so the menu holding the panel can close. */ + onDone: () => void; +} + +/** Presets, a custom time, End of song and Quit Flow, for the shared sleep timer. */ +export function SleepTimerPanel({ tone, onDone }: SleepTimerPanelProps) { + const status = useSleepTimerStatus(); + const activeQuit = useSleepTimerStore((s) => s.quitOnExpiry); + const start = useSleepTimerStore((s) => s.start); + const startEndOfSong = useSleepTimerStore((s) => s.startEndOfSong); + const cancel = useSleepTimerStore((s) => s.cancel); + const setQuitOnExpiry = useSleepTimerStore((s) => s.setQuitOnExpiry); + const [quit, setQuit] = useState(() => (status.mode === "off" ? prefersQuitOnExpiry() : activeQuit)); + const [custom, setCustom] = useState(""); + const [customInvalid, setCustomInvalid] = useState(false); + + const choose = (action: () => void) => { + action(); + onDone(); + }; + + const startCustom = () => { + const minutes = parseCustomMinutes(custom); + if (minutes === null) { + setCustomInvalid(true); + return; + } + choose(() => start(minutes, quit)); + }; + + const toggleQuit = (next: boolean) => { + setQuit(next); + void setPrefersQuitOnExpiry(next); + setQuitOnExpiry(next); + }; + + return ( +
+ {status.detail &&

{status.detail}

} + + {SLEEP_TIMER_PRESETS.map((minutes) => ( + + ))} + + + +
{ + event.preventDefault(); + startCustom(); + }} + > + { + setCustom(event.target.value); + setCustomInvalid(false); + }} + placeholder={getString("sleep_timer_custom")} + aria-label={getString("sleep_timer_custom")} + aria-invalid={customInvalid} + className="h-9 min-w-0 flex-1 rounded-md border border-chrome-neutral-700 bg-transparent px-2 text-sm text-chrome-neutral-100 outline-none placeholder:text-chrome-neutral-500 focus:border-chrome-neutral-500" + /> + +
+ {customInvalid &&

{getString("sleep_timer_custom_invalid")}

} + +
+ {getString("sleep_timer_quit_on_end")} + +
+ + {status.mode !== "off" && ( + + )} +
+ ); +} diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index b050850f..8c3cf8e0 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -661,6 +661,19 @@ "settings_music_restore_queue_desc": "Reopen the music player on the song you stopped at, paused, with its queue", "settings_music_resume_long_tracks": "Resume long tracks", "settings_music_resume_long_tracks_desc": "Mixes, podcasts and episodes of 10 minutes or more start where you left them", + "settings_music_sleep_timer_button": "Sleep timer button", + "settings_music_sleep_timer_button_desc": "Show a sleep timer button next to Like in the full music player", + "sleep_timer": "Sleep timer", + "sleep_timer_off": "Off", + "sleep_timer_minutes": "%s min", + "sleep_timer_custom": "Custom minutes", + "sleep_timer_custom_invalid": "Enter a whole number from 1 to 1440", + "sleep_timer_start": "Start", + "sleep_timer_end_of_song": "End of song", + "sleep_timer_quit_on_end": "Quit Flow when it ends", + "sleep_timer_turn_off": "Turn off timer", + "sleep_timer_stops_in": "Stops in %s", + "sleep_timer_stops_at_end": "Stops when this song ends", "settings_group_layout": "Layout", "settings_grid_columns": "Grid Columns", "settings_grid_columns_desc": "Up to %s videos per row — narrower windows automatically use fewer", diff --git a/src/lib/useSleepTimerStatus.ts b/src/lib/useSleepTimerStatus.ts new file mode 100644 index 00000000..82576c0d --- /dev/null +++ b/src/lib/useSleepTimerStatus.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +import { useSleepTimerStore, type SleepTimerMode } from "../store/useSleepTimerStore"; +import { getString } from "./i18n/index"; +import { formatRemaining } from "./sleepTimer"; + +export interface SleepTimerStatus { + mode: SleepTimerMode; + /** A short label: Off, the time left, or End of song. */ + label: string; + /** A sentence for the panel's header, or null while no timer is set. */ + detail: string | null; +} + +/** The timer's state for display, ticking once a second while a countdown runs. */ +export function useSleepTimerStatus(): SleepTimerStatus { + const mode = useSleepTimerStore((s) => s.mode); + const endsAt = useSleepTimerStore((s) => s.endsAt); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (mode !== "timer") return; + setNow(Date.now()); + const interval = setInterval(() => setNow(Date.now()), 1_000); + return () => clearInterval(interval); + }, [mode]); + + if (mode === "timer" && endsAt !== null) { + const left = formatRemaining(endsAt - now); + return { mode, label: left, detail: getString("sleep_timer_stops_in", left) }; + } + if (mode === "endOfSong") { + return { mode, label: getString("sleep_timer_end_of_song"), detail: getString("sleep_timer_stops_at_end") }; + } + return { mode, label: getString("sleep_timer_off"), detail: null }; +} From 735c2a46fde0c5335e2a3a8f6f07f6c840650360 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:51:44 +0300 Subject: [PATCH 3/3] docs(changelog): shared sleep timer with fade-out --- changelog/v0.1.0-beta3.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index a83ea355..c351884e 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -132,6 +132,9 @@ PLAYER - Shorts in Subscriptions are taken from each channel's own Shorts tab instead of guessed from length, so long Shorts and short videos land in the right place. Premieres show as upcoming instead of "Just now", members-only uploads are left out, and cards show view counts. - The Shorts feed's subscription lane finds your channels' Shorts again; it was asking for their Videos tab. - The Shorts feed loads its reels in pages about a hundred times smaller, drops the ads mixed into them, and gives them their real titles and channels so your interests and blocked channels apply to them. +- One sleep timer now serves both the video and the music player, with Flow for Android's choices: 10 to 90 minutes, a custom time, End of song, and Quit Flow when it ends, which is remembered. +- Music fades out over the last 20 seconds before the timer stops it, and the fade keeps going while Flow is in the background. +- A Sleep timer button can be added next to Like in the full music player from Settings, Player. SYNC - Syncing with a phone works in every direction again: the desktop handles collections it doesn't know yet, like Notes on newer Android builds, files each collection by name, and no longer hangs when it scans a phone's send code.