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
4 changes: 2 additions & 2 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ The line being sung takes a colour of the user's choosing in the immersive lyric

Entry points in the [`PlayerBar`](../../src/components/player/PlayerBar.tsx): clicking the cover (mirrors Spotify) or the Immersive button, plus the Maximize2 icon in the side `LyricsPanel` header. `PlayerContext` exposes `immersiveOpen` + `immersiveInitialTab` + `openImmersive`/`closeImmersive`; the old `openFullscreenNowPlaying`/`openFullscreenLyrics`/`close*` names stay as back-compat aliases that all drive the one merged view.

**Transition hygiene** — the view paints a solid `bg-zinc-950` on the outer wrapper from the first frame; the `animate-fade-in` keyframe lives on the inner backdrop + foreground layers, not the wrapper. Without that opaque base the wrapper's own opacity ramp (0 → 1 over 300 ms) would let the page underneath bleed through during the transition.
**Transition hygiene** — the view paints a solid `bg-zinc-950` on the outer wrapper from the first frame; the `animate-fade-in` keyframe lives on the inner backdrop + foreground layers, not the wrapper. Without that opaque base the wrapper's own opacity ramp (0 → 1 over 300 ms) would let the page underneath bleed through during the transition. **Linux opens without the fade.** WebKitGTK 2.54 rebuilds its layers each time a video appears inside a full-window layer that is still animating, and a motion cover mounting during the fade blacked the window out three times. Without the fade one short flash is left, WebKit creating the video layer; neither the backdrop blur nor the clip's own 700 ms fade played a part, each ruled out on its own. The AppImage's older WebKit never flashed.

**Skin neutrality** — the immersive view carries `role="dialog"` + a `shadow-2xl` cover, which the skins' modal / surface / text-colour chrome would otherwise capture (Liquid-light + Editorial repainted it as a light glass slab with dark, invisible text + washed-out controls; Lounge / Pulse flattened the cover backdrop). The root is tagged `data-immersive` + a nested `dark` context (so the shared `PlaybackControls` / `VolumeControl` / `ProgressBar` render their dark-theme variants over the always-dark backdrop), and every skin's relevant rules carry `:not([data-immersive]):not([data-immersive] *)` so the view renders identically across skins.

Expand All @@ -184,7 +184,7 @@ Entry points in the [`PlayerBar`](../../src/components/player/PlayerBar.tsx): cl

**Playing a local clip on Linux.** WebKitGTK plays `<video>` through GStreamer, and none of the obvious ways to hand it a local file works for every clip, which is why Canvas clips and motion covers never played on Linux before 1.8.0. The asset protocol has no GStreamer source ("Requested protocol: asset (allowed: no)"; allowing it with `WEBKIT_GST_ALLOWED_URI_PROTOCOLS` only moves the failure to "no URI handler implemented for asset"). A `blob:` URL corrupts a **fragmented** MP4 (`moof`/`mdat` pairs, which is what Apple's motion covers are) and errors on a 20 MB ordinary one, and MediaSource never finishes appending one. Over HTTP an ordinary MP4 plays, but a fragmented one stops after about two seconds for good: WebKit suspends the download once its queue fills and never resumes it — measured against a local server and against Apple's CDN alike, so it is not the server, and the system WebKit of a native package does no better. So [`usePlayableVideo`](../../src/hooks/usePlayableVideo.ts), shared by `CanvasStage` and [`MotionCoverOverlay`](../../src/components/player/MotionCoverOverlay.tsx), streams every local clip from [`media_loopback`](../../src-tauri/crates/app/src/media_loopback.rs), an HTTP server on `127.0.0.1` started on first use, with an ephemeral port and a token minted per launch, answering `Range` requests for video files the asset scope already allows and nothing else. Its path must be absolute with no `..` component, since the scope patterns match text; beyond that the check is the asset protocol's own. The first time the server is asked for a fragmented file, it converts it to an ordinary MP4 ([`mp4_defrag`](../../src-tauri/crates/core/src/artwork/mp4_defrag.rs): every frame copied as is, one index up front, the timeline moved to zero — Apple's covers start ten seconds into their own), which takes a few tens of milliseconds. A file under WaveFlow's own data or cache directories is rewritten in place; anything else, such as a clip in the user's music folder, is never touched, and the converted copy goes to `linux_video/` under the cache root (512 MB, oldest evicted first) to be served instead. An uncached motion cover would reach the webview as a remote URL and stall, so on Linux [`fetch_album_motion_artwork`](../../src-tauri/crates/app/src/commands/motion_artwork.rs) downloads it whatever the cache setting says: with the cache off, into a 256 MB LRU of its own (`motion_cache/linux-playback/`) that the Settings tally leaves out and "Clear cache" empties. Remote URLs, and every URL on Windows and macOS, are used as they are.

Two WebKitGTK limits remain. The first pass of a long clip still jumps back to its start after two or three seconds, WebKit reloading what it stopped downloading; after that it loops. And a looping `<video>` goes blank while it seeks back to its start, which lets the static cover flash through, so [`useLoopFrameHold`](../../src/hooks/usePlayableVideo.ts) copies the current frame into a `<canvas>` under the video at each `timeupdate` and shows it during that seek — at each update rather than near the reported end, because WebKit can loop well before the `duration` it reports. Neither runs on Windows or macOS. The AppImage additionally needs its own GStreamer plugins — see [RELEASING.md](../RELEASING.md#the-appimage-carries-its-own-gstreamer).
Two WebKitGTK limits remain. The first pass of a long clip still jumps back to its start after two or three seconds, WebKit reloading what it stopped downloading; after that it loops. And a looping `<video>` goes blank while it seeks back to its start, which lets the static cover flash through, so [`useLoopFrameHold`](../../src/hooks/usePlayableVideo.ts) copies the current frame into a `<canvas>` under the video at each `timeupdate` and shows it during that seek — at each update rather than near the reported end, because WebKit can loop well before the `duration` it reports. That copy needs frames the page can read back, and WebKitGTK's DMA-BUF video sink hands out GL textures it cannot map (`Cannot map External OES textures`, four times a second, on Fedora's WebKitGTK 2.54, where the held frame stayed empty), so [`render_mode::apply`](../../src-tauri/crates/app/src/render_mode.rs) sets `WEBKIT_GST_DMABUF_SINK_DISABLED=1` on Linux in every mode unless the user set it. The GL sink WebKit falls back to keeps the compositor's DMA-BUF path; measured on an Iris Xe, nothing slowed down, unlike `WEBKIT_DISABLE_DMABUF_RENDERER`, which made the whole view lag and stretched the clip. Neither limit applies on Windows or macOS. The AppImage additionally needs its own GStreamer plugins — see [RELEASING.md](../RELEASING.md#the-appimage-carries-its-own-gstreamer).

**"Show Canvas" toggle** — a [`CanvasToggleButton`](../../src/components/player/CanvasToggleButton.tsx) (Spotify's control) appears in the immersive top bar, the `NowPlayingPanel` header **and** the [mini-player](#mini-player)'s top bar **only when the current track has a Canvas** and motion isn't reduced, so it is never a dead control. It drives the global [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) preference (localStorage, **default OFF** — the cover shows first, the clip takes over on click, matching "click Show Canvas to reveal"). `prefers-reduced-motion` ([`usePrefersReducedMotion`](../../src/hooks/usePrefersReducedMotion.ts)) suppresses the clip and hides the toggle; radio (negative sentinel id) and Spotify tracks are excluded. Setting a Canvas is immersive-only in v1 (the panel only reflects + toggles).

Expand Down
10 changes: 10 additions & 0 deletions src-tauri/crates/app/src/render_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,16 @@ fn set_unless_present(key: &str, value: &str) {

/// Turn the decision into the environment the web engine will read.
fn apply(mode: RenderMode) {
// Every mode, GPU included: WebKitGTK's DMA-BUF video sink hands the
// page frames as GL textures it cannot map back. Copying one into a
// `<canvas>`, which `useLoopFrameHold` does four times a second to
// hide a looping clip's jump back, then fails with `Cannot map
// External OES textures`, and the held frame stays empty. The GL sink
// WebKit uses instead keeps the compositor's own DMA-BUF path, so
// nothing slows down: `WEBKIT_DISABLE_DMABUF_RENDERER`, which software
// mode sets below, is the switch that costs.
#[cfg(target_os = "linux")]
set_unless_present("WEBKIT_GST_DMABUF_SINK_DISABLED", "1");
if mode == RenderMode::Gpu {
return;
}
Expand Down
19 changes: 15 additions & 4 deletions src/components/player/ImmersiveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,22 @@ import { ImmersiveLyricsColumn } from "./ImmersiveLyricsColumn";
import { ImmersiveShareButton } from "./ImmersiveShareButton";
import { CanvasToggleButton } from "./CanvasToggleButton";
import { CanvasPickerModal } from "../common/CanvasPickerModal";
import { LINUX } from "../../hooks/usePlayableVideo";

/** Below this width the dual-column layout collapses to a single column
* with a now-playing ⇄ panel toggle — two columns only make sense at
* desktop widths. */
const NARROW_BREAKPOINT = 900;

/**
* The view fades in everywhere but Linux. WebKitGTK 2.54 rebuilds its
* layers each time a video appears inside a full-window layer that is
* still animating, and a motion cover or Canvas mounting during the fade
* blacked the whole window out three times before it settled. Without the
* fade one short flash is left: WebKit creating the video layer itself.
*/
const FADE_IN = LINUX ? "" : " animate-fade-in";

interface ImmersiveViewProps {
/** Which entry point opened the view — only used to pick the first
* column in the narrow single-column fallback. */
Expand Down Expand Up @@ -247,8 +257,9 @@ export function ImmersiveView({
className="dark fixed inset-0 z-100 bg-zinc-950"
>
{/* Blurred artwork background — flat dark gradient fallback. Same
recipe as the old overlays. `animate-fade-in` lives here so the
opaque `bg-zinc-950` above paints solid from frame 1.
recipe as the old overlays. The fade-in (`FADE_IN`, none on
Linux) lives here so the opaque `bg-zinc-950` above paints solid
from frame 1.

Deliberately a pre-resized variant, and **only** one: behind
`blur-3xl` at 150% scale a 128 px source is indistinguishable,
Expand All @@ -266,7 +277,7 @@ export function ImmersiveView({
variant means the gradient, not the animation. It still plays,
once, on the foreground cover. A stream's `path` is the one
exception — see `streamBackdrop`. */}
<div className="absolute inset-0 overflow-hidden animate-fade-in">
<div className={`absolute inset-0 overflow-hidden${FADE_IN}`}>
{staticBackdrop ? (
<Artwork
path={streamBackdrop}
Expand All @@ -284,7 +295,7 @@ export function ImmersiveView({
</div>

{/* Foreground */}
<div className="relative h-full flex flex-col text-white animate-fade-in">
<div className={`relative h-full flex flex-col text-white${FADE_IN}`}>
{/* Shared top bar — panel toggle + share + close. Absolute so
the columns own the full height underneath. The side panel
measures it to know how much of its own top line is free.
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/usePlayableVideo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getLocalVideoBaseUrl } from "../lib/tauri/canvas";
* seconds in and never resumes. WebView2 and WKWebView play the asset URL
* directly.
*/
const LINUX =
export const LINUX =
/linux/i.test(navigator.userAgent) && !/android/i.test(navigator.userAgent);

/** Asked once per launch: the server's port and token do not change. */
Expand Down
Loading