From 86aa8d38fb5e76f6fc7be632a4bacec4f54b82c3 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Mon, 12 Oct 2026 16:03:35 -0500 Subject: [PATCH] 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. --- Engine/Core/Text/Test/word_filter_tests.cpp | 7 + Engine/Core/Text/word_filter.cpp | 16 +- Engine/Game/Build/20260929/lua_natives.h | 28 + Extension/Console/perf_commands.cpp | 29 +- Extension/Music/app_audio_capture.cpp | 42 +- Extension/Music/audio_dsp.cpp | 122 ++++ Extension/Music/audio_dsp.h | 23 + Extension/Music/local_music_assets.cpp | 8 +- Extension/Music/local_music_playback.cpp | 10 +- Extension/Music/local_music_shelf.cpp | 10 +- Extension/Music/local_music_ui.cpp | 6 +- Extension/Music/music_inject.cpp | 32 +- Extension/Music/music_page.cpp | 310 ++++++++++ Extension/Music/setup_page.html | 610 ++++++++++++++++++++ Extension/Music/spotify_dashboard.cpp | 304 ++++++---- Extension/Music/spotify_dashboard.h | 8 +- Extension/Music/spotify_device.cpp | 2 +- Extension/Music/spotify_rows.cpp | 14 +- Extension/Music/spotify_station.cpp | 14 +- Extension/Music/spotify_web.cpp | 58 +- Extension/Music/spotify_web.h | 13 +- Extension/Music/youtube_music.cpp | 518 +++++++++++++++-- Extension/Music/youtube_music.h | 64 +- Extension/Scripting/lua_native_block.cpp | 126 ++++ Extension/Scripting/lua_native_block.h | 20 + Extension/Skater/GRIND_EXITS.md | 3 +- Extension/Skater/grind_exit_control.cpp | 2 +- Extension/Skater/grind_exit_control.h | 3 +- Extension/UI/Overlay/skate_menu.cpp | 7 +- Extension/UI/Overlay/skate_menu_internal.h | 2 + MUSIC-SETUP.md | 81 ++- Runtime/bootstrap.cpp | 4 + Runtime/client_tick.cpp | 2 +- SpotifyHelper/Cargo.lock | 31 + SpotifyHelper/Cargo.toml | 4 +- SpotifyHelper/examples/decode.rs | 34 +- SpotifyHelper/src/stream.rs | 309 +++++++++- cmake/Runtime.cmake | 9 + cmake/UI.cmake | 1 + 39 files changed, 2565 insertions(+), 321 deletions(-) create mode 100644 Engine/Game/Build/20260929/lua_natives.h create mode 100644 Extension/Music/audio_dsp.cpp create mode 100644 Extension/Music/audio_dsp.h create mode 100644 Extension/Music/music_page.cpp create mode 100644 Extension/Music/setup_page.html create mode 100644 Extension/Scripting/lua_native_block.cpp create mode 100644 Extension/Scripting/lua_native_block.h diff --git a/Engine/Core/Text/Test/word_filter_tests.cpp b/Engine/Core/Text/Test/word_filter_tests.cpp index 2c76449..a9ff6cb 100644 --- a/Engine/Core/Text/Test/word_filter_tests.cpp +++ b/Engine/Core/Text/Test/word_filter_tests.cpp @@ -121,6 +121,13 @@ int main() { "homo", "homos", "rapers"}) if (!contains_forbidden_words(said)) std::printf("FAIL: not forbidden: %s\n", said), ++failures; expect(mask_bad_words("bigger coons") == "bigger *****", "a short word is not masked only where it is said"); + // A number is not a word written with look-alike digits: a time, a score. + const std::vector short_words{"ss", "ass", "tit"}; + set_word_lists(short_words, {}); + expect(mask_bad_words("TallyMark finished 4th in 2:55.0!") == "TallyMark finished 4th in 2:55.0!" && !contains_bad_words("455 points, 717 to go") && + !contains_bad_words("5 5 5"), + "a number is filtered as a word"); + expect(contains_bad_words("you a55") && contains_bad_words("@55") && contains_bad_words("t1t"), "a word with digits for letters is not filtered"); set_word_lists({}, {}); expect(!contains_bad_words("darn zorblat fuck") && !contains_forbidden_words("zorblat") && mask_bad_words("darn") == "darn", "empty lists still filter"); diff --git a/Engine/Core/Text/word_filter.cpp b/Engine/Core/Text/word_filter.cpp index 0989948..bef749b 100644 --- a/Engine/Core/Text/word_filter.cpp +++ b/Engine/Core/Text/word_filter.cpp @@ -157,16 +157,24 @@ struct Word { std::vector words(std::string_view text) { std::vector out; Word current; + // A run of digits alone is a number, not a word written with look-alikes: a time such as + // "2:55.0" is not "ss" (seen 2026-10-12, a race's finishing time masked in the chat). + bool number = true; + const auto take = [&] { + if (!current.letters.empty() && !number) out.push_back(std::move(current)); + current = {}; + number = true; + }; for (std::size_t i = 0; i < text.size(); ++i) { if (const char letter = fold(text[i])) { current.letters += letter; current.at.push_back(i); - } else if (!current.letters.empty()) { - out.push_back(std::move(current)); - current = {}; + if (text[i] < '0' || text[i] > '9') number = false; + } else { + take(); } } - if (!current.letters.empty()) out.push_back(std::move(current)); + take(); // Three or more letters spelled out one at a time ("f u c k", "s.o.b.") also read as a word. std::vector spelled; for (std::size_t i = 0; i < out.size();) { diff --git a/Engine/Game/Build/20260929/lua_natives.h b/Engine/Game/Build/20260929/lua_natives.h new file mode 100644 index 0000000..8a2b8e1 --- /dev/null +++ b/Engine/Game/Build/20260929/lua_natives.h @@ -0,0 +1,28 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" + +// The game's Lua is stock Lua 5.2 with every standard library open. These are the functions in +// it that reach outside Lua (Extension/Scripting/lua_native_block.h). Found from the libraries' +// registration tables in .rodata (package at 0x6445c30, os at 0x6445f90, io at 0x6446150). +namespace dingosdk::game::build::v20260929::lua_natives { +// int ll_loadfunc(lua_State*, const char* path, const char* symbol): the one place Lua loads a +// DLL (LoadLibraryExA). package.loadlib calls it, and so does require through both C searchers +// (by way of loadfunc at 0x334cfa0). 0 on success; 1 with a message pushed when the DLL does +// not load. +inline constexpr Fingerprint load_library{0x334d4d0, {0x48,0x89,0x5c,0x24,0x10,0x48,0x89,0x6c,0x24,0x18,0x56,0x57,0x41,0x54,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x30,0x48,0x8b,0x59,0x10,0x4c,0x8d,0x25,0xdb,0x91,0x0f}}; +// const char* lua_pushfstring(lua_State*, const char* format, ...). +inline constexpr Fingerprint push_format{0x33663f0, {0x48,0x89,0x54,0x24,0x10,0x4c,0x89,0x44,0x24,0x18,0x4c,0x89,0x4c,0x24,0x20,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x41,0x18,0x48,0x8b,0xd9,0x48,0x83,0x78,0x18,0x00}}; +// void f_parser(lua_State*, SParser*): compiles a chunk, or undumps one that starts with the +// bytecode signature. It checks the chunk's kind against SParser::mode. +inline constexpr Fingerprint parse_chunk{0x3347400, {0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x38,0x4c,0x8b,0x02,0x48,0x8b,0xda,0x48,0x8b,0xf1,0x45,0x8b,0x08,0x41,0x8d,0x41,0xff,0x41,0x89,0x00,0x48,0x8b,0x3a,0x45}}; +// const char* SParser::mode ("b", "t", "bt" or null for both); name follows at 0x50. +inline constexpr std::uintptr_t parser_mode_offset = 0x48; + +// lua_CFunctions of the os and io libraries. +inline constexpr Fingerprint os_execute{0x33379b0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0x41,0x20,0x48,0x8b,0xd9,0x48,0x8b,0x10,0x48,0x83,0xc2,0x10,0x48,0x3b,0x51,0x10,0x73,0x7c,0x48,0x8d}}; +inline constexpr Fingerprint os_remove{0x3337a90, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0x41,0x20,0x48,0x8d,0x1d,0x56,0xdf,0x10,0x03,0x48,0x8b,0xf9,0x48,0x8b,0x10}}; +inline constexpr Fingerprint os_rename{0x3337ba0, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x48,0x8b,0x41,0x20,0x48,0x8d,0x3d,0x46,0xde,0x10,0x03,0x48,0x8b,0xf7,0x48,0x8b,0xd9}}; +inline constexpr Fingerprint io_popen{0x333bc00, {0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x48,0x8b,0x41,0x20,0x48,0x8d,0x1d,0xec,0x9d,0x10,0x03,0x48,0x8b,0xf3,0x48,0x8b,0xf9,0x48,0x8b,0x10,0x48,0x83,0xc2}}; +inline constexpr Fingerprint io_open{0x333b9f0, {0x40,0x53,0x55,0x56,0x57,0x48,0x83,0xec,0x28,0x48,0x8b,0x41,0x20,0x48,0x8d,0x1d,0xfc,0x9f,0x10,0x03,0x48,0x8b,0xf3,0x48,0x8b,0xf9,0x48,0x8b,0x10,0x48,0x83,0xc2}}; +inline constexpr Fingerprint io_output{0x333be90, {0x4c,0x8d,0x05,0xa1,0x15,0xe6,0x02,0x48,0x8d,0x15,0x4a,0xb8,0x10,0x03,0xe9,0xcd,0xd8,0x00,0x00,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc}}; +} diff --git a/Extension/Console/perf_commands.cpp b/Extension/Console/perf_commands.cpp index b9e770b..831fc9d 100644 --- a/Extension/Console/perf_commands.cpp +++ b/Extension/Console/perf_commands.cpp @@ -276,65 +276,66 @@ void start_memory_trace_from_environment() noexcept { } void register_perf_commands(Commands& registry) { - // Spotify in the music screen (spotify_station.h): what it sees, and the player's account. - auto spotify = action("spotify", "Spotify in the music screen: what is selected, what Spotify is playing, and the account", Group::console); + // The music sources in the music screen (Spotify, YouTube Music, the player's files, radio, + // another application): what is on, what is playing, and the Spotify account. + auto spotify = action("music", "The music sources (Spotify, YouTube Music, local files, radio, external audio): what is on, what is playing, and the Spotify account", Group::console); spotify.execution = Execution::local; spotify.run = [](const Model&, const Values&, const Output& out) { out(spotify_station::status()); out(music_inject::status()); out(spotify_device::status()); const auto account = spotify_web::state(); - out(std::format("Spotify account: {}{}; {} playlist(s){}{}.", !account.has_client ? "no client id set (spotify client )" : account.signed_in ? "signed in" : "not signed in (spotify login)", + out(std::format("Spotify account: {}{}; {} playlist(s){}{}.", !account.has_client ? "no client id set (music spotify client )" : account.signed_in ? "signed in" : "not signed in (music spotify login)", account.user.empty() ? std::string() : " as " + account.user, account.playlists, account.busy ? "; busy" : "", account.problem.empty() ? std::string() : "; " + account.problem)); }; registry.add(std::move(spotify)); - auto setup = action("spotify setup", "Open the page in your browser that sets Spotify up (the device, your playlists, how loud it is)", Group::console); + auto setup = action("music setup", "Open the page in your browser that sets the music sources up (Spotify, YouTube Music, local files, radio, external audio, and how it sounds)", Group::console); setup.execution = Execution::local; setup.run = [](const Model&, const Values&, const Output& out) { if (spotify_dashboard::open()) out("Opening " + spotify_dashboard::address() + " in your browser."); - else out("The Spotify setup page is not being served."); + else out("The music setup page is not being served."); }; registry.add(std::move(setup)); - auto client = action("spotify client", "The client id of your Spotify application (its redirect address: http://127.0.0.1:47824/callback)", Group::console, + auto client = action("music spotify client", "The client id of your Spotify application (its redirect address: http://127.0.0.1:47824/callback)", Group::console, {argument("id")}); client.execution = Execution::local; client.run = [](const Model&, const Values& args, const Output& out) { spotify_web::set_client(std::get(args[0])); - out("Spotify client id set. Sign in with: spotify login"); + out("Spotify client id set. Sign in with: music spotify login"); }; registry.add(std::move(client)); - auto login = action("spotify login", "Sign in to Spotify in your browser, for your playlists in the music screen", Group::console); + auto login = action("music spotify login", "Sign in to Spotify in your browser, for your playlists in the music screen", Group::console); login.execution = Execution::local; login.run = [](const Model&, const Values&, const Output& out) { - if (!spotify_web::state().has_client) return out("Set your Spotify application's client id first: spotify client "); + if (!spotify_web::state().has_client) return out("Set your Spotify application's client id first: music spotify client "); spotify_web::login(); out("Your browser is opening Spotify's sign-in. Your playlists are in the music screen the next time the game starts."); }; registry.add(std::move(login)); - auto logout = action("spotify logout", "Forget the Spotify sign-in", Group::console); + auto logout = action("music spotify logout", "Forget the Spotify sign-in", Group::console); logout.execution = Execution::local; logout.run = [](const Model&, const Values&, const Output& out) { spotify_web::logout(); out("The Spotify sign-in is forgotten."); }; registry.add(std::move(logout)); - auto through = variable("spotify through", "Spotify's sound through the game on the Spotify station: 0 off, 1 in place of songs, 2 in place of every stereo sound file", + auto through = variable("music through", "The music sources' sound through the game while one of their tiles is on: 0 off, 1 in place of songs, 2 in place of every stereo sound file", Group::console, argument("0-2", Type::integer)); through.execution = Execution::local; through.inspect = [](const Model&) { return State{true, std::to_string(music_inject::mode()), {}, "0 off, 1 songs, 2 every stereo sound file", false}; }; through.run = [](const Model&, const Values& args, const Output& out) { music_inject::set_mode(static_cast(std::clamp(std::get(args[0]), 0, 3))); - out(std::format("Spotify through the game: mode {}.", music_inject::mode())); + out(std::format("Music through the game: mode {}.", music_inject::mode())); }; registry.add(std::move(through)); - auto gain = variable("spotify gain", "How loud Spotify is through the game, in percent of how Spotify plays it (100 as it is, up to 1600)", + auto gain = variable("music gain", "How loud the music sources are through the game, in percent (100 as it is, up to 1600)", Group::console, argument("percent", Type::integer)); gain.execution = Execution::local; gain.inspect = [](const Model&) { return State{true, std::to_string(static_cast(music_inject::gain() * 100.0f + 0.5f)), {}, "percent", false}; }; gain.run = [](const Model&, const Values& args, const Output& out) { music_inject::set_gain(static_cast(std::clamp(std::get(args[0]), 0, 1600)) / 100.0f); - out(std::format("Spotify through the game: {}% loud.", static_cast(music_inject::gain() * 100.0f + 0.5f))); + out(std::format("Music through the game: {}% loud.", static_cast(music_inject::gain() * 100.0f + 0.5f))); }; registry.add(std::move(gain)); diff --git a/Extension/Music/app_audio_capture.cpp b/Extension/Music/app_audio_capture.cpp index 5a39883..80c78fb 100644 --- a/Extension/Music/app_audio_capture.cpp +++ b/Extension/Music/app_audio_capture.cpp @@ -1,4 +1,5 @@ #include "app_audio_capture.h" +#include "audio_dsp.h" #include #include #include @@ -177,10 +178,29 @@ bool set_mute(const std::vector& processes, bool mute) { std::atomic external{}; std::atomic source_rate{capture_rate}; +// Puts sound into the ring, turned up by `gain` and shaped as the player set it (audio_dsp.h); +// silence when there are no samples. How loud the loudest of it was. +float store(const float* samples, unsigned frames, float gain, unsigned rate) { + auto at = written.load(std::memory_order_relaxed); + float loudest = 0, piece[1024]; + for (unsigned done = 0; done < frames;) { + const unsigned count = std::min(frames - done, 512u); + for (unsigned i = 0; i < count * 2; ++i) piece[i] = samples ? std::clamp(samples[done * 2 + i] * gain, -1.0f, 1.0f) : 0.0f; + audio_dsp::process(piece, count, rate); + for (unsigned i = 0; i < count; ++i, ++at) { + ring[(at & (ring_frames - 1)) * 2] = piece[i * 2]; + ring[(at & (ring_frames - 1)) * 2 + 1] = piece[i * 2 + 1]; + loudest = std::max({loudest, std::fabs(piece[i * 2]), std::fabs(piece[i * 2 + 1])}); + } + done += count; + } + written.store(at, std::memory_order_release); + return loudest; +} + void write_frames(const BYTE* data, UINT32 frames, bool silent) { if (external.load(std::memory_order_relaxed)) return; // (the sound is another's for now) - auto at = written.load(std::memory_order_relaxed); - float loudest = 0, raw = 0; + float raw = 0; const auto* samples = reinterpret_cast(data); if (!silent) for (UINT32 i = 0; i < frames * 2; ++i) raw = std::max(raw, std::fabs(samples[i])); @@ -188,13 +208,7 @@ void write_frames(const BYTE* data, UINT32 frames, bool silent) { // turned down (the change had not reached it yet): then it is left as it is. float gain = is_muted.load(std::memory_order_relaxed) ? 1.0f / quiet_volume : 1.0f; if (raw * gain > 1.5f) gain = 1.0f; - for (UINT32 i = 0; i < frames; ++i, ++at) { - const float left = silent ? 0.0f : std::clamp(samples[i * 2] * gain, -1.0f, 1.0f), right = silent ? 0.0f : std::clamp(samples[i * 2 + 1] * gain, -1.0f, 1.0f); - ring[(at & (ring_frames - 1)) * 2] = left; - ring[(at & (ring_frames - 1)) * 2 + 1] = right; - loudest = std::max({loudest, std::fabs(left), std::fabs(right)}); - } - written.store(at, std::memory_order_release); + const float loudest = store(silent ? nullptr : samples, frames, gain, capture_rate); // (A slow fall, so the level reads as the last second's.) const auto before = peak.load(std::memory_order_relaxed); peak.store(std::max(loudest, before * 0.97f), std::memory_order_relaxed); @@ -397,15 +411,7 @@ void set_external(bool on, unsigned rate) noexcept { source_rate.store(on && rate ? rate : capture_rate, std::memory_order_relaxed); } void push(const float* interleaved, unsigned frames) noexcept { - auto at = written.load(std::memory_order_relaxed); - float loudest = 0; - for (unsigned i = 0; i < frames; ++i, ++at) { - const float left = interleaved[i * 2], right = interleaved[i * 2 + 1]; - ring[(at & (ring_frames - 1)) * 2] = left; - ring[(at & (ring_frames - 1)) * 2 + 1] = right; - loudest = std::max({loudest, std::fabs(left), std::fabs(right)}); - } - written.store(at, std::memory_order_release); + const float loudest = store(interleaved, frames, 1.0f, source_rate.load(std::memory_order_relaxed)); peak.store(std::max(loudest, peak.load(std::memory_order_relaxed) * 0.97f), std::memory_order_relaxed); } std::uint64_t ahead_frames() noexcept { diff --git a/Extension/Music/audio_dsp.cpp b/Extension/Music/audio_dsp.cpp new file mode 100644 index 0000000..aaaf41b --- /dev/null +++ b/Extension/Music/audio_dsp.cpp @@ -0,0 +1,122 @@ +#include "audio_dsp.h" +#include +#include +#include +#include +#include + +namespace dingosdk::audio_dsp { +namespace { +std::array, bands> wanted{}; +std::atomic level{}; +std::atomic changes{1}; + +// One filter of the equaliser, for both channels (the "Audio EQ Cookbook" forms). +struct Filter { + float b0{1}, b1{}, b2{}, a1{}, a2{}; + float x1[2]{}, x2[2]{}, y1[2]{}, y2[2]{}; + bool flat{true}; + void set(int which, float decibels, float rate) { + flat = std::fabs(decibels) < 0.05f; + if (flat) return; + const float pi = 3.14159265f; + const float amount = std::pow(10.0f, decibels / 40.0f); + const float angle = 2.0f * pi * std::min(centres[which], rate * 0.45f) / rate; + const float cosine = std::cos(angle), sine = std::sin(angle); + float a0{}; + if (which == 0 || which == bands - 1) { + // A shelf: everything below the first band's centre, or above the last one's. + const float alpha = sine / 2.0f * std::sqrt(2.0f), root = 2.0f * std::sqrt(amount) * alpha; + const float sign = which == 0 ? 1.0f : -1.0f; + b0 = amount * ((amount + 1) - sign * (amount - 1) * cosine + root); + b1 = sign * 2 * amount * ((amount - 1) - sign * (amount + 1) * cosine); + b2 = amount * ((amount + 1) - sign * (amount - 1) * cosine - root); + a0 = (amount + 1) + sign * (amount - 1) * cosine + root; + a1 = sign * -2 * ((amount - 1) + sign * (amount + 1) * cosine); + a2 = (amount + 1) + sign * (amount - 1) * cosine - root; + } else { + const float alpha = sine / (2.0f * 0.9f); + b0 = 1 + alpha * amount; + b1 = -2 * cosine; + b2 = 1 - alpha * amount; + a0 = 1 + alpha / amount; + a1 = -2 * cosine; + a2 = 1 - alpha / amount; + } + b0 /= a0, b1 /= a0, b2 /= a0, a1 /= a0, a2 /= a0; + } + float run(float in, int channel) { + const float out = b0 * in + b1 * x1[channel] + b2 * x2[channel] - a1 * y1[channel] - a2 * y2[channel]; + x2[channel] = x1[channel], x1[channel] = in; + y2[channel] = y1[channel], y1[channel] = std::fabs(out) < 1e-20f ? 0.0f : out; // (no lingering in numbers too small to hear) + return out; + } +}; + +std::mutex mutex; // what is below: the sound comes from one thread at a time, but not always the same one +std::array filters; +unsigned set_for{}, set_rate{}; +float power{0.02f}, gain{1.0f}; // the loudness of the last seconds (mean square), and what it is levelled by + +// Nothing over full scale: what nears it is bent under it, not cut off. +float limited(float sample) { + const float size = std::fabs(sample); + if (size <= 0.8f) return sample; + return std::copysign(0.8f + 0.2f * std::tanh((size - 0.8f) / 0.2f), sample); +} +} + +void set_band(int which, float decibels) noexcept { + if (which < 0 || which >= bands || !std::isfinite(decibels)) return; + wanted[static_cast(which)].store(std::clamp(decibels, -most_decibels, most_decibels), std::memory_order_relaxed); + changes.fetch_add(1, std::memory_order_relaxed); +} +float band(int which) noexcept { return which < 0 || which >= bands ? 0.0f : wanted[static_cast(which)].load(std::memory_order_relaxed); } +void set_levelling(bool on) noexcept { level.store(on, std::memory_order_relaxed); } +bool levelling() noexcept { return level.load(std::memory_order_relaxed); } + +void process(float* interleaved, unsigned frames, unsigned rate) noexcept { + if (!interleaved || !frames || rate < 8000) return; + std::lock_guard lock(mutex); + if (const auto now = changes.load(std::memory_order_relaxed); now != set_for || rate != set_rate) { + for (int i = 0; i < bands; ++i) filters[static_cast(i)].set(i, band(i), static_cast(rate)); + set_for = now; + set_rate = rate; + } + const bool equalised = std::any_of(filters.begin(), filters.end(), [](const Filter& filter) { return !filter.flat; }); + const bool levelled = level.load(std::memory_order_relaxed); + if (!equalised && !levelled) { + gain = 1.0f; + return; + } + if (equalised) + for (auto& filter : filters) { + if (filter.flat) continue; + for (unsigned i = 0; i < frames; ++i) { + interleaved[i * 2] = filter.run(interleaved[i * 2], 0); + interleaved[i * 2 + 1] = filter.run(interleaved[i * 2 + 1], 1); + } + } + float aim = 1.0f; + if (levelled) { + // How loud this piece is, into how loud the last three seconds were; silence between + // songs is left out, so that the next one does not begin too loud. + double sum{}; + for (unsigned i = 0; i < frames * 2; ++i) sum += static_cast(interleaved[i]) * interleaved[i]; + const float piece = static_cast(sum / (frames * 2.0)); + if (piece > 1e-5f) { + const float share = 1.0f - std::exp(-static_cast(frames) / (static_cast(rate) * 3.0f)); + power += (piece - power) * share; + } + // Towards a loudness of about -16 dB, by no more than three times up or down. + aim = std::clamp(0.16f / std::sqrt(std::max(power, 1e-6f)), 1.0f / 3.0f, 3.0f); + } + // (It comes down in a fifth of a second and goes up in a second, so it is not heard working.) + const float down = 1.0f - std::exp(-1.0f / (static_cast(rate) * 0.2f)), up = 1.0f - std::exp(-1.0f / static_cast(rate)); + for (unsigned i = 0; i < frames; ++i) { + gain += (aim - gain) * (aim < gain ? down : up); + interleaved[i * 2] = limited(interleaved[i * 2] * gain); + interleaved[i * 2 + 1] = limited(interleaved[i * 2 + 1] * gain); + } +} +} diff --git a/Extension/Music/audio_dsp.h b/Extension/Music/audio_dsp.h new file mode 100644 index 0000000..19b44cf --- /dev/null +++ b/Extension/Music/audio_dsp.h @@ -0,0 +1,23 @@ +#pragma once + +// How the sound of the music sources (Spotify, YouTube Music, the player's own files, radio, +// another application) is shaped on its way into the game: an equaliser of five bands, and a +// levelling of its loudness, so that a quiet song and a loud one are heard much the same. Both +// are set from the setup page (spotify_dashboard.h) and are as the player left them. The +// game's own music is not touched. +namespace dingosdk::audio_dsp { +inline constexpr int bands = 5; +// What each band is centred on, in Hz (the first and the last are shelves: all below, all above). +inline constexpr float centres[bands] = {60.0f, 250.0f, 1000.0f, 4000.0f, 12000.0f}; +inline constexpr float most_decibels = 12.0f; + +// A band's gain in decibels, from -12 to 12 (0 leaves it as it is). +void set_band(int band, float decibels) noexcept; +float band(int band) noexcept; +void set_levelling(bool on) noexcept; +bool levelling() noexcept; + +// Shapes `frames` of stereo sound at `rate` Hz, where it is. It keeps what it needs of the +// sound before, so it is to be given the sound in the order it is heard. +void process(float* interleaved, unsigned frames, unsigned rate) noexcept; +} diff --git a/Extension/Music/local_music_assets.cpp b/Extension/Music/local_music_assets.cpp index 3a5570e..cc1899c 100644 --- a/Extension/Music/local_music_assets.cpp +++ b/Extension/Music/local_music_assets.cpp @@ -84,11 +84,13 @@ bool read_music_catalog(MusicCatalog& result) { // (And the External Audio station, external_audio.h, when an application is picked for it: // it is in a row of its own in the music screen.) if (!external_audio::app().empty()) spotify_playlists.push_back({external_audio::station, external_audio::station_name, {}, {}}); - // (And YouTube Music's playlists, youtube_music.h, each with its songs as its rows.) + // (And YouTube Music's playlists, the player's folders of music and their radio stations, + // youtube_music.h, each with its songs as its rows.) { std::size_t number{}; for (const auto& theirs : youtube_music::playlists()) { spotify_web::Playlist playlist{youtube_music::station_prefix + std::to_string(number++), theirs.name, theirs.artwork, {}}; + if (!theirs.usable) continue; for (const auto& song : theirs.songs) playlist.tracks.push_back({song.artist, song.title, youtube_music::track_prefix + song.id}); spotify_playlists.push_back(std::move(playlist)); } @@ -263,7 +265,7 @@ bool read_music_catalog(MusicCatalog& result) { const bool liked = theirs.id == spotify_web::liked_songs; const bool tube = theirs.id.starts_with(youtube_music::station_prefix); id = theirs.id == external_audio::station ? std::string(external_audio::playlist_id) - : tube ? youtube_music::playlist_prefix + theirs.id.substr(std::strlen(youtube_music::station_prefix)) + : tube ? youtube_music::tile_id(std::strtoul(theirs.id.c_str() + std::strlen(youtube_music::station_prefix), nullptr, 10)) : spotify_station::playlist_prefix + (liked ? "!" + theirs.id : theirs.id); name = theirs.name; // (Spotify's own picture is for Spotify's playlists that have none.) @@ -294,7 +296,7 @@ bool read_music_catalog(MusicCatalog& result) { static std::string last; if (said != last) { last = said; - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Spotify stations and the songs they were given: {}.", said.empty() ? "none" : said); + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Spotify stations and the songs they were given: {}.", said.empty() ? "none" : said); } } for (auto it = playlists.begin(); it != playlists.end();) diff --git a/Extension/Music/local_music_playback.cpp b/Extension/Music/local_music_playback.cpp index b888206..0f2f7a1 100644 --- a/Extension/Music/local_music_playback.cpp +++ b/Extension/Music/local_music_playback.cpp @@ -485,7 +485,7 @@ std::int32_t __fastcall music_select_next_hook(std::uintptr_t queue, std::uintpt std::uint8_t asked{}; native_queue_channel(image_base.load(std::memory_order_acquire), queue, chosen_channel); dingosdk::memory::peek(queue + 0xf1, asked); - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Trace: channel {} (queue 0x{:x}) chose song {} of its queue{}.", chosen_channel, queue, chosen, + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Trace: channel {} (queue 0x{:x}) chose song {} of its queue{}.", chosen_channel, queue, chosen, asked ? " (asked for its next)" : ""); } catch (...) {} } @@ -562,7 +562,7 @@ std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channe } if (station >= 0) { try { - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Spotify station: playlist {} has {} member(s), its first is row {} of station {}, its place is {}{}.", playlist_id, station_members, first, station, cursor, add ? "; the game's song was added" : ""); } catch (...) {} @@ -589,7 +589,7 @@ std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channe if (auto own = bound_playlist[channel].load(std::memory_order_relaxed); !from_screen && own && image) { spotify_station::grabbed(); try { - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Spotify station: channel {} was asked for playlist {} by the world, not the music screen (a speaker's music was grabbed): it stays on Spotify.", channel, playlist_id); } catch (...) {} return bind_channel_playlist(channel_pointer, reinterpret_cast(&own)); @@ -599,7 +599,7 @@ std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channe : bind_channel_playlist(channel_pointer, playlist_id_pointer); if (have_binding && channel < bound_playlist.size()) bound_playlist[channel].store(playlist_id, std::memory_order_relaxed); if (have_binding && channel < channel_bound_at.size()) channel_bound_at[channel].store(GetTickCount64(), std::memory_order_relaxed); - if (static std::atomic said{}; have_binding && said.fetch_add(1, std::memory_order_relaxed) < 600) { + if (static std::atomic said{}; have_binding && dingosdk::logging::enabled(dingosdk::logging::Level::debug) && said.fetch_add(1, std::memory_order_relaxed) < 600) { try { void* frames[10]{}; const auto count = CaptureStackBackTrace(1, 10, frames, nullptr); @@ -610,7 +610,7 @@ std::uintptr_t __fastcall music_channel_playlist_bind_hook(std::uintptr_t channe std::snprintf(text, sizeof(text), " +0x%llx", static_cast(reinterpret_cast(frames[i]) - image)); callers += text; } - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Trace: channel {} bound to playlist {} ({} member(s), station {}); the Spotify station is on channel {}, a menu is {}; from{}.", + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Trace: channel {} bound to playlist {} ({} member(s), station {}); the Spotify station is on channel {}, a menu is {}; from{}.", channel, playlist_id, station_members, station, static_cast(spotify_station::channel()), spotify_station::music_screen() ? "up" : "not up", callers); } catch (...) {} } diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp index a561483..5efbc48 100644 --- a/Extension/Music/local_music_shelf.cpp +++ b/Extension/Music/local_music_shelf.cpp @@ -41,13 +41,16 @@ struct ShelfRow { bool mods_row(std::string_view id) { return id.starts_with("mod:"); } bool spotify_row(std::string_view id) { return spotify_station::is_station(id); } bool youtube_row(std::string_view id) { return id.starts_with(youtube_music::playlist_prefix); } +bool files_row(std::string_view id) { return id.starts_with(youtube_music::files_prefix); } +bool radio_row(std::string_view id) { return id.starts_with(youtube_music::radio_prefix); } bool external_row(std::string_view id) { return id.starts_with(external_audio::playlist_prefix); } struct ModsShelf { std::uintptr_t manager{}; md::Value featured_anchor{}, list{}; ULONGLONG next_poll{}; // In the order they are shown. - std::array rows{{{"Spotify", &spotify_row, 0x5d}, {"YouTube Music", &youtube_row, 0x7d}, {"External Audio", &external_row, 0x6d}, {"Mods", &mods_row, 0x4d}}}; + std::array rows{{{"Spotify", &spotify_row, 0x5d}, {"YouTube Music", &youtube_row, 0x7d}, {"Local Files", &files_row, 0x8d}, {"Online Radio", &radio_row, 0x9d}, + {"External Audio", &external_row, 0x6d}, {"Mods", &mods_row, 0x4d}}}; }; ModsShelf mods_shelf; md::MenuLifetime music_shelf_lifetime; @@ -176,7 +179,7 @@ void music_poll_mods(std::uintptr_t base) { } } // Publish the independent Mods shelf before native widgets bind. -std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list, const std::array& wanted) { +std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list, const std::array& wanted) { namespace md = dingosdk::multiplayer::menu_data; game::ModelWriteLock lock(model); md::Context shelf{base, model}; @@ -306,7 +309,8 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo bool has_mod_playlists = false; for (const auto& playlist : mod_music_playlists()) if (!playlist.songs.empty()) { has_mod_playlists = true; break; } - const auto report = music_shelf_append_one(local_runtime().base, manager, handle, {spotify_station::spotify_used(), !youtube_music::playlists().empty(), !external_audio::app().empty(), has_mod_playlists}); + const auto report = music_shelf_append_one(local_runtime().base, manager, handle, {spotify_station::spotify_used(), youtube_music::has(youtube_music::Kind::youtube), youtube_music::has(youtube_music::Kind::files), youtube_music::has(youtube_music::Kind::radio), + !external_audio::app().empty(), has_mod_playlists}); dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "music_shelf_append"}, {"report", report}}.dump().c_str()); } catch (...) {} // A shelf failure must not interrupt the native constructor. diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index 55c64f7..870eb21 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -469,7 +469,7 @@ void update_music_catalog_refresh() { } catch (...) { outcome = "it failed"; } - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Music screen: its playlists and songs were given again ({}): {} playlist(s), {} song(s).", outcome, + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Music screen: its playlists and songs were given again ({}): {} playlist(s), {} song(s).", outcome, playlist_total, song_total); } @@ -483,7 +483,7 @@ void update_spotify_cover() { const auto why = [](int reason, const char* text) { if (said_why == reason) return; said_why = reason; - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Spotify station cover: {}.", text); + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Spotify station cover: {}.", text); }; if (!s.active.load(std::memory_order_acquire) || ui.updating || cosmetic_runtime().update_thread != GetCurrentThreadId() || !spotify_station::active()) return; const auto now = GetTickCount64(); @@ -524,6 +524,6 @@ void update_spotify_cover() { said_why = 0; // The game looks a song's cover up when the song starts, and this one has started. spotify_station::refresh_now_playing(); - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Spotify station cover: \"{}\" has {}.", id, cover); + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Spotify station cover: \"{}\" has {}.", id, cover); } } diff --git a/Extension/Music/music_inject.cpp b/Extension/Music/music_inject.cpp index be213ca..61e8273 100644 --- a/Extension/Music/music_inject.cpp +++ b/Extension/Music/music_inject.cpp @@ -272,6 +272,34 @@ void tick() noexcept { if (started) app_audio::mute_source(on && !device && !tube && since < 2500); } if (now - looked < 1000) return; + // How fast the game is taking the sound, against the clock: more than it can play (it is + // catching up after standing still, as when the player comes back to its window, and the + // music is heard fast for a moment: reported 2026-10-12) or less. Said when it is not as it + // should be, with whether the game's window is the one in front. + { + static std::uint64_t counted{}, counted_starved{}; + static unsigned told{}; + const auto frames = replaced_frames.load(std::memory_order_relaxed); + const auto capture = app_audio::state(); + const double seconds = static_cast(now - looked) / 1000.0; + if (on && looked && seconds < 3.0 && capture.readers && told < 80) { + const double pace = static_cast(frames - counted) / (48000.0 * seconds * capture.readers); + DWORD front{}; + GetWindowThreadProcessId(GetForegroundWindow(), &front); + const bool focused = front == GetCurrentProcessId(); + static bool was_focused{true}; + if (pace > 1.12 || (pace < 0.88 && pace > 0.01) || focused != was_focused) { + ++told; + try { + logging::log(logging::Level::info, logging::Channel::music, "Pace: the game took the sound at {:.2f} of the clock's speed over {:.1f} s ({} reader(s), {} frame(s) it wanted were not there); its window is {}.", pace, + seconds, capture.readers, capture.starved - counted_starved, focused ? "in front" : "not in front"); + } catch (...) {} + } + was_focused = focused; + } + counted = frames; + counted_starved = capture.starved; + } looked = now; // How the capture of an application's sound is going, into the log as it changes (a player // who hears nothing has this to send): whether it runs, why not, and whether any sound has @@ -286,7 +314,7 @@ void tick() noexcept { if (text != said_capture) { said_capture = text; try { - logging::log(capture.capturing ? logging::Level::info : logging::Level::warning, logging::Channel::music, "Sound through the game: {}.", text); + logging::log(capture.capturing ? logging::Level::debug : logging::Level::warning, logging::Channel::music, "Sound through the game: {}.", text); } catch (...) {} } } @@ -301,7 +329,7 @@ void tick() noexcept { char name[sizeof(player.name)]; std::memcpy(name, player.name, sizeof(name)); name[sizeof(name) - 1] = 0; - logging::log(logging::Level::info, logging::Channel::music, "Sound file player {}: \"{}\", {} channel(s) at {} Hz, {} s long{}.", i, name, + logging::log(logging::Level::debug, logging::Channel::music, "Sound file player {}: \"{}\", {} channel(s) at {} Hz, {} s long{}.", i, name, player.channels.load(std::memory_order_relaxed), player.rate.load(std::memory_order_relaxed), player.seconds.load(std::memory_order_relaxed), spotify_station::active() ? " (the Spotify station is on)" : ""); } catch (...) {} diff --git a/Extension/Music/music_page.cpp b/Extension/Music/music_page.cpp new file mode 100644 index 0000000..65dcfd8 --- /dev/null +++ b/Extension/Music/music_page.cpp @@ -0,0 +1,310 @@ +#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 +#include +#include +#include +#include +#include +#include + +// 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; + std::string title, artist, source; + bool available{}, playing{}, station{}; + std::array find{}; + // The sliders, as they are while one is held. + float gain{100}; + std::array 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(i)] = audio_dsp::band(i); + } +} + +bool found(const std::array& 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(std::tolower(static_cast(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(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 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(i); + const float x = origin.x + (column + gap) * static_cast(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 bands; + }; + static constexpr std::array 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(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(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(); +} +} diff --git a/Extension/Music/setup_page.html b/Extension/Music/setup_page.html new file mode 100644 index 0000000..9f8aebc --- /dev/null +++ b/Extension/Music/setup_page.html @@ -0,0 +1,610 @@ + + + + + +ReSkate Music + + + +
+
+
+
+

ReSkate Music

+

Play Spotify, YouTube Music, your own files, internet radio or any app on your PC through the in-game music player. Set up what you want below; skip the rest.

+
+
Connecting to the game
+
+ + +
+

Spotify

Needs Spotify Premium. Sound comes straight from Spotify into the game.
…
+
+
    +
  1. Pick “ReSkate” in Spotify
    +

    Open Spotify on your phone or this PC (same Wi-Fi or network as this PC), open its device list, and choose ReSkate. You only do this once.

    +
    +
  2. +
  3. Play it in the game
    +

    In the game’s music screen, open the Spotify row and pick the Spotify tile. If the row is not there yet, restart the game once.

    +
  4. +
  5. Show your own playlistsOptional
    +

    Adds your playlists and Liked Songs as tiles, with their tracks listed. Spotify asks for a free “developer app” for this. It takes about two minutes.

    +
    +
      +
    1. Create the app
      +

      Sign in, press Create app, and give it any name and description.

      + +
    2. +
    3. Paste this as its “Redirect URI”
      +
      http://127.0.0.1:47824/callback
      +

      Tick Web API if it asks which API you use, then save the app.

      +
    4. +
    5. Paste the app’s Client ID here
      +

      It is on the app’s page, under Basic information.

      +
      +
    6. +
    7. Sign in
      +

      A Spotify tab opens. Press Agree, then come back here.

      +
      +
    8. +
    +
    +
    +
    +
      +
      +
      +
      +
    • +
    +
    +
    + + +
    +

    YouTube Music

    Paste links to videos or playlists. Nothing to install by hand.
    …
    +
    +
    +
      +
    1. Get the YouTube tool
      +

      Downloads yt-dlp (about 18 MB) from its official GitHub page into your ReSkate folder. Playing YouTube this way is against YouTube’s terms of service; whether to use it is up to you.

      +
      +
      +
    2. +
    3. Add a video or playlist
      +

      Copy a link from YouTube or YouTube Music and paste it here. Up to 100 songs each.

      +
      +
        +
      • +
      • Play it in the game
        +

        In the game’s music screen, open the YouTube Music row and press a song. Each song takes a few seconds to start. If the row is not there yet, restart the game once.

        +
      • +
      +
      +
      + Songs being skipped? +

      Private and age-restricted songs only play when YouTube can see you are signed in. There are two ways to do that. Pick one; you do not need both.

      +
      +
      +
      Option ARecommendedIn use
      +
      Give it a cookies.txt file
      +

      Works with every browser. In your browser, signed in to YouTube, install the browser extension Get cookies.txt LOCALLY, open youtube.com, press its Export button, then choose the file here. It stays on this PC. Treat it like a password.

      +
      +
      +
      +
      or
      +
      +
      Option BIn use
      +
      Firefox only: read the sign-in straight from the browser
      +

      Only use this if Firefox is the browser you are signed in to YouTube with. There is no file to export. Chrome, Edge, Opera and Brave block this, so for those use Option A.

      +
      +
      +
      +

      With a sign-in set, songs take a little longer to start.

      +
      +
      +
      +
      + + +
      +

      Local files

      Music saved on this PC: MP3, M4A, FLAC, Ogg and WAV.
      …
      +
      +
      +
        +
      1. Add a folder of music
        +

        Open the folder in File Explorer, click the address bar at the top, copy what it shows and paste it here. Folders inside it are included. Each folder becomes a tile with up to 200 songs; names and covers come from the files’ tags.

        +
        +
        +
          +
        • +
        • Play it in the game
          +

          In the game’s music screen, open the Local Files row and press a song. Put new files in a folder? Press Rescan next to it. If the row is not there yet, restart the game once.

          +
        • +
        +
        +
        +
        + + +
        +

        Online radio

        Live internet radio stations. Each one you add becomes a tile.
        …
        +
        +
        +
          +
        1. Find a station
          +

          Type part of a station’s name. This searches radio-browser.info, a free public directory of stations.

          +
          +
            +
            + Have a stream’s address instead? +

            Paste the address of an MP3 or AAC stream, or of a .pls or .m3u file. Addresses ending in .m3u8 (HLS) are not supported.

            +
            +
            +
            +
          • +
          • Your stations
            +

            None yet. Press Add next to a station above.

            +
              +
            • +
            • Play it in the game
              +

              In the game’s music screen, open the Online Radio row and pick a station. It is live: pausing leaves the station, and playing tunes back in. If the row is not there yet, restart the game once.

              +
            • +
            +
            +
            +
            + + +
            +

            External audio

            Any app on this PC: a browser, a media player, another music service.
            …
            +
            +
              +
            1. Start the app playing something
              +

              Only apps that are making sound right now show up in the list.

              +
            2. +
            3. Choose the app
              +
              +
              +
            4. +
            5. Play it in the game
              +

              In the game’s music screen, open the External Audio row and pick its tile. The app’s own sound is turned down while it plays through the game, and put back when you leave. If the row is not there yet, restart the game once.

              +
            6. +
            +
            +
            + + +
            +

            Sound

            Loudness, equaliser, and where it plays.
            +
            +
            Loudness
            +
            100%
            + +
            Equaliser
            +

            Drag a slider up for more of that part of the sound, down for less. It only changes the music you play through ReSkate, not the game’s own.

            +
            +
            + +
            +
            +
            + + + +
            + +
            + +
            Nothing playing
            Pick a tile in the game’s music screen
            +
            +
            + + + + diff --git a/Extension/Music/spotify_dashboard.cpp b/Extension/Music/spotify_dashboard.cpp index fc54fbc..a3a1901 100644 --- a/Extension/Music/spotify_dashboard.cpp +++ b/Extension/Music/spotify_dashboard.cpp @@ -3,6 +3,7 @@ #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" @@ -11,13 +12,17 @@ #include "spotify_web.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" +#include "Extension/Boot/user_data_redirect.h" #include #include #include #include #include #include +#include +#include #include +#include #include #include @@ -27,113 +32,46 @@ constexpr unsigned short first_port = 8421; std::atomic port{}; std::atomic started{}; -constexpr char page[] = R"html( - -ReSkate · Spotify -
            -

            ReSkate · Spotify

            Set up Spotify for the in-game music player. This page is only reachable from this computer.

            +// 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(&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(LockResource(handle)) : nullptr; + return data ? std::string(data, SizeofResource(module, resource)) : std::string(); +} -

            Now playing

            -
            Nothing yet
            -
            -
            - -

            Spotify device

            -
            …
            -
            1. Open Spotify on your phone or this computer (same network as this PC).
            2. Open the device list and pick ReSkate.
            3. That is remembered: next time it connects by itself.
            -Needs Spotify Premium. Audio comes straight from Spotify into the game. -
            - -

            Your playlists

            -
            …
            -
            Listing your playlists uses a Spotify developer app. Create one at developer.spotify.com, add the redirect URI http://127.0.0.1:47824/callback, and paste its client ID here. -
            -
            -
              -New playlists appear as tiles in the game's music screen the next time it is opened. A whole new row may need the game restarted. -
              - -

              YouTube Music

              -
              Plays YouTube videos and playlists through the game. It needs yt-dlp, a free tool that is not part of ReSkate: this downloads it (about 18 MB) from its own releases on GitHub into your ReSkate folder. Using it is against YouTube's terms of service; that choice is yours. -
              -
              Paste a link to a YouTube or YouTube Music video or playlist (up to 100 songs each). -
              -
                -
                Private and age-restricted songs
                These need your YouTube sign-in as a Netscape cookies.txt exported from your browser. Use an extension like Get cookies.txt LOCALLY on youtube.com to export it, then choose the file here. It is kept on this computer only, in your ReSkate folder; treat it like a password. With a sign-in set, each song takes a few seconds longer to start. -
                -
                YouTube sign-in from
                -Or let yt-dlp read the sign-in from a browser directly. Firefox works best; Chrome and Edge often refuse while they are open. A cookies.txt, when you have added one, is used instead.
                New playlists appear as tiles in the game's music screen the next time it is opened. A whole new row may need the game restarted.
                -
                -
                - -

                External audio

                -Play any app on this computer through the game: pick it here, then choose the External Audio tile in the game's music screen. The app's own sound is turned down while it plays through the game. -
                - -
                - -

                Sound

                -
                Loudness
                -
                -
                -
                -)html"; +// The sound's settings, as the player left them: \\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(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::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(in), std::istreambuf_iterator())); + if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast(std::clamp(json.at("gain").get(), 0, 400)) / 100.0f); + if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get() ? 1 : 3); + if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get()); + if (json.contains("notices") && json.at("notices").is_boolean()) youtube_music::set_notices(json.at("notices").get()); + 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(i), static_cast(std::clamp(json.at("equaliser").at(i).get(), -12, 12))); + } catch (...) {} +} std::string state_json() { Json out = Json::object(); @@ -143,10 +81,12 @@ std::string state_json() { 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(); @@ -177,15 +117,94 @@ std::string state_json() { youtube["browser"] = tube.browser; youtube["cookies"] = tube.cookies; youtube["runtime"] = tube.runtime; - Json lists = Json::array(); - for (const auto& playlist : tube.playlists) { + // 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(i); entry["name"] = playlist.name; entry["songs"] = static_cast(playlist.songs.size()); - lists.push_back(std::move(entry)); + 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(tube.playing_list); + queue["name"] = playlist.name; + queue["kind"] = playlist.kind == Kind::files ? "local" : playlist.kind == Kind::radio ? "radio" : "youtube"; + queue["at"] = static_cast(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 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(0); + queue["name"] = "Spotify"; + queue["kind"] = "spotify"; + queue["at"] = static_cast(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(); @@ -204,6 +223,10 @@ std::string state_json() { Json settings = Json::object(); settings["gain"] = static_cast(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::lround(audio_dsp::band(i)))); + settings["equaliser"] = std::move(bands); out["settings"] = std::move(settings); return out.dump(); } @@ -244,7 +267,33 @@ bool change(const std::string& path, const std::string& body) { 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/youtube/remove") { + } 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()) { + const auto count = static_cast(std::clamp(json.at("song").get(), 0, 25)); + // (One at a time: a press while it is still going on is not taken.) + static std::atomic 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::clamp(json.at("playlist").get(), 0, 1000)), + static_cast(std::clamp(json.at("song").get(), 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::clamp(json.at("index").get(), 0, 1000))); } else if (path == "/api/external") { @@ -254,15 +303,20 @@ bool change(const std::string& path, const std::string& body) { } else if (path == "/api/settings") { if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast(std::clamp(json.at("gain").get(), 0, 400)) / 100.0f); if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get() ? 1 : 3); + if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get()); + 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(i), static_cast(std::clamp(json.at("equaliser").at(i).get(), -12, 12))); + keep_settings(); } else { return false; } return true; } -void answer(SOCKET client, const char* status, const char* type, const std::string& body) { - const auto head = std::format("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", status, type, - body.size()); +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(head.size()), 0); for (std::size_t sent = 0; sent < body.size();) { const int did = send(client, body.data() + sent, static_cast(std::min(body.size() - sent, 65536)), 0); @@ -312,7 +366,23 @@ void serve(SOCKET client) { 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")) return answer(client, "200 OK", "text/html; charset=utf-8", page); + 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//. + 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 @@ -342,12 +412,12 @@ void run() noexcept { } if (listener == INVALID_SOCKET) { try { - logging::log(logging::Level::warning, logging::Channel::music, "Spotify setup page: no port on this computer was free for it."); + 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, "Spotify setup page: {} (\"spotify setup\" in the console opens it).", address()); + 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); @@ -366,10 +436,12 @@ void run() noexcept { 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(); diff --git a/Extension/Music/spotify_dashboard.h b/Extension/Music/spotify_dashboard.h index c0109c9..227a1c9 100644 --- a/Extension/Music/spotify_dashboard.h +++ b/Extension/Music/spotify_dashboard.h @@ -1,10 +1,11 @@ #pragma once #include -// A page in the player's browser for setting Spotify up, served by the game on this computer +// A page in the player's browser (setup_page.html, built into the DLL) for setting the music +// sources up, in the mod's own colours and type, served by the game on this computer // alone (http://127.0.0.1:8421): the game's Spotify device and whether an account is on it, // the sign-in that lists the player's playlists (its client id, signing in and out), what is -// playing with play, pause and next, and how loud it is through the game. "spotify setup" in +// playing with play, pause and next, and how loud it is through the game. "music setup" in // the console opens it. // // It listens on the loopback address only, answers only requests addressed to it by that @@ -17,4 +18,7 @@ void start() noexcept; std::string address(); // Opens it in the player's browser. bool open() noexcept; +// Keeps the sound's settings (loudness, levelling, the equaliser, the speakers) as they are +// now, for the next time the game is run: after one is changed from somewhere other than the page. +void save_settings() noexcept; } diff --git a/Extension/Music/spotify_device.cpp b/Extension/Music/spotify_device.cpp index 3860483..6d0bc59 100644 --- a/Extension/Music/spotify_device.cpp +++ b/Extension/Music/spotify_device.cpp @@ -79,7 +79,7 @@ void told(const char* text) { if (parts.empty()) return; if (parts[0] == "LOG") { static std::atomic logged{}; - if (parts.size() > 1 && logged.fetch_add(1, std::memory_order_relaxed) < 300) say(logging::Level::info, "(librespot) " + parts[1]); + if (parts.size() > 1 && logged.fetch_add(1, std::memory_order_relaxed) < 300) say(logging::Level::debug, "(librespot) " + parts[1]); // (What it says just before it gives up on an account that is not Premium.) if (parts.size() > 1 && parts[1].find("does not support") != std::string::npos && parts[1].find("accounts") != std::string::npos) not_premium.store(true); } else if (parts[0] == "READY") { diff --git a/Extension/Music/spotify_rows.cpp b/Extension/Music/spotify_rows.cpp index 517561c..623a350 100644 --- a/Extension/Music/spotify_rows.cpp +++ b/Extension/Music/spotify_rows.cpp @@ -110,7 +110,7 @@ std::uintptr_t play_song_hook(std::uintptr_t player, std::uintptr_t asset, std:: } } // (Each song the game begins while a station is on, into the log while this is worked out.) - if (static std::atomic said{}; said.fetch_add(1, std::memory_order_relaxed) < 600) + if (static std::atomic said{}; logging::enabled(logging::Level::debug) && said.fetch_add(1, std::memory_order_relaxed) < 600) { // (Which song, and from where in the game it was begun.) std::uintptr_t metadata{}, artist{}, title{}; @@ -125,11 +125,11 @@ std::uintptr_t play_song_hook(std::uintptr_t player, std::uintptr_t asset, std:: const auto image = reinterpret_cast(GetModuleHandleW(nullptr)); std::string callers; for (unsigned i = 0; i < count; ++i) callers += std::format(" +0x{:x}", reinterpret_cast(frames[i]) - image); - logging::log(logging::Level::info, logging::Channel::music, "Spotify rows: the game began {} (\"{} - {}\") on channel {}, from{}.", ours ? "a row of ours" : "a song of its own", + logging::log(logging::Level::debug, logging::Channel::music, "Spotify rows: the game began {} (\"{} - {}\") on channel {}, from{}.", ours ? "a row of ours" : "a song of its own", artist_text, title_text, channel, callers); } // (A row of ours keeps its own artist and title: they are what its row says.) - // The station's own song beginning: its sound file is known for it ("spotify through 3"). + // The station's own song beginning: its sound file is known for it ("music through 3"). if (!ours && spotify_station::active() && channel == spotify_station::channel()) music_inject::station_song_begins(); // Any song of the game's begun while a station is on has Spotify's sound (a speaker's in // the world), so it reads as Spotify's. (A row of ours keeps what its row says.) @@ -159,7 +159,7 @@ void trace(const char* what, std::uint32_t playlist, const unsigned char* song) if (const auto now = GetTickCount64() / 1000; second.exchange(now, std::memory_order_relaxed) != now) in_second.store(0, std::memory_order_relaxed); if (in_second.fetch_add(1, std::memory_order_relaxed) >= 30 || traced.fetch_add(1, std::memory_order_relaxed) >= 1500) return; try { - logging::log(logging::Level::info, logging::Channel::music, "Playlist {}: {}{}.", playlist, what, song ? " " + song_text(song) : std::string()); + logging::log(logging::Level::debug, logging::Channel::music, "Playlist {}: {}{}.", playlist, what, song ? " " + song_text(song) : std::string()); } catch (...) {} } using PlaylistSong = void* (*)(std::uintptr_t, std::uint32_t*, const unsigned char*); @@ -201,7 +201,7 @@ void request_hook(std::uintptr_t queue, std::uintptr_t asset) { } } // (Each song asked for, into the log while grabbing a speaker's music is worked out.) - if (static std::atomic said{}; spotify_station::recent() && said.fetch_add(1, std::memory_order_relaxed) < 600) { + if (static std::atomic said{}; logging::enabled(logging::Level::debug) && spotify_station::recent() && said.fetch_add(1, std::memory_order_relaxed) < 600) { std::uintptr_t metadata{}, artist{}, title{}; char artist_text[96]{}, title_text[96]{}; if (graph && memory::peek(graph + graph_metadata, metadata) && (metadata &= ~std::uintptr_t{4}) != 0 && memory::peek(metadata + metadata_artist, artist) && @@ -214,7 +214,7 @@ void request_hook(std::uintptr_t queue, std::uintptr_t asset) { const auto image = reinterpret_cast(GetModuleHandleW(nullptr)); std::string callers; for (unsigned i = 0; i < count; ++i) callers += std::format(" +0x{:x}", reinterpret_cast(frames[i]) - image); - logging::log(logging::Level::info, logging::Channel::music, "Trace: queue 0x{:x} was asked for {} (\"{} - {}\"), from{}.", queue, ours ? "a row of ours" : "a song of the game's", artist_text, title_text, callers); + logging::log(logging::Level::debug, logging::Channel::music, "Trace: queue 0x{:x} was asked for {} (\"{} - {}\"), from{}.", queue, ours ? "a row of ours" : "a song of the game's", artist_text, title_text, callers); } } catch (...) {} request_original(queue, asset); @@ -294,7 +294,7 @@ std::string ensure(std::uintptr_t base, std::uintptr_t manager, const std::strin return {}; } try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify rows: registered \"{}\".", id); + logging::log(logging::Level::debug, logging::Channel::music, "Spotify rows: registered \"{}\".", id); } catch (...) {} return id; } diff --git a/Extension/Music/spotify_station.cpp b/Extension/Music/spotify_station.cpp index 597237c..97f4d29 100644 --- a/Extension/Music/spotify_station.cpp +++ b/Extension/Music/spotify_station.cpp @@ -99,7 +99,7 @@ void show_locked(std::uintptr_t metadata) noexcept { // (Each song made to read as Spotify's, into the log while the speakers are worked out.) if (static int said{}; said++ < 60) { try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: a song of the game's {} as Spotify's ({} in all).", done ? "now reads" : "could not be made to read", shown.size()); + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: a song of the game's {} as Spotify's ({} in all).", done ? "now reads" : "could not be made to read", shown.size()); } catch (...) {} } } @@ -206,7 +206,7 @@ void bound(std::uint32_t channel, bool music_channel, std::uint32_t playlist, st if (const auto signature = (std::uint64_t{channel} << 40) ^ (std::uint64_t{playlist} << 8) ^ members ^ (static_cast(station + 1) << 52); last_said.exchange(signature, std::memory_order_relaxed) != signature && station >= 0) { try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: the game bound channel {} to playlist {}, station {}{}.", + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: the game bound channel {} to playlist {}, station {}{}.", channel, playlist, station, music_channel ? "" : " (not yet a music channel)"); } catch (...) {} } @@ -322,7 +322,7 @@ void tick() noexcept { else if (first) external_player::send(external_player::Command::play); entered = GetTickCount64(); try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: station {} is on{}.", now, + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: station {} is on{}.", now, playlist.empty() ? std::string() : " (Spotify playlist " + playlist + ")"); } catch (...) {} } else { @@ -364,7 +364,7 @@ void tick() noexcept { station_index.store(found, std::memory_order_relaxed); station_channel.store(channel, std::memory_order_relaxed); try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: station {} is taken to be on, on channel {} (its tile has no playlist of the game's behind it yet).", found, channel); + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: station {} is taken to be on, on channel {} (its tile has no playlist of the game's behind it yet).", found, channel); } catch (...) {} } } @@ -390,7 +390,7 @@ void tick() noexcept { else if (!playlist.empty() && !track.empty()) spotify_web::play(playlist, track); picked_at = GetTickCount64(); // (with a track or not: the game's next that follows is not Spotify's) try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: a row was picked{}.", track.empty() ? std::string(" (not one with a track)") : " (" + track + ")"); + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: a row was picked{}.", track.empty() ? std::string(" (not one with a track)") : " (" + track + ")"); } catch (...) {} } // A YouTube Music station that has come on starts its playlist a moment after, unless a row @@ -422,7 +422,7 @@ void tick() noexcept { } catch (...) {} if (playing) external_player::send(external_player::Command::next); try { - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: the game asked for its next song; {}.", playing ? "Spotify's next" : "Spotify is paused, so it is left where it is"); + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: the game asked for its next song; {}.", playing ? "Spotify's next" : "Spotify is paused, so it is left where it is"); } catch (...) {} } try { @@ -443,7 +443,7 @@ void tick() noexcept { if (unshown || revision != said) show(now, track.artist, track.title); if (revision != said) { said = revision; - logging::log(logging::Level::info, logging::Channel::music, "Spotify station: {} {}: {} - {}{}.", track.spotify ? "Spotify" : track.app, + logging::log(logging::Level::debug, logging::Channel::music, "Spotify station: {} {}: {} - {}{}.", track.spotify ? "Spotify" : track.app, track.playing ? "is playing" : "is paused on", track.artist, track.title, track.artwork.empty() ? "" : " (with a cover)"); } } catch (...) {} diff --git a/Extension/Music/spotify_web.cpp b/Extension/Music/spotify_web.cpp index 077fca0..dae258c 100644 --- a/Extension/Music/spotify_web.cpp +++ b/Extension/Music/spotify_web.cpp @@ -39,6 +39,8 @@ std::condition_variable wake; std::deque commands; // "login", "logout", "refresh", "play " std::string client_id, refresh_token, user_name, problem_text; std::vector known; +std::vector lined_up; +std::atomic queue_asked{}; bool is_busy{}; std::atomic started{}; // (The worker's alone.) @@ -209,7 +211,7 @@ std::string token() { refresh_token.clear(); } keep(); - problem("the sign-in is no longer good; sign in again (spotify login)"); + problem("the sign-in is no longer good; sign in again (music spotify login)"); } else { problem(std::format("Spotify could not be reached to renew the sign-in ({})", reply.status)); } @@ -306,6 +308,35 @@ std::vector tracks_of(const Reply& reply) { return tracks; } +// What Spotify has lined up (its "queue"): the song playing and those to come. +void fetch_queue() { + const auto reply = api(L"GET", "/v1/me/player/queue"); + std::vector found; + if (reply.status == 200) { + try { + const auto json = Json::parse(reply.body); + const auto take = [&](const Json& song) { + if (!song.is_object() || found.size() >= 40) return; + Queued queued{{}, song.value("name", ""), song.value("uri", ""), {}}; + if (song.contains("artists") && song.at("artists").is_array() && song.at("artists").size() && song.at("artists").at(std::size_t{0}).is_object()) + queued.artist = song.at("artists").at(std::size_t{0}).value("name", ""); + // (The smallest of its album's pictures: they are listed from the largest.) + if (song.contains("album") && song.at("album").is_object() && song.at("album").contains("images") && song.at("album").at("images").is_array() && song.at("album").at("images").size()) { + const auto& images = song.at("album").at("images"); + if (const auto& image = images.at(images.size() - 1); image.is_object()) queued.cover = image.value("url", ""); + if (!queued.cover.starts_with("https://")) queued.cover.clear(); + } + if (!queued.title.empty()) found.push_back(std::move(queued)); + }; + if (json.contains("currently_playing")) take(json.at("currently_playing")); + if (json.contains("queue") && json.at("queue").is_array()) + for (std::size_t i = 0; i < json.at("queue").size(); ++i) take(json.at("queue").at(i)); + } catch (...) {} + } + std::lock_guard lock(mutex); + lined_up = std::move(found); +} + void fetch_playlists() { std::vector found; if (const auto me = api(L"GET", "/v1/me"); me.status == 200) { @@ -318,7 +349,7 @@ void fetch_playlists() { // Liked Songs first, when the sign-in lets them be read (one made before this asked for // that does not: signing in again does). if (const auto liked = api(L"GET", "/v1/me/tracks?limit=50"); liked.status == 200) found.push_back({liked_songs, "Liked Songs", {}, tracks_of(liked)}); - else if (liked.status == 401 || liked.status == 403) say(logging::Level::info, "Liked Songs need the sign-in made again (spotify login)."); + else if (liked.status == 401 || liked.status == 403) say(logging::Level::info, "Liked Songs need the sign-in made again (music spotify login)."); const auto reply = api(L"GET", "/v1/me/playlists?limit=50"); if (reply.status != 200) { if (reply.status) problem(std::format("the playlists could not be fetched ({})", reply.status)); @@ -364,7 +395,7 @@ void sign_in() { std::lock_guard lock(mutex); client = client_id; } - if (client.empty()) return problem("no client id is set (spotify client )"); + if (client.empty()) return problem("no client id is set (music spotify client )"); const auto verifier = random_text(64), state = random_text(16); const auto challenge = sha256_base64url(verifier); if (verifier.empty() || challenge.empty()) return problem("the sign-in could not be prepared"); @@ -429,7 +460,7 @@ void start_playlist(const std::string& playlist, const std::string& track) { } if (reply.status == 403) return problem("Spotify only lets Premium accounts start a playlist from another application"); if (reply.status != 200 && reply.status != 202 && reply.status != 204) return problem(std::format("the playlist could not be started ({})", reply.status)); - say(logging::Level::info, "started playlist " + playlist + (track.empty() ? std::string() : " at " + track) + "."); + say(logging::Level::debug, "started playlist " + playlist + (track.empty() ? std::string() : " at " + track) + "."); } void run() noexcept { @@ -446,10 +477,11 @@ void run() noexcept { wake.wait(lock, [] { return !commands.empty(); }); command = std::move(commands.front()); commands.pop_front(); - is_busy = true; + is_busy = command != "queue"; // (a look at the queue is not something the player waits on) } try { if (command == "login") sign_in(); + else if (command == "queue") fetch_queue(); else if (command == "refresh") fetch_playlists(); else if (command.starts_with("client ")) { { @@ -464,6 +496,7 @@ void run() noexcept { std::lock_guard lock(mutex); refresh_token.clear(); known.clear(); + lined_up.clear(); user_name.clear(); problem_text.clear(); } @@ -482,6 +515,7 @@ void ask(std::string command) { std::lock_guard lock(mutex); // (Only the last playlist asked for matters.) if (command.starts_with("play ")) std::erase_if(commands, [](const std::string& waiting) { return waiting.starts_with("play "); }); + if (command == "queue") std::erase_if(commands, [](const std::string& waiting) { return waiting == "queue"; }); commands.push_back(std::move(command)); } wake.notify_one(); @@ -527,6 +561,20 @@ std::vector playlists() { std::lock_guard lock(mutex); return known; } +std::vector queue() { + std::lock_guard lock(mutex); + return refresh_token.empty() ? std::vector{} : lined_up; +} +void want_queue(bool now) { + { + std::lock_guard lock(mutex); + if (refresh_token.empty()) return; + } + const auto tick = GetTickCount64(); + if (!now && tick - queue_asked.load(std::memory_order_relaxed) < 4000) return; + queue_asked.store(tick, std::memory_order_relaxed); + ask("queue"); +} State state() { std::lock_guard lock(mutex); return {!client_id.empty(), !refresh_token.empty(), is_busy, user_name, problem_text, known.size()}; diff --git a/Extension/Music/spotify_web.h b/Extension/Music/spotify_web.h index 3df9f5d..7e57631 100644 --- a/Extension/Music/spotify_web.h +++ b/Extension/Music/spotify_web.h @@ -7,9 +7,9 @@ // screen's Spotify row (spotify_station.h), and starting one of them in their Spotify app. // // Spotify gives this to an application registered with it, by its client id. ReSkate has -// none of its own built in: the id is a setting ("spotify client " in the console), of an +// none of its own built in: the id is a setting ("music spotify client " in the console), of an // application whose redirect address is http://127.0.0.1:47824/callback. The player signs in -// once in their browser ("spotify login"); what is kept is the token that renews the sign-in, +// once in their browser ("music spotify login"); what is kept is the token that renews the sign-in, // encrypted for their Windows account, in \ReSkate\spotify.json. Starting a // playlist needs Spotify Premium (Spotify's rule) and the Spotify app open. namespace dingosdk::spotify_web { @@ -24,6 +24,10 @@ struct Playlist { std::string artwork; // served by the artwork server (music_artwork.h), or empty std::vector tracks; // its first fifty }; +// A song of what Spotify has lined up, with where its album's picture is (Spotify's address). +struct Queued { + std::string artist, title, uri, cover; +}; struct State { bool has_client{}, signed_in{}, busy{}; std::string user, problem; @@ -42,5 +46,10 @@ std::vector playlists(); // Starts a playlist in the player's Spotify app, at one of its tracks when `track` (its // Spotify address) is given. void play(const std::string& playlist, const std::string& track = {}); +// What Spotify has lined up, as last fetched: the song that is playing, then those after it +// (Spotify tells of twenty or so). Empty when the player has not signed in. `want_queue` has +// it fetched again, no more often than every few seconds unless `now`; any thread. +std::vector queue(); +void want_queue(bool now = false); State state(); } diff --git a/Extension/Music/youtube_music.cpp b/Extension/Music/youtube_music.cpp index 5f7c7f1..a700298 100644 --- a/Extension/Music/youtube_music.cpp +++ b/Extension/Music/youtube_music.cpp @@ -6,9 +6,11 @@ #include "Engine/Core/Log/logging.h" #include "Engine/Vfs/https_download.h" #include "Extension/Boot/user_data_redirect.h" +#include "Extension/UI/Overlay/overlay.h" #include #include #include +#include #include #include #include @@ -17,6 +19,8 @@ #include #include #include +#include +#include #include #if DINGOSDK_SPOTIFY_DEVICE @@ -24,6 +28,10 @@ extern "C" { std::uint64_t reskate_stream_play(const char* program, const char* arguments, void (*sound)(const float* samples, std::uint32_t frames, std::uint32_t rate), void (*ended)(std::uint64_t stream, const char* how)); +std::uint64_t reskate_stream_play_file(const char* path, void (*sound)(const float* samples, std::uint32_t frames, std::uint32_t rate), void (*ended)(std::uint64_t stream, const char* how)); +std::uint64_t reskate_stream_play_url(const char* address, void (*sound)(const float* samples, std::uint32_t frames, std::uint32_t rate), void (*ended)(std::uint64_t stream, const char* how), + void (*titled)(std::uint64_t stream, const char* title)); +std::uint32_t reskate_file_about(const char* path, char* artist, char* title, std::uint32_t text_room, std::uint8_t* picture, std::uint32_t picture_room); void reskate_stream_stop(); } #endif @@ -34,20 +42,26 @@ constexpr wchar_t agent[] = L"ReSkate"; constexpr wchar_t tool_address[] = L"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"; constexpr wchar_t runtime_address[] = L"https://github.com/quickjs-ng/quickjs/releases/latest/download/qjs-windows-x86_64.exe"; constexpr wchar_t sums_address[] = L"https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS"; -constexpr std::size_t most_songs = 100, most_playlists = 12; +constexpr std::size_t most_songs = 100, most_files = 200, most_kept = 48, most_found = 12; +// How many playlists of a kind there is room for. +constexpr std::size_t room(Kind kind) { return kind == Kind::radio ? 24 : 12; } +constexpr const char* kind_name(Kind kind) { return kind == Kind::files ? "files" : kind == Kind::radio ? "radio" : "youtube"; } std::mutex mutex; // everything below it std::condition_variable wake; std::deque commands; // "tools", "add ", "covers" std::vector library; std::string problem_text, browser_name; +Kind problem_of{}; +std::vector found_stations; // of the last search for radio stations +std::string searched_for; bool is_busy{}, have_tools{}, have_cookies{}; -std::atomic started{}, is_on{}, paused{}; +std::atomic started{}, is_on{}, paused{}, say_notices{true}; std::atomic sound_rate{44100}, fetched_percent{}; https::Watch tool_watch; // What is playing. std::size_t playing_list{}, playing_song{}; -std::uint64_t playing_stream{}; +std::uint64_t playing_stream{}, begun_number{}; int failures{}; external_player::Track track; std::atomic changes{1}, wanted_stream{}, begun_at{}, heard_stream{}; @@ -57,13 +71,19 @@ void say(logging::Level level, const std::string& text) noexcept { logging::log(level, logging::Channel::music, "YouTube Music: {}", text); } catch (...) {} } -void problem(const std::string& text) { +void problem(Kind kind, const std::string& text) { { std::lock_guard lock(mutex); problem_text = text; + problem_of = kind; } if (!text.empty()) say(logging::Level::warning, text); } +void problem(const std::string& text) { problem(Kind::youtube, text); } +// (With the mutex held.) +std::size_t count_of(Kind kind) { + return static_cast(std::count_if(library.begin(), library.end(), [&](const Playlist& playlist) { return playlist.kind == kind; })); +} std::filesystem::path home() { return redirected_local_app_data().parent_path(); } std::filesystem::path tool() { return home() / L"bin" / L"yt-dlp.exe"; } std::filesystem::path cookies_file() { return home() / L"youtube-cookies.txt"; } @@ -96,6 +116,17 @@ std::string shown(std::string text) { } return text; } +// The id of a song that is not a video: made of where it is. +std::string made_id(const std::string& source) { + std::uint64_t hash = 1469598103934665603ull; + for (const unsigned char c : source) hash = (hash ^ c) * 1099511628211ull; + return std::format("{:016x}", hash); +} +// An address on the web, with nothing in it but what one is written with. +bool good_address(const std::string& address) { + return (address.starts_with("http://") || address.starts_with("https://")) && address.size() > 10 && address.size() <= 400 && + std::none_of(address.begin(), address.end(), [](unsigned char c) { return c <= 0x20 || c == '"' || c == '<' || c == '>' || c == '\\' || c >= 0x7f; }); +} // A JavaScript runtime for yt-dlp, which YouTube's pages need worked out when they are asked // for as a signed-in player (without one, every song fails once there is a sign-in: seen @@ -140,11 +171,13 @@ void keep() { entry["id"] = song.id; entry["artist"] = song.artist; entry["title"] = song.title; + if (!song.source.empty()) entry["source"] = song.source; songs.push_back(std::move(entry)); } Json entry = Json::object(); entry["name"] = playlist.name; entry["url"] = playlist.url; + entry["kind"] = kind_name(playlist.kind); entry["songs"] = std::move(songs); lists.push_back(std::move(entry)); } @@ -172,17 +205,21 @@ void read_kept() { if (!json.contains("playlists") || !json.at("playlists").is_array()) return; std::vector read; const auto& lists = json.at("playlists"); - for (std::size_t i = 0; i < lists.size() && read.size() < most_playlists; ++i) { + for (std::size_t i = 0; i < lists.size() && read.size() < most_kept; ++i) { const auto& list = lists.at(i); if (!list.is_object() || !list.contains("songs") || !list.at("songs").is_array()) continue; - Playlist playlist{shown(list.value("name", "")), list.value("url", ""), {}, {}}; + const auto kind_text = list.value("kind", "youtube"); + const Kind kind = kind_text == "files" ? Kind::files : kind_text == "radio" ? Kind::radio : Kind::youtube; + Playlist playlist{shown(list.value("name", "")), list.value("url", ""), {}, {}, kind}; const auto& songs = list.at("songs"); - for (std::size_t s = 0; s < songs.size() && playlist.songs.size() < most_songs; ++s) { + for (std::size_t s = 0; s < songs.size() && playlist.songs.size() < most_files; ++s) { if (!songs.at(s).is_object()) continue; - Song song{songs.at(s).value("id", ""), shown(songs.at(s).value("artist", "")), shown(songs.at(s).value("title", ""))}; - if (video_id(song.id) && !song.artist.empty() && !song.title.empty()) playlist.songs.push_back(std::move(song)); + Song song{songs.at(s).value("id", ""), shown(songs.at(s).value("artist", "")), shown(songs.at(s).value("title", "")), songs.at(s).value("source", "")}; + const bool good = kind == Kind::youtube ? video_id(song.id) : !song.id.empty() && (kind == Kind::radio ? good_address(song.source) : !song.source.empty()); + if (good && !song.artist.empty() && !song.title.empty()) playlist.songs.push_back(std::move(song)); } - if (!playlist.name.empty() && !playlist.songs.empty()) read.push_back(std::move(playlist)); + const auto same = static_cast(std::count_if(read.begin(), read.end(), [&](const Playlist& other) { return other.kind == kind; })); + if (!playlist.name.empty() && !playlist.songs.empty() && same < room(kind)) read.push_back(std::move(playlist)); } std::lock_guard lock(mutex); library = std::move(read); @@ -282,8 +319,9 @@ void add_link(const std::string& link) { if (playlist.name.empty() || playlist.songs.empty()) return problem("there is nothing to play at that link"); { std::lock_guard lock(mutex); - if (library.size() >= most_playlists) { + if (count_of(Kind::youtube) >= room(Kind::youtube) || library.size() >= most_kept) { problem_text = "there is room for no more than twelve"; + problem_of = Kind::youtube; return; } library.push_back(playlist); @@ -293,24 +331,241 @@ void add_link(const std::string& link) { say(logging::Level::info, std::format("added \"{}\" ({} song(s)).", playlist.name, playlist.songs.size())); } -// The picture of a song (its video's), for a playlist's tile or for what is playing. -std::vector picture(const std::string& id) { - const auto image = https::get_text(L"https://i.ytimg.com/vi/" + wide(id) + L"/hqdefault.jpg", 2 * 1024 * 1024, 10, agent); - return image ? std::vector(image->begin(), image->end()) : std::vector{}; +// --- the player's own files --- + +bool sound_file(const std::filesystem::path& file) { + auto extension = file.extension().wstring(); + for (auto& c : extension) c = static_cast(towlower(c)); + for (const wchar_t* known : {L".mp3", L".m4a", L".aac", L".ogg", L".oga", L".flac", L".wav"}) + if (extension == known) return true; + return false; } -// Each playlist's tile is its first song's picture. -void make_covers() { - std::vector> wanted; +// A file as a song: what its tags say, and for one that says nothing its name, which is +// taken apart when it is of the form "artist - title" (a track's number before it is left out). +Song file_song(const std::filesystem::path& file, const std::string& folder) { + Song song; + song.source = narrow(file.wstring()); + song.id = made_id(song.source); +#if DINGOSDK_SPOTIFY_DEVICE + char artist[256]{}, title[256]{}; + reskate_file_about(song.source.c_str(), artist, title, sizeof(artist), nullptr, 0); + song.artist = shown(artist); + song.title = shown(title); +#endif + if (song.title.empty()) { + auto name = narrow(file.stem().wstring()); + std::size_t digits{}; + while (digits < name.size() && digits < 3 && std::isdigit(static_cast(name[digits]))) ++digits; + if (digits && digits < name.size() && (name[digits] == '.' || name[digits] == '-' || name[digits] == '_' || (name[digits] == ' ' && (name[0] == '0' || name.find(" - ") == digits)))) { + auto rest = name.find_first_not_of(" .-_", digits); + if (rest != std::string::npos) name.erase(0, rest); + } + if (const auto dash = name.find(" - "); dash != std::string::npos && dash > 0 && dash + 3 < name.size()) { + if (song.artist.empty()) song.artist = shown(name.substr(0, dash)); + song.title = shown(name.substr(dash + 3)); + } else { + song.title = shown(name); + } + } + if (song.artist.empty()) song.artist = folder; + return song; +} +void add_folder_now(std::string given) { + std::erase(given, '"'); + while (!given.empty() && given.back() == ' ') given.pop_back(); + while (!given.empty() && given.front() == ' ') given.erase(given.begin()); + std::filesystem::path folder; + if (given.empty()) { + // (None given: the player's Music folder.) + PWSTR known{}; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Music, 0, nullptr, &known)) && known) folder = known; + CoTaskMemFree(known); + } else { + folder = wide(given); + } + std::error_code failed; + if (folder.empty() || !folder.is_absolute() || !std::filesystem::is_directory(folder, failed)) + return problem(Kind::files, "that is not a folder on this computer: give its whole path, such as C:\\Users\\you\\Music"); + folder = folder.lexically_normal(); + if (!folder.has_filename()) folder = folder.parent_path(); // (it was written with a slash at its end) + std::vector files; + std::filesystem::recursive_directory_iterator walk(folder, std::filesystem::directory_options::skip_permission_denied, failed); + for (const std::filesystem::recursive_directory_iterator end; !failed && walk != end && files.size() < 5000; walk.increment(failed)) { + if (walk.depth() >= 4) walk.disable_recursion_pending(); + std::error_code ignored; + if (walk->is_regular_file(ignored) && sound_file(walk->path())) files.push_back(walk->path()); + } + std::sort(files.begin(), files.end()); + Playlist playlist{shown(narrow(folder.filename().wstring())), narrow(folder.wstring()), {}, {}, Kind::files}; + if (playlist.name.empty()) playlist.name = "Local Files"; + for (const auto& file : files) { + if (playlist.songs.size() >= most_files) break; + if (auto song = file_song(file, playlist.name); !song.artist.empty() && !song.title.empty() && + std::none_of(playlist.songs.begin(), playlist.songs.end(), [&](const Song& other) { return other.artist == song.artist && other.title == song.title; })) + playlist.songs.push_back(std::move(song)); + } + if (playlist.songs.empty()) return problem(Kind::files, "there is no music this can play in that folder (MP3, M4A, AAC, Ogg, FLAC, WAV)"); { std::lock_guard lock(mutex); - for (std::size_t i = 0; i < library.size(); ++i) - if (library[i].artwork.empty() && !library[i].songs.empty()) wanted.emplace_back(i, library[i].songs.front().id); + // (A folder that is there already is read again: that is how new files in it are taken in.) + const auto same = std::find_if(library.begin(), library.end(), [&](const Playlist& other) { return other.kind == Kind::files && _stricmp(other.url.c_str(), playlist.url.c_str()) == 0; }); + if (same != library.end()) { + *same = playlist; + } else if (count_of(Kind::files) >= room(Kind::files) || library.size() >= most_kept) { + problem_text = "there is room for no more than twelve folders"; + problem_of = Kind::files; + return; + } else { + library.push_back(playlist); + } + problem_text.clear(); } - for (const auto& [index, id] : wanted) { - const auto address = spotify_cover::serve(picture(id), "youtube-" + id + ".png"); + keep(); + say(logging::Level::info, std::format("added the folder \"{}\" ({} of its {} file(s)).", playlist.name, playlist.songs.size(), files.size())); +} + +// --- radio --- + +std::string encoded(const std::string& text) { + std::string out; + for (const unsigned char c : text) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out += static_cast(c); + else out += std::format("%{:02X}", c); + } + return out; +} +// Stations by their name, from the radio-browser.info directory (a list kept by its +// community, free to ask), the most listened to first; those this can play. +void search_now(const std::string& asked) { + auto query = shown(asked); + if (query.size() > 60) query.resize(60); + if (query.size() < 2) return problem(Kind::radio, "type at least two letters of a station's name"); + std::optional text; + for (const wchar_t* server : {L"de1", L"de2"}) { + text = https::get_text(std::wstring(L"https://") + server + L".api.radio-browser.info/json/stations/search?limit=40&hidebroken=true&order=clickcount&reverse=true&name=" + wide(encoded(query)), + 1024 * 1024, 15, agent); + if (text) break; + } + if (!text) return problem(Kind::radio, "the station directory (radio-browser.info) could not be reached"); + std::vector found; + try { + const auto list = Json::parse(*text); + for (std::size_t i = 0; list.is_array() && i < list.size() && found.size() < most_found; ++i) { + try { + const auto& entry = list.at(i); + if (!entry.is_object()) continue; + // (Sound sent in pieces over many addresses, HLS, is not played here.) + if (entry.contains("hls") && entry.at("hls").is_number() && entry.at("hls").get() != 0) continue; + const auto codec = entry.value("codec", ""); + if (codec != "MP3" && codec != "AAC" && codec != "AAC+" && codec != "OGG") continue; + auto address = entry.value("url_resolved", ""); + if (!good_address(address)) address = entry.value("url", ""); + Found station{shown(entry.value("name", "")), address, entry.value("favicon", ""), {}}; + if (!good_address(station.address) || station.name.empty()) continue; + if (!good_address(station.icon) || !station.icon.starts_with("https://")) station.icon.clear(); + if (std::any_of(found.begin(), found.end(), [&](const Found& other) { return other.address == station.address || other.name == station.name; })) continue; + station.about = shown(entry.value("country", "")); + station.about += (station.about.empty() ? "" : ", ") + codec; + if (entry.contains("bitrate") && entry.at("bitrate").is_number() && entry.at("bitrate").get() > 0) + station.about += " " + std::to_string(entry.at("bitrate").get()) + " kbps"; + found.push_back(std::move(station)); + } catch (...) {} // (one that is not as the others are is passed over) + } + } catch (...) { + return problem(Kind::radio, "what the station directory said could not be read"); + } + std::lock_guard lock(mutex); + problem_of = Kind::radio; + problem_text = found.empty() ? "no station of that name that this can play was found" : ""; + found_stations = std::move(found); + searched_for = query; +} +// A station is a playlist of one row, which is the station. +void add_station_now(const std::string& told) { + std::string name, address, icon; + try { + const auto json = Json::parse(told); + name = shown(json.value("name", "")); + address = json.value("address", ""); + icon = json.value("icon", ""); + } catch (...) { + return; + } + if (!good_address(address)) return problem(Kind::radio, "that is not a station's address: one begins with http:// or https://"); + if (!good_address(icon) || !icon.starts_with("https://")) icon.clear(); + if (name.empty()) { + // (None given: the name of where it is.) + const auto from = address.find("//") + 2; + name = address.substr(from, address.find_first_of("/:?", from) - from); + } + Playlist playlist{name, icon, {}, {Song{made_id(address), name, "Live Radio", address}}, Kind::radio}; + { std::lock_guard lock(mutex); - if (index < library.size() && !library[index].songs.empty() && library[index].songs.front().id == id) library[index].artwork = address; + problem_of = Kind::radio; + if (std::any_of(library.begin(), library.end(), [&](const Playlist& other) { return other.kind == Kind::radio && !other.songs.empty() && other.songs.front().source == address; })) { + problem_text = "that station is there already"; + return; + } + if (count_of(Kind::radio) >= room(Kind::radio) || library.size() >= most_kept) { + problem_text = "there is room for no more than twenty-four stations"; + return; + } + library.push_back(playlist); + problem_text.clear(); } + keep(); + say(logging::Level::info, std::format("added the station \"{}\".", name)); +} + +// --- pictures --- + +std::vector fetched(const std::wstring& address) { + const auto image = https::get_text(address, 2 * 1024 * 1024, 10, agent); + return image ? std::vector(image->begin(), image->end()) : std::vector{}; +} +// The picture of a song, for its playlist's tile or for what is playing: its video's, the one +// in its file's tags, or its station's. +std::vector picture(Kind kind, const Song& song, const std::string& icon) { + if (kind == Kind::youtube) return fetched(L"https://i.ytimg.com/vi/" + wide(song.id) + L"/hqdefault.jpg"); + if (kind == Kind::radio) return icon.starts_with("https://") ? fetched(wide(icon)) : std::vector{}; +#if DINGOSDK_SPOTIFY_DEVICE + std::vector image(4 * 1024 * 1024); + char artist[8]{}, title[8]{}; + image.resize(reskate_file_about(song.source.c_str(), artist, title, sizeof(artist), image.data(), static_cast(image.size()))); + return image; +#else + return {}; +#endif +} +// Each playlist's tile is its first song's picture (of a folder, the first of its first few +// files that has one). +void make_covers() { + struct Wanted { + std::size_t index; + Kind kind; + std::string icon; + std::vector songs; + }; + std::vector wanted; + { + std::lock_guard lock(mutex); + for (std::size_t i = 0; i < library.size(); ++i) { + const auto& playlist = library[i]; + if (!playlist.artwork.empty() || playlist.songs.empty()) continue; + const auto count = std::min(playlist.songs.size(), playlist.kind == Kind::files ? 4 : 1); + wanted.push_back({i, playlist.kind, playlist.url, {playlist.songs.begin(), playlist.songs.begin() + static_cast(count)}}); + } + } + for (const auto& want : wanted) + for (const auto& song : want.songs) { + const auto image = picture(want.kind, song, want.icon); + if (image.empty()) continue; + const auto address = spotify_cover::serve(image, std::string(kind_name(want.kind)) + "-" + song.id + ".png"); + if (address.empty()) continue; + std::lock_guard lock(mutex); + if (want.index < library.size() && !library[want.index].songs.empty() && library[want.index].songs.front().id == want.songs.front().id) library[want.index].artwork = address; + break; + } } std::string hex_sha256(const std::filesystem::path& file) { @@ -416,10 +671,14 @@ void run() noexcept { fetch_tool(); fetch_runtime(); } else if (command == "runtime") fetch_runtime(); - if (command.starts_with("add ")) { - add_link(command.substr(4)); - make_covers(); + if (command.starts_with("add ")) add_link(command.substr(4)); + else if (command.starts_with("folder ")) add_folder_now(command.substr(7)); + else if (command.starts_with("station ")) add_station_now(command.substr(8)); + else if (command.starts_with("search ")) { + search_now(command.substr(7)); + continue; // (nothing of the music screen's has changed) } + make_covers(); profile_runtime::request_music_catalog_refresh(); } catch (...) {} } @@ -443,7 +702,7 @@ void sound(const float* samples, std::uint32_t frames, std::uint32_t rate) { sound_rate.store(rate, std::memory_order_relaxed); // (The first of a song's sound: how long it was in coming, into the log.) if (mine && heard_stream.exchange(mine, std::memory_order_relaxed) != mine) - say(logging::Level::info, std::format("the song's sound began {:.1f} s after it was asked for.", static_cast(GetTickCount64() - begun_at.load(std::memory_order_relaxed)) / 1000.0)); + say(logging::Level::debug, std::format("the song's sound began {:.1f} s after it was asked for.", static_cast(GetTickCount64() - begun_at.load(std::memory_order_relaxed)) / 1000.0)); for (int waited = 0; paused.load(std::memory_order_relaxed) || app_audio::ahead_frames() > rate * 15 / 100; ++waited) { if (wanted_stream.load(std::memory_order_relaxed) != mine) return; // (another song is wanted) if (!paused.load(std::memory_order_relaxed) && waited >= 125) return; // (the game is not listening) @@ -451,26 +710,49 @@ void sound(const float* samples, std::uint32_t frames, std::uint32_t rate) { } app_audio::push(samples, frames); } -void begin(std::size_t list, std::size_t song); -// A song is over: the next of its playlist, and round again after its last. +void begin(std::size_t list, std::size_t song, bool asked = false); +// Whether a song has been asked for whose sound has not begun yet. (With the mutex held.) +bool loading_now() { + return playing_stream && heard_stream.load(std::memory_order_relaxed) != playing_stream && !paused.load() && + GetTickCount64() - begun_at.load(std::memory_order_relaxed) < 20000; +} +// A song is over: the next of its playlist, and round again after its last. A station that +// went quiet is turned to again. void ended(std::uint64_t stream, const char* how) { try { std::size_t list{}, next{}; { std::lock_guard lock(mutex); - if (stream != playing_stream || list >= library.size()) return; // (it was stopped for another) + if (stream != playing_stream) return; // (it was stopped for another) list = playing_list; if (list >= library.size() || library[list].songs.empty()) return; - const bool failed = std::string_view(how).starts_with("failed"); - failures = failed ? failures + 1 : 0; + const auto kind = library[list].kind; + const std::string_view said(how ? how : ""); + bool failed = said.starts_with("failed"); if (failed) { - const auto& title = library[list].songs[playing_song % library[list].songs.size()].title; - say(logging::Level::warning, std::format("\"{}\" {}.", title, how)); - // (The usual reason: YouTube wants a sign-in before it plays it.) - problem_text = "\"" + title + "\" could not be played and was passed over. YouTube asks for a sign-in for private and age-restricted songs: add your cookies.txt below."; + const auto& title = kind == Kind::radio ? library[list].name : library[list].songs[playing_song % library[list].songs.size()].title; + say(logging::Level::warning, std::format("\"{}\" {}.", title, said)); + if (say_notices.load(std::memory_order_relaxed)) overlay::notify(overlay::NoticeLevel::warning, kind == Kind::radio ? "Station could not be played" : "Song could not be played, skipping", title); + problem_of = kind; + // (The usual reason, of YouTube's: it wants a sign-in before it plays it.) + problem_text = kind == Kind::youtube ? "\"" + title + "\" could not be played and was passed over. YouTube asks for a sign-in for private and age-restricted songs: add your cookies.txt below." + : kind == Kind::files ? "\"" + title + "\" could not be played and was passed over: the file is gone, or is of a kind this does not play." + : "\"" + title + "\" could not be played: " + std::string(said.substr(std::min(said.size(), 7))) + "."; } + if (kind == Kind::radio) { + // (One that ends as soon as it is turned to is not turned to over and over.) + if (!failed && GetTickCount64() - begun_at.load(std::memory_order_relaxed) < 10000) failed = true; + playing_stream = 0; + if (failed && (said.starts_with("failed") || failures >= 3)) { + track.playing = false; + changes.fetch_add(1, std::memory_order_relaxed); + return; + } + } + failures = failed ? failures + 1 : 0; if (failures >= 4) { - problem_text = "songs are not playing (yt-dlp may need to be fetched again, or YouTube is not letting them be)"; + problem_of = kind; + problem_text = kind == Kind::youtube ? "songs are not playing (yt-dlp may need to be fetched again, or YouTube is not letting them be)" : "songs are not playing"; return; } next = (playing_song + 1) % library[list].songs.size(); @@ -478,57 +760,95 @@ void ended(std::uint64_t stream, const char* how) { begin(list, next); } catch (...) {} } -void fetch_picture(std::string id, std::uint64_t of) { +// What a station says is playing ("artist - title", usually). +void titled(std::uint64_t stream, const char* title) { try { - auto image = picture(id); + const auto text = shown(title ? title : ""); + if (text.empty()) return; + std::lock_guard lock(mutex); + if (stream != playing_stream || playing_list >= library.size()) return; + if (const auto dash = text.find(" - "); dash != std::string::npos && dash > 0 && dash + 3 < text.size()) { + track.artist = text.substr(0, dash); + track.title = text.substr(dash + 3); + } else { + track.artist = library[playing_list].name; + track.title = text; + } + ++track.revision; + ++track.artwork_revision; // (the station's picture is the new song's as well) + changes.fetch_add(1, std::memory_order_relaxed); + } catch (...) {} +} +void fetch_picture(Kind kind, Song song, std::string icon, std::uint64_t of) { + try { + auto image = picture(kind, song, icon); if (image.empty()) return; std::lock_guard lock(mutex); - if (track.revision != of) return; + if (begun_number != of) return; track.artwork = std::move(image); ++track.artwork_revision; changes.fetch_add(1, std::memory_order_relaxed); } catch (...) {} } -void begin(std::size_t list, std::size_t song) { +void begin(std::size_t list, std::size_t song, bool asked) { #if DINGOSDK_SPOTIFY_DEVICE - std::string id; + Song chosen; + Kind kind{}; + std::string icon; std::uint64_t of{}; { std::lock_guard lock(mutex); - if (!have_tools || list >= library.size() || library[list].songs.empty()) return; + if (list >= library.size() || library[list].songs.empty()) return; + kind = library[list].kind; + if (kind == Kind::youtube && !have_tools) return; song %= library[list].songs.size(); if (playing_stream && list == playing_list && song == playing_song && GetTickCount64() - begun_at.load(std::memory_order_relaxed) < 3000) return; + // Presses that come fast, while a song is still on its way: the same song again is + // not begun again (it would only be longer in coming), and another is not taken up + // until the one before has had a moment (each is a program run and a fetch begun). + if (asked && loading_now() && ((list == playing_list && song == playing_song) || GetTickCount64() - begun_at.load(std::memory_order_relaxed) < 1200)) return; begun_at.store(GetTickCount64(), std::memory_order_relaxed); - const auto& chosen = library[list].songs[song]; - id = chosen.id; + chosen = library[list].songs[song]; + icon = library[list].url; playing_list = list; playing_song = song; track.available = true; track.spotify = false; - track.app = "YouTube Music"; + track.app = kind == Kind::youtube ? "YouTube Music" : kind == Kind::files ? "Local Files" : "Online Radio"; track.can_next = track.can_previous = track.can_pause = track.can_play = true; track.artist = chosen.artist; track.title = chosen.title; track.album.clear(); track.artwork.clear(); track.playing = true; - of = ++track.revision; + ++track.revision; + of = ++begun_number; paused.store(false); - const auto program = narrow(tool().wstring()); - std::string arguments = "-f\n140/bestaudio[ext=m4a]\n--no-playlist\n--quiet\n--no-warnings\n"; - for (const auto& argument : sign_in()) arguments += argument + "\n"; - arguments += "-o\n-\nhttps://www.youtube.com/watch?v=" + id; // (The number the stream will have is known before it is begun: its sound is asked for it.) - playing_stream = reskate_stream_play(program.c_str(), arguments.c_str(), &sound, &ended); + if (kind == Kind::files) { + playing_stream = reskate_stream_play_file(chosen.source.c_str(), &sound, &ended); + } else if (kind == Kind::radio) { + playing_stream = reskate_stream_play_url(chosen.source.c_str(), &sound, &ended, &titled); + } else { + const auto program = narrow(tool().wstring()); + std::string arguments = "-f\n140/bestaudio[ext=m4a]\n--no-playlist\n--quiet\n--no-warnings\n"; + for (const auto& argument : sign_in()) arguments += argument + "\n"; + arguments += "-o\n-\nhttps://www.youtube.com/watch?v=" + chosen.id; + playing_stream = reskate_stream_play(program.c_str(), arguments.c_str(), &sound, &ended); + } wanted_stream.store(playing_stream, std::memory_order_relaxed); } changes.fetch_add(1, std::memory_order_relaxed); + // (Said in the corner of the game, for a song that takes a moment to come: the player + // sees that the press was taken.) + if (asked && kind != Kind::files && say_notices.load(std::memory_order_relaxed)) overlay::notify(overlay::NoticeLevel::info, kind == Kind::radio ? "Tuning in..." : "Loading song...", kind == Kind::radio ? chosen.artist : chosen.artist + " - " + chosen.title); try { - std::thread(fetch_picture, id, of).detach(); + std::thread(fetch_picture, kind, chosen, icon, of).detach(); } catch (...) {} #else (void)list; (void)song; + (void)asked; #endif } } // namespace @@ -553,11 +873,30 @@ State state() { out.cookies = have_cookies; out.runtime = javascript().first; out.playlists = library; + for (auto& playlist : out.playlists) playlist.usable = playlist.kind != Kind::youtube || have_tools; + out.problem_kind = problem_of; + out.found = found_stations; + out.searched = searched_for; + out.playing = is_on.load(std::memory_order_relaxed) && (playing_stream != 0 || paused.load()) && playing_list < library.size(); + out.loading = out.playing && loading_now(); + out.playing_list = playing_list; + out.playing_song = playing_song; return out; } std::vector playlists() { std::lock_guard lock(mutex); - return have_tools ? library : std::vector{}; + auto out = library; + for (auto& playlist : out) playlist.usable = playlist.kind != Kind::youtube || have_tools; + return out; +} +bool has(Kind kind) { + std::lock_guard lock(mutex); + return (kind != Kind::youtube || have_tools) && count_of(kind) > 0; +} +std::string tile_id(std::size_t playlist) { + std::lock_guard lock(mutex); + const auto kind = playlist < library.size() ? library[playlist].kind : Kind::youtube; + return (kind == Kind::files ? files_prefix : kind == Kind::radio ? radio_prefix : playlist_prefix) + std::to_string(playlist); } void fetch_tools() { ask("tools"); } void add(const std::string& link) { ask("add " + link); } @@ -566,11 +905,36 @@ void remove(std::size_t playlist) { std::lock_guard lock(mutex); if (playlist >= library.size()) return; library.erase(library.begin() + static_cast(playlist)); + // (What is playing is told from the others by its place among them.) + if (playlist == playing_list && playing_stream) { +#if DINGOSDK_SPOTIFY_DEVICE + wanted_stream.store(0, std::memory_order_relaxed); + reskate_stream_stop(); +#endif + playing_stream = 0; + track.playing = false; + } else if (playlist < playing_list) { + --playing_list; + } } keep(); profile_runtime::request_music_catalog_refresh(); } +void add_folder(const std::string& path) { + if (path.size() <= 1000 && std::none_of(path.begin(), path.end(), [](unsigned char c) { return c < 0x20; })) ask("folder " + path); +} +void search_radio(const std::string& query) { + if (query.size() <= 200) ask("search " + query); +} +void add_station(const std::string& name, const std::string& address, const std::string& icon) { + Json json = Json::object(); + json["name"] = name; + json["address"] = address; + json["icon"] = icon; + ask("station " + json.dump()); +} + void set_browser(const std::string& browser) { if (!good_browser(browser)) return; { @@ -611,6 +975,8 @@ bool set_cookies(const std::string& text) { return true; } +bool notices() noexcept { return say_notices.load(std::memory_order_relaxed); } +void set_notices(bool on) noexcept { say_notices.store(on, std::memory_order_relaxed); } bool on() noexcept { return is_on.load(std::memory_order_relaxed); } void set_on(bool on) noexcept { if (is_on.exchange(on, std::memory_order_relaxed) && !on) { @@ -641,7 +1007,24 @@ void play(std::size_t playlist, const std::string& song) { if (library[playlist].songs[i].id == song) at = i; failures = 0; } - begin(playlist, at); + begin(playlist, at, true); +} +void play_at(std::size_t playlist, std::size_t song) { + { + std::lock_guard lock(mutex); + if (!is_on.load(std::memory_order_relaxed) || playlist != playing_list || playlist >= library.size() || song >= library[playlist].songs.size()) return; + failures = 0; + } + begin(playlist, song, true); +} +std::vector art(std::size_t playlist, std::size_t song) { + Song chosen; + { + std::lock_guard lock(mutex); + if (playlist >= library.size() || library[playlist].kind != Kind::files || song >= library[playlist].songs.size()) return {}; + chosen = library[playlist].songs[song]; + } + return picture(Kind::files, chosen, {}); } external_player::Track now() { std::lock_guard lock(mutex); @@ -652,22 +1035,37 @@ void send(external_player::Command command) noexcept { try { using external_player::Command; std::size_t list{}, song{}, count{}; + bool radio{}; { std::lock_guard lock(mutex); list = playing_list; song = playing_song; count = list < library.size() ? library[list].songs.size() : 0; + radio = count && library[list].kind == Kind::radio; if (command == Command::pause || command == Command::play || command == Command::toggle) { const bool pause = command == Command::pause || (command == Command::toggle && !paused.load()); - paused.store(pause); - track.playing = !pause && playing_stream != 0; - changes.fetch_add(1, std::memory_order_relaxed); - return; + // A station cannot be held where it is: it is left, and turned to again. + if (radio && pause) { +#if DINGOSDK_SPOTIFY_DEVICE + wanted_stream.store(0, std::memory_order_relaxed); + reskate_stream_stop(); +#endif + playing_stream = 0; + } + if (!radio || pause) { + paused.store(pause); + track.playing = !pause && playing_stream != 0; + changes.fetch_add(1, std::memory_order_relaxed); + return; + } + if (playing_stream) return; // (it is playing already) + } else if (radio) { + return; // (there is no next of a station) } failures = 0; } if (!count) return; - begin(list, command == Command::next ? (song + 1) % count : (song + count - 1) % count); + begin(list, radio ? song : command == Command::next ? (song + 1) % count : (song + count - 1) % count, true); } catch (...) {} } } // namespace dingosdk::youtube_music diff --git a/Extension/Music/youtube_music.h b/Extension/Music/youtube_music.h index 0915de4..4dc7459 100644 --- a/Extension/Music/youtube_music.h +++ b/Extension/Music/youtube_music.h @@ -3,30 +3,44 @@ #include #include -// "YouTube Music" in the game's music screen: videos and playlists the player has added by -// their links (in the setup page, spotify_dashboard.h), each a tile there with its songs as -// rows. A song's sound is fetched by yt-dlp, which this runs, and decoded by the library that -// is built into the DLL (SpotifyHelper/src/stream.rs); it is heard through the game as -// Spotify's is. +// The playlists of the player's own in the game's music screen that are not Spotify's, each a +// tile there with its songs as rows, in a row of its kind: +// "YouTube Music" videos and playlists added by their links; a song's sound is fetched by +// yt-dlp, which this runs +// "Local Files" folders of music on this computer (MP3, M4A, AAC, Ogg, FLAC, WAV) +// "Online Radio" internet radio stations, each a tile of its own, found by name in the +// radio-browser.info directory or added by their address +// All are added in the setup page (spotify_dashboard.h), decoded by the library that is built +// into the DLL (SpotifyHelper/src/stream.rs), and heard through the game as Spotify's is. // // yt-dlp is not part of ReSkate: the player has it fetched from the setup page, from its own // releases on GitHub, into \ReSkate\bin. What is added is kept in // \ReSkate\youtube-music.json. namespace dingosdk::youtube_music { // What a station's playlist is called among the stations (spotify_station.h): this and its -// number; what a row's track is called: this and the video's id. +// number; what a row's track is called: this and the song's id. inline constexpr char station_prefix[] = "~yt:"; inline constexpr char track_prefix[] = "yt:"; -// What the ids of its row's playlists in the music screen begin with. +// What the ids of each kind's playlists in the music screen begin with. inline constexpr char playlist_prefix[] = "youtube:"; +inline constexpr char files_prefix[] = "localfiles:"; +inline constexpr char radio_prefix[] = "radio:"; +enum class Kind { youtube, files, radio }; struct Song { - std::string id, artist, title; + std::string id, artist, title; // the id is the video's, or one made of the source + std::string source; // the file, or the station's address; none for a video }; struct Playlist { - std::string name, url; - std::string artwork; // served by the artwork server, or empty + std::string name, url; // the link it was added by; the folder; the station's picture + std::string artwork; // served by the artwork server, or empty std::vector songs; + Kind kind{}; + bool usable{true}; // (a YouTube playlist is not, until yt-dlp is there) +}; +// A radio station found by its name. +struct Found { + std::string name, address, icon, about; }; struct State { bool available{}; // the DLL was built with the library that decodes @@ -34,19 +48,37 @@ struct State { bool busy{}; unsigned percent{}; // of yt-dlp fetched, while it is being std::string problem; + Kind problem_kind{}; // which kind the problem is one of std::string browser; // whose YouTube sign-in yt-dlp uses, or empty bool cookies{}; // a cookies.txt of the player's is kept, and is what it uses std::string runtime; // the JavaScript runtime yt-dlp is given ("node", "deno"), or empty: a sign-in does not work without one std::vector playlists; + std::vector found; // the stations of the last search + std::string searched; // what it was for + // What is playing, when one of these is: which playlist, and which song of it. + bool playing{}; + bool loading{}; // a song was asked for and its sound has not begun yet + std::size_t playing_list{}, playing_song{}; }; // Reads what was kept, once, on a thread of its own, which does what takes time below. void start() noexcept; State state(); +// All of them, in the order their stations are numbered. std::vector playlists(); -// Fetches yt-dlp. Adds what a link is of (a video, a playlist). Takes one out. +// Whether there is a playlist of a kind to show. +bool has(Kind kind); +// The id of a playlist's tile in the music screen. +std::string tile_id(std::size_t playlist); +// Fetches yt-dlp. Adds what a link is of (a video, a playlist). Takes a playlist of any kind out. void fetch_tools(); void add(const std::string& link); void remove(std::size_t playlist); +// Adds a folder's music (and what is in the folders inside it), or reads it again when it is +// there already. Empty for the player's Music folder. +void add_folder(const std::string& path); +// Looks for radio stations by name, into the state. Adds one. +void search_radio(const std::string& query); +void add_station(const std::string& name, const std::string& address, const std::string& icon); // The browser whose YouTube sign-in yt-dlp is to use ("firefox", "edge", "chrome", "brave", // "opera", "vivaldi"; empty for none): YouTube asks for one before it plays songs it has // marked as not for everyone. @@ -57,13 +89,21 @@ void set_browser(const std::string& browser); // False when what was given is no such file. bool set_cookies(const std::string& text); +// Whether a song that takes a moment to come is said in the corner of the game ("Loading +// song...", and one that could not be played); it is, unless the player turns it off. +bool notices() noexcept; +void set_notices(bool on) noexcept; // Whether one of its stations is the one that is on. bool on() noexcept; void set_on(bool on) noexcept; // The rate of the sound it is giving, in Hz. unsigned rate() noexcept; -// Plays a playlist, from a song of it when given (its video's id), else from where it was. +// Plays a playlist, from a song of it when given (its id), else from where it was. void play(std::size_t playlist, const std::string& song = {}); +// Goes to a song of the playlist that is playing, by its place in it (the setup page's queue). +void play_at(std::size_t playlist, std::size_t song); +// The picture in the tags of a song that is a file (as it is there: a JPEG or a PNG), or nothing. +std::vector art(std::size_t playlist, std::size_t song); // What is playing, as external_player gives it; and play, pause, next and previous. external_player::Track now(); std::uint64_t revision() noexcept; diff --git a/Extension/Scripting/lua_native_block.cpp b/Extension/Scripting/lua_native_block.cpp new file mode 100644 index 0000000..98d1313 --- /dev/null +++ b/Extension/Scripting/lua_native_block.cpp @@ -0,0 +1,126 @@ +#include "lua_native_block.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/lua_natives.h" +#include +#include +#include +#include +#include + +namespace dingosdk { +namespace { +namespace lua = addr::lua_natives; +using LoadLibraryFn = int (*)(void* state, const char* path, const char* symbol); +using PushFormat = const char* (*)(void* state, const char* format, ...); +using ParseChunk = void (*)(void* state, void* parser); +using LuaFunction = int (*)(void* state); + +std::mutex install_mutex; +std::uintptr_t installed_base{}; +PushFormat push_format{}; +std::atomic original_parse{}; + +// A script that keeps trying must not fill the log. +void refused(std::atomic& count, std::string_view what, std::string_view detail = {}) noexcept { + if (count.fetch_add(1, std::memory_order_relaxed) >= 8) return; + try { + logging::log(logging::Level::warning, logging::Channel::assets, "Lua script refused: {}{}{}. ReSkate does not let Lua do this.", + what, detail.empty() ? "" : " ", detail); + } catch (...) {} +} + +// A path a script made up, for the log: one line, printable, not too long. +std::string shown(const char* text) noexcept { + std::array copy{}; + if (!text || memory::peek_cstring(reinterpret_cast(text), copy.data(), copy.size()) < 0) return {}; + std::string result(copy.data()); + for (auto& c : result) if (static_cast(c) < 32 || static_cast(c) > 126) c = '?'; + return result; +} + +// The DLL "did not load": package.loadlib answers nil and this message, require raises it. +int load_library(void* state, const char* path, const char*) { + static std::atomic count{}; + refused(count, "loading a DLL", shown(path)); + push_format(state, "ReSkate does not let Lua load a DLL"); + return 1; +} + +// Text only. A chunk that starts with the bytecode signature fails Lua's own check +// ("attempt to load a binary chunk (mode is 't')"). +void parse_chunk(void* state, void* parser) { + if (parser) *reinterpret_cast(static_cast(parser) + lua::parser_mode_offset) = "t"; + original_parse.load(std::memory_order_acquire)(state, parser); +} + +// The rest answer nothing at all, which a script reads as nil. +struct Refusal { const game::build::Fingerprint& contract; LuaFunction replacement; }; +constexpr std::size_t refusal_count = 6; +constexpr std::array refusal_names{ + "os.execute (starting a program)", "io.popen (starting a program)", "io.open (opening a file on disk)", + "io.output (opening a file on disk)", "os.remove (deleting a file)", "os.rename (renaming a file)"}; +template int nothing(void*) { + static std::atomic count{}; + refused(count, refusal_names[Index]); + return 0; +} +const std::array refusals{{ + {lua::os_execute, ¬hing<0>}, {lua::io_popen, ¬hing<1>}, {lua::io_open, ¬hing<2>}, + {lua::io_output, ¬hing<3>}, {lua::os_remove, ¬hing<4>}, {lua::os_rename, ¬hing<5>}}}; + +bool matches(std::uintptr_t base, const game::build::Fingerprint& contract) noexcept { + std::array bytes{}; + return memory::read_bytes(base + contract.rva, bytes.data(), bytes.size()) && bytes == contract.bytes; +} +} // namespace + +bool start_lua_native_block(std::uintptr_t base, std::string& error) { + std::lock_guard lock(install_mutex); + error.clear(); + if (installed_base) { + if (installed_base == base) return true; + error = "Lua's native functions are already switched off on another image"; + return false; + } + if (!base || !matches(base, lua::load_library) || !matches(base, lua::push_format) || !matches(base, lua::parse_chunk)) { + error = "Native Lua contract does not match the supported game"; return false; + } + for (const auto& refusal : refusals) + if (!matches(base, refusal.contract)) { error = "Native Lua library contract does not match the supported game"; return false; } + push_format = reinterpret_cast(base + lua::push_format.rva); + + std::array targets{}; + std::size_t prepared = 0; + const auto undo = [&] { for (std::size_t i = 0; i < prepared; ++i) hook_remove(targets[i]); }; + const auto prepare = [&](std::uintptr_t rva, void* replacement, void** original) { + auto* target = reinterpret_cast(base + rva); + const auto status = hook_prepare(target, replacement, original); + if (status != HookOk) { error = "Cannot prepare the Lua native block: " + std::to_string(status); return false; } + targets[prepared++] = target; + return true; + }; + ParseChunk parse{}; + bool ready = prepare(lua::load_library.rva, reinterpret_cast(&load_library), nullptr) && + prepare(lua::parse_chunk.rva, reinterpret_cast(&parse_chunk), reinterpret_cast(&parse)) && parse; + for (std::size_t i = 0; ready && i < refusals.size(); ++i) + ready = prepare(refusals[i].contract.rva, reinterpret_cast(refusals[i].replacement), nullptr); + if (!ready) { + if (error.empty()) error = "Cannot prepare the Lua native block"; + undo(); return false; + } + original_parse.store(parse, std::memory_order_release); + auto status = HookOk; + for (std::size_t i = 0; status == HookOk && i < prepared; ++i) status = hook_queue_enable(targets[i]); + if (status == HookOk) status = hook_apply_queued(); + if (status != HookOk) { + undo(); + original_parse.store(nullptr, std::memory_order_release); + error = "Cannot switch off Lua's native functions: " + std::to_string(status); return false; + } + installed_base = base; + return true; +} +} // namespace dingosdk diff --git a/Extension/Scripting/lua_native_block.h b/Extension/Scripting/lua_native_block.h new file mode 100644 index 0000000..aad94b6 --- /dev/null +++ b/Extension/Scripting/lua_native_block.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +// Lua in the game is Lua and nothing else, whoever wrote the script: a loose file in scripts/, +// a custom script, or one of the game's own. The game ships stock Lua 5.2 with every library +// open, so a script could otherwise load a DLL or start a program. These are switched off in +// the game's own Lua functions, before any Lua state exists, so no script runs ahead of it and +// none can keep a copy of the working function: +// * loading a DLL: package.loadlib, and require through the two C searchers; +// * starting a program: os.execute, io.popen; +// * writing, deleting or renaming a file on the player's disk: io.open, io.output, os.remove, +// os.rename (a script that can write a file can leave a program where Windows starts it); +// * loading precompiled chunks: bytecode is not checked by Lua and is a known way to run +// native code. Every script the game ships is text. +// The game's scripts use none of them: they read through the engine's own vfs and dofile. +// Each attempt is refused the way Lua reports a failure, and logged. +namespace dingosdk { +bool start_lua_native_block(std::uintptr_t base, std::string& error); +} diff --git a/Extension/Skater/GRIND_EXITS.md b/Extension/Skater/GRIND_EXITS.md index 6bd8d4f..42814ce 100644 --- a/Extension/Skater/GRIND_EXITS.md +++ b/Extension/Skater/GRIND_EXITS.md @@ -64,7 +64,8 @@ same thing; a regular skater's recording would confirm which of the two the flag around grinds, the pad and the launch calls, to CSV, plus snapshots of the skater's state memory). It is not part of this change; it can be offered separately for re-measuring after a game update. -Every pop out of a grind is written to `ReSkate.log` with what was decided and why. +With the log level set to debug (`loglevel debug`), every pop out of a grind is written to +`ReSkate.log` with what was decided and why. ## Measured diff --git a/Extension/Skater/grind_exit_control.cpp b/Extension/Skater/grind_exit_control.cpp index 87b475b..4dd2c72 100644 --- a/Extension/Skater/grind_exit_control.cpp +++ b/Extension/Skater/grind_exit_control.cpp @@ -450,7 +450,7 @@ void on_client_tick(std::uintptr_t client) noexcept { try { const char* trigger = d.trigger > 0 ? "LT" : d.trigger < 0 ? "RT" : "-"; const char* toward = d.toward > 0 ? "left of travel" : d.toward < 0 ? "right of travel" : "-"; - logging::log(logging::Level::info, logging::Channel::skater, + logging::log(logging::Level::debug, logging::Channel::skater, "Grind exit: {} ({} -> {}; {}; {}{}). state {}, rail speed {:.2f}, along {:.2f}, up {:.2f}, sideways {:+.2f} -> {:+.2f}, toe across {:+.2f}", reason_text(d.reason), trigger, toward, d.trigger ? basis_text(d.basis) : "-", d.switch_stance ? "switch" : "normal/fakie", d.lean_blocked ? ", lean held back" : "", d.state, d.rail_speed, diff --git a/Extension/Skater/grind_exit_control.h b/Extension/Skater/grind_exit_control.h index 1f5c6a0..f81cd9e 100644 --- a/Extension/Skater/grind_exit_control.h +++ b/Extension/Skater/grind_exit_control.h @@ -30,7 +30,8 @@ // result through the poses ReSkate already syncs. // It is limited to the supported game build: the launch hook is the trainer's, which installs // only when the game's code matches (Extension/Trainer/trainer_jump.cpp), and every address -// comes from Engine/Game/Build/20260929. Every pop out of a grind is written to ReSkate.log with +// comes from Engine/Game/Build/20260929. With debug logging on, every +// pop out of a grind is written to ReSkate.log with // what was decided and why. On/off, the side convention and the stance are kept in // %LOCALAPPDATA%\ReSkate\grindexit\control.txt. namespace dingosdk::grind_exit_control { diff --git a/Extension/UI/Overlay/skate_menu.cpp b/Extension/UI/Overlay/skate_menu.cpp index 8bef7cb..400c0db 100644 --- a/Extension/UI/Overlay/skate_menu.cpp +++ b/Extension/UI/Overlay/skate_menu.cpp @@ -278,12 +278,12 @@ ImFont* embedded_font(const wchar_t* name, float size, const ImWchar* ranges) { &config, ranges); } -enum Page { map, world, build, skater, training, multiplayer, progress, mods, settings, special, developer, page_count }; +enum Page { map, world, build, skater, training, multiplayer, progress, mods, music, settings, special, developer, page_count }; constexpr std::array page_names{ - "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "SETTINGS", "SPECIAL", "DEVELOPER"}; + "MAP", "WORLD", "BUILD", "SKATER", "TRAINER", "MULTIPLAYER", "PROGRESS", "MODS", "MUSIC", "SETTINGS", "SPECIAL", "DEVELOPER"}; constexpr std::array page_subtitles{ "Pick your spot.", "Set the vibe.", "Make the park yours.", "Ride it your way.", "Tune it. Drill it. Measure it.", - "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your controls, your screen.", "Not everyone gets this page.", + "Bring your crew.", "Pick up where you want.", "Bring your own.", "Your sound, in the game.", "Your controls, your screen.", "Not everyone gets this page.", "Under the hood."}; } @@ -441,6 +441,7 @@ void draw_skate_menu(SkateMenu& menu, const Model& model, const CallbacksV3& cal ImGui::EndChild(); ImGui::PopID(); break; + case music: music_page(menu, model, callbacks); break; case settings: settings_page(menu, model, callbacks); break; case special: special_page(menu, model, callbacks); break; case developer: developer_page(menu, model, callbacks); break; diff --git a/Extension/UI/Overlay/skate_menu_internal.h b/Extension/UI/Overlay/skate_menu_internal.h index e54a81c..3f5e53d 100644 --- a/Extension/UI/Overlay/skate_menu_internal.h +++ b/Extension/UI/Overlay/skate_menu_internal.h @@ -60,6 +60,8 @@ void settings_page(SkateMenu&, const Model&, const CallbacksV3&); // Settings > Post FX (skate_menu_world.cpp). void graphics_page(SkateMenu&, const Model&, const CallbacksV3&); void developer_page(SkateMenu&, const Model&, const CallbacksV3&); +// Extension/Music/music_page.cpp +void music_page(SkateMenu&, const Model&, const CallbacksV3&); // Extension/Trainer/trainer_page.cpp void trainer_page(SkateMenu&, const Model&, const CallbacksV3&); bool trainer_page_wanted(); diff --git a/MUSIC-SETUP.md b/MUSIC-SETUP.md index 9b2ea9d..79c8132 100644 --- a/MUSIC-SETUP.md +++ b/MUSIC-SETUP.md @@ -1,16 +1,17 @@ # ReSkate music sources: setup -ReSkate can play **Spotify**, **YouTube Music** and **any app on your PC** through the in-game -music player. Each one shows up as its own row in the game's music screen, with tiles you pick +ReSkate can play **Spotify**, **YouTube Music**, **your own music files**, **internet radio** and +**any app on your PC** through the in-game music player. Each one shows up as its own row in the game's music screen, with tiles you pick like any other playlist. Pause, next, track name and cover art all work from the game. -Everything is set up from one page in your browser. +Everything is set up from one page in your browser. Each source has numbered steps on the page +that tick off as you do them, so you can also just follow the page. ## Open the setup page 1. Start the game. 2. Press **~** to open the console. -3. Type `spotify setup` and press Enter. +3. Type `music setup` and press Enter. (The menu's **MUSIC** page has a button for it too, under **SOURCES**.) Your browser opens `http://127.0.0.1:8421/`. The page only works on the PC the game is running on, and only while the game is running. @@ -47,8 +48,8 @@ Spotify developer app. This is free and takes a couple of minutes. 2. Create an app. Any name and description will do. 3. Add this redirect URI exactly: `http://127.0.0.1:47824/callback` 4. Copy the app's **Client ID**. -5. On the setup page, under **Your playlists**, paste the Client ID and press **Save**. -6. Press **Sign in** and approve it in the browser tab that opens. +5. On the setup page, under **Spotify → Show your own playlists**, paste the Client ID and press **Save**. +6. Press **Sign in with Spotify** and approve it in the browser tab that opens. Your first 50 playlists appear, each with its first 50 tracks. Liked Songs shows your 50 most recent. Pressing a track in the game starts Spotify at that track. @@ -61,7 +62,7 @@ recent. Pressing a track in the game starts Spotify at that track. > Playing YouTube this way is against YouTube's terms of service. Whether to use it is up to you. -1. On the setup page, under **YouTube Music**, press **Set up YouTube Music**. This downloads +1. On the setup page, under **YouTube Music**, press **Download and set up**. This downloads `yt-dlp` (about 18 MB) from its official GitHub releases. 2. Paste a link to a YouTube or YouTube Music **video or playlist** and press **Add**. Up to 100 songs per playlist, 12 playlists. @@ -75,10 +76,10 @@ Each song takes about two seconds to start. Private and age-restricted songs only play if YouTube can see you're signed in. If songs are being skipped: -1. In your browser, signed in to YouTube, install an extension such as +1. In your browser, signed in to YouTube, install the extension **Get cookies.txt LOCALLY**. 2. On youtube.com, use it to export a `cookies.txt`. -3. On the setup page, under **Private and age-restricted songs**, choose that file. +3. On the setup page, under **YouTube Music → Songs being skipped?**, choose that file. ReSkate downloads one more small tool (QuickJS, about 2 MB) the first time you add a sign-in; there is nothing else to install. With a sign-in set, each song takes about seven seconds to @@ -91,6 +92,44 @@ weeks later, export a fresh file. --- +## Local files + +Plays music saved on your PC: MP3, M4A, AAC, Ogg, FLAC and WAV. + +1. Open the folder with your music in File Explorer, click the address bar at the top and copy + what it shows. +2. On the setup page, under **Local files**, paste it and press **Add**. Or press + **Or use my Music folder**. +3. In the game's music screen, open the **Local Files** row and pick the folder, or press a + song in it. + +Each folder is a tile with up to 200 songs, including the folders inside it. Song names and +covers come from the files' tags; a file with no tags is named after its file name +(`Artist - Title.mp3` works best). Up to 12 folders. + +Added new files to a folder? Press **Rescan** next to it on the setup page. + +--- + +## Online radio + +Plays live internet radio stations. Each station you add is its own tile. + +1. On the setup page, under **Online radio**, type part of a station's name and press + **Search**. This searches [radio-browser.info](https://www.radio-browser.info), a free public + directory. +2. Press **Add** next to the station you want. Up to 24 stations. +3. In the game's music screen, open the **Online Radio** row and pick the station. + +The track name updates as the station changes song, when the station sends it. Radio is live, +so pausing leaves the station and playing tunes back in; there is no next or previous. + +If you have a stream's address instead, open **Have a stream's address instead?** and paste it. +MP3 and AAC streams work, and `.pls` / `.m3u` links to them. Addresses ending in `.m3u8` (HLS) +are not supported. + +--- + ## External audio Plays the sound of any app on your PC through the game: a browser, a media player, another @@ -111,12 +150,25 @@ doesn't will still be heard through the game, but can't be paused or skipped fro ## Sound settings -At the bottom of the setup page: +In the **Sound** section of the setup page: - **Loudness:** how loud the music is through the game. +- **Keep every song at the same loudness:** turns quiet songs up and loud ones down. +- **Equaliser:** five sliders, from bass to treble, with a few presets. Up is more, down is + less. It only changes the music you play through ReSkate, not the game's own. - **Speakers in the world play Spotify too:** when on, speakers in the world play what you're playing, and grabbing music from one keeps you on your own music. +These settings are remembered between game sessions. + +--- + +## Queue + +The **Queue** section of the setup page lists the songs of the YouTube Music or Local Files +playlist you are playing, with the current one marked. Press a song to jump to it, or type in +the box to find one. Spotify keeps its own queue, in the Spotify app. + --- ## Troubleshooting @@ -124,11 +176,13 @@ At the bottom of the setup page: | Problem | Try this | | --- | --- | | "ReSkate" isn't in Spotify's device list | Make sure the game is running, both are on the same network, and the game was allowed through the firewall. | -| Spotify tile is silent | Type `spotify` in the console. It shows whether a Spotify account is on the device. If not, pick **ReSkate** in Spotify's device list again. | +| Spotify tile is silent | Type `music` in the console. It shows whether a Spotify account is on the device. If not, pick **ReSkate** in Spotify's device list again. | | New playlist or row isn't in the music screen | Restart the game. | | A newly added playlist plays the wrong music | Press a song in it instead of the tile, or restart the game. | | YouTube songs keep getting skipped | See "Songs that get skipped" above. | -| YouTube stopped working altogether | YouTube changes things often. Close the game, delete `yt-dlp.exe` from `%LOCALAPPDATA%\ReSkate\bin`, start the game and press **Set up YouTube Music** again to get the latest version. | +| YouTube stopped working altogether | YouTube changes things often. Close the game, delete `yt-dlp.exe` from `%LOCALAPPDATA%\ReSkate\bin`, start the game and press **Download and set up** again to get the latest version. | +| A local file gets skipped | It is a kind ReSkate can't play (for example Apple Lossless or WMA), or the file has moved. Press **Rescan**. | +| A radio station won't play | Some stations only send HLS, which isn't supported. Try another entry for the same station in the search results. | | The setup page says the game isn't answering | The game has been closed. The page only works while it's running. | ## Where things are kept @@ -139,7 +193,8 @@ Everything is on your PC, in `%LOCALAPPDATA%\ReSkate`: | --- | --- | | `spotify-device\` | the remembered Spotify sign-in for the in-game device | | `spotify.json` | your Client ID and playlist sign-in (encrypted for your Windows account) | -| `youtube-music.json` | your saved YouTube playlists | +| `youtube-music.json` | your saved YouTube playlists, music folders and radio stations | +| `music-settings.json` | loudness, equaliser and the other sound settings | | `youtube-cookies.txt` | your YouTube sign-in, if you added one | | `external-audio.json` | which app External Audio uses | | `bin\yt-dlp.exe` | the YouTube tool | diff --git a/Runtime/bootstrap.cpp b/Runtime/bootstrap.cpp index 1fe558a..6a75d1d 100644 --- a/Runtime/bootstrap.cpp +++ b/Runtime/bootstrap.cpp @@ -11,6 +11,7 @@ #include "Extension/Assets/mod_layers.h" #include "Extension/Assets/loose_files.h" #include "Extension/Scripting/custom_script_loader.h" +#include "Extension/Scripting/lua_native_block.h" #include "Extension/Assets/morph_memory_pool.h" #include "Extension/Assets/native_render_resource_pool.h" #include "Extension/Rendering/display_startup.h" @@ -43,6 +44,9 @@ bool initialize_bootstrap(std::uintptr_t base) { if (!stage(Channel::assets, "Native InitFS/package support", ready, error)) return false; error.clear(); ready = start_mod_layers(base, error); if (!stage(Channel::assets, "Mod layout layers", ready, error)) return false; + // Before anything that lets a script of the player's own into the game's Lua. + error.clear(); ready = start_lua_native_block(base, error); + if (!stage(Channel::assets, "Lua kept to Lua (no DLLs, programs, file writes or bytecode)", ready, error)) return false; error.clear(); ready = start_loose_files(base, error); if (!stage(Channel::assets, "Loose Lua/config files", ready, error)) return false; error.clear(); ready = start_custom_script_loader(base, error); diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index d6d21a5..a87cff4 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -1231,7 +1231,7 @@ void tick(std::uintptr_t client, std::uintptr_t update) { if (menu != was) { was = menu; try { - dingosdk::logging::log(dingosdk::logging::Level::info, dingosdk::logging::Channel::music, "Trace: a menu of the game's is {}.", menu ? "up" : "no longer up"); + dingosdk::logging::log(dingosdk::logging::Level::debug, dingosdk::logging::Channel::music, "Trace: a menu of the game's is {}.", menu ? "up" : "no longer up"); } catch (...) {} } dingosdk::spotify_station::set_music_screen(menu); diff --git a/SpotifyHelper/Cargo.lock b/SpotifyHelper/Cargo.lock index 3bcf8c1..bd18a4b 100644 --- a/SpotifyHelper/Cargo.lock +++ b/SpotifyHelper/Cargo.lock @@ -404,6 +404,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.5.0" @@ -1922,6 +1928,7 @@ dependencies = [ "librespot-metadata", "librespot-playback", "log", + "reqwest", "sha1", "symphonia", "tokio", @@ -2249,10 +2256,12 @@ dependencies = [ "symphonia-bundle-flac", "symphonia-bundle-mp3", "symphonia-codec-aac", + "symphonia-codec-pcm", "symphonia-codec-vorbis", "symphonia-core", "symphonia-format-isomp4", "symphonia-format-ogg", + "symphonia-format-riff", "symphonia-metadata", ] @@ -2291,6 +2300,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + [[package]] name = "symphonia-codec-vorbis" version = "0.5.5" @@ -2340,6 +2359,18 @@ dependencies = [ "symphonia-utils-xiph", ] +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "symphonia-metadata" version = "0.5.5" diff --git a/SpotifyHelper/Cargo.toml b/SpotifyHelper/Cargo.toml index dacdac2..214a9a1 100644 --- a/SpotifyHelper/Cargo.toml +++ b/SpotifyHelper/Cargo.toml @@ -22,7 +22,9 @@ log = "0.4" sha1 = "0.10" hex = "0.4" # (For sound that is not Spotify's: a program's output, decoded. The version librespot uses.) -symphonia = { version = "0.5.5", default-features = false, features = ["aac", "isomp4", "mp3", "ogg", "vorbis"] } +symphonia = { version = "0.5.5", default-features = false, features = ["aac", "isomp4", "mp3", "ogg", "vorbis", "flac", "wav", "pcm"] } +# (For an internet radio station: what is at an address, as it comes. The one already in the tree.) +reqwest = { version = "0.12", default-features = false, features = ["blocking", "native-tls"] } [profile.release] opt-level = 2 diff --git a/SpotifyHelper/examples/decode.rs b/SpotifyHelper/examples/decode.rs index 0724092..f9349f1 100644 --- a/SpotifyHelper/examples/decode.rs +++ b/SpotifyHelper/examples/decode.rs @@ -1,5 +1,7 @@ -//! By hand: runs a program and says how much sound came of its output. -//! cargo run --release --example decode -- ... +//! By hand: plays a stream for a while and says how much sound came of it. +//! cargo run --release --example decode -- run ... +//! cargo run --release --example decode -- file +//! cargo run --release --example decode -- url
                [seconds] use std::ffi::{c_char, CStr, CString}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -15,16 +17,36 @@ extern "C" fn ended(stream: u64, how: *const c_char) { println!("stream {stream}: {}", unsafe { CStr::from_ptr(how) }.to_string_lossy()); DONE.store(true, Ordering::Relaxed); } +extern "C" fn titled(_stream: u64, title: *const c_char) { + println!("playing: {}", unsafe { CStr::from_ptr(title) }.to_string_lossy()); +} fn main() { let mut arguments = std::env::args().skip(1); - let program = CString::new(arguments.next().expect("a program")).unwrap(); - let rest = CString::new(arguments.collect::>().join("\n")).unwrap(); - let stream = reskate_spotify::stream::reskate_stream_play(program.as_ptr(), rest.as_ptr(), sound, ended); + let how = arguments.next().expect("run, file or url"); + let first = CString::new(arguments.next().expect("what to play")).unwrap(); + let rest: Vec = arguments.collect(); + let mut seconds = 0u64; + let stream = match how.as_str() { + "run" => { + let rest = CString::new(rest.join("\n")).unwrap(); + reskate_spotify::stream::reskate_stream_play(first.as_ptr(), rest.as_ptr(), sound, ended) + } + "file" => reskate_spotify::stream::reskate_stream_play_file(first.as_ptr(), sound, ended), + _ => { + seconds = rest.first().and_then(|text| text.parse().ok()).unwrap_or(8); + reskate_spotify::stream::reskate_stream_play_url(first.as_ptr(), sound, ended, titled) + } + }; println!("began stream {stream}"); + let started = std::time::Instant::now(); while !DONE.load(Ordering::Relaxed) { std::thread::sleep(std::time::Duration::from_millis(50)); + if seconds > 0 && started.elapsed().as_secs() >= seconds { + reskate_spotify::stream::reskate_stream_stop(); + seconds = 0; + } } let (frames, rate) = (FRAMES.load(Ordering::Relaxed), RATE.load(Ordering::Relaxed)); - println!("{frames} frames at {rate} Hz: {:.1} s", frames as f64 / rate.max(1) as f64); + println!("{frames} frames at {rate} Hz: {:.1} s of sound", frames as f64 / rate.max(1) as f64); } diff --git a/SpotifyHelper/src/stream.rs b/SpotifyHelper/src/stream.rs index 4065266..cfc9fc0 100644 --- a/SpotifyHelper/src/stream.rs +++ b/SpotifyHelper/src/stream.rs @@ -1,31 +1,40 @@ -//! Sound that is not Spotify's: a program is run and what it writes to its output (an M4A, -//! MP3 or Ogg file, as it comes) is decoded and handed to the game. ReSkate's YouTube Music -//! runs yt-dlp this way (Extension/Music/youtube_music.cpp). +//! Sound that is not Spotify's, decoded and handed to the game as it is to be heard +//! (Extension/Music/youtube_music.cpp). One stream at a time, from one of: +//! a program's output yt-dlp is run this way for YouTube Music +//! a file the player's own music (MP3, M4A, Ogg, FLAC, WAV) +//! an address an internet radio station (MP3 or AAC as it comes) use std::ffi::{c_char, CStr, CString}; use std::io::Read; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; use symphonia::core::formats::FormatOptions; -use symphonia::core::io::{MediaSourceStream, ReadOnlySource}; -use symphonia::core::meta::MetadataOptions; +use symphonia::core::io::{MediaSource, MediaSourceStream, ReadOnlySource}; +use symphonia::core::meta::{MetadataOptions, MetadataRevision, StandardTagKey}; use symphonia::core::probe::Hint; /// The sound: frames of two floats, at `rate` Hz. It returns when the game has room for more. pub type Sound = extern "C" fn(samples: *const f32, frames: u32, rate: u32); /// How a stream came to its end: "ended", or "failed ". pub type Ended = extern "C" fn(stream: u64, how: *const c_char); +/// What a radio station says it is playing, each time it says so. +pub type Titled = extern "C" fn(stream: u64, title: *const c_char); /// The stream that is wanted: one that is not it stops at its next piece of sound. static WANTED: AtomicU64 = AtomicU64::new(0); -fn decode(source: impl Read + Send + Sync + 'static, stream: u64, sound: Sound) -> Result<(), String> { - let media = MediaSourceStream::new(Box::new(ReadOnlySource::new(source)), Default::default()); +fn decode(source: Box, extension: Option<&str>, stream: u64, sound: Sound) -> Result<(), String> { + let media = MediaSourceStream::new(source, Default::default()); + let mut hint = Hint::new(); + if let Some(extension) = extension { + hint.with_extension(extension); + } let probed = symphonia::default::get_probe() - .format(&Hint::new(), media, &FormatOptions::default(), &MetadataOptions::default()) + .format(&hint, media, &FormatOptions::default(), &MetadataOptions::default()) .map_err(|error| format!("it is no sound this can read ({error})"))?; let mut format = probed.format; let track = format.tracks().iter().find(|track| track.codec_params.codec != CODEC_TYPE_NULL).ok_or("it has no sound in it")?; @@ -41,8 +50,9 @@ fn decode(source: impl Read + Send + Sync + 'static, stream: u64, sound: Sound) } let packet = match format.next_packet() { Ok(packet) => packet, - // (The end of what the program wrote.) + // (The end of the file, or of what the program or the station sent.) Err(symphonia::core::errors::Error::IoError(_)) => return Ok(()), + Err(symphonia::core::errors::Error::ResetRequired) => return Ok(()), Err(error) => return Err(error.to_string()), }; if packet.track_id() != track_id { @@ -56,6 +66,10 @@ fn decode(source: impl Read + Send + Sync + 'static, stream: u64, sound: Sound) }; let spec = *decoded.spec(); let channels = spec.channels.count().max(1); + // (A piece with more in it than the last: room is made for it.) + if samples.as_ref().is_some_and(|buffer| buffer.capacity() < decoded.capacity() * channels) { + samples = None; + } let buffer = samples.get_or_insert_with(|| SampleBuffer::::new(decoded.capacity() as u64, spec)); buffer.copy_interleaved_ref(decoded); let interleaved = buffer.samples(); @@ -80,29 +94,13 @@ fn text(pointer: *const c_char) -> Option { unsafe { CStr::from_ptr(pointer) }.to_str().ok().map(str::to_string) } -/// Runs `program` with `arguments` (one to a line) and plays what it writes, in the place of -/// any stream before it. Returns the stream's number (0 when it could not be begun), which -/// `ended` is told with when it is over. -#[no_mangle] -pub extern "C" fn reskate_stream_play(program: *const c_char, arguments: *const c_char, sound: Sound, ended: Ended) -> u64 { - let (Some(program), Some(arguments)) = (text(program), text(arguments)) else { return 0 }; +/// Runs `work` as the stream that is wanted from now on, on a thread of its own, and tells +/// `ended` how it came out. Returns the stream's number, or 0 when it could not be begun. +fn begin(ended: Ended, work: impl FnOnce(u64) -> Result<(), String> + Send + 'static) -> u64 { let stream = WANTED.fetch_add(1, Ordering::Relaxed) + 1; let begun = std::thread::Builder::new().name("ReSkate stream".to_string()).spawn(move || { - let outcome = std::panic::catch_unwind(move || { - let mut command = Command::new(&program); - command.args(arguments.lines().filter(|line| !line.is_empty())).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } - let mut child = command.spawn().map_err(|error| format!("{program} could not be run ({error})"))?; - let output = child.stdout.take().ok_or("it has no output")?; - let result = decode(output, stream, sound); - let _ = child.kill(); - let _ = child.wait(); - result - }); + // (Nothing that goes wrong in here is to take the game with it.) + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || work(stream))); let how = match outcome { Ok(Ok(())) => "ended".to_string(), Ok(Err(why)) => format!("failed {why}"), @@ -119,6 +117,257 @@ pub extern "C" fn reskate_stream_play(program: *const c_char, arguments: *const } } +/// Runs `program` with `arguments` (one to a line) and plays what it writes, in the place of +/// any stream before it. +#[no_mangle] +pub extern "C" fn reskate_stream_play(program: *const c_char, arguments: *const c_char, sound: Sound, ended: Ended) -> u64 { + let (Some(program), Some(arguments)) = (text(program), text(arguments)) else { return 0 }; + begin(ended, move |stream| { + let mut command = Command::new(&program); + command.args(arguments.lines().filter(|line| !line.is_empty())).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + let mut child = command.spawn().map_err(|error| format!("{program} could not be run ({error})"))?; + let output = child.stdout.take().ok_or("it has no output")?; + let result = decode(Box::new(ReadOnlySource::new(Watched { inner: output, stream })), None, stream, sound); + let _ = child.kill(); + let _ = child.wait(); + result + }) +} + +/// Plays a file of the player's. +#[no_mangle] +pub extern "C" fn reskate_stream_play_file(path: *const c_char, sound: Sound, ended: Ended) -> u64 { + let Some(path) = text(path) else { return 0 }; + begin(ended, move |stream| { + let file = std::fs::File::open(&path).map_err(|error| format!("the file could not be opened ({error})"))?; + let extension = std::path::Path::new(&path).extension().and_then(|extension| extension.to_str()).map(str::to_lowercase); + decode(Box::new(file), extension.as_deref(), stream, sound) + }) +} + +/// What a file says of itself in its tags: its artist, its title, and its picture. +fn about(path: &str) -> Option<(String, String, Vec)> { + let file = std::fs::File::open(path).ok()?; + let mut hint = Hint::new(); + if let Some(extension) = std::path::Path::new(path).extension().and_then(|extension| extension.to_str()) { + hint.with_extension(&extension.to_lowercase()); + } + let media = MediaSourceStream::new(Box::new(file), Default::default()); + let mut probed = symphonia::default::get_probe().format(&hint, media, &FormatOptions::default(), &MetadataOptions::default()).ok()?; + let (mut artist, mut title, mut picture) = (String::new(), String::new(), Vec::new()); + let mut take = |revision: &MetadataRevision| { + for tag in revision.tags() { + match tag.std_key { + Some(StandardTagKey::Artist) if artist.is_empty() => artist = tag.value.to_string(), + Some(StandardTagKey::TrackTitle) if title.is_empty() => title = tag.value.to_string(), + _ => {} + } + } + if picture.is_empty() { + if let Some(visual) = revision.visuals().first() { + picture = visual.data.to_vec(); + } + } + }; + { + let metadata = probed.format.metadata(); + if let Some(revision) = metadata.current() { + take(revision); + } + } + if let Some(metadata) = probed.metadata.get() { + if let Some(revision) = metadata.current() { + take(revision); + } + } + Some((artist, title, picture)) +} + +/// Writes `text` where the game gave room for it, ended with a zero, and whole characters only. +fn write(text: &str, to: *mut c_char, room: u32) { + if to.is_null() || room == 0 { + return; + } + let mut most = text.len().min(room as usize - 1); + while !text.is_char_boundary(most) { + most -= 1; + } + // SAFETY: the game gave `room` bytes at `to`. + unsafe { + std::ptr::copy_nonoverlapping(text.as_ptr(), to.cast::(), most); + *to.add(most) = 0; + } +} + +/// A file's artist and title (each into `text_room` bytes; empty when it does not say), and +/// its picture when room is given for one. Returns the picture's size, or 0 when it has none +/// or it is larger than the room. +#[no_mangle] +pub extern "C" fn reskate_file_about(path: *const c_char, artist: *mut c_char, title: *mut c_char, text_room: u32, picture: *mut u8, picture_room: u32) -> u32 { + write("", artist, text_room); + write("", title, text_room); + let Some(path) = text(path) else { return 0 }; + let Ok(Some((its_artist, its_title, its_picture))) = std::panic::catch_unwind(move || about(&path)) else { return 0 }; + write(its_artist.replace('', " ").trim(), artist, text_room); + write(its_title.replace('', " ").trim(), title, text_room); + if picture.is_null() || its_picture.is_empty() || its_picture.len() > picture_room as usize { + return 0; + } + // SAFETY: the game gave `picture_room` bytes at `picture`. + unsafe { std::ptr::copy_nonoverlapping(its_picture.as_ptr(), picture, its_picture.len()) }; + its_picture.len() as u32 +} + +/// What a station sends, with what it says of itself taken out of the sound: every so many +/// bytes (it says how many) there is a note of what is playing ("StreamTitle='...';"). +struct Station { + inner: R, + every: usize, + left: usize, + stream: u64, + titled: Titled, + last: String, +} +impl Station { + fn note(&mut self) -> std::io::Result<()> { + let mut length = [0u8; 1]; + self.inner.read_exact(&mut length)?; + let mut note = vec![0u8; usize::from(length[0]) * 16]; + self.inner.read_exact(&mut note)?; + let note = String::from_utf8_lossy(¬e); + if let Some(from) = note.find("StreamTitle='") { + let rest = ¬e[from + 13..]; + let title = rest.find("';").map_or(rest, |end| &rest[..end]).trim_matches('\0').trim().to_string(); + if title != self.last { + self.last = title.clone(); + if let Ok(title) = CString::new(title.replace('\0', " ")) { + (self.titled)(self.stream, title.as_ptr()); + } + } + } + Ok(()) + } +} +impl Read for Station { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if self.every == 0 { + return self.inner.read(buffer); + } + if self.left == 0 { + self.note()?; + self.left = self.every; + } + let most = buffer.len().min(self.left); + let got = self.inner.read(&mut buffer[..most])?; + self.left -= got; + Ok(got) + } +} + +/// A reader that comes to its end once its stream is no longer the one wanted, so that one +/// which is waiting on a station or a program is not waited on in turn. +struct Watched { + inner: R, + stream: u64, +} +impl Read for Watched { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if WANTED.load(Ordering::Relaxed) != self.stream { + return Ok(0); + } + self.inner.read(buffer) + } +} + +/// AAC as stations send it (ADTS), from the start of a frame, and with each frame marked as +/// the decoder knows it: it takes only the mark of MPEG-4 (ff f1), and many stations send +/// that of MPEG-2 (ff f9), which is the same sound (seen 2026-10-12, SomaFM). +struct Adts { + inner: R, + pending: Vec, + pass: usize, +} +impl Read for Adts { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + loop { + if self.pass > 0 && !self.pending.is_empty() { + let count = buffer.len().min(self.pass).min(self.pending.len()); + buffer[..count].copy_from_slice(&self.pending[..count]); + self.pending.drain(..count); + self.pass -= count; + return Ok(count); + } + if self.pass == 0 && self.pending.len() >= 7 { + let header = &mut self.pending[..7]; + let length = (usize::from(header[3] & 3) << 11) | (usize::from(header[4]) << 3) | usize::from(header[5] >> 5); + if header[0] == 0xff && header[1] & 0xf6 == 0xf0 && length >= 7 { + header[1] &= !0x08; + self.pass = length; + } else { + self.pending.remove(0); // (not the start of a frame: the next byte is tried) + } + continue; + } + let mut more = [0u8; 4096]; + let got = self.inner.read(&mut more)?; + if got == 0 { + return Ok(0); + } + self.pending.extend_from_slice(&more[..got]); + } + } +} + +/// Plays what is at an address as it comes: an internet radio station. An address that is a +/// list of addresses (.m3u, .pls) is followed to its first. +#[no_mangle] +pub extern "C" fn reskate_stream_play_url(address: *const c_char, sound: Sound, ended: Ended, titled: Titled) -> u64 { + let Some(mut address) = text(address) else { return 0 }; + begin(ended, move |stream| { + let client = reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(None::) // (a station has no end) + .user_agent("ReSkate") + .build() + .map_err(|error| error.to_string())?; + for _ in 0..3 { + let response = client.get(&address).header("Icy-MetaData", "1").send().map_err(|error| format!("the station could not be reached ({error})"))?; + if !response.status().is_success() { + return Err(format!("the station answered {}", response.status().as_u16())); + } + let kind = response.headers().get("content-type").and_then(|value| value.to_str().ok()).unwrap_or("").to_lowercase(); + let path = address.split(['?', '#']).next().unwrap_or("").to_lowercase(); + if kind.contains("mpegurl") || kind.contains("scpls") || path.ends_with(".m3u") || path.ends_with(".pls") || path.ends_with(".m3u8") { + let mut list = String::new(); + response.take(64 * 1024).read_to_string(&mut list).map_err(|error| error.to_string())?; + if list.contains("#EXT-X-") { + return Err("the station sends its sound in a way this does not play (HLS)".to_string()); + } + let next = list.lines().map(str::trim).find_map(|line| { + let line = line.split_once('=').filter(|(key, _)| key.to_lowercase().starts_with("file")).map_or(line, |(_, value)| value); + (line.starts_with("http://") || line.starts_with("https://")).then(|| line.to_string()) + }); + address = next.ok_or("the station's list has no address in it")?; + continue; + } + let every = response.headers().get("icy-metaint").and_then(|value| value.to_str().ok()).and_then(|value| value.parse::().ok()).unwrap_or(0); + let extension = if kind.contains("aac") { Some("aac") } else if kind.contains("ogg") { Some("ogg") } else if kind.contains("mpeg") { Some("mp3") } else { None }; + let station = Watched { inner: Station { inner: response, every, left: every, stream, titled, last: String::new() }, stream }; + return if extension == Some("aac") { + decode(Box::new(ReadOnlySource::new(Adts { inner: station, pending: Vec::new(), pass: 0 })), extension, stream, sound) + } else { + decode(Box::new(ReadOnlySource::new(station)), extension, stream, sound) + }; + } + Err("the station's address led round and round".to_string()) + }) +} + /// Stops the stream that is playing, if any. #[no_mangle] pub extern "C" fn reskate_stream_stop() { diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index c7cf461..eb23e16 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -79,6 +79,7 @@ add_library(dingosdk_runtime SHARED Extension/Music/spotify_dashboard.cpp Extension/Music/external_audio.cpp Extension/Music/youtube_music.cpp + Extension/Music/audio_dsp.cpp Extension/Music/spotify_rows.cpp Extension/Music/app_audio_capture.cpp Extension/Music/music_inject.cpp @@ -110,6 +111,7 @@ add_library(dingosdk_runtime SHARED Launcher/mod_manager.cpp Extension/Assets/loose_files.cpp Extension/Scripting/lua_startup.cpp + Extension/Scripting/lua_native_block.cpp Extension/Scripting/custom_script_loader.cpp Extension/Assets/morph_memory_pool.cpp Extension/Assets/native_render_resource_pool.cpp @@ -303,6 +305,13 @@ if(EXISTS "${dingosdk_emotes}/emotes.json" AND EXISTS "${dingosdk_emotes}/emotes "EMOTES_PNG RCDATA \"${dingosdk_emotes_native}\\\\emotes.png\"\n") target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/emotes.rc") endif() +# The music sources' setup page (Extension/Music/spotify_dashboard.h). +file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/Extension/Music/setup_page.html" dingosdk_music_page_native) +string(REPLACE "\\" "\\\\" dingosdk_music_page_native "${dingosdk_music_page_native}") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/music_setup_page.rc" + "MUSIC_SETUP_HTML RCDATA \"${dingosdk_music_page_native}\"\n") +target_sources(dingosdk_runtime PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated/music_setup_page.rc") +set_property(SOURCE "${CMAKE_CURRENT_BINARY_DIR}/generated/music_setup_page.rc" APPEND PROPERTY OBJECT_DEPENDS "${PROJECT_SOURCE_DIR}/Extension/Music/setup_page.html") # The Spotify station's picture in the music screen (Extension/Music/spotify_cover.h). if(EXISTS "${PROJECT_SOURCE_DIR}/assets/spotify.png") file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/assets/spotify.png" dingosdk_spotify_art_native) diff --git a/cmake/UI.cmake b/cmake/UI.cmake index 34634f2..83ec6ba 100644 --- a/cmake/UI.cmake +++ b/cmake/UI.cmake @@ -39,6 +39,7 @@ add_library(dingosdk_overlay STATIC Extension/UI/Overlay/multiplayer_lobbies.cpp Extension/UI/Overlay/multiplayer_session.cpp Extension/Trainer/trainer_page.cpp + Extension/Music/music_page.cpp Extension/Trainer/trainer_view.cpp Extension/HallOfMeat/hall_of_meat_overlay.cpp )