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 @@ -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.
Expand Down
41 changes: 34 additions & 7 deletions src/components/music/AmbientBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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;
}
Expand All @@ -39,29 +42,53 @@ const EASE = IS_LINUX_RUNTIME ? "" : "player-palette-ease";
const ARTWORK_OPACITY: Partial<Record<PlayerBackgroundStyle, string>> = {
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);

return (
<div className={`${EASE} pointer-events-none absolute inset-0 z-0 overflow-hidden bg-[var(--player-ground)]`}>
{artworkOpacity && imageSrc && (
<SoftBackdrop
src={toSrc(imageSrc)}
src={imageSrc}
detail={8}
saturate
className={`absolute inset-0 h-full w-full object-cover ${artworkOpacity}`}
/>
)}
{style === "immersive" && imageSrc && (
<img
src={imageSrc}
alt=""
draggable={false}
className={`absolute inset-y-0 left-0 h-full object-cover ${artworkAtStart ? "w-[62%] object-center" : "w-full object-top"}`}
style={{
maskImage: IMMERSIVE_MASK[artworkAtStart ? "start" : "top"],
WebkitMaskImage: IMMERSIVE_MASK[artworkAtStart ? "start" : "top"],
}}
/>
)}
{wash && <div className={`${EASE} absolute inset-0`} style={wash} />}
</div>
);
Expand Down
9 changes: 7 additions & 2 deletions src/components/music/MusicOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
<AmbientBackdrop src={currentTrack.thumbnail} style={playerColors.style} />
<AmbientBackdrop imageSrc={accentSrc} style={playerColors.style} artworkAtStart={wide} />

{/* TOP BAR */}
<div className="absolute top-0 z-20 grid w-full grid-cols-[1fr_auto_1fr] items-center gap-4 p-6">
Expand Down Expand Up @@ -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"
>
<div className={`flex min-h-0 min-w-0 justify-center ${wide ? "flex-1" : isQueue ? "w-1/2" : "w-full max-w-2xl"}`}>
<NowPlayingColumn track={currentTrack} compact={!wide && isQueue} onOpenEqualizer={() => setEqOpen(true)} />
<NowPlayingColumn
track={currentTrack}
compact={!wide && isQueue}
artworkHidden={playerColors.style === "immersive"}
onOpenEqualizer={() => setEqOpen(true)}
/>
</div>

{wide ? (
Expand Down
7 changes: 5 additions & 2 deletions src/components/music/player/NowPlayingColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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<HTMLDivElement>(null);
const { width, height } = useElementSize(columnRef);
Expand Down Expand Up @@ -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 }}
/>

Expand Down
3 changes: 3 additions & 0 deletions src/components/music/useNowPlayingMenuActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -31,6 +33,7 @@ export function useNowPlayingMenuActions(
const onOpenEqualizer = options?.onOpenEqualizer;
return [
...radioActions,
...watchVideoActions,
...navActions,
{
id: 'add-to-album',
Expand Down
52 changes: 52 additions & 0 deletions src/components/music/useTrackWatchVideoAction.tsx
Original file line number Diff line number Diff line change
@@ -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: <Clapperboard size={16} />,
onSelect: () => void open(),
},
];
}
1 change: 1 addition & 0 deletions src/components/settings/tabs/PlayerTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
]} />
</SettingItem>
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -998,6 +999,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",
Expand Down
13 changes: 13 additions & 0 deletions src/lib/musicVideo.ts
Original file line number Diff line number Diff line change
@@ -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<YTItem, { type: "song" }> => 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<SongItem | null> {
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);
}
45 changes: 45 additions & 0 deletions src/lib/musicVideoMatching.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
70 changes: 70 additions & 0 deletions src/lib/musicVideoMatching.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/lib/playerColors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
4 changes: 2 additions & 2 deletions src/lib/playerColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading