Files
ReSkate/Extension/Music/spotify_dashboard.cpp
T
AyeZeeBB 86aa8d38fb Music: local files, online radio, equaliser, queue, and a Music page in the menu
Sources
- Local Files: add a folder of music from the setup page (MP3, M4A, AAC, Ogg,
  FLAC, WAV; up to 200 songs a folder, 12 folders). Names and covers come from
  the files' tags. Each folder is a tile in its own row.
- Online Radio: find stations by name (radio-browser.info) or add a stream's
  address (MP3, AAC, .pls, .m3u; no HLS). Each station is a tile in its own
  row; the track name follows what the station sends. Up to 24.
- Both are decoded by the library built into the DLL (SpotifyHelper), which
  now plays files and HTTP streams as well as a program's output, reads tags
  and embedded pictures, and accepts MPEG-2 ADTS from AAC stations.

Sound
- Five-band equaliser (60 Hz, 250 Hz, 1 kHz, 4 kHz, 12 kHz) and loudness
  levelling, applied to every source on its way into the game.
- Loudness, the speakers option, the equaliser and levelling are now kept in
  music-settings.json between sessions.

Setup page
- Remade in the mod's own colours and type, with numbered steps per source.
- Queue pinned to the right: now playing, the playlist's songs with covers,
  find, and press to jump. Spotify's own queue shows once signed in.
- Cards for Local files and Online radio; equaliser and levelling under Sound.

In game
- MUSIC page in the menu: player and queue, sound settings, source status and
  a button for the setup page.
- "Loading song..." notice in the corner for songs that take a moment, with a
  switch to hide it. Presses made while a song is loading no longer restart it.
- The Spotify row is hidden when Spotify is not in use.
- Console: "spotify ..." is now "music ..." (music, music setup, music gain,
  music through, music spotify client|login|logout).

Logs
- The music sources' working lines (traces, per-song and per-bind lines,
  librespot's own) are debug only; stack captures are skipped unless debug
  logging is on. Grind exits: the per-pop line is debug only too.
- A "Pace:" line when the game takes the sound faster or slower than real
  time, to find why music sounds fast after tabbing back in.

Chat filter
- A run of digits is a number, not a word spelled with look-alikes: a race
  time such as 2:55.0 is no longer masked.
2026-10-12 16:03:35 -05:00

460 lines
24 KiB
C++

#include "spotify_dashboard.h"
#include "external_player.h"
#include "external_audio.h"
#include "youtube_music.h"
#include "app_audio_capture.h"
#include "audio_dsp.h"
#include "local_music_ui.h"
#include "music_inject.h"
#include "spotify_cover.h"
#include "spotify_device.h"
#include "spotify_station.h"
#include "spotify_web.h"
#include "Engine/Core/Json/json.h"
#include "Engine/Core/Log/logging.h"
#include "Extension/Boot/user_data_redirect.h"
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <Windows.h>
#include <shellapi.h>
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <filesystem>
#include <format>
#include <fstream>
#include <string>
#include <thread>
namespace dingosdk::spotify_dashboard {
namespace {
constexpr unsigned short first_port = 8421;
std::atomic<unsigned short> port{};
std::atomic<bool> started{};
// Something built into the DLL (cmake/Runtime.cmake): the page itself (setup_page.html), and
// the mod's own fonts, which the page is set in.
std::string built_in(const wchar_t* name) {
HMODULE module{};
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast<LPCWSTR>(&built_in), &module)) return {};
const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10) /* RT_RCDATA */);
const auto handle = resource ? LoadResource(module, resource) : nullptr;
const auto* data = handle ? static_cast<const char*>(LockResource(handle)) : nullptr;
return data ? std::string(data, SizeofResource(module, resource)) : std::string();
}
// The sound's settings, as the player left them: <Local AppData>\\ReSkate\\music-settings.json.
std::filesystem::path settings_file() { return redirected_local_app_data().parent_path() / L"music-settings.json"; }
void keep_settings() {
try {
Json json = Json::object();
json["gain"] = static_cast<std::uint64_t>(music_inject::gain() * 100.0f + 0.5f);
json["speakers"] = music_inject::mode() != 3;
json["levelling"] = audio_dsp::levelling();
json["notices"] = youtube_music::notices();
Json bands = Json::array();
for (int i = 0; i < audio_dsp::bands; ++i) bands.push_back(static_cast<std::int64_t>(std::lround(audio_dsp::band(i))));
json["equaliser"] = std::move(bands);
std::ofstream(settings_file(), std::ios::binary | std::ios::trunc) << json.dump(2);
} catch (...) {}
}
void read_settings() {
try {
std::ifstream in(settings_file(), std::ios::binary);
if (!in) return;
const auto json = Json::parse(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()));
if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast<float>(std::clamp<std::int64_t>(json.at("gain").get<std::int64_t>(), 0, 400)) / 100.0f);
if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get<bool>() ? 1 : 3);
if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get<bool>());
if (json.contains("notices") && json.at("notices").is_boolean()) youtube_music::set_notices(json.at("notices").get<bool>());
if (json.contains("equaliser") && json.at("equaliser").is_array())
for (std::size_t i = 0; i < json.at("equaliser").size() && i < audio_dsp::bands; ++i)
if (json.at("equaliser").at(i).is_number()) audio_dsp::set_band(static_cast<int>(i), static_cast<float>(std::clamp<std::int64_t>(json.at("equaliser").at(i).get<std::int64_t>(), -12, 12)));
} catch (...) {}
}
std::string state_json() {
Json out = Json::object();
const auto track = external_player::now();
Json now = Json::object();
now["artist"] = track.available ? track.artist : std::string();
now["title"] = track.available ? track.title : std::string();
now["playing"] = track.available && track.playing;
now["cover"] = track.available ? spotify_cover::url() : std::string();
now["loading"] = youtube_music::on() && youtube_music::state().loading;
out["now"] = std::move(now);
out["station"] = spotify_station::active();
Json device = Json::object();
device["connected"] = spotify_device::connected();
device["in_use"] = spotify_device::in_use();
device["text"] = spotify_device::status();
out["device"] = std::move(device);
const auto account_state = spotify_web::state();
Json account = Json::object();
account["has_client"] = account_state.has_client;
account["signed_in"] = account_state.signed_in;
account["busy"] = account_state.busy;
account["user"] = account_state.user;
account["problem"] = account_state.problem;
account["client"] = spotify_web::client();
Json playlists = Json::array();
for (const auto& playlist : spotify_web::playlists()) {
Json entry = Json::object();
entry["name"] = playlist.name;
entry["tracks"] = static_cast<std::uint64_t>(playlist.tracks.size());
playlists.push_back(std::move(entry));
}
account["playlists"] = std::move(playlists);
out["account"] = std::move(account);
{
const auto tube = youtube_music::state();
Json youtube = Json::object();
youtube["available"] = tube.available;
youtube["tools"] = tube.tools;
youtube["busy"] = tube.busy;
youtube["percent"] = static_cast<std::uint64_t>(tube.percent);
youtube["problem"] = tube.problem;
youtube["browser"] = tube.browser;
youtube["cookies"] = tube.cookies;
youtube["runtime"] = tube.runtime;
// The playlists of each kind (youtube_music.h), each with its place among them all,
// which is what it is taken out by.
using youtube_music::Kind;
Json lists = Json::array(), folders = Json::array(), stations = Json::array();
for (std::size_t i = 0; i < tube.playlists.size(); ++i) {
const auto& playlist = tube.playlists[i];
Json entry = Json::object();
entry["index"] = static_cast<std::uint64_t>(i);
entry["name"] = playlist.name;
entry["songs"] = static_cast<std::uint64_t>(playlist.songs.size());
if (playlist.kind == Kind::files) entry["path"] = playlist.url;
if (playlist.kind == Kind::radio && !playlist.songs.empty()) entry["address"] = playlist.songs.front().source;
(playlist.kind == Kind::files ? folders : playlist.kind == Kind::radio ? stations : lists).push_back(std::move(entry));
}
youtube["playlists"] = std::move(lists);
const auto problem_of = [&](Kind kind) { return tube.problem_kind == kind ? tube.problem : std::string(); };
youtube["problem"] = problem_of(Kind::youtube);
out["youtube"] = std::move(youtube);
Json local = Json::object();
local["available"] = tube.available;
local["busy"] = tube.busy;
local["problem"] = problem_of(Kind::files);
local["folders"] = std::move(folders);
out["local"] = std::move(local);
Json radio = Json::object();
radio["available"] = tube.available;
radio["busy"] = tube.busy;
radio["problem"] = problem_of(Kind::radio);
radio["stations"] = std::move(stations);
radio["searched"] = tube.searched;
Json found = Json::array();
for (const auto& station : tube.found) {
Json entry = Json::object();
entry["name"] = station.name;
entry["address"] = station.address;
entry["icon"] = station.icon;
entry["about"] = station.about;
found.push_back(std::move(entry));
}
radio["found"] = std::move(found);
out["radio"] = std::move(radio);
// What is lined up: the songs of the playlist that is playing, when it is one of these.
if (tube.playing && tube.playing_list < tube.playlists.size()) {
const auto& playlist = tube.playlists[tube.playing_list];
Json queue = Json::object();
queue["playlist"] = static_cast<std::uint64_t>(tube.playing_list);
queue["name"] = playlist.name;
queue["kind"] = playlist.kind == Kind::files ? "local" : playlist.kind == Kind::radio ? "radio" : "youtube";
queue["at"] = static_cast<std::uint64_t>(tube.playing_song);
Json songs = Json::array();
for (const auto& song : playlist.songs) {
Json entry = Json::object();
entry["artist"] = song.artist;
entry["title"] = song.title;
// Its picture: its video's and its station's from where they are; its file's from here.
entry["cover"] = playlist.kind == Kind::youtube ? "https://i.ytimg.com/vi/" + song.id + "/mqdefault.jpg"
: playlist.kind == Kind::radio ? (playlist.url.starts_with("https://") ? playlist.url : std::string())
: std::format("/api/art/{}/{}?{}", tube.playing_list, songs.size(), song.id);
songs.push_back(std::move(entry));
}
queue["songs"] = std::move(songs);
out["queue"] = std::move(queue);
}
}
// (And Spotify's own queue, when Spotify is what is playing and the player has signed in:
// the song playing, then those Spotify says come after it.)
if (!out.contains("queue") && spotify_station::active() && !youtube_music::on() && !external_audio::on() && account_state.signed_in) {
spotify_web::want_queue();
// (Looked at again at once when the song has changed.)
static std::atomic<std::uint64_t> seen{};
if (seen.exchange(track.revision) != track.revision) spotify_web::want_queue(true);
if (const auto lined_up = spotify_web::queue(); !lined_up.empty()) {
Json queue = Json::object();
queue["playlist"] = static_cast<std::uint64_t>(0);
queue["name"] = "Spotify";
queue["kind"] = "spotify";
queue["at"] = static_cast<std::uint64_t>(0);
Json songs = Json::array();
for (const auto& song : lined_up) {
Json entry = Json::object();
entry["artist"] = song.artist;
entry["title"] = song.title;
entry["cover"] = song.cover;
songs.push_back(std::move(entry));
}
queue["songs"] = std::move(songs);
out["queue"] = std::move(queue);
}
}
Json external = Json::object();
external["app"] = external_audio::app();
external["on"] = external_audio::on();
{
// (How its capture is going, for the player who hears nothing.)
const auto capture = app_audio::state();
external["capture"] = !external_audio::on() ? std::string()
: !capture.capturing ? "Not capturing it" + (capture.problem.empty() ? std::string() : ": " + capture.problem)
: capture.level > 0.0005f ? std::string("Capturing it: sound is coming through") : std::string("Capturing it, but it is silent: is it playing, and not muted in Windows' volume mixer?");
}
Json apps = Json::array();
for (const auto& name : external_audio::apps()) apps.push_back(name);
external["apps"] = std::move(apps);
out["external"] = std::move(external);
Json settings = Json::object();
settings["gain"] = static_cast<std::uint64_t>(music_inject::gain() * 100.0f + 0.5f);
settings["speakers"] = music_inject::mode() != 3;
settings["levelling"] = audio_dsp::levelling();
Json bands = Json::array();
for (int i = 0; i < audio_dsp::bands; ++i) bands.push_back(static_cast<std::int64_t>(std::lround(audio_dsp::band(i))));
settings["equaliser"] = std::move(bands);
out["settings"] = std::move(settings);
return out.dump();
}
// What a change asks for, done. False when it is not one of them.
bool change(const std::string& path, const std::string& body) {
Json json = Json::object();
try {
if (!body.empty()) json = Json::parse(body);
} catch (...) {
return false;
}
if (!json.is_object()) return false;
if (path == "/api/command") {
const auto command = json.value("command", "");
using external_player::Command;
if (command == "play") external_player::send(Command::play);
else if (command == "pause") external_player::send(Command::pause);
else if (command == "toggle") external_player::send(Command::toggle);
else if (command == "next") external_player::send(Command::next);
else if (command == "previous") external_player::send(Command::previous);
else return false;
} else if (path == "/api/client") {
const auto id = json.value("id", "");
if (id.empty() || id.size() > 64 || !std::all_of(id.begin(), id.end(), [](unsigned char c) { return std::isalnum(c) != 0; })) return false;
spotify_web::set_client(id);
} else if (path == "/api/login") {
spotify_web::login();
} else if (path == "/api/logout") {
spotify_web::logout();
} else if (path == "/api/refresh") {
spotify_web::refresh();
} else if (path == "/api/youtube/tools") {
youtube_music::fetch_tools();
} else if (path == "/api/youtube/add") {
youtube_music::add(json.value("link", ""));
} else if (path == "/api/youtube/cookies") {
if (!youtube_music::set_cookies(json.value("text", ""))) return false;
} else if (path == "/api/youtube/browser") {
youtube_music::set_browser(json.value("browser", ""));
} else if (path == "/api/local/add") {
youtube_music::add_folder(json.value("path", ""));
} else if (path == "/api/radio/search") {
youtube_music::search_radio(json.value("query", ""));
} else if (path == "/api/radio/add") {
youtube_music::add_station(json.value("name", ""), json.value("address", ""), json.value("icon", ""));
} else if (path == "/api/queue/play") {
if (!json.contains("playlist") || !json.at("playlist").is_number() || !json.contains("song") || !json.at("song").is_number()) return false;
// Spotify has no way to go straight to a song of its queue: it is gone on to, song by song.
if (json.contains("spotify") && json.at("spotify").is_boolean() && json.at("spotify").get<bool>()) {
const auto count = static_cast<int>(std::clamp<std::int64_t>(json.at("song").get<std::int64_t>(), 0, 25));
// (One at a time: a press while it is still going on is not taken.)
static std::atomic<bool> going{};
if (going.exchange(true)) return true;
std::thread([count] {
for (int i = 0; i < count; ++i) {
external_player::send(external_player::Command::next);
Sleep(450);
}
spotify_web::want_queue(true);
going.store(false);
}).detach();
return true;
}
youtube_music::play_at(static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("playlist").get<std::int64_t>(), 0, 1000)),
static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("song").get<std::int64_t>(), 0, 100000)));
} else if (path == "/api/youtube/remove" || path == "/api/library/remove") {
if (!json.contains("index") || !json.at("index").is_number()) return false;
youtube_music::remove(static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("index").get<std::int64_t>(), 0, 1000)));
} else if (path == "/api/external") {
external_audio::set_app(json.value("app", ""));
profile_runtime::request_music_catalog_refresh();
if (external_audio::on()) external_player::use_app(external_audio::app());
} else if (path == "/api/settings") {
if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast<float>(std::clamp<std::int64_t>(json.at("gain").get<std::int64_t>(), 0, 400)) / 100.0f);
if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get<bool>() ? 1 : 3);
if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get<bool>());
if (json.contains("equaliser") && json.at("equaliser").is_array())
for (std::size_t i = 0; i < json.at("equaliser").size() && i < audio_dsp::bands; ++i)
if (json.at("equaliser").at(i).is_number()) audio_dsp::set_band(static_cast<int>(i), static_cast<float>(std::clamp<std::int64_t>(json.at("equaliser").at(i).get<std::int64_t>(), -12, 12)));
keep_settings();
} else {
return false;
}
return true;
}
void answer(SOCKET client, const char* status, const char* type, const std::string& body, const char* cache = "no-store") {
const auto head = std::format("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", status, type,
body.size(), cache);
send(client, head.data(), static_cast<int>(head.size()), 0);
for (std::size_t sent = 0; sent < body.size();) {
const int did = send(client, body.data() + sent, static_cast<int>(std::min<std::size_t>(body.size() - sent, 65536)), 0);
if (did <= 0) break;
sent += static_cast<std::size_t>(did);
}
}
std::string header(const std::string& request, const std::string& name) {
// (Names are matched as browsers write them here.)
const auto at = request.find("\r\n" + name + ": ");
if (at == std::string::npos) return {};
const auto from = at + name.size() + 4;
return request.substr(from, request.find("\r\n", from) - from);
}
void serve(SOCKET client) {
DWORD timeout = 3000;
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
std::string request;
char buffer[4096];
std::size_t head_end = std::string::npos;
while (head_end == std::string::npos && request.size() < 16384) {
const int got = recv(client, buffer, sizeof(buffer), 0);
if (got <= 0) return;
request.append(buffer, static_cast<std::size_t>(got));
head_end = request.find("\r\n\r\n");
}
if (head_end == std::string::npos) return;
std::size_t length{};
try {
if (const auto text = header(request, "Content-Length"); !text.empty()) length = std::stoul(text);
} catch (...) {
return;
}
if (length > 2 * 1024 * 1024) return answer(client, "413 Payload Too Large", "text/plain", "too large");
while (request.size() < head_end + 4 + length) {
const int got = recv(client, buffer, sizeof(buffer), 0);
if (got <= 0) return;
request.append(buffer, static_cast<std::size_t>(got));
}
const auto line = request.substr(0, request.find("\r\n"));
const auto method = line.substr(0, line.find(' '));
const auto path_from = method.size() + 1;
const auto path = line.substr(path_from, line.find(' ', path_from) - path_from);
// Only as itself: a request that names another host (a site made to resolve to this
// computer) is not answered.
const auto host = header(request, "Host");
const auto own = std::to_string(port.load());
if (host != "127.0.0.1:" + own && host != "localhost:" + own) return answer(client, "403 Forbidden", "text/plain", "not for this address");
if (method == "GET" && (path == "/" || path == "/index.html")) {
const auto page = built_in(L"MUSIC_SETUP_HTML");
return page.empty() ? answer(client, "500 Internal Server Error", "text/plain", "the page was not built into this ReSkate.dll") : answer(client, "200 OK", "text/html; charset=utf-8", page);
}
if (method == "GET" && path.starts_with("/font/")) {
const auto font = built_in(path == "/font/heading.ttf" ? L"FONT_HEADING" : path == "/font/body.ttf" ? L"FONT_BODY" : path == "/font/brush.ttf" ? L"FONT_BRUSH" : L"");
return font.empty() ? answer(client, "404 Not Found", "text/plain", "no such font") : answer(client, "200 OK", "font/ttf", font, "public, max-age=86400");
}
if (method == "GET" && path.starts_with("/api/art/")) {
// The picture in a file of the player's, for the queue: /api/art/<playlist>/<song>.
std::size_t playlist{}, song{};
if (sscanf_s(path.c_str(), "/api/art/%zu/%zu", &playlist, &song) != 2) return answer(client, "404 Not Found", "text/plain", "no such picture");
const auto image = youtube_music::art(playlist, song);
const bool png = image.size() > 8 && image[0] == 0x89 && image[1] == 'P', jpeg = image.size() > 8 && image[0] == 0xff && image[1] == 0xd8;
if (!png && !jpeg) return answer(client, "404 Not Found", "text/plain", "no picture", "private, max-age=3600");
return answer(client, "200 OK", png ? "image/png" : "image/jpeg", std::string(image.begin(), image.end()), "private, max-age=3600");
}
if (method == "GET" && path == "/api/state") return answer(client, "200 OK", "application/json", state_json());
if (method == "POST") {
// A change comes from this page alone: its script sends a header that a page of another
// site cannot send without the browser asking first, which is refused by not answering.
if (header(request, "X-ReSkate") != "1") return answer(client, "403 Forbidden", "text/plain", "not from the page");
return change(path, request.substr(head_end + 4, length)) ? answer(client, "200 OK", "application/json", "{}") : answer(client, "400 Bad Request", "text/plain", "not understood");
}
answer(client, "404 Not Found", "text/plain", "nothing here");
}
void run() noexcept {
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return;
SOCKET listener = INVALID_SOCKET;
for (unsigned short candidate = first_port; candidate < first_port + 10 && listener == INVALID_SOCKET; ++candidate) {
const SOCKET made = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in at{};
at.sin_family = AF_INET;
at.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
at.sin_port = htons(candidate);
if (made != INVALID_SOCKET && bind(made, reinterpret_cast<const sockaddr*>(&at), sizeof(at)) == 0 && listen(made, 8) == 0) {
listener = made;
port.store(candidate);
} else if (made != INVALID_SOCKET) {
closesocket(made);
}
}
if (listener == INVALID_SOCKET) {
try {
logging::log(logging::Level::warning, logging::Channel::music, "Music setup page: no port on this computer was free for it.");
} catch (...) {}
return;
}
try {
logging::log(logging::Level::info, logging::Channel::music, "Music setup page: {} (\"music setup\" in the console opens it).", address());
} catch (...) {}
for (;;) {
const SOCKET client = accept(listener, nullptr, nullptr);
if (client == INVALID_SOCKET) {
Sleep(50);
continue;
}
try {
serve(client);
} catch (...) {}
shutdown(client, SD_SEND);
closesocket(client);
}
}
} // namespace
void start() noexcept {
if (started.exchange(true)) return;
read_settings();
try {
std::thread(run).detach();
} catch (...) {}
}
void save_settings() noexcept { keep_settings(); }
std::string address() {
const auto at = port.load();
return at ? "http://127.0.0.1:" + std::to_string(at) + "/" : std::string();
}
bool open() noexcept {
try {
const auto where = address();
if (where.empty()) return false;
const std::wstring wide(where.begin(), where.end());
return reinterpret_cast<INT_PTR>(ShellExecuteW(nullptr, L"open", wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL)) > 32;
} catch (...) {
return false;
}
}
} // namespace dingosdk::spotify_dashboard