From 514b9cea99ccdd6a0ccaa185d006c44e858c9855 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:53:40 +0300 Subject: [PATCH 1/5] feat(music): find a song's official video the way Flow Android does --- src/lib/musicVideo.ts | 13 ++++++ src/lib/musicVideoMatching.test.ts | 45 +++++++++++++++++++ src/lib/musicVideoMatching.ts | 70 ++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/lib/musicVideo.ts create mode 100644 src/lib/musicVideoMatching.test.ts create mode 100644 src/lib/musicVideoMatching.ts diff --git a/src/lib/musicVideo.ts b/src/lib/musicVideo.ts new file mode 100644 index 00000000..c3428d77 --- /dev/null +++ b/src/lib/musicVideo.ts @@ -0,0 +1,13 @@ +import type { SongItem, YTItem } from "../types/music"; +import { searchMusicTyped } from "./api/music"; +import { pickOfficialVideo } from "./musicVideoMatching"; + +const isSong = (item: YTItem): item is Extract => item.type === "song"; + +/** The song's official music video, found through a YouTube Music video search; null when it has none. */ +export async function findOfficialVideo(song: SongItem): Promise { + const artist = song.artists[0]?.name ?? ""; + const response = await searchMusicTyped(`${artist} ${song.title}`.trim(), "videos"); + const candidates = response.sections.flatMap((section) => section.items).filter(isSong); + return pickOfficialVideo(song, candidates); +} diff --git a/src/lib/musicVideoMatching.test.ts b/src/lib/musicVideoMatching.test.ts new file mode 100644 index 00000000..2e34aa41 --- /dev/null +++ b/src/lib/musicVideoMatching.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import type { SongItem } from "../types/music"; +import { isOfficialVideoOf, musicTitleKey, pickOfficialVideo } from "./musicVideoMatching"; + +const item = (title: string, artist: string, duration: number | null, type = "MUSIC_VIDEO_TYPE_OMV", id: string | null = null): SongItem => ({ + id: title, + title, + artists: [{ name: artist, id }], + album: null, + duration, + musicVideoType: type, + thumbnail: "", + explicit: false, + videoId: title, + playlistId: null, + params: null, +}); + +const song = item("Blinding Lights", "The Weeknd", 202, "MUSIC_VIDEO_TYPE_ATV"); + +describe("musicVideoMatching", () => { + it("keys titles the same way everywhere", () => { + expect(musicTitleKey("The Weeknd - Blinding Lights (Official Video)", ["The Weeknd"])).toBe("blinding lights"); + expect(musicTitleKey("Levitating (feat. DaBaby)")).toBe("levitating"); + expect(musicTitleKey("Café del Mar")).toBe("cafe del mar"); + }); + + it("takes the artist's official upload of the same song, even when it runs longer", () => { + expect(isOfficialVideoOf(song, item("The Weeknd - Blinding Lights (Official Video)", "The Weeknd", 263))).toBe(true); + }); + + it("refuses other versions, other artists, audio uploads and implausible lengths", () => { + expect(isOfficialVideoOf(song, item("Blinding Lights (Live)", "The Weeknd", 210))).toBe(false); + expect(isOfficialVideoOf(song, item("Blinding Lights", "Someone Else", 202))).toBe(false); + expect(isOfficialVideoOf(song, item("Blinding Lights", "The Weeknd", 202, "MUSIC_VIDEO_TYPE_UGC"))).toBe(false); + expect(isOfficialVideoOf(song, item("Blinding Lights (Official Audio)", "The Weeknd", 202))).toBe(false); + expect(isOfficialVideoOf(song, item("Blinding Lights", "The Weeknd", 600))).toBe(false); + }); + + it("picks the first match", () => { + const match = item("Blinding Lights", "The Weeknd", 202); + expect(pickOfficialVideo(song, [item("Blinding Lights (Remix)", "The Weeknd", 200), match])).toBe(match); + }); +}); diff --git a/src/lib/musicVideoMatching.ts b/src/lib/musicVideoMatching.ts new file mode 100644 index 00000000..db5e8722 --- /dev/null +++ b/src/lib/musicVideoMatching.ts @@ -0,0 +1,70 @@ +/** + * Picks a song's official music video out of a video search, ported from Flow + * Android's `MusicVideoMatching`. Signed-out responses never carry YouTube + * Music's own song-to-video link, so the match is the artist's official upload + * of the same title, never a live take, remix, cover or an "official audio" upload. + */ +import type { SongItem } from "../types/music"; + +const OFFICIAL_VIDEO = "MUSIC_VIDEO_TYPE_OMV"; +const BRACKETS = /\s*[([][^)\]]*[)\]]/g; +const FEATURING = /\s+(feat\.?|ft\.?|featuring)\s.*$/; +const NON_WORD = /[^\p{L}\p{N}]+/gu; +const TOPIC_SUFFIX = " - topic"; +const MAX_EXTRA_SECONDS = 60; + +// An official upload of the same title in one of these forms is a different recording or no video. +const OTHER_VERSIONS = [ + "live", "remix", "cover", "karaoke", "instrumental", "sped up", "slowed", "reverb", "acoustic", + "teaser", "trailer", "nightcore", "extended", "audio", "visualizer", "visualiser", "behind the scenes", +]; + +/** Lower case without accents, as Android's `foldForSearch`. */ +export function foldForSearch(text: string): string { + return text.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase(); +} + +export function musicArtistKey(name: string): string { + const folded = foldForSearch(name); + return (folded.endsWith(TOPIC_SUFFIX) ? folded.slice(0, -TOPIC_SUFFIX.length) : folded).trim(); +} + +/** "The Weeknd - Blinding Lights (Official Video)" and "Blinding Lights" share a key when the artist is The Weeknd. */ +export function musicTitleKey(title: string, artists: string[] = []): string { + let folded = foldForSearch(title).replace(BRACKETS, "").replace(FEATURING, "").trim(); + for (const artist of artists.map(musicArtistKey)) { + const prefix = `${artist} - `; + if (artist && folded.startsWith(prefix)) folded = folded.slice(prefix.length); + } + return folded.replace(NON_WORD, " ").trim(); +} + +const isWordIn = (word: string, title: string) => ` ${foldForSearch(title).replace(NON_WORD, " ")} `.includes(` ${word} `); + +// An intro or outro makes a video longer than its song, but not twice as long and never half. +function plausibleLength(songSeconds: number | null, videoSeconds: number | null): boolean { + if (!songSeconds || songSeconds <= 0 || !videoSeconds || videoSeconds <= 0) return true; + return videoSeconds * 2 >= songSeconds && videoSeconds <= songSeconds * 2 + MAX_EXTRA_SECONDS; +} + +function sameArtist(song: SongItem, candidate: SongItem): boolean { + const ids = new Set(song.artists.map((a) => a.id).filter((id): id is string => Boolean(id))); + if (candidate.artists.some((a) => a.id && ids.has(a.id))) return true; + const names = new Set(song.artists.map((a) => musicArtistKey(a.name))); + return candidate.artists.some((a) => names.has(musicArtistKey(a.name))); +} + +export function isOfficialVideoOf(song: SongItem, candidate: SongItem): boolean { + const names = song.artists.map((a) => a.name); + return ( + candidate.musicVideoType === OFFICIAL_VIDEO && + sameArtist(song, candidate) && + musicTitleKey(candidate.title, names) === musicTitleKey(song.title, names) && + OTHER_VERSIONS.every((word) => !isWordIn(word, candidate.title) || isWordIn(word, song.title)) && + plausibleLength(song.duration, candidate.duration) + ); +} + +export function pickOfficialVideo(song: SongItem, candidates: SongItem[]): SongItem | null { + return candidates.find((candidate) => isOfficialVideoOf(song, candidate)) ?? null; +} From 400444846dbacefda01853d687a6f81978e45f46 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:53:40 +0300 Subject: [PATCH 2/5] refactor(player): share the seek that waits for a new video --- src/lib/playerSeek.ts | 18 ++++++++++++++++++ src/lib/useDeepLinkHandoff.ts | 20 ++------------------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/lib/playerSeek.ts b/src/lib/playerSeek.ts index 68a9b3fd..0746e37e 100644 --- a/src/lib/playerSeek.ts +++ b/src/lib/playerSeek.ts @@ -2,3 +2,21 @@ export const seekToTime = (seconds: number) => { window.dispatchEvent(new CustomEvent("flow-player-seek", { detail: { time: seconds } })); }; + +/** + * Seeks the video player once a freshly opened video knows its duration: the + * element only accepts a seek after that, so this polls briefly after navigation. + */ +export function seekWhenVideoReady(seconds: number): void { + let attempts = 0; + const tick = () => { + attempts += 1; + const video = document.querySelector("video"); + if (video && Number.isFinite(video.duration) && video.duration > 0) { + seekToTime(seconds); + return; + } + if (attempts < 20) window.setTimeout(tick, 300); + }; + window.setTimeout(tick, 400); +} diff --git a/src/lib/useDeepLinkHandoff.ts b/src/lib/useDeepLinkHandoff.ts index 9b0c78b2..cbcb9111 100644 --- a/src/lib/useDeepLinkHandoff.ts +++ b/src/lib/useDeepLinkHandoff.ts @@ -7,7 +7,7 @@ import { getVideoDetails } from "./api/youtube"; import { getMusicQueue } from "./api/music"; import { addWatchRecord } from "./api/db"; import { shouldRecordWatchHistory } from "./deepFlow"; -import { seekToTime } from "./playerSeek"; +import { seekWhenVideoReady } from "./playerSeek"; import { prefetchStreamInfo } from "./streamResolution"; import { getString } from "./i18n/index"; import { useDownloadStore } from "../store/useDownloadStore"; @@ -67,22 +67,6 @@ async function resolveSong(videoId: string): Promise { } } -// The video element only accepts a seek once it knows its duration. Poll briefly -// after navigation, then hand off to the player's existing seek event bus. -function applyStartOffset(seconds: number) { - let attempts = 0; - const tick = () => { - attempts += 1; - const video = document.querySelector("video"); - if (video && Number.isFinite(video.duration) && video.duration > 0) { - seekToTime(seconds); - return; - } - if (attempts < 20) window.setTimeout(tick, 300); - }; - window.setTimeout(tick, 400); -} - /** * Subscribes to `flow://` deep links and bridge handoffs and routes them: * `watch`/`music` → the Watch page (which resolves the video from its id), @@ -189,7 +173,7 @@ export function useDeepLinkHandoff() { prefetchStreamInfo(handoff.v); navigate(`/watch/${handoff.v}`); - if (handoff.t && handoff.t > 0) applyStartOffset(handoff.t); + if (handoff.t && handoff.t > 0) seekWhenVideoReady(handoff.t); }, [navigate], ); From 482f252559eadfa786da4b1cf8e9ffcbc9b82fc5 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:53:40 +0300 Subject: [PATCH 3/5] feat(music): open the song's video from the now-playing menu --- .../music/useNowPlayingMenuActions.tsx | 3 ++ .../music/useTrackWatchVideoAction.tsx | 52 +++++++++++++++++++ src/lib/i18n/strings.json | 2 + 3 files changed, 57 insertions(+) create mode 100644 src/components/music/useTrackWatchVideoAction.tsx diff --git a/src/components/music/useNowPlayingMenuActions.tsx b/src/components/music/useNowPlayingMenuActions.tsx index 59254dc7..325aa227 100644 --- a/src/components/music/useNowPlayingMenuActions.tsx +++ b/src/components/music/useNowPlayingMenuActions.tsx @@ -10,6 +10,7 @@ import { useTrackDownloadAction } from './useTrackDownloadAction'; import { useTrackBlockActions } from './useTrackBlockActions'; import { useTrackNavActions } from './useTrackNavActions'; import { useTrackRadioAction } from './useTrackRadioAction'; +import { useTrackWatchVideoAction } from './useTrackWatchVideoAction'; function videoIdOf(track: SongItem): string { return track.videoId ?? track.id; @@ -22,6 +23,7 @@ export function useNowPlayingMenuActions( ): MusicMenuAction[] { const openAddToAlbum = useAlbumLibraryStore((s) => s.openAddToAlbum); const radioActions = useTrackRadioAction(track); + const watchVideoActions = useTrackWatchVideoAction(track, options); const navActions = useTrackNavActions(track, options); const downloadActions = useTrackDownloadAction(track); const blockActions = useTrackBlockActions(track); @@ -31,6 +33,7 @@ export function useNowPlayingMenuActions( const onOpenEqualizer = options?.onOpenEqualizer; return [ ...radioActions, + ...watchVideoActions, ...navActions, { id: 'add-to-album', diff --git a/src/components/music/useTrackWatchVideoAction.tsx b/src/components/music/useTrackWatchVideoAction.tsx new file mode 100644 index 00000000..1b93213a --- /dev/null +++ b/src/components/music/useTrackWatchVideoAction.tsx @@ -0,0 +1,52 @@ +import { Clapperboard } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { musicAudioEngine } from '../../lib/audio/musicAudioEngine'; +import { getString } from '../../lib/i18n/index'; +import { findOfficialVideo } from '../../lib/musicVideo'; +import { seekWhenVideoReady } from '../../lib/playerSeek'; +import { prefetchStreamInfo } from '../../lib/streamResolution'; +import { useMusicPlayerStore } from '../../store/useMusicPlayerStore'; +import { useUiStore } from '../../store/useUiStore'; +import type { SongItem } from '../../types/music'; +import type { MusicMenuAction } from './MusicCardMenu'; + +const VIDEO_TYPES = new Set(['MUSIC_VIDEO_TYPE_OMV', 'MUSIC_VIDEO_TYPE_UGC']); + +/** + * "Watch video" for the playing song: its official music video opens in the + * video player at the moment the song had reached, and the song pauses. The + * desktop counterpart of Android's Song/Video switch, using the full video player. + */ +export function useTrackWatchVideoAction( + track: SongItem | null, + options?: { onNavigate?: () => void }, +): MusicMenuAction[] { + const navigate = useNavigate(); + const showToast = useUiStore((s) => s.showToast); + if (!track) return []; + + const open = async () => { + const video = VIDEO_TYPES.has(track.musicVideoType ?? '') ? track : await findOfficialVideo(track).catch(() => null); + const videoId = video ? video.videoId ?? video.id : null; + if (!videoId) { + showToast({ variant: 'error', message: getString('music_no_official_video') }); + return; + } + const at = musicAudioEngine.getCurrentTime(); + useMusicPlayerStore.getState().pause(); + options?.onNavigate?.(); + prefetchStreamInfo(videoId); + navigate(`/watch/${videoId}`); + if (at > 1) seekWhenVideoReady(at); + }; + + return [ + { + id: 'watch-video', + label: getString('music_watch_video'), + icon: , + onSelect: () => void open(), + }, + ]; +} diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index dd0ed3e7..8493e1bc 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -998,6 +998,8 @@ "music_no_blocked_artists": "No blocked artists.", "music_up_next": "Next up", "music_lyrics_full_player": "Show lyrics across the whole player", + "music_watch_video": "Watch video", + "music_no_official_video": "This song has no official video on YouTube", "music_lyrics_back_to_pane": "Back to the side pane", "music_autoplay": "Autoplay", "music_autoplay_desc": "Keep playing similar music when the queue ends", From 818b13e95d42b77bda600fe3f62e2996a1e81339 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:53:40 +0300 Subject: [PATCH 4/5] feat(music): add the Immersive artwork background --- src/components/music/AmbientBackdrop.tsx | 41 +++++++++++++++---- src/components/music/MusicOverlay.tsx | 9 +++- .../music/player/NowPlayingColumn.tsx | 7 +++- src/components/settings/tabs/PlayerTab.tsx | 1 + src/lib/i18n/strings.json | 1 + src/lib/playerColors.test.ts | 1 + src/lib/playerColors.ts | 4 +- src/lib/settings/schema.ts | 2 +- 8 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/components/music/AmbientBackdrop.tsx b/src/components/music/AmbientBackdrop.tsx index 34a35882..eddcd153 100644 --- a/src/components/music/AmbientBackdrop.tsx +++ b/src/components/music/AmbientBackdrop.tsx @@ -2,9 +2,7 @@ import type { CSSProperties } from "react"; import { IS_LINUX_RUNTIME } from "../../lib/platform"; import type { PlayerBackgroundStyle } from "../../lib/playerColors"; -import { upgradeMusicImageUrl } from "../../lib/thumbnails"; import { SoftBackdrop } from "../ui/SoftBackdrop"; -import { useProxyImageSrc } from "../../lib/useImageProxy"; /** `var(--player-*)` at an alpha, for gradient stops. */ const at = (role: "ground" | "base" | "accent", alpha: number) => @@ -27,6 +25,11 @@ function washFor(style: PlayerBackgroundStyle): CSSProperties | null { return { background: `linear-gradient(to bottom, ${at("ground", 0.4)} 0%, ${at("accent", 0.22)} 30%, ${at("base", 0.34)} 55%, ${at("ground", 0.8)} 80%, ${at("ground", 0.95)} 100%)`, }; + // A scrim over the photo, so the controls stay readable. + case "immersive": + return { + background: `linear-gradient(to bottom, transparent 0%, ${at("ground", 0.28)} 52%, ${at("ground", 0.86)} 100%)`, + }; case "default": return null; } @@ -39,16 +42,28 @@ const EASE = IS_LINUX_RUNTIME ? "" : "player-palette-ease"; const ARTWORK_OPACITY: Partial> = { blur: "opacity-[0.62]", blur_gradient: "opacity-[0.55]", + immersive: "opacity-[0.85]", }; +/** + * Immersive's full-bleed cover (Android issue #954): it holds the top of the + * player, or the start side of a wide one, then melts into its own blurred + * continuation underneath. + */ +const IMMERSIVE_MASK = { + top: "linear-gradient(to bottom, black 42%, transparent 85%)", + start: "linear-gradient(to right, black 50%, transparent 100%)", +} as const; + interface AmbientBackdropProps { - src?: string | null; + /** The cover as the artwork card loads it, so both share one cached copy. */ + imageSrc?: string | null; style?: PlayerBackgroundStyle; + /** Wide layouts put the immersive cover on the start side, where the controls are not. */ + artworkAtStart?: boolean; } -export function AmbientBackdrop({ src, style = "blur_gradient" }: AmbientBackdropProps) { - const toSrc = useProxyImageSrc(); - const imageSrc = upgradeMusicImageUrl(src); +export function AmbientBackdrop({ imageSrc, style = "blur_gradient", artworkAtStart = false }: AmbientBackdropProps) { const artworkOpacity = ARTWORK_OPACITY[style]; const wash = washFor(style); @@ -56,12 +71,24 @@ export function AmbientBackdrop({ src, style = "blur_gradient" }: AmbientBackdro
{artworkOpacity && imageSrc && ( )} + {style === "immersive" && imageSrc && ( + + )} {wash &&
}
); diff --git a/src/components/music/MusicOverlay.tsx b/src/components/music/MusicOverlay.tsx index d927781a..fb321b3d 100644 --- a/src/components/music/MusicOverlay.tsx +++ b/src/components/music/MusicOverlay.tsx @@ -135,7 +135,7 @@ export function MusicOverlay() { className="fixed inset-x-0 bottom-0 top-[var(--app-titlebar-height)] z-[60] flex flex-col overflow-hidden bg-[var(--player-ground)] outline-none" style={playerColors.rootStyle} > - + {/* TOP BAR */}
@@ -254,7 +254,12 @@ export function MusicOverlay() { className="relative z-10 flex min-h-0 flex-1 items-stretch justify-center gap-8 px-8 pb-10 pt-24" >
- setEqOpen(true)} /> + setEqOpen(true)} + />
{wide ? ( diff --git a/src/components/music/player/NowPlayingColumn.tsx b/src/components/music/player/NowPlayingColumn.tsx index 06ab6c8d..b84c598d 100644 --- a/src/components/music/player/NowPlayingColumn.tsx +++ b/src/components/music/player/NowPlayingColumn.tsx @@ -31,6 +31,8 @@ interface NowPlayingColumnProps { track: SongItem; /** A narrower column next to the queue, with a smaller title. */ compact: boolean; + /** Immersive draws the cover full-bleed behind the player, so the card keeps its place but not its picture. */ + artworkHidden: boolean; onOpenEqualizer: () => void; } @@ -39,7 +41,7 @@ interface NowPlayingColumnProps { * transport. The artwork takes whatever square the column has left above the * details, so it never pushes the controls out of a short window. */ -export function NowPlayingColumn({ track, compact, onOpenEqualizer }: NowPlayingColumnProps) { +export function NowPlayingColumn({ track, compact, artworkHidden, onOpenEqualizer }: NowPlayingColumnProps) { const navigate = useNavigate(); const columnRef = useRef(null); const { width, height } = useElementSize(columnRef); @@ -86,7 +88,8 @@ export function NowPlayingColumn({ track, compact, onOpenEqualizer }: NowPlaying alt={track.title} loading={loading} iconClassName="h-16 w-16" - className="aspect-square shrink-0 rounded-2xl" + // Visibility, not opacity: the shared-layout morph writes its own inline opacity. + className={`aspect-square shrink-0 rounded-2xl ${artworkHidden ? "invisible" : ""}`} style={{ width: artSize }} /> diff --git a/src/components/settings/tabs/PlayerTab.tsx b/src/components/settings/tabs/PlayerTab.tsx index 9d291e9b..d3e44c63 100644 --- a/src/components/settings/tabs/PlayerTab.tsx +++ b/src/components/settings/tabs/PlayerTab.tsx @@ -217,6 +217,7 @@ export function PlayerTab() { { value: 'blur_gradient', label: getString('settings_music_player_background_blur_gradient') }, { value: 'blur', label: getString('settings_music_player_background_blur') }, { value: 'gradient', label: getString('settings_music_player_background_gradient') }, + { value: 'immersive', label: getString('settings_music_player_background_immersive') }, { value: 'default', label: getString('settings_music_player_background_default') }, ]} /> diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index 8493e1bc..b3fc05cc 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -647,6 +647,7 @@ "settings_music_player_background_blur_gradient": "Blur + Gradient", "settings_music_player_background_blur": "Blurred Artwork", "settings_music_player_background_gradient": "Palette Gradient", + "settings_music_player_background_immersive": "Immersive Artwork", "settings_music_player_background_default": "Default Surface", "settings_music_artwork_control_colors": "Artwork colors on controls", "settings_music_artwork_control_colors_desc": "Tint the buttons and seek bar from the cover", diff --git a/src/lib/playerColors.test.ts b/src/lib/playerColors.test.ts index f3549fb9..a6e755fd 100644 --- a/src/lib/playerColors.test.ts +++ b/src/lib/playerColors.test.ts @@ -45,6 +45,7 @@ describe("playerColors", () => { expect(playerTurnsLight("blur", true, white)).toBe(true); expect(playerTurnsLight("blur", false, white)).toBe(false); expect(playerTurnsLight("default", true, white)).toBe(false); + expect(playerTurnsLight("immersive", true, white)).toBe(false); expect(playerTurnsLight("blur", true, { r: 10, g: 10, b: 10 })).toBe(false); }); diff --git a/src/lib/playerColors.ts b/src/lib/playerColors.ts index 41e8c2e4..2f049fd3 100644 --- a/src/lib/playerColors.ts +++ b/src/lib/playerColors.ts @@ -10,8 +10,8 @@ import type { ThemeColors } from "./themes"; import type { Rgb } from "./useDominantColor"; /** Android's `MusicPlayerBackgroundStyle`, under the same names. */ -export type PlayerBackgroundStyle = "blur_gradient" | "blur" | "gradient" | "default"; -export const PLAYER_BACKGROUND_STYLES: readonly PlayerBackgroundStyle[] = ["blur_gradient", "blur", "gradient", "default"]; +export type PlayerBackgroundStyle = "blur_gradient" | "blur" | "gradient" | "immersive" | "default"; +export const PLAYER_BACKGROUND_STYLES: readonly PlayerBackgroundStyle[] = ["blur_gradient", "blur", "gradient", "immersive", "default"]; export function isPlayerBackgroundStyle(value: string): value is PlayerBackgroundStyle { return (PLAYER_BACKGROUND_STYLES as readonly string[]).includes(value); diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index bc30fa70..27e6e0e7 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -277,7 +277,7 @@ export const SETTING_DEFINITIONS = [ bool(SETTINGS.PIP_ALWAYS_ON_TOP, "player", true, "wired"), bool(SETTINGS.PIP_ON_MINIMIZE, "player", true, "wired"), str(SETTINGS.MUSIC_PLAYER_BACKGROUND, "player", "blur_gradient", "wired", - ["blur_gradient", "blur", "gradient", "default"]), + ["blur_gradient", "blur", "gradient", "immersive", "default"]), bool(SETTINGS.MUSIC_ARTWORK_CONTROL_COLORS, "player", true, "wired"), str(SETTINGS.MUSIC_PLAIN_CONTROL_COLORS, "player", "monochrome", "wired", ["monochrome", "app_theme"]), bool(SETTINGS.MUSIC_PLAYER_FOLLOW_LIGHT_THEME, "player", false, "wired"), From d5c56494b4e6ce0c21537865124b2ce9e40903f3 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:53:46 +0300 Subject: [PATCH 5/5] docs(changelog): note the Immersive background and Watch video --- 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 5d021b62..37b89f6d 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -31,6 +31,8 @@ MUSIC - The full player answers the same keys as Flow for Android: Space or K to play and pause, J, L and the arrow keys to seek, Up and Down for volume, M to mute, Shift+N and Shift+P to skip, 0 to 9 to jump through the song, S, R and Q for shuffle, repeat and the queue. Escape closes an open menu before the player. - The full player has its own volume button, the mouse wheel changes volume over either volume button, and Shift with the wheel seeks over the seek bar. Hovering Next shows the song that plays next, and the top shows what you are playing from. - The song menu in the player adds Start radio, Equalizer, Not interested and Don't recommend this artist. +- New Immersive Artwork background for the full player: the cover fills the top, or the left side of a wide window, and fades into the player. The blurred backgrounds now load the cover through the same copy as the artwork, so they no longer stay black when the cover is slow. +- "Watch video" in the player's song menu opens the song's official music video at the same moment and pauses the song. The video is matched the same way Flow for Android does, so live takes, remixes and audio uploads are skipped. - 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. - Shuffle now rearranges the queue, so every song plays once, Previous and Up next follow the shuffled order, and turning shuffle off puts the original order back.