SongSpotlight: shabloink (#1230)

* SongSpotlight: shabloink

* SongSpotlight: zazabloink

* Update AudioPlayer.tsx

* Update pnpm-workspace.yaml

* SongSpotlight: big refactors awoooooga

* [autofix.ci] apply automated fixes

* SongSpotlight: parity fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
nexpid 2026-07-09 22:34:54 +02:00 • committed by GitHub
parent cf08239295
commit 8e9d78c3aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 201 additions and 163 deletions

View file

@ -45,7 +45,7 @@
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@intrnl/xxhash64": "^0.1.2",
"@song-spotlight/api": "^2.1.3",
"@song-spotlight/api": "^2.2.0",
"@streamparser/json": "^0.0.22",
"@types/less": "^3.0.8",
"@types/stylus": "^0.48.42",

26
pnpm-lock.yaml generated
View file

@ -21,8 +21,8 @@ importers:
specifier: ^0.1.2
version: 0.1.2
'@song-spotlight/api':
specifier: ^2.1.3
version: 2.1.3(typescript@6.0.3)
specifier: ^2.2.0
version: 2.2.0(typescript@6.0.3)
'@streamparser/json':
specifier: ^0.0.22
version: 0.0.22
@ -575,8 +575,8 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@song-spotlight/api@2.1.3':
resolution: {integrity: sha512-Wai7JHd9lrHtM/lkhfWQIFjIWKtP8iD+zj4Lnwbs/ykELR0MB2k56TDnQaRAelvu8XOygt27wfDBDQSEepPqdA==}
'@song-spotlight/api@2.2.0':
resolution: {integrity: sha512-zKH9X+G1uRYmEHFgluExPEFJWtA87PG8C39Xng0XG7Oh42ycOHjZtqtyEbfGM+hs+QEVos4wOoySeBAggnK7Iw==}
peerDependencies:
typescript: ^5
@ -2698,7 +2698,7 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@song-spotlight/api@2.1.3(typescript@6.0.3)':
'@song-spotlight/api@2.2.0(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
optionalDependencies:
@ -2958,10 +2958,10 @@ snapshots:
array-includes@3.1.9:
dependencies:
call-bind: 1.0.8
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.1
es-abstract: 1.24.2
es-object-atoms: 1.1.2
get-intrinsic: 1.3.0
is-string: 1.1.1
@ -2978,10 +2978,10 @@ snapshots:
array.prototype.findlastindex@1.2.6:
dependencies:
call-bind: 1.0.8
call-bind: 1.0.9
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.24.1
es-abstract: 1.24.2
es-errors: 1.3.0
es-object-atoms: 1.1.2
es-shim-unscopables: 1.1.0
@ -2995,9 +2995,9 @@ snapshots:
array.prototype.flat@1.3.3:
dependencies:
call-bind: 1.0.8
call-bind: 1.0.9
define-properties: 1.2.1
es-abstract: 1.24.1
es-abstract: 1.24.2
es-shim-unscopables: 1.1.0
array.prototype.flatmap@1.3.3:
@ -4298,9 +4298,9 @@ snapshots:
object.groupby@1.0.3:
dependencies:
call-bind: 1.0.8
call-bind: 1.0.9
define-properties: 1.2.1
es-abstract: 1.24.1
es-abstract: 1.24.2
object.values@1.2.1:
dependencies:

View file

@ -17,3 +17,5 @@ allowBuilds:
esbuild: true
pmOnFail: warn
verifyDepsBeforeRun: prompt
minimumReleaseAgeExclude:
- '@song-spotlight/api@2.2.0'

View file

@ -12,7 +12,6 @@ import definePlugin from "@utils/types";
import { useAuthorizationStore } from "./lib/stores/AuthorizationStore";
import { useSongStore } from "./lib/stores/SongStore";
import { Native } from "./service";
import settings from "./settings";
import ProfileSongs from "./ui/songs/ProfileSongs";
import WidgetSongs from "./ui/songs/WidgetSongs";
@ -48,12 +47,6 @@ export default definePlugin({
},
},
start() {
// the cache lives in native.ts so it persists between reloads and
// only gets cleared on full restart. we don't want that since
// audio preview URLs expire very fast, so we just clear it on
// plugin restart instead
Native.clearCache();
useSongStore.getState().$refresh();
useAuthorizationStore.persist.rehydrate();
},

View file

@ -20,7 +20,3 @@ export async function renderSong(_: IpcMainInvokeEvent, song: Song) {
export async function validateSong(_: IpcMainInvokeEvent, song: Song) {
return handlers.validateSong(song);
}
export function clearCache() {
return handlers.clearCache();
}

View file

@ -22,6 +22,13 @@ export default definePluginSettings({
default: apiConstants.songLimit,
markers: makeRange(1, 3),
},
previewVolume: {
type: OptionType.SLIDER,
description: "Volume of song previews when played",
markers: [0, 25, 50, 100],
default: 100,
stickToMarkers: false
},
manager: {
type: OptionType.COMPONENT,
component: () => <Settings />,

View file

@ -145,6 +145,7 @@
gap: 6px;
&.vc-songspotlight-songs-container-sidebar {
margin: 12px 16px 0;
padding: 12px;
}
}
@ -201,10 +202,6 @@
padding: 8px;
}
.user-profile-popout:not(.custom-user-profile-theme) .vc-songspotlight-song-container {
background: var(--opacity-16);
}
.vc-songspotlight-song {
height: 100%;
animation: songspotlight-fade-in var(--time) linear forwards;
@ -301,7 +298,7 @@
.vc-songspotlight-song-progress {
position: absolute;
inset: 0;
color: white;
color: var(--icon-default);
pointer-events: none;
> circle {
@ -369,4 +366,4 @@
from {
opacity: 0;
}
}
}

View file

@ -5,109 +5,138 @@
*/
import { logger } from "@equicordplugins/songSpotlight.desktop/lib/utils";
import { RenderInfoEntry } from "@song-spotlight/api/handlers";
import settings from "@equicordplugins/songSpotlight.desktop/settings";
import { RenderInfoEntry, RenderInfoEntryAudio } from "@song-spotlight/api/handlers";
import { showToast, Toasts, useCallback, useEffect, useMemo, useRef } from "@webpack/common";
import { RefObject } from "react";
interface AudioItemProps {
audio: RenderInfoEntryAudio;
index: number;
handleRef(index: number, node: HTMLAudioElement | null): void;
handleLoaded(index: number, state: boolean): void;
handleStopped(index: number, ended?: boolean): void;
}
function AudioItem({ audio, index, handleRef, handleLoaded, handleStopped }: AudioItemProps) {
const refCallback = useCallback((node: HTMLAudioElement | null) => handleRef(index, node), [index, handleRef]);
const loadedCallback = useCallback(() => handleLoaded(index, true), [index, handleLoaded]);
const erroredCallback = useCallback(() => handleLoaded(index, false), [index, handleLoaded]);
const pausedCallback = useCallback(() => handleStopped(index), [index, handleStopped]);
const endedCallback = useCallback(() => handleStopped(index, true), [index, handleStopped]);
return <audio
src={audio.previewUrl}
preload="metadata"
ref={refCallback}
onLoadedData={loadedCallback}
onError={erroredCallback}
onPause={pausedCallback}
onEnded={endedCallback}
/>;
}
interface AudioPlayerProps {
audioRef: RefObject<HTMLAudioElement | undefined>;
list: RenderInfoEntry[];
playing: number | false;
setPlaying(playing: number | false): void;
setLoaded(index: number, state: boolean): void;
playing?: number;
setPlaying(state: number | undefined): void;
setLoadedAudio(index: number, state: boolean): void;
}
const DEFAULT_VOLUME = 0.35;
const BASE_VOLUME = 0.35;
// only allow one song to play at a time
// only allows one song to play at a time
let globalPlaying: HTMLAudioElement | undefined = undefined;
export default function AudioPlayer({ audioRef, list, playing, setPlaying, setLoaded }: AudioPlayerProps) {
const urls = useMemo(() => list.map(x => x.audio?.previewUrl), [list]);
const audios = useRef(new Map<number, HTMLAudioElement>());
export default function AudioPlayer({ audioRef, list, playing, setPlaying, setLoadedAudio }: AudioPlayerProps) {
const audios = useMemo(() => list.map(x => x.audio), [list]);
const nodes = useRef(new Map<number, HTMLAudioElement>());
const loaded = useRef(new Set<number>());
const nodeEvents = useRef(new Map<number, () => void>());
const handleRef = useCallback((index: number, node: HTMLAudioElement | null) => {
if (node) {
nodes.current.set(index, node);
const audio = audios[index];
if (audio?.previewStart === undefined || !audio.previewSlice) return;
const endTime = (audio.previewStart + audio.previewSlice) / 1000;
function timeUpdated() {
if (node && node.currentTime >= endTime) {
node.volume = 0;
handleStopped(index, true);
}
}
node.addEventListener("timeupdate", timeUpdated);
nodeEvents.current.set(index, () => node.removeEventListener("timeupdate", timeUpdated));
} else {
nodes.current.delete(index);
nodeEvents.current.get(index)?.();
nodeEvents.current.delete(index);
}
}, [audios]);
const handleLoaded = useCallback((index: number, state: boolean) => {
if (state) loaded.current.add(index);
else loaded.current.delete(index);
setLoadedAudio(index, state);
}, [setLoadedAudio]);
const handleStopped = useCallback((index: number, ended?: boolean) => {
if (ended) {
const nextIndex = loaded.current.values().toArray().sort().find(x => x > index);
setPlaying(nextIndex !== -1 ? nextIndex : undefined);
} else if (playing === index) {
setPlaying(undefined);
}
}, [playing, setPlaying]);
useEffect(() => {
if (playing !== false) {
const audio = audios.current.get(playing);
if (audio && loaded.current.has(playing)) {
if (playing !== undefined) {
const audio = audios[playing], node = nodes.current.get(playing);
if (audio && node && loaded.current.has(playing)) {
if (globalPlaying) globalPlaying.pause();
audio.currentTime = 0;
audio.volume = DEFAULT_VOLUME;
audio.play().catch(error => {
node.currentTime = audio.previewStart ? audio.previewStart / 1000 : 0;
node.volume = BASE_VOLUME * (settings.store.previewVolume / 100);
node.play().catch(error => {
showToast("Failed to play song preview!", Toasts.Type.FAILURE);
logger.error("Failed to play audio", error);
setPlaying(false);
setPlaying(undefined);
});
globalPlaying = audio;
globalPlaying = node;
} else {
setPlaying(false);
}
} else {
for (const audio of audios.current.values()) {
if (globalPlaying === audio) audio.pause();
setPlaying(undefined);
}
}
for (const audio of audios.current.values()) {
if (audio !== globalPlaying && !audio.paused) {
audio.pause();
} else if (audio === globalPlaying && audio.paused) {
for (const [index, node] of nodes.current) {
if (playing !== index && !node.paused) {
node.pause();
} else if (globalPlaying === node && node.paused) {
globalPlaying = undefined;
audioRef.current = undefined;
} else if (audio === globalPlaying) {
audioRef.current = audio;
} else if (node === globalPlaying) {
audioRef.current = node;
}
}
}, [playing]);
const handleRef = useCallback((index: number, audio: HTMLAudioElement | null) => {
if (audio) audios.current.set(index, audio);
else audios.current.delete(index);
}, []);
const handleLoaded = useCallback((index: number) => {
loaded.current.add(index);
setLoaded(index, true);
}, [setLoaded]);
const handleErrored = useCallback((index: number) => {
loaded.current.delete(index);
setLoaded(index, false);
}, [setLoaded]);
// onPaused runs before onEnded
const justPaused = useRef<number>(undefined);
const handleEnded = useCallback((index: number) => {
if (justPaused.current !== index && playing !== index) return;
const nextIndex = urls.findIndex((url, j) => url && j > index);
setPlaying(nextIndex !== -1 ? nextIndex : false);
}, [playing, setPlaying]);
const handlePaused = useCallback((index: number) => {
if (playing === index) {
justPaused.current = index;
setPlaying(false);
}
}, [playing, setPlaying]);
}, [playing, audios]);
return (
<div style={{ display: "none" }} aria-hidden="true">
{urls.map((url, i) =>
url && (
<audio
key={url}
src={url}
preload="metadata"
ref={audio => handleRef(i, audio)}
onLoadedData={() => handleLoaded(i)}
onError={() => handleErrored(i)}
onEnded={() => handleEnded(i)}
onPause={() => handlePaused(i)}
{audios.map((audio, index) =>
audio && (
<AudioItem
key={audio.previewUrl}
audio={audio}
index={index}
handleRef={handleRef}
handleLoaded={handleLoaded}
handleStopped={handleStopped}
/>
)
)}

View file

@ -4,18 +4,22 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { RenderInfoEntry } from "@song-spotlight/api/handlers";
import { useEffect, useMemo, useState } from "@webpack/common";
import { JSX, RefObject } from "react";
interface ProgressCircleProps extends SvgProps {
border: number;
audioRef: RefObject<HTMLAudioElement | undefined>;
playingRef: RefObject<RenderInfoEntry | undefined>;
}
type SvgProps = JSX.IntrinsicElements["svg"];
export default function ProgressCircle({ border, audioRef, ...props }: ProgressCircleProps) {
const SIZE = 50;
export default function ProgressCircle({ border, audioRef, playingRef, ...props }: ProgressCircleProps) {
const { radius, stroke, circumference } = useMemo(() => {
const radius = 50 - border * 2;
const radius = SIZE - border * 2;
return {
radius,
stroke: border * 2,
@ -26,9 +30,14 @@ export default function ProgressCircle({ border, audioRef, ...props }: ProgressC
useEffect(() => {
let handle = requestAnimationFrame(function update() {
const audio = audioRef.current;
if (audio && !Number.isNaN(audio.duration) && !audio.paused) {
setProgress(audio.currentTime / audio.duration);
const audio = audioRef.current, playing = playingRef.current?.audio;
if (audio && playing && !Number.isNaN(audio.duration) && !audio.paused) {
let start = 0, slice = audio.duration;
if (playing.previewStart !== undefined && playing.previewSlice) {
start = playing.previewStart / 1000;
slice = playing.previewSlice / 1000;
}
setProgress(Math.min(Math.max((audio.currentTime - start) / slice, 0), 1));
} else {
setProgress(0);
}
@ -42,11 +51,11 @@ export default function ProgressCircle({ border, audioRef, ...props }: ProgressC
return (
<svg
{...props}
viewBox="0 0 100 100"
viewBox={`0 0 ${SIZE * 2} ${SIZE * 2}`}
>
<circle
cx={50}
cy={50}
cx={SIZE}
cy={SIZE}
r={radius}
fill="none"
stroke="currentColor"
@ -54,7 +63,7 @@ export default function ProgressCircle({ border, audioRef, ...props }: ProgressC
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
strokeLinecap="round"
transform="rotate(-90 50 50)"
transform={`rotate(-90 ${SIZE} ${SIZE})`}
data-empty={progress === 0}
/>
</svg>

View file

@ -31,18 +31,29 @@ import {
import { DragEvent } from "react";
interface EditableSongProps {
index: number;
song: Song;
insert?: "top" | "bottom";
setSongRef(div: HTMLAnchorElement | null): void;
onDrag(event: DragEvent<HTMLAnchorElement>): void;
setSongRef(index: number, div: HTMLAnchorElement | null): void;
onDrag(index: number, event: DragEvent<HTMLAnchorElement>): void;
onDrop(song: Song): void;
onRemove(song: Song): void;
}
function EditableSong({ song, insert, setSongRef, onDrag, onDrop, onRemove }: EditableSongProps) {
function EditableSong({ index, song, insert, setSongRef, onDrag, onDrop, onRemove }: EditableSongProps) {
const { render, failed } = useRender(song);
const [dragging, setDragging] = useState(false);
const refCallback = useCallback((div: HTMLAnchorElement | null) => setSongRef(index, div), [index, setSongRef]);
const dragCallback = useCallback((event: DragEvent<HTMLAnchorElement>) => {
setDragging(true);
onDrag(index, event);
}, [index, onDrag]);
const dragEndCallback = useCallback(() => {
setDragging(false);
onDrop(song);
}, [song, onDrop]);
return (
<Link
href={render?.link}
@ -72,17 +83,10 @@ function EditableSong({ song, insert, setSongRef, onDrag, onDrop, onRemove }: Ed
draggable="true"
data-dragging={dragging}
data-insert={insert}
onDragStart={event => {
setDragging(true);
onDrag(event);
}}
onDrag={onDrag}
onDragEnd={() => {
setDragging(false);
onDrop(song);
}}
useDefaultUnderlineStyles={false}
ref={div => setSongRef(div)}
onDragStart={dragCallback}
onDrag={dragCallback}
onDragEnd={dragEndCallback}
ref={refCallback}
className={cl("editable-song-container")}
>
<Flex alignItems="center" gap="12px" className={cl("editable-song")}>
@ -211,10 +215,11 @@ export default function SongList({ localData, setLocalData }: SongListProps) {
if (slot === "song") {
return (
<EditableSong
index={i}
song={song}
insert={last && insert === i + 1 ? "bottom" : insert === i ? "top" : undefined}
setSongRef={element => handleRef(i, element)}
onDrag={event => handleDrag(i, event)}
setSongRef={handleRef}
onDrag={handleDrag}
onDrop={handleDrop}
onRemove={handleRemove}
key={sid(song)}

View file

@ -17,6 +17,7 @@ import { RenderSongInfo } from "@song-spotlight/api/handlers";
import { UserData } from "@song-spotlight/api/structs";
import { sid } from "@song-spotlight/api/util";
import { classes } from "@utils/index";
import { User } from "@vencord/discord-types";
import {
SelectedChannelStore,
SelectedGuildStore,
@ -27,12 +28,12 @@ import {
useState,
} from "@webpack/common";
import { ProfileSongsProps } from "./ProfileSongs";
const shownSongs = 4;
interface CollapsedProfileSongsProps extends ProfileSongsProps {
interface CollapsedProfileSongsProps {
data?: UserData;
user: User;
isSideBar: boolean;
}
export default function CollapsedProfileSongs({ data, user, isSideBar }: CollapsedProfileSongsProps) {

View file

@ -39,7 +39,7 @@ import {
import Song from ".";
import CollapsedProfileSongs from "./CollapsedProfileSongs";
export interface ProfileSongsProps {
interface ProfileSongsProps {
user: User;
isSideBar: boolean;
}
@ -49,7 +49,7 @@ export default function ProfileSongs({ user, isSideBar }: ProfileSongsProps) {
const { isAuthorized } = useAuthorizationStore();
const { users } = useSongStore();
const { profileSongsLimit, collapseSongList } = settings.use();
const userId = user?.id;
const userId = user.id;
const data = users[userId]?.data;
useEffect(() => {
if (isAuthorized() && !data) listData(userId).catch(() => setFailed(true));

View file

@ -21,7 +21,7 @@ import { JSX } from "react";
import Song from ".";
export interface WidgetSongsProps {
interface WidgetSongsProps {
user: User;
}

View file

@ -25,7 +25,7 @@ import AudioPlayer from "@equicordplugins/songSpotlight.desktop/ui/components/Au
import ProgressCircle from "@equicordplugins/songSpotlight.desktop/ui/components/ProgressCircle";
import ServiceIcon from "@equicordplugins/songSpotlight.desktop/ui/components/ServiceIcon";
import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/settings";
import { RenderInfoEntryBased, RenderSongInfo } from "@song-spotlight/api/handlers";
import { RenderInfoEntry, RenderInfoEntryBased, RenderSongInfo } from "@song-spotlight/api/handlers";
import { Song as SongType } from "@song-spotlight/api/structs";
import { isListLayout, sid } from "@song-spotlight/api/util";
import { copyWithToast } from "@utils/discord";
@ -43,7 +43,7 @@ import {
useCallback,
useMemo,
useRef,
useState,
useState
} from "@webpack/common";
interface SongEntryProps {
@ -134,28 +134,29 @@ interface SongInfoProps {
}
function SongInfo({ owned, song, render, big }: SongInfoProps) {
const [playing, setPlaying] = useState<number | false>(false);
const [playing, setPlaying] = useState<number | undefined>(undefined);
const [loaded, setLoaded] = useState(new Set<number>());
const setLoadedAudio = useCallback((index: number, state: boolean) => {
if (state) loaded.add(index);
else loaded.delete(index);
setLoaded(new Set(loaded));
}, [loaded]);
const audios = useMemo(() => render.form === "single" ? [render.single] : render.list, [render]);
const audioRef = useRef<HTMLAudioElement>(undefined);
const playingRef = useRef<RenderInfoEntry>(undefined);
playingRef.current = playing !== undefined ? audios[playing] : undefined;
const duration = useMemo(
() => {
if (playing !== false) {
return audios[playing].audio?.duration;
} else {
return render.form === "single" ? render.single.audio?.duration : undefined;
}
},
() =>
render.form === "single" ?
render.single.audio?.duration :
playing !== undefined ?
render.list[playing].audio?.duration :
undefined,
[playing, render],
);
const setLoadedAudio = useCallback((index: number, state: boolean) =>
setLoaded(ld => {
if (state) ld.add(index);
else ld.delete(index);
return new Set(ld);
}), []);
const baseSize = big ? "md" : "sm";
const subSize = big ? "sm" : "xs";
@ -166,7 +167,7 @@ function SongInfo({ owned, song, render, big }: SongInfoProps) {
list={audios}
playing={playing}
setPlaying={setPlaying}
setLoaded={setLoadedAudio}
setLoadedAudio={setLoadedAudio}
/>
<div className={cl("song-grid")}>
<Flex gap="8px" alignItems="center" className={cl("song-core")}>
@ -273,7 +274,7 @@ function SongInfo({ owned, song, render, big }: SongInfoProps) {
alignItems="flex-end"
gap="6px"
className={cl("song-player")}
data-idle={playing === false && !big}
data-idle={playing === undefined && !big}
>
{duration && (
<BaseText size={subSize} weight="medium" className={cl("mono", "sub")}>
@ -282,19 +283,19 @@ function SongInfo({ owned, song, render, big }: SongInfoProps) {
)}
<div className={cl("song-progress-container")}>
<ProgressCircle
border={2}
border={2.5}
playingRef={playingRef}
audioRef={audioRef}
className={cl("song-progress")}
/>
<PlayButton
state={playing !== false}
state={playing !== undefined}
disabled={loaded.size < 1}
onClick={() => {
const loadedI = loaded.values().toArray().sort()[0];
if (loadedI === undefined) return;
if (playing !== undefined) return setPlaying(undefined);
if (playing !== false) setPlaying(false);
else setPlaying(loadedI);
const loadedIndex = loaded.values().toArray().sort()[0];
if (loadedIndex !== undefined) setPlaying(loadedIndex);
}}
/>
</div>
@ -312,10 +313,8 @@ function SongInfo({ owned, song, render, big }: SongInfoProps) {
isLoaded={loaded.has(i)}
isPlaying={playing === i}
onClick={() => {
if (!loaded.has(i)) return;
if (playing !== i) setPlaying(i);
else setPlaying(false);
if (playing === i) setPlaying(undefined);
else if (loaded.has(i)) setPlaying(i);
}}
big={big}
key={entry.link}