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
2 changes: 2 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ MUSIC
- Music search only offers the categories YouTube Music has results for, and its filters use the parameters YouTube Music sends today.
- An artist's Shuffle plays YouTube Music's shuffle of their whole catalogue and Mix plays their station, instead of both reshuffling the few top songs on the page. Artist pages sort their shelves the same way in every language and link the sources under the bio.
- Songs added by radio keep their album, explicit badge and song or video type, and radio continues YouTube Music's own mix for the song. Lyrics from YouTube Music show their source.
- The full player's seek bar glides with the song instead of stepping, and dragging it seeks once when you let go instead of on every move. The page behind the full player stops drawing while it is covered.
- Mood and genre pages keep their shelves with titles instead of one long grid, album cards start playing without loading the album first, and playlist cards show their song count instead of the word Playlist.

MACOS
- Flow uses the native macOS window controls and titlebar again. Closing the window hides it and keeps playback and downloads running until you quit, and clicking the Dock icon brings it back.
- The window background now follows your theme. A Tauri bug was clearing it, which showed a mismatched color around the app while resizing.
- On 120 Hz ProMotion screens Flow now draws at 120 frames per second. WebKit was holding every animation, scroll and lyric line to 60.

LINUX
- The AppImage is built on Ubuntu 24.04 with a newer WebKitGTK that no longer crashes on color emoji fonts, the cause of the freeze on Fedora 44 and Bazzite. It needs glibc 2.39 or newer, so on Ubuntu 22.04, Mint 21 or Debian 12 use the .deb instead.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ windows = { version = "0.62", features = ["Win32_UI_Shell"] }
# Single-instance: forward a second `flow://` launch to the running window
# instead of spawning a duplicate. The "deep-link" feature makes it hand the
# argv URL to the deep-link plugin's on_open_url automatically.
# The version wry uses, for WebKit preferences wry does not expose (src/macos_window.rs).
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"

[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-window-state = "2"
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/commands/pip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ pub async fn open_pip_window<R: Runtime>(

#[cfg(target_os = "linux")]
crate::web_process::watch(&window);
#[cfg(target_os = "macos")]
crate::macos_window::allow_full_frame_rate(&window);

let handle = app.clone();
window.on_window_event(move |event| {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
if let Some(main_window) = app.get_webview_window("main") {
macos_window::install_close_handler(&main_window);
macos_window::allow_full_frame_rate(&main_window);
}

#[cfg(target_os = "linux")]
Expand Down
70 changes: 70 additions & 0 deletions src-tauri/src/macos_window.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
use objc2::runtime::AnyObject;
use objc2::{class, msg_send, sel};
use tauri::{AppHandle, Manager, WebviewWindow, WindowEvent};

/// The `WebKit` feature key for rendering pages near 60 fps on faster displays.
const NEAR_60_FPS_FEATURE: &std::ffi::CStr = c"PreferPageRenderingUpdatesNear60FPSEnabled";

pub fn install_close_handler(window: &WebviewWindow) {
let main_window = window.clone();
window.on_window_event(move |event| {
Expand All @@ -24,3 +29,68 @@ pub fn reopen(app: &AppHandle) {
}
}
}

/// Lets the webview render at the display's own rate. `WebKit` turns on "Prefer
/// Page Rendering Updates near 60fps" by default, so on a 120 Hz `ProMotion`
/// screen every animation, scroll and frame loop in the app ran at 60 fps.
pub fn allow_full_frame_rate<R: tauri::Runtime>(window: &WebviewWindow<R>) {
let result = window.with_webview(|webview| {
// SAFETY: `inner()` is the live WKWebView, and this closure runs on
// the main thread, where AppKit objects may be used.
let disabled = unsafe { disable_near_60_fps(webview.inner().cast()) };
if !disabled {
tracing::info!("frame_rate_preference_unavailable");
}
});
if let Err(error) = result {
tracing::warn!(%error, "frame_rate_preference_failed");
}
}

/// Turns the feature off through the `WebKit` feature list, which is private
/// API: every step checks the selector first, so a `WebKit` without it is left
/// untouched.
unsafe fn disable_near_60_fps(view: *mut AnyObject) -> bool {
let Some(view) = (unsafe { view.as_ref() }) else {
return false;
};
let preferences_class = class!(WKPreferences);
let lists_features: bool =
unsafe { msg_send![preferences_class, respondsToSelector: sel!(_features)] };
if !lists_features {
return false;
}
let configuration: *mut AnyObject = unsafe { msg_send![view, configuration] };
let Some(configuration) = (unsafe { configuration.as_ref() }) else {
return false;
};
let preferences: *mut AnyObject = unsafe { msg_send![configuration, preferences] };
let Some(preferences) = (unsafe { preferences.as_ref() }) else {
return false;
};
let can_set: bool =
unsafe { msg_send![preferences, respondsToSelector: sel!(_setEnabled:forFeature:)] };
if !can_set {
return false;
}
let wanted: *mut AnyObject =
unsafe { msg_send![class!(NSString), stringWithUTF8String: NEAR_60_FPS_FEATURE.as_ptr()] };
let features: *mut AnyObject = unsafe { msg_send![preferences_class, _features] };
let Some(features) = (unsafe { features.as_ref() }) else {
return false;
};
let count: usize = unsafe { msg_send![features, count] };
for index in 0..count {
let feature: *mut AnyObject = unsafe { msg_send![features, objectAtIndex: index] };
let Some(feature) = (unsafe { feature.as_ref() }) else {
continue;
};
let key: *mut AnyObject = unsafe { msg_send![feature, key] };
let matches: bool = unsafe { msg_send![key, isEqualToString: wanted] };
if matches {
let _: () = unsafe { msg_send![preferences, _setEnabled: false, forFeature: feature] };
return true;
}
}
false
}
5 changes: 5 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@
animation: sidebar-slide-in 0.2s cubic-bezier(0.2, 0, 0, 1) forwards;
}

/* The routed page while the full music player covers it (useCoverAppContent). */
[data-app-content][data-covered] {
content-visibility: hidden;
}

@keyframes music-wave {
0%,
100% {
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function App() {
<div style={windowLayoutStyle} className="relative flex h-screen flex-col overflow-hidden bg-background text-chrome-zinc-100 font-sans">
<ThemeController />
<TitleBar />
<div className="relative min-h-0 flex-1">
<div data-app-content className="relative min-h-0 flex-1">
<Routes>
<Route path="/onboarding" element={<Onboarding />} />

Expand Down
12 changes: 6 additions & 6 deletions src/components/music/GlobalMusicAudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@ export function GlobalMusicAudio() {
if (duration > 0 && duration - el.currentTime <= PRELOAD_LEAD_SECONDS) store()._preloadUpcoming();

/*
The store's clock follows this event (about 4 Hz) instead of a frame
loop. A store write notifies every subscriber, so each one costs a
selector run across the whole music UI, and MusicItemCard holds six of
them per card. The only consumer of `progress` is MusicScrubber, whose
bar and second-granularity readout can't show more than this; the
lyrics canvas reads the audio engine directly for frame accuracy.
The store's clock follows this event instead of a frame loop, and
`_syncTime` publishes it only when the whole second changes. A store
write notifies every subscriber, so each one costs a selector run
across the whole music UI, and MusicItemCard holds six of them per
card. Anything that needs the exact position (the full player's seek
bar, the lyrics) reads the audio engine directly.
*/
store()._syncTime(el.currentTime, el.duration);

Expand Down
11 changes: 11 additions & 0 deletions src/components/music/MusicOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {

import { useMusicPlayerStore } from "../../store/useMusicPlayerStore";
import { useMusicPlayerError } from "../../lib/useMusicPlayerError";
import { useCoverAppContent } from "../../lib/useCoverAppContent";
import { PlayerErrorState } from "../ui/PlayerErrorState";
import { useLikesStore } from "../../store/useLikesStore";
import { useUiStore } from "../../store/useUiStore";
Expand Down Expand Up @@ -109,6 +110,12 @@ export function MusicOverlay() {
);

const open = overlayOpen;
// The page only goes away once the fade-in has finished covering it.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!open) setEntered(false);
}, [open]);
useCoverAppContent(open && entered);
const isQueue = viewState === "queue";
const isLyrics = viewState === "lyrics";
const loading = isBuffering || loadingStreamId !== null;
Expand Down Expand Up @@ -152,6 +159,10 @@ export function MusicOverlay() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
onAnimationComplete={(definition) => {
// Also fires when the exit fade ends; only the fade-in counts.
if ((definition as { opacity?: number }).opacity === 1) setEntered(true);
}}
className="fixed inset-x-0 bottom-0 top-[var(--app-titlebar-height)] z-[60] flex flex-col overflow-hidden bg-chrome-neutral-950"
>
<AmbientBackdrop src={currentTrack.thumbnail} accent={accent} style={backgroundStyle} />
Expand Down
8 changes: 8 additions & 0 deletions src/components/music/MusicScrubber.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { musicAudioEngine } from "../../lib/audio/musicAudioEngine";
import { useMusicPlayerStore } from "../../store/useMusicPlayerStore";
import { MediaScrubber } from "../ui/MediaScrubber";

Expand All @@ -21,6 +22,7 @@ export function MusicScrubber({
const progress = useMusicPlayerStore((s) => s.progress);
const duration = useMusicPlayerStore((s) => s.duration);
const seek = useMusicPlayerStore((s) => s.seek);
const isPlaying = useMusicPlayerStore((s) => s.isPlaying);

return (
<MediaScrubber
Expand All @@ -33,6 +35,12 @@ export function MusicScrubber({
countdown={countdown}
className={className}
expressive={expressive}
// Only the full player's large bar follows the audio frame by frame; the
// dock's edge line moves too little per second to need it.
clock={size === "lg" ? readAudioClock : undefined}
playing={isPlaying}
/>
);
}

const readAudioClock = () => musicAudioEngine.getCurrentTime();
63 changes: 55 additions & 8 deletions src/components/ui/MediaScrubber.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState, type CSSProperties } from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type React from "react";

import { getString } from "../../lib/i18n/index";
Expand All @@ -17,6 +17,12 @@ interface MediaScrubberProps {
className?: string;
/** Opt in to the Material 3 Expressive look; ignored when the user turns it off. */
expressive?: boolean;
/**
* The media's own clock. While `playing`, the bar follows it every frame
* instead of waiting for `progress`, which only changes about once a second.
*/
clock?: () => number;
playing?: boolean;
}

export function MediaScrubber({
Expand All @@ -30,17 +36,51 @@ export function MediaScrubber({
ariaLabel = getString("music_seek"),
className = "",
expressive = false,
clock,
playing = false,
}: MediaScrubberProps) {
const expressiveEnabled = useExpressiveSliders();
const isExpressive = expressive && expressiveEnabled;
const trackRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLSpanElement | null>(null);
const [dragging, setDragging] = useState(false);
const [dragRatio, setDragRatio] = useState(0);
const dragRatioRef = useRef(0);

const liveRatio = duration > 0 ? Math.min(1, Math.max(0, progress / duration)) : 0;
const following = Boolean(clock) && playing && !dragging && duration > 0;
const toRatio = (seconds: number) => (duration > 0 ? Math.min(1, Math.max(0, seconds / duration)) : 0);
// Read the clock while rendering too, so a re-render never pulls the bar back
// to the last whole second the store saw.
const liveRatio = toRatio(following && clock ? clock() : progress);
const ratio = dragging ? dragRatio : liveRatio;
const pct = ratio * 100;

useEffect(() => {
if (!following || !clock) return;
let frame: number | null = null;
const paint = () => {
const value = Math.min(1, Math.max(0, clock() / duration));
if (isExpressive) {
trackRef.current?.style.setProperty("--m3-fill", String(value));
} else {
if (fillRef.current) fillRef.current.style.width = `${value * 100}%`;
if (thumbRef.current) thumbRef.current.style.left = `${value * 100}%`;
}
frame = document.visibilityState === "visible" ? requestAnimationFrame(paint) : null;
};
// A hidden window gets no frames; pick the loop back up when it returns.
const onVisibility = () => {
if (document.visibilityState === "visible" && frame === null) frame = requestAnimationFrame(paint);
};
frame = requestAnimationFrame(paint);
document.addEventListener("visibilitychange", onVisibility);
return () => {
if (frame !== null) cancelAnimationFrame(frame);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [following, clock, duration, isExpressive]);

const ratioFromClientX = (clientX: number): number => {
const el = trackRef.current;
if (!el) return 0;
Expand All @@ -49,20 +89,23 @@ export function MediaScrubber({
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};

// A drag previews its position and seeks once, on release: every seek makes
// the media element drop its buffer and fetch again from the new spot.
const moveDrag = (r: number) => {
dragRatioRef.current = r;
setDragRatio(r);
};

const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (duration <= 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
const r = ratioFromClientX(e.clientX);
setDragging(true);
setDragRatio(r);
onSeek(r * duration);
moveDrag(ratioFromClientX(e.clientX));
};

const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging || duration <= 0) return;
const r = ratioFromClientX(e.clientX);
setDragRatio(r);
onSeek(r * duration);
moveDrag(ratioFromClientX(e.clientX));
};

const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
Expand All @@ -72,7 +115,9 @@ export function MediaScrubber({
} catch {
/* pointer already released */
}
if (e.type === "pointerup") moveDrag(ratioFromClientX(e.clientX));
setDragging(false);
onSeek(dragRatioRef.current * duration);
};

const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -136,11 +181,13 @@ export function MediaScrubber({
>
<div className={`relative ${trackH} w-full overflow-hidden ${trackShape}`}>
<div
ref={fillRef}
className={`absolute inset-y-0 left-0 bg-[var(--color-primary)] ${fillShape}`}
style={{ width: `${pct}%` }}
/>
</div>
<span
ref={thumbRef}
className={`pointer-events-none absolute ${thumbPosition} ${thumbSize} -translate-x-1/2 rounded-full bg-chrome-white transition-opacity duration-150 ${
dragging ? "opacity-100" : "opacity-0 group-hover/scrub:opacity-100"
}`}
Expand Down
21 changes: 21 additions & 0 deletions src/lib/useCoverAppContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect } from "react";

/**
* While an opaque full-window layer covers the routed page, the page is kept
* mounted (scroll position and state survive) but marked inert and hidden from
* rendering, so its playing waves, spinners and shimmers stop painting frames
* nobody can see and keyboard focus can't wander behind the layer.
*/
export function useCoverAppContent(covering: boolean): void {
useEffect(() => {
if (!covering) return;
const content = document.querySelector<HTMLElement>("[data-app-content]");
if (!content) return;
content.inert = true;
content.dataset.covered = "true";
return () => {
content.inert = false;
delete content.dataset.covered;
};
}, [covering]);
}
Loading
Loading