diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index 1eb0c964..6d1eb748 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -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. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 056bf6b0..5bc21904 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1482,6 +1482,7 @@ dependencies = [ "local-ip-address", "lofty", "mp4", + "objc2", "rand 0.10.3", "reqwest", "rust-ini", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 550dd716..ac91ef71 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/pip.rs b/src-tauri/src/commands/pip.rs index 7002823e..ec9ad925 100644 --- a/src-tauri/src/commands/pip.rs +++ b/src-tauri/src/commands/pip.rs @@ -168,6 +168,8 @@ pub async fn open_pip_window( #[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| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 54d59e2e..113f1ef7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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")] diff --git a/src-tauri/src/macos_window.rs b/src-tauri/src/macos_window.rs index 83e38742..4eb00ba8 100644 --- a/src-tauri/src/macos_window.rs +++ b/src-tauri/src/macos_window.rs @@ -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| { @@ -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(window: &WebviewWindow) { + 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 +} diff --git a/src/App.css b/src/App.css index 884c5049..26cc5486 100644 --- a/src/App.css +++ b/src/App.css @@ -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% { diff --git a/src/App.tsx b/src/App.tsx index 58a73d08..3de7af1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -151,7 +151,7 @@ function App() {
-
+
} /> diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx index 9c20fc78..a964d268 100644 --- a/src/components/music/GlobalMusicAudio.tsx +++ b/src/components/music/GlobalMusicAudio.tsx @@ -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); diff --git a/src/components/music/MusicOverlay.tsx b/src/components/music/MusicOverlay.tsx index 429580e7..6659579e 100644 --- a/src/components/music/MusicOverlay.tsx +++ b/src/components/music/MusicOverlay.tsx @@ -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"; @@ -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; @@ -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" > diff --git a/src/components/music/MusicScrubber.tsx b/src/components/music/MusicScrubber.tsx index de1dd2a0..231668e6 100644 --- a/src/components/music/MusicScrubber.tsx +++ b/src/components/music/MusicScrubber.tsx @@ -1,3 +1,4 @@ +import { musicAudioEngine } from "../../lib/audio/musicAudioEngine"; import { useMusicPlayerStore } from "../../store/useMusicPlayerStore"; import { MediaScrubber } from "../ui/MediaScrubber"; @@ -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 ( ); } + +const readAudioClock = () => musicAudioEngine.getCurrentTime(); diff --git a/src/components/ui/MediaScrubber.tsx b/src/components/ui/MediaScrubber.tsx index 9d46eb96..b9d38d62 100644 --- a/src/components/ui/MediaScrubber.tsx +++ b/src/components/ui/MediaScrubber.tsx @@ -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"; @@ -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({ @@ -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(null); + const fillRef = useRef(null); + const thumbRef = useRef(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; @@ -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) => { 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) => { if (!dragging || duration <= 0) return; - const r = ratioFromClientX(e.clientX); - setDragRatio(r); - onSeek(r * duration); + moveDrag(ratioFromClientX(e.clientX)); }; const endDrag = (e: React.PointerEvent) => { @@ -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) => { @@ -136,11 +181,13 @@ export function MediaScrubber({ >