Files
ReSkate/Extension/Music/music_page.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

311 lines
16 KiB
C++

#include "Extension/UI/Overlay/skate_menu_internal.h"
#include "Extension/UI/skate_theme.h"
#include "audio_dsp.h"
#include "external_audio.h"
#include "external_player.h"
#include "music_inject.h"
#include "spotify_dashboard.h"
#include "spotify_device.h"
#include "spotify_station.h"
#include "spotify_web.h"
#include "youtube_music.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <format>
#include <string>
#include <vector>
// The MUSIC page of the menu: what is playing from the music sources (Spotify, YouTube Music,
// the player's files, radio, another application) with play, pause and next, what is lined up,
// and how it sounds (loudness, levelling, the equaliser). Setting a source up is done in the
// browser (spotify_dashboard.h), which this opens. Presentation thread: it reads what the
// sources say of themselves a few times a second, and asks of them what the setup page does.
namespace dingosdk::overlay::menu {
namespace {
struct Page {
int tab{};
double read_at{-1};
youtube_music::State streams;
std::vector<spotify_web::Queued> spotify;
std::string title, artist, source;
bool available{}, playing{}, station{};
std::array<char, 96> find{};
// The sliders, as they are while one is held.
float gain{100};
std::array<float, audio_dsp::bands> bands{};
bool editing{};
double pressed{-1};
};
Page& page() {
static auto* value = new Page;
return *value;
}
void read(Page& p) {
if (p.read_at >= 0 && ImGui::GetTime() - p.read_at < 0.4) return;
p.read_at = ImGui::GetTime();
p.streams = youtube_music::state();
p.station = spotify_station::active();
const auto now = external_player::now();
p.available = now.available && p.station;
p.title = now.title;
p.artist = now.artist;
p.playing = now.playing;
const bool streams = youtube_music::on(), others = external_audio::on();
p.source = !p.station ? "" : others ? "EXTERNAL AUDIO" : !streams ? "SPOTIFY"
: p.streams.playing_list < p.streams.playlists.size() && p.streams.playlists[p.streams.playing_list].kind == youtube_music::Kind::files ? "LOCAL FILES"
: p.streams.playing_list < p.streams.playlists.size() && p.streams.playlists[p.streams.playing_list].kind == youtube_music::Kind::radio ? "ONLINE RADIO" : "YOUTUBE MUSIC";
p.spotify.clear();
if (p.station && !streams && !others) {
spotify_web::want_queue();
p.spotify = spotify_web::queue();
}
if (!p.editing) {
p.gain = music_inject::gain() * 100.0f;
for (int i = 0; i < audio_dsp::bands; ++i) p.bands[static_cast<std::size_t>(i)] = audio_dsp::band(i);
}
}
bool found(const std::array<char, 96>& find, const std::string& artist, const std::string& title) {
if (!find[0]) return true;
const auto lower = [](std::string text) {
for (auto& c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return text;
};
return lower(title + " " + artist).find(lower(find.data())) != std::string::npos;
}
void player_tab(SkateMenu& menu, Page& p) {
begin_card(menu, "music-now", "NOW PLAYING");
if (!p.available || p.title.empty()) {
note(p.station ? "Waiting for the first song." : "Nothing is playing. Pick a tile in the game's music screen: Spotify, YouTube Music, Local Files, Online Radio or External Audio.");
} else {
ImGui::PushFont(menu.heading);
ImGui::TextUnformatted(p.title.c_str());
ImGui::PopFont();
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(p.artist.c_str());
ImGui::PopStyleColor();
if (!p.source.empty()) tag(menu, p.source.c_str(), skate_theme::blue);
if (p.streams.loading) {
ImGui::SameLine(0, px(8));
tag(menu, "LOADING", skate_theme::warning);
}
}
ImGui::Dummy(ImVec2(0, px(4)));
const float gap = px(6), width = (ImGui::GetContentRegionAvail().x - gap * 2) / 3;
ImGui::BeginDisabled(!p.station);
using external_player::Command;
if (ImGui::Button("PREVIOUS", ImVec2(width, px(36)))) external_player::send(Command::previous);
ImGui::SameLine(0, gap);
skate_theme::push_primary_button();
if (ImGui::Button(p.playing ? "PAUSE" : "PLAY", ImVec2(width, px(36)))) {
external_player::send(Command::toggle);
p.read_at = -1;
}
skate_theme::pop_primary_button();
ImGui::SameLine(0, gap);
if (ImGui::Button("NEXT", ImVec2(width, px(36)))) external_player::send(Command::next);
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, px(4)));
bool notices = youtube_music::notices();
if (toggle_row(menu, "Show \"Loading song\" in the corner", "The notice in the top left while a YouTube song or a radio station is on its way.", notices)) {
youtube_music::set_notices(notices);
spotify_dashboard::save_settings();
}
end_card();
// What is lined up: the playlist that is playing, or what Spotify says comes next.
const auto& s = p.streams;
const bool lined_up = s.playing && s.playing_list < s.playlists.size();
begin_card(menu, "music-queue", "QUEUE", lined_up ? s.playlists[s.playing_list].name.c_str() : !p.spotify.empty() ? "Up next on Spotify" : nullptr);
if (lined_up) {
const auto& songs = s.playlists[s.playing_list].songs;
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##music-find", "Find a song or artist", p.find.data(), p.find.size());
ImGui::BeginChild("music-songs", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_None);
// (One press at a time: a song that is loading is not begun again under more presses.)
const bool held = s.loading || (p.pressed >= 0 && ImGui::GetTime() - p.pressed < 1.5);
for (std::size_t i = 0; i < songs.size(); ++i) {
if (!found(p.find, songs[i].artist, songs[i].title)) continue;
ImGui::PushID(static_cast<int>(i));
const bool current = i == s.playing_song;
if (current) ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue_hover);
if (ImGui::Selectable(std::format("{:>3} {}", i + 1, songs[i].title).c_str(), current, held ? ImGuiSelectableFlags_Disabled : ImGuiSelectableFlags_None) && !current) {
youtube_music::play_at(s.playing_list, i);
p.pressed = ImGui::GetTime();
p.read_at = -1;
}
if (current) ImGui::PopStyleColor();
ImGui::SameLine(0, px(12));
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(songs[i].artist.c_str());
ImGui::PopStyleColor();
ImGui::PopID();
}
ImGui::EndChild();
} else if (!p.spotify.empty()) {
ImGui::BeginChild("music-spotify", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_None);
for (std::size_t i = 0; i < p.spotify.size(); ++i) {
ImGui::PushStyleColor(ImGuiCol_Text, i ? skate_theme::white : skate_theme::blue_hover);
ImGui::TextUnformatted(std::format("{:>3} {}", i + 1, p.spotify[i].title).c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, px(12));
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(p.spotify[i].artist.c_str());
ImGui::PopStyleColor();
}
ImGui::EndChild();
} else {
note(p.source == "SPOTIFY" ? "To see Spotify's queue, sign in under \"Show your own playlists\" on the setup page (SOURCES tab)." : "Nothing is lined up.");
}
end_card();
}
void sound_tab(SkateMenu& menu, Page& p) {
bool editing{}, changed{};
begin_card(menu, "music-loudness", "LOUDNESS");
field(menu, "Music loudness", "How loud the music you play through ReSkate is in the game.");
if (ImGui::SliderFloat("##music-gain", &p.gain, 0.0f, 400.0f, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) music_inject::set_gain(p.gain / 100.0f);
editing |= ImGui::IsItemActive();
changed |= ImGui::IsItemDeactivatedAfterEdit();
bool levelling = audio_dsp::levelling();
if (toggle_row(menu, "Keep every song at the same loudness", "Turns quiet songs up and loud ones down.", levelling)) {
audio_dsp::set_levelling(levelling);
changed = true;
}
bool speakers = music_inject::mode() != 3;
if (toggle_row(menu, "Speakers in the world play it too", "Speakers around the map play what you are playing.", speakers)) {
music_inject::set_mode(speakers ? 1 : 3);
changed = true;
}
end_card();
begin_card(menu, "music-equaliser", "EQUALISER", "Up for more of that part of the sound, down for less.");
static constexpr std::array<const char*, audio_dsp::bands> names{"BASS", "LOW MID", "MID", "HIGH MID", "TREBLE"}, where{"60 Hz", "250 Hz", "1 kHz", "4 kHz", "12 kHz"};
// Each band is a column: how much it is set to, a narrow track with a line where 0 dB is
// and a filled bar from that line to the handle, then its name and where it sits.
const float gap = px(10), column = std::min(px(96), (ImGui::GetContentRegionAvail().x - gap * (audio_dsp::bands - 1)) / audio_dsp::bands);
const float track = px(34), tall = px(150);
const auto origin = ImGui::GetCursorScreenPos();
auto* draw = ImGui::GetWindowDrawList();
const auto centred = [&](ImFont* font, float size, float x, float y, ImU32 colour, const char* text) {
const auto extent = font->CalcTextSizeA(size, FLT_MAX, 0, text);
draw->AddText(font, size, ImVec2(x + (column - extent.x) * .5f, y), colour, text);
};
for (int i = 0; i < audio_dsp::bands; ++i) {
const auto at = static_cast<std::size_t>(i);
const float x = origin.x + (column + gap) * static_cast<float>(i);
const float value = p.bands[at];
centred(menu.bold, px(15), x, origin.y, std::fabs(value) < 0.5f ? skate_theme::grey_text : skate_theme::white, std::format("{:+.0f} dB", value).c_str());
const ImVec2 top(x + (column - track) * .5f, origin.y + px(24));
ImGui::PushID(i);
ImGui::SetCursorScreenPos(top);
// (The slider itself is not drawn: only its handle, over what is drawn here.)
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32(0, 0, 0, 0));
if (ImGui::VSliderFloat("##band", ImVec2(track, tall), &p.bands[at], -audio_dsp::most_decibels, audio_dsp::most_decibels, "", ImGuiSliderFlags_AlwaysClamp)) {
p.bands[at] = std::round(p.bands[at]);
audio_dsp::set_band(i, p.bands[at]);
}
ImGui::PopStyleColor(5);
const bool hot = ImGui::IsItemHovered() || ImGui::IsItemActive();
editing |= ImGui::IsItemActive();
changed |= ImGui::IsItemDeactivatedAfterEdit();
if (ImGui::IsItemFocused() && ImGui::GetIO().NavVisible) draw->AddRect(top, ImVec2(top.x + track, top.y + tall), skate_theme::white, 0, 0, px(2));
ImGui::PopID();
const float middle = top.y + tall * .5f, rail = px(6), handle = px(8);
const float level = middle - p.bands[at] / audio_dsp::most_decibels * (tall * .5f - handle * .5f);
const float centre = top.x + track * .5f;
draw->AddRectFilled(ImVec2(centre - rail * .5f, top.y), ImVec2(centre + rail * .5f, top.y + tall), skate_theme::tile_light);
draw->AddRectFilled(ImVec2(centre - rail * .5f, std::min(level, middle)), ImVec2(centre + rail * .5f, std::max(level, middle)), skate_theme::blue);
draw->AddRectFilled(ImVec2(top.x, middle - px(1)), ImVec2(top.x + track, middle + px(1)), skate_theme::grey_text);
draw->AddRectFilled(ImVec2(top.x, level - handle * .5f), ImVec2(top.x + track, level + handle * .5f), hot ? skate_theme::blue_hover : skate_theme::white);
centred(menu.bold, px(13), x, top.y + tall + px(8), skate_theme::white, names[at]);
centred(menu.body, px(13), x, top.y + tall + px(26), skate_theme::grey_text, where[at]);
}
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2((column + gap) * audio_dsp::bands - gap, px(24) + tall + px(46)));
ImGui::Dummy(ImVec2(0, px(6)));
struct Preset {
const char* name;
std::array<float, audio_dsp::bands> bands;
};
static constexpr std::array<Preset, 5> presets{{{"FLAT", {0, 0, 0, 0, 0}}, {"BASS BOOST", {6, 3, 0, 0, 0}}, {"LOUD", {5, 2, -1, 2, 4}}, {"VOCAL", {-2, 0, 3, 3, 0}}, {"BRIGHT", {0, 0, 0, 3, 5}}}};
for (std::size_t i = 0; i < presets.size(); ++i) {
if (i) ImGui::SameLine(0, px(6));
const bool on = presets[i].bands == p.bands;
if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
if (ImGui::Button(presets[i].name)) {
p.bands = presets[i].bands;
for (int band = 0; band < audio_dsp::bands; ++band) audio_dsp::set_band(band, p.bands[static_cast<std::size_t>(band)]);
changed = true;
}
if (on) ImGui::PopStyleColor();
}
note("The loudness, levelling and equaliser only change music played through ReSkate, not the game's own.");
end_card();
p.editing = editing;
if (changed) spotify_dashboard::save_settings();
}
void sources_tab(SkateMenu& menu, Page& p) {
begin_card(menu, "music-setup", "SET UP", "Sign in, add playlists, folders and stations.");
note("Sources are set up on a page in your browser. It only works on this PC, while the game is running.");
if (primary_button(menu, "OPEN THE SETUP PAGE IN YOUR BROWSER"))
feedback(menu, spotify_dashboard::open() ? "Opened the setup page in your browser. Alt-Tab to it." : "The setup page could not be opened.");
end_card();
using youtube_music::Kind;
const auto count = [&](Kind kind) { return std::count_if(p.streams.playlists.begin(), p.streams.playlists.end(), [&](const youtube_music::Playlist& playlist) { return playlist.kind == kind; }); };
begin_card(menu, "music-sources", "SOURCES");
info(menu, "Spotify", spotify_device::connected() ? "Connected" : spotify_device::in_use() ? "Remembered, connects by itself" : "Pick \"ReSkate\" in Spotify's device list");
info(menu, "YouTube Music", !p.streams.tools ? "Not set up" : std::format("{} playlist(s)", count(Kind::youtube)));
info(menu, "Local files", count(Kind::files) ? std::format("{} folder(s)", count(Kind::files)) : std::string("No folder added"));
info(menu, "Online radio", count(Kind::radio) ? std::format("{} station(s)", count(Kind::radio)) : std::string("No station added"));
info(menu, "External audio", external_audio::app().empty() ? std::string("No app chosen") : external_audio::app());
if (!p.streams.problem.empty()) warn(p.streams.problem.c_str());
end_card();
if (count(Kind::files)) {
begin_card(menu, "music-folders", "LOCAL FOLDERS", "Rescan after adding files to one.");
for (std::size_t i = 0; i < p.streams.playlists.size(); ++i) {
const auto& playlist = p.streams.playlists[i];
if (playlist.kind != Kind::files) continue;
ImGui::PushID(static_cast<int>(i));
ImGui::BeginDisabled(p.streams.busy);
if (ImGui::Button("RESCAN")) {
youtube_music::add_folder(playlist.url);
feedback(menu, "Reading the folder again.");
p.read_at = -1;
}
ImGui::EndDisabled();
ImGui::SameLine(0, px(10));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(std::format("{} ({} songs)", playlist.name, playlist.songs.size()).c_str());
ImGui::PopID();
}
end_card();
}
}
}
void music_page(SkateMenu& menu, const Model&, const CallbacksV3&) {
auto& p = page();
read(p);
category_tabs(menu, p.tab, {"PLAYER", "SOUND", "SOURCES"}, "music-tabs");
ImGui::PushID(p.tab);
ImGui::BeginChild("music-tab", ImVec2(0, page_body_height(menu)));
if (p.tab == 0) player_tab(menu, p);
else if (p.tab == 1) sound_tab(menu, p);
else sources_tab(menu, p);
ImGui::EndChild();
ImGui::PopID();
}
}