Compare commits
10
Commits
2026.08.27.01
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0282fd66d | ||
|
|
9fb948fe83 | ||
|
|
de8949efa7 | ||
|
|
f17a88be9a | ||
|
|
76d7ce6ccd | ||
|
|
8814f90780 | ||
|
|
f63e6000f9 | ||
|
|
e0e96d10d7 | ||
|
|
fe1405f273 | ||
|
|
3368f5def9 |
+1
-1
@@ -40,7 +40,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
|||||||
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
version: '2026.08.27.01',
|
version: '2026.09.23.01',
|
||||||
platform: '>=1.0.0',
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
label: 'Music',
|
label: 'Music',
|
||||||
|
|||||||
Executable
+203
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Slim the embedded cover art across the music library.
|
||||||
|
|
||||||
|
WHY. iOS AVPlayer fetches the head of a streamed file in ~32 KB range requests, one round trip
|
||||||
|
each, and it cannot reach audio until it has read past the whole ID3 tag. A 955 KB embedded JPEG
|
||||||
|
(Ben Harper, 2026-08-29) therefore cost ~30 sequential round trips - a two-second silence on the
|
||||||
|
iPhone that Android (ExoPlayer, one connection) never feels. 28% of the library rips carry art
|
||||||
|
this size.
|
||||||
|
|
||||||
|
THE LIMIT, AND WHY. Every track KEEPS an embedded front cover - the folder cover.jpg is this
|
||||||
|
plugin's primary art, but embedded art is what any OTHER player/user of the files sees, so it
|
||||||
|
stays. It is recompressed to fit the transport: 600x600 max (the iTunes standard, plenty for any
|
||||||
|
lock screen), JPEG, quality stepped down from 85 until it fits in EMBED_TARGET = 80 KB. 80 KB
|
||||||
|
plus the text frames keeps the whole tag within ~3 range-request chunks - imperceptible - while
|
||||||
|
staying visually indistinguishable at the sizes any UI renders it.
|
||||||
|
|
||||||
|
Only files whose picture frames total more than PROCESS_OVER = 100 KB are touched at all; a file
|
||||||
|
already carrying reasonable art is left byte-identical.
|
||||||
|
|
||||||
|
SAFETY. Work happens on a temp copy; the post-tag byte region (the audio, verbatim) is hashed
|
||||||
|
before and after and must match or the original is kept. If an album folder has NO cover.jpg or
|
||||||
|
folder.jpg, the original full-resolution embedded art is rescued to cover.jpg before compression.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
slim-embedded-art.py dry-run over ~/Music
|
||||||
|
slim-embedded-art.py "Albums/Ben Harper/[1994] ..." dry-run, one directory
|
||||||
|
slim-embedded-art.py [subpath] --apply actually rewrite
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mutagen.id3 import ID3, APIC, ID3NoHeaderError
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = os.path.expanduser("~/Music")
|
||||||
|
EMBED_TARGET = 80_000
|
||||||
|
PROCESS_OVER = 100_000
|
||||||
|
# No song has 50 KB of lyrics. The frame that set this limit held the complete text of Frank
|
||||||
|
# McCourt's "Angela's Ashes" - 1.5 MB of memoir in an Eels track's USLT, courtesy of some tagger
|
||||||
|
# malfunction - and AVPlayer pages through ALL of it before the first audio frame, art or not.
|
||||||
|
LYRICS_SANE = 50_000
|
||||||
|
# The last 47 oversized files held no big art and no big lyrics - just PADDING: empty space some
|
||||||
|
# tagger reserved for future edits, up to 478 KB of it against 39 bytes of actual frames (the DJ
|
||||||
|
# Sets tree, 2026-08-29). The player pages through padding like anything else, so a tag this far
|
||||||
|
# past its content gets rewritten (the save already zeroes padding).
|
||||||
|
TAG_SANE = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
def audio_region_hash(path, offset=None):
|
||||||
|
"""SHA-256 of the audio: after the ID3v2 tag, before any trailing ID3v1 tag.
|
||||||
|
|
||||||
|
The v2 offset comes from mutagen's own parse. The trailing 128-byte ID3v1 block (starts
|
||||||
|
"TAG") is EXCLUDED: mutagen updates an existing v1 tag on save - measured on Shamrocks and
|
||||||
|
Shenanigans (2026-08-29), it normalised the v1 genre byte 0xFF to 0x0F (Rap) and the guard
|
||||||
|
refused a whole album of perfectly good rewrites over that one metadata byte.
|
||||||
|
"""
|
||||||
|
if offset is None:
|
||||||
|
try:
|
||||||
|
offset = ID3(path).size
|
||||||
|
except Exception:
|
||||||
|
offset = 0
|
||||||
|
if not offset:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
head = f.read(10)
|
||||||
|
if head[:3] == b"ID3":
|
||||||
|
offset = 10 + ((head[6] << 21) | (head[7] << 14) | (head[8] << 7) | head[9])
|
||||||
|
if head[5] & 0x10: # v2.4 footer
|
||||||
|
offset += 10
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
end = size
|
||||||
|
if size - offset >= 128:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
f.seek(size - 128)
|
||||||
|
if f.read(3) == b"TAG":
|
||||||
|
end = size - 128
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
h = hashlib.sha256()
|
||||||
|
remaining = end - offset
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = f.read(min(1 << 20, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def pictures(tag):
|
||||||
|
return tag.getall("APIC") + tag.getall("PIC")
|
||||||
|
|
||||||
|
|
||||||
|
def compress_cover(data):
|
||||||
|
"""(jpeg_bytes, note) at <= EMBED_TARGET, stepping quality then size down."""
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
for side in (600, 500, 400):
|
||||||
|
work = img.copy()
|
||||||
|
work.thumbnail((side, side))
|
||||||
|
for q in (85, 80, 70, 60, 50):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
work.save(buf, "JPEG", quality=q, optimize=True)
|
||||||
|
if buf.tell() <= EMBED_TARGET:
|
||||||
|
return buf.getvalue(), f"{side}px q{q}"
|
||||||
|
return buf.getvalue(), f"{side}px q{q} OVER" # pathological; still vastly smaller
|
||||||
|
|
||||||
|
|
||||||
|
def rescue_cover(dirpath, data):
|
||||||
|
"""Write the original art to cover.jpg when the folder has no art file at all."""
|
||||||
|
for name in ("cover.jpg", "folder.jpg"):
|
||||||
|
if os.path.exists(os.path.join(dirpath, name)):
|
||||||
|
return False
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(os.path.join(dirpath, "cover.jpg"), "JPEG", quality=90)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def process(path, apply):
|
||||||
|
try:
|
||||||
|
tag = ID3(path)
|
||||||
|
except ID3NoHeaderError:
|
||||||
|
return None
|
||||||
|
pics = pictures(tag)
|
||||||
|
total = sum(len(p.data) for p in pics)
|
||||||
|
junk_lyrics = [f for f in tag.getall("USLT") if len(f.text or "") > LYRICS_SANE]
|
||||||
|
bloated = tag.size > TAG_SANE
|
||||||
|
if total <= PROCESS_OVER and not junk_lyrics and not bloated:
|
||||||
|
return None
|
||||||
|
largest = max(pics, key=lambda p: len(p.data)) if pics else None
|
||||||
|
if not apply:
|
||||||
|
parts = []
|
||||||
|
if total > PROCESS_OVER: parts.append(f"{total // 1024} KB of art")
|
||||||
|
if junk_lyrics: parts.append(f"{sum(len(f.text) for f in junk_lyrics) // 1024} KB of junk lyrics")
|
||||||
|
if not parts and bloated: parts.append(f"{tag.size // 1024} KB tag of mostly padding")
|
||||||
|
return "would slim " + " + ".join(parts)
|
||||||
|
|
||||||
|
before = audio_region_hash(path)
|
||||||
|
tmp = path + ".slimtmp"
|
||||||
|
shutil.copy2(path, tmp)
|
||||||
|
try:
|
||||||
|
note = "art untouched"
|
||||||
|
t = ID3(tmp)
|
||||||
|
if largest is not None and total > PROCESS_OVER:
|
||||||
|
rescued = rescue_cover(os.path.dirname(path), largest.data)
|
||||||
|
jpeg, note = compress_cover(largest.data)
|
||||||
|
t.delall("APIC")
|
||||||
|
t.delall("PIC")
|
||||||
|
t.add(APIC(encoding=0, mime="image/jpeg", type=3, desc="", data=jpeg))
|
||||||
|
else:
|
||||||
|
rescued = False
|
||||||
|
jpeg = b""
|
||||||
|
for f in junk_lyrics:
|
||||||
|
t.delall(f.HashKey)
|
||||||
|
v2 = 4 if tag.version >= (2, 4, 0) else 3
|
||||||
|
# v1=0 REMOVES any trailing ID3v1 tag: it is a 128-byte legacy appendix fully redundant
|
||||||
|
# with the v2 frames, and keeping it in sync is what tripped the audio guard before the
|
||||||
|
# guard learned to exclude it. Decided 2026-08-29: strip it wherever we rewrite.
|
||||||
|
t.save(v1=0, v2_version=v2, padding=lambda info: 0)
|
||||||
|
if audio_region_hash(tmp) != before:
|
||||||
|
raise RuntimeError("audio bytes changed - keeping original")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
note_out = f"{total // 1024} KB -> {len(jpeg) // 1024} KB ({note})"
|
||||||
|
if junk_lyrics:
|
||||||
|
note_out += f" -{sum(len(f.text) for f in junk_lyrics) // 1024} KB junk lyrics"
|
||||||
|
return note_out + (" +rescued cover.jpg" if rescued else "")
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
return f"FAILED: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = [a for a in sys.argv[1:] if a != "--apply"]
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
base = os.path.join(ROOT, args[0]) if args else ROOT
|
||||||
|
done = failed = skipped = 0
|
||||||
|
for dirpath, _, files in os.walk(base):
|
||||||
|
for name in sorted(files):
|
||||||
|
if not name.lower().endswith(".mp3"):
|
||||||
|
continue
|
||||||
|
result = process(os.path.join(dirpath, name), apply)
|
||||||
|
rel = os.path.join(dirpath, name)[len(ROOT) + 1 :]
|
||||||
|
if result is None:
|
||||||
|
skipped += 1
|
||||||
|
elif result.startswith("FAILED"):
|
||||||
|
failed += 1
|
||||||
|
print(f" !! {rel} {result}", flush=True)
|
||||||
|
else:
|
||||||
|
done += 1
|
||||||
|
if done % 250 == 0 or not apply:
|
||||||
|
print(f" {rel} {result}", flush=True)
|
||||||
|
print(f"{'applied' if apply else 'dry-run'}: {done} slimmed, {skipped} already fine, {failed} failed", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,6 +3,7 @@ import { join, basename } from 'node:path';
|
|||||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
|
import { randomSongs } from './random';
|
||||||
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||||
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
@@ -73,6 +74,8 @@ const asKeys = (v: unknown): string[] | null =>
|
|||||||
//
|
//
|
||||||
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
||||||
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
||||||
|
// GET /random?path=<rel>&n=<1..100> n random tracks from that scope, uniform over TRACKS (albums
|
||||||
|
// weighted by count). Reads only the albums picked — see random.ts.
|
||||||
// GET /manifest pure read of the last completed index (NO build triggered) —
|
// GET /manifest pure read of the last completed index (NO build triggered) —
|
||||||
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
|
||||||
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
||||||
@@ -251,6 +254,23 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync surface ──
|
// ── Sync surface ──
|
||||||
|
/*
|
||||||
|
A random handful of tracks, chosen here rather than on the phone.
|
||||||
|
|
||||||
|
See random.ts for why: the app used to flatten every album's meta into one array to pick twenty
|
||||||
|
songs, which on a large library meant a six-figure object array living in the JS heap and being
|
||||||
|
copied on every draw. The manifest already holds per-album track counts, so the choice costs
|
||||||
|
arithmetic plus a read of only the albums actually picked.
|
||||||
|
*/
|
||||||
|
if (url.pathname === '/random') {
|
||||||
|
const scope = url.searchParams.get('path') ?? '';
|
||||||
|
const raw = Number(url.searchParams.get('n') ?? '20');
|
||||||
|
// Clamped: this reads one meta.json per distinct album picked, so an unbounded n is an
|
||||||
|
// unbounded amount of disk work from a query string.
|
||||||
|
const n = Number.isFinite(raw) ? Math.max(1, Math.min(100, Math.floor(raw))) : 20;
|
||||||
|
return json({ songs: await randomSongs(scope, n) }, { headers: NO_CACHE });
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/manifest') {
|
if (url.pathname === '/manifest') {
|
||||||
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
|
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
|
||||||
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
|
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { getManifest, metaFilePath, type IndexMeta, type IndexTrack } from './indexer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A random handful of tracks, chosen by the SERVER.
|
||||||
|
*
|
||||||
|
* ## Why this is not the app's job
|
||||||
|
*
|
||||||
|
* The app used to shuffle by building a song index for the whole scope — every album's meta.json,
|
||||||
|
* flattened into one array — and then picking from it. For a library of a hundred thousand tracks
|
||||||
|
* that meant holding a hundred thousand objects in the JS heap permanently, copying the array on
|
||||||
|
* every draw, and paying for all of it in garbage collection on a phone. Measured on device: the JS
|
||||||
|
* thread never went idle during playback, and the device reached "serious" thermal state.
|
||||||
|
*
|
||||||
|
* Picking twenty songs never needed any of that. The manifest already knows how many tracks each
|
||||||
|
* album holds, so the choice can be made from COUNTS and only the chosen albums read.
|
||||||
|
*
|
||||||
|
* ## Uniform over tracks, not albums
|
||||||
|
*
|
||||||
|
* Picking an album at random and then a track from it over-represents short albums: a two-track EP
|
||||||
|
* would come up as often as a sixty-track box set. So albums are weighted by their track count — a
|
||||||
|
* running total, then a random offset into it, binary-searched back to the album that contains it.
|
||||||
|
* That is the same distribution as shuffling every track and taking the first n, which is what a
|
||||||
|
* shuffle is understood to mean.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RandomSong = {
|
||||||
|
/** Home-relative path, the id the player streams by. */
|
||||||
|
homePath: string;
|
||||||
|
albumRel: string;
|
||||||
|
file: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
durationSec?: number;
|
||||||
|
/** Whether this album has a cover the app can request. Saves it guessing and 404ing. */
|
||||||
|
cover: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMeta = async (rel: string): Promise<IndexMeta | null> => {
|
||||||
|
const path = metaFilePath(rel);
|
||||||
|
if (!path) return null;
|
||||||
|
try {
|
||||||
|
const f = Bun.file(path);
|
||||||
|
return (await f.exists()) ? ((await f.json()) as IndexMeta) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleOf = (t: IndexTrack): string => t.title || t.file.replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
|
export async function randomSongs(scopeRel: string, n: number): Promise<RandomSong[]> {
|
||||||
|
const manifest = await getManifest();
|
||||||
|
|
||||||
|
/*
|
||||||
|
In scope, and actually holding tracks.
|
||||||
|
|
||||||
|
An empty scope means the whole library. Otherwise it is the folder itself or anything beneath it —
|
||||||
|
`startsWith(scope + '/')` rather than `startsWith(scope)`, so "Albums" does not also match a
|
||||||
|
sibling called "Albums Live".
|
||||||
|
*/
|
||||||
|
const rels = Object.keys(manifest.albums).filter((rel) => {
|
||||||
|
const entry = manifest.albums[rel];
|
||||||
|
if (!entry || !entry.tracks) return false;
|
||||||
|
return scopeRel === '' || rel === scopeRel || rel.startsWith(`${scopeRel}/`);
|
||||||
|
});
|
||||||
|
if (!rels.length) return [];
|
||||||
|
|
||||||
|
// Running total of track counts — the weighting described above. Numbers only; no track is read yet.
|
||||||
|
const cumulative: number[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const rel of rels) {
|
||||||
|
total += manifest.albums[rel]!.tracks;
|
||||||
|
cumulative.push(total);
|
||||||
|
}
|
||||||
|
if (total === 0) return [];
|
||||||
|
|
||||||
|
/** Which album owns global track offset `x`. */
|
||||||
|
const albumFor = (x: number): number => {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = cumulative.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (cumulative[mid]! <= x) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Draw distinct offsets, then group by album so each album's meta.json is read ONCE however many of
|
||||||
|
its tracks were picked. `want` is capped at the library size, and the attempt limit stops a tiny
|
||||||
|
library spinning forever trying to find offsets it does not have.
|
||||||
|
*/
|
||||||
|
const want = Math.min(n, total);
|
||||||
|
const offsets = new Set<number>();
|
||||||
|
for (let attempts = 0; offsets.size < want && attempts < want * 20; attempts++) {
|
||||||
|
offsets.add(Math.floor(Math.random() * total));
|
||||||
|
}
|
||||||
|
|
||||||
|
const byAlbum = new Map<number, number[]>();
|
||||||
|
for (const off of offsets) {
|
||||||
|
const idx = albumFor(off);
|
||||||
|
const base = idx === 0 ? 0 : cumulative[idx - 1]!;
|
||||||
|
const within = off - base;
|
||||||
|
const list = byAlbum.get(idx) ?? [];
|
||||||
|
list.push(within);
|
||||||
|
byAlbum.set(idx, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs: RandomSong[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
[...byAlbum.entries()].map(async ([idx, withins]) => {
|
||||||
|
const rel = rels[idx]!;
|
||||||
|
const meta = await readMeta(rel);
|
||||||
|
if (!meta?.tracks?.length) return;
|
||||||
|
for (const within of withins) {
|
||||||
|
// The manifest's count and the meta file can disagree if the index is mid-rebuild; modulo
|
||||||
|
// keeps that a slightly different song rather than an empty slot.
|
||||||
|
const t = meta.tracks[within % meta.tracks.length];
|
||||||
|
if (!t) continue;
|
||||||
|
songs.push({
|
||||||
|
homePath: `Music/${rel}/${t.file}`,
|
||||||
|
albumRel: rel,
|
||||||
|
file: t.file,
|
||||||
|
title: titleOf(t),
|
||||||
|
artist: t.artist || t.albumArtist || '',
|
||||||
|
album: t.album || '',
|
||||||
|
durationSec: t.durationSec,
|
||||||
|
cover: !!manifest.albums[rel]?.cover,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grouping by album clustered them; a shuffle must not come back sorted by album.
|
||||||
|
for (let i = songs.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[songs[i], songs[j]] = [songs[j]!, songs[i]!];
|
||||||
|
}
|
||||||
|
return songs;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user