From a2f85afcc2d0a5f09691363f93285cc5238f3296 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:15:45 +0300 Subject: [PATCH 1/6] perf(macos): render at the display's full refresh rate --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 4 ++ src-tauri/src/commands/pip.rs | 2 + src-tauri/src/lib.rs | 1 + src-tauri/src/macos_window.rs | 69 +++++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+) 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..5fbfd10f 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}; +/// WebKit's 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,67 @@ 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 WebKit's feature list, which is SPI: 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 +} From 5bed2110eda4fbf9eddef36140339d060867dc90 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:07 +0300 Subject: [PATCH 2/6] perf(music): move the full player's seek bar with the audio clock and seek on release --- src/components/music/MusicScrubber.tsx | 8 ++++ src/components/ui/MediaScrubber.tsx | 63 ++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 8 deletions(-) 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({ >
Date: Sat, 24 Oct 2026 15:26:07 +0300 Subject: [PATCH 3/6] perf(music): publish the playback time once a second --- src/components/music/GlobalMusicAudio.tsx | 12 +++---- src/store/useMusicPlayerStore.clock.test.ts | 35 +++++++++++++++++++++ src/store/useMusicPlayerStore.ts | 13 +++++--- 3 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 src/store/useMusicPlayerStore.clock.test.ts 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/store/useMusicPlayerStore.clock.test.ts b/src/store/useMusicPlayerStore.clock.test.ts new file mode 100644 index 00000000..0207c66f --- /dev/null +++ b/src/store/useMusicPlayerStore.clock.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/api/music", () => ({ + getMusicWatchQueue: vi.fn(), + getMusicQueueContinuation: vi.fn(), + rankMusicCandidates: vi.fn(), +})); + +import { useMusicPlayerStore } from "./useMusicPlayerStore"; + +describe("the published music clock", () => { + beforeEach(() => { + useMusicPlayerStore.setState({ progress: 0, duration: 200 }); + }); + + it("notifies subscribers once per whole second", () => { + const listener = vi.fn(); + const unsubscribe = useMusicPlayerStore.subscribe(listener); + const { _syncTime } = useMusicPlayerStore.getState(); + for (const t of [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]) _syncTime(t, 200); + unsubscribe(); + expect(listener).toHaveBeenCalledTimes(2); + expect(useMusicPlayerStore.getState().progress).toBe(2); + }); + + it("publishes a new duration even within the same second", () => { + useMusicPlayerStore.getState()._syncTime(0.4, 215); + expect(useMusicPlayerStore.getState().duration).toBe(215); + }); + + it("keeps the listed duration when the element reports an endless stream", () => { + useMusicPlayerStore.getState()._syncTime(3.2, Number.POSITIVE_INFINITY); + expect(useMusicPlayerStore.getState()).toMatchObject({ progress: 3.2, duration: 200 }); + }); +}); diff --git a/src/store/useMusicPlayerStore.ts b/src/store/useMusicPlayerStore.ts index 9e07758b..d646996f 100644 --- a/src/store/useMusicPlayerStore.ts +++ b/src/store/useMusicPlayerStore.ts @@ -802,12 +802,15 @@ export const useMusicPlayerStore = create((set, get) => ({ // --- internal (element → store) -------------------------------------- + // Every store write runs every subscriber's selector, so the clock is only + // published when the whole second changes; the full player's bar reads the + // audio element itself for anything finer. _syncTime: (progress, duration) => { - const next: Partial = { - progress: Number.isFinite(progress) ? progress : 0, - }; - if (Number.isFinite(duration) && duration > 0) next.duration = duration; - set(next); + const seconds = Number.isFinite(progress) ? progress : 0; + const nextDuration = Number.isFinite(duration) && duration > 0 ? duration : get().duration; + const current = get(); + if (Math.floor(seconds) === Math.floor(current.progress) && nextDuration === current.duration) return; + set({ progress: seconds, duration: nextDuration }); }, _reflectPlaying: (isPlaying) => set({ isPlaying }), From e22cdbcb0c3ec73d7ea2ad2150cb849b41f781f0 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:07 +0300 Subject: [PATCH 4/6] perf(music): stop painting the page hidden behind the full player --- src/App.css | 5 +++++ src/App.tsx | 2 +- src/components/music/MusicOverlay.tsx | 11 +++++++++++ src/lib/useCoverAppContent.ts | 21 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/lib/useCoverAppContent.ts 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/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/lib/useCoverAppContent.ts b/src/lib/useCoverAppContent.ts new file mode 100644 index 00000000..ff7258c1 --- /dev/null +++ b/src/lib/useCoverAppContent.ts @@ -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("[data-app-content]"); + if (!content) return; + content.inert = true; + content.dataset.covered = "true"; + return () => { + content.inert = false; + delete content.dataset.covered; + }; + }, [covering]); +} From ced789ed43ac6ec07b027de1e243881d526fcedf Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:21 +0300 Subject: [PATCH 5/6] docs(changelog): note the smoother player and 120 Hz drawing --- changelog/v0.1.0-beta3.txt | 2 ++ 1 file changed, 2 insertions(+) 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. From 7e17e0255477a66ed0706e5027f58bd90683b472 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:16:53 +0300 Subject: [PATCH 6/6] docs(macos): quote engine names in the frame rate comments --- src-tauri/src/macos_window.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/macos_window.rs b/src-tauri/src/macos_window.rs index 5fbfd10f..4eb00ba8 100644 --- a/src-tauri/src/macos_window.rs +++ b/src-tauri/src/macos_window.rs @@ -2,7 +2,7 @@ use objc2::runtime::AnyObject; use objc2::{class, msg_send, sel}; use tauri::{AppHandle, Manager, WebviewWindow, WindowEvent}; -/// WebKit's feature key for rendering pages near 60 fps on faster displays. +/// 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) { @@ -30,8 +30,8 @@ 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 +/// 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| { @@ -47,8 +47,9 @@ pub fn allow_full_frame_rate(window: &WebviewWindow) { } } -/// Turns the feature off through WebKit's feature list, which is SPI: every -/// step checks the selector first, so a WebKit without it is left untouched. +/// 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;