Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/components/music/GlobalMusicAudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +38,7 @@ export function GlobalMusicAudio() {

useMusicListenHistory();
useMusicQueuePersistence();
useSleepTimerDriver();
useMusicMediaSession();

const store = () => useMusicPlayerStore.getState();
Expand Down Expand Up @@ -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.
Expand All @@ -87,6 +90,7 @@ export function GlobalMusicAudio() {
}),
onEnded: fromActive(() => {
musicListens.finish(Date.now());
if (sleepTimerTakesEnd()) return;
store().handleEnded();
}),
onError: (event: SyntheticEvent<HTMLAudioElement>) => {
Expand Down
49 changes: 49 additions & 0 deletions src/components/music/MusicSleepTimerButton.tsx
Original file line number Diff line number Diff line change
@@ -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<MenuAnchor | null>(null);
if (!enabled) return null;

return (
<>
<HapticButton
onClick={(event) => {
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}
>
<Moon className="h-5 w-5" />
</HapticButton>
{anchor && (
<AnchoredPortalMenu
anchor={anchor}
onClose={() => setAnchor(null)}
className="z-[70] w-72 rounded-xl border border-chrome-neutral-800 bg-surface-container-high p-2"
>
<SleepTimerPanel tone="music" onDone={() => setAnchor(null)} />
</AnchoredPortalMenu>
)}
</>
);
}
2 changes: 2 additions & 0 deletions src/components/music/player/NowPlayingColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -116,6 +117,7 @@ export function NowPlayingColumn({ track, compact, artworkHidden, onOpenEqualize
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
<MusicSleepTimerButton className={META_BTN} activeClassName={`${META_BTN} text-[var(--color-primary)]`} />
<HapticButton
onClick={() => void handleLike()}
aria-label={getString("music_like")}
Expand Down
35 changes: 6 additions & 29 deletions src/components/player/FlowPlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -80,9 +82,6 @@ export interface FlowPlayerControlsProps {

bufferedPct: number;

sleepMinutes: number;
setSleepMinutes: React.Dispatch<React.SetStateAction<number>>;

muted: boolean;
setMuted: React.Dispatch<React.SetStateAction<boolean>>;

Expand Down Expand Up @@ -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<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
Expand Down Expand Up @@ -174,8 +166,6 @@ export const FlowPlayerControls: React.FC<FlowPlayerControlsProps> = ({
selectedAudioTrackId,
setSelectedAudioTrackId,
bufferedPct,
sleepMinutes,
setSleepMinutes,
muted,
setMuted,
isFullscreen,
Expand Down Expand Up @@ -907,7 +897,7 @@ export const FlowPlayerControls: React.FC<FlowPlayerControlsProps> = ({
? "Subtitle Style"
: settingsPane === "audio"
? "Audio track"
: "Sleep timer"}
: getString("sleep_timer")}
</button>
{settingsPane === "captions" && (
<button
Expand Down Expand Up @@ -957,8 +947,8 @@ export const FlowPlayerControls: React.FC<FlowPlayerControlsProps> = ({
() => setSettingsPane("audio")
)}
{renderSettingRow(
"Sleep timer",
sleepMinutes ? `${sleepMinutes} min` : "Off",
getString("sleep_timer"),
<SleepTimerLabel />,
<Pause size={18} />,
() => setSettingsPane("sleep")
)}
Expand Down Expand Up @@ -1184,20 +1174,7 @@ export const FlowPlayerControls: React.FC<FlowPlayerControlsProps> = ({

{settingsPane === "sleep" && (
<div className="space-y-1 animate-pane-in max-h-60 overflow-y-auto pr-1 select-scrollbar-hidden">
{sleepOptions.map((option) => (
<button
key={option.minutes}
type="button"
onClick={() => {
setSleepMinutes(option.minutes);
setSettingsPane("root");
}}
className="flex h-10 w-full items-center justify-between rounded-md px-3 text-sm font-medium hover:bg-chrome-white/10"
>
<span>{option.label}</span>
{sleepMinutes === option.minutes && <Check size={17} />}
</button>
))}
<SleepTimerPanel tone="video" onDone={() => setSettingsPane("root")} />
</div>
)}
</div>
Expand Down
19 changes: 5 additions & 14 deletions src/components/player/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -357,7 +358,6 @@ export const Player: React.FC<PlayerProps> = ({
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pointerInsideRef = useRef(false);
const keyboardFocusInsideRef = useRef(false);
const sleepTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const skippedSegmentsRef = useRef<Set<string>>(new Set());
const undoSkippedSegmentsRef = useRef<Set<string>>(new Set());
const mutedSegmentsRecordedRef = useRef<Set<string>>(new Set());
Expand Down Expand Up @@ -527,7 +527,6 @@ export const Player: React.FC<PlayerProps> = ({
setIsBuffering(false);
}, []);
const [bufferedPct, setBufferedPct] = useState(0);
const [sleepMinutes, setSleepMinutes] = useState(0);
const [selectedCaptionId, setSelectedCaptionId] = useState<string>("off");
const [selectedAudioTrackId, setSelectedAudioTrackId] = useState<string | null>(null);
const [hasStartedPlayback, setHasStartedPlayback] = useState(false);
Expand Down Expand Up @@ -2142,16 +2141,6 @@ export const Player: React.FC<PlayerProps> = ({
};
}, []);

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;
Expand Down Expand Up @@ -2567,6 +2556,10 @@ export const Player: React.FC<PlayerProps> = ({
setIsPlaying(false);
}}
onEnded={() => {
if (sleepTimerTakesEnd()) {
setPlaybackDesired(false);
return;
}
if (videoLoopEnabled && !isLive) {
seekTo(0);
setPlaybackDesired(true);
Expand Down Expand Up @@ -2794,8 +2787,6 @@ export const Player: React.FC<PlayerProps> = ({
selectedAudioTrackId={selectedAudioTrackId}
setSelectedAudioTrackId={setSelectedAudioTrackId}
bufferedPct={bufferedPct}
sleepMinutes={sleepMinutes}
setSleepMinutes={setSleepMinutes}
muted={muted}
setMuted={setMuted}
isFullscreen={isFullscreen}
Expand Down
4 changes: 4 additions & 0 deletions src/components/settings/tabs/PlayerTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -243,6 +244,9 @@ export function PlayerTab() {
<SettingItem title={getString('settings_music_resume_long_tracks')} description={getString('settings_music_resume_long_tracks_desc')}>
<ToggleSwitch checked={resumeLongTracks} onChange={setResumeLongTracks} />
</SettingItem>
<SettingItem title={getString('settings_music_sleep_timer_button')} description={getString('settings_music_sleep_timer_button_desc')}>
<ToggleSwitch checked={sleepTimerButton} onChange={setSleepTimerButton} />
</SettingItem>
</SettingsGroup>
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions src/components/sleep/SleepTimerLabel.tsx
Original file line number Diff line number Diff line change
@@ -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}</>;
}
Loading
Loading