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.
This commit is contained in:
AyeZeeBB committed 2026-10-12 16:03:35 -05:00
1 parent ea17559e3d
commit 86aa8d38fb
39 files changed
+2565 -321

No files matched your search

@@ -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<std::string> 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");
+12 -4
View File
@@ -157,16 +157,24 @@ struct Word {
std::vector<Word> words(std::string_view text) {
std::vector<Word> 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<Word> spelled;
for (std::size_t i = 0; i < out.size();) {
+28
View File
@@ -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}};
}
+15 -14
View File
@@ -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 <id>)" : 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 <id>)" : 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<std::string>(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 <id>");
if (!spotify_web::state().has_client) return out("Set your Spotify application's client id first: music spotify client <id>");
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<int>(std::clamp<std::int64_t>(std::get<std::int64_t>(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<int>(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<float>(std::clamp<std::int64_t>(std::get<std::int64_t>(args[0]), 0, 1600)) / 100.0f);
out(std::format("Spotify through the game: {}% loud.", static_cast<int>(music_inject::gain() * 100.0f + 0.5f)));
out(std::format("Music through the game: {}% loud.", static_cast<int>(music_inject::gain() * 100.0f + 0.5f)));
};
registry.add(std::move(gain));
+24 -18
View File
@@ -1,4 +1,5 @@
#include "app_audio_capture.h"
#include "audio_dsp.h"
#include <Windows.h>
#include <audioclient.h>
#include <audioclientactivationparams.h>
@@ -177,10 +178,29 @@ bool set_mute(const std::vector<DWORD>& processes, bool mute) {
std::atomic<bool> external{};
std::atomic<unsigned> 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<const float*>(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 {
+122
View File
@@ -0,0 +1,122 @@
#include "audio_dsp.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <mutex>
namespace dingosdk::audio_dsp {
namespace {
std::array<std::atomic<float>, bands> wanted{};
std::atomic<bool> level{};
std::atomic<unsigned> 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<Filter, bands> 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<std::size_t>(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<std::size_t>(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<std::size_t>(i)].set(i, band(i), static_cast<float>(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<double>(interleaved[i]) * interleaved[i];
const float piece = static_cast<float>(sum / (frames * 2.0));
if (piece > 1e-5f) {
const float share = 1.0f - std::exp(-static_cast<float>(frames) / (static_cast<float>(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<float>(rate) * 0.2f)), up = 1.0f - std::exp(-1.0f / static_cast<float>(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);
}
}
}
+23
View File
@@ -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;
}
+5 -3
View File
@@ -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();)
+5 -5
View File
@@ -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<std::uintptr_t>(&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<int> said{}; have_binding && said.fetch_add(1, std::memory_order_relaxed) < 600) {
if (static std::atomic<int> 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<unsigned long long>(reinterpret_cast<std::uintptr_t>(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<int>(spotify_station::channel()), spotify_station::music_screen() ? "up" : "not up", callers);
} catch (...) {}
}
+7 -3
View File
@@ -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<ShelfRow, 4> rows{{{"Spotify", &spotify_row, 0x5d}, {"YouTube Music", &youtube_row, 0x7d}, {"External Audio", &external_row, 0x6d}, {"Mods", &mods_row, 0x4d}}};
std::array<ShelfRow, 6> 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<bool, 4>& wanted) {
std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list, const std::array<bool, 6>& 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.
+3 -3
View File
@@ -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);
}
}
+30 -2
View File
@@ -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<double>(now - looked) / 1000.0;
if (on && looked && seconds < 3.0 && capture.readers && told < 80) {
const double pace = static_cast<double>(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 (...) {}
+310
View File
@@ -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 <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <format>
#include <string>
#include <vector>
// The MUSIC page of the menu: what is playing from the music sources (Spotify, YouTube Music,
// the player's files, radio, another application) with play, pause and next, what is lined up,
// and how it sounds (loudness, levelling, the equaliser). Setting a source up is done in the
// browser (spotify_dashboard.h), which this opens. Presentation thread: it reads what the
// sources say of themselves a few times a second, and asks of them what the setup page does.
namespace dingosdk::overlay::menu {
namespace {
struct Page {
int tab{};
double read_at{-1};
youtube_music::State streams;
std::vector<spotify_web::Queued> spotify;
std::string title, artist, source;
bool available{}, playing{}, station{};
std::array<char, 96> find{};
// The sliders, as they are while one is held.
float gain{100};
std::array<float, audio_dsp::bands> bands{};
bool editing{};
double pressed{-1};
};
Page& page() {
static auto* value = new Page;
return *value;
}
void read(Page& p) {
if (p.read_at >= 0 && ImGui::GetTime() - p.read_at < 0.4) return;
p.read_at = ImGui::GetTime();
p.streams = youtube_music::state();
p.station = spotify_station::active();
const auto now = external_player::now();
p.available = now.available && p.station;
p.title = now.title;
p.artist = now.artist;
p.playing = now.playing;
const bool streams = youtube_music::on(), others = external_audio::on();
p.source = !p.station ? "" : others ? "EXTERNAL AUDIO" : !streams ? "SPOTIFY"
: p.streams.playing_list < p.streams.playlists.size() && p.streams.playlists[p.streams.playing_list].kind == youtube_music::Kind::files ? "LOCAL FILES"
: p.streams.playing_list < p.streams.playlists.size() && p.streams.playlists[p.streams.playing_list].kind == youtube_music::Kind::radio ? "ONLINE RADIO" : "YOUTUBE MUSIC";
p.spotify.clear();
if (p.station && !streams && !others) {
spotify_web::want_queue();
p.spotify = spotify_web::queue();
}
if (!p.editing) {
p.gain = music_inject::gain() * 100.0f;
for (int i = 0; i < audio_dsp::bands; ++i) p.bands[static_cast<std::size_t>(i)] = audio_dsp::band(i);
}
}
bool found(const std::array<char, 96>& find, const std::string& artist, const std::string& title) {
if (!find[0]) return true;
const auto lower = [](std::string text) {
for (auto& c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return text;
};
return lower(title + " " + artist).find(lower(find.data())) != std::string::npos;
}
void player_tab(SkateMenu& menu, Page& p) {
begin_card(menu, "music-now", "NOW PLAYING");
if (!p.available || p.title.empty()) {
note(p.station ? "Waiting for the first song." : "Nothing is playing. Pick a tile in the game's music screen: Spotify, YouTube Music, Local Files, Online Radio or External Audio.");
} else {
ImGui::PushFont(menu.heading);
ImGui::TextUnformatted(p.title.c_str());
ImGui::PopFont();
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(p.artist.c_str());
ImGui::PopStyleColor();
if (!p.source.empty()) tag(menu, p.source.c_str(), skate_theme::blue);
if (p.streams.loading) {
ImGui::SameLine(0, px(8));
tag(menu, "LOADING", skate_theme::warning);
}
}
ImGui::Dummy(ImVec2(0, px(4)));
const float gap = px(6), width = (ImGui::GetContentRegionAvail().x - gap * 2) / 3;
ImGui::BeginDisabled(!p.station);
using external_player::Command;
if (ImGui::Button("PREVIOUS", ImVec2(width, px(36)))) external_player::send(Command::previous);
ImGui::SameLine(0, gap);
skate_theme::push_primary_button();
if (ImGui::Button(p.playing ? "PAUSE" : "PLAY", ImVec2(width, px(36)))) {
external_player::send(Command::toggle);
p.read_at = -1;
}
skate_theme::pop_primary_button();
ImGui::SameLine(0, gap);
if (ImGui::Button("NEXT", ImVec2(width, px(36)))) external_player::send(Command::next);
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, px(4)));
bool notices = youtube_music::notices();
if (toggle_row(menu, "Show \"Loading song\" in the corner", "The notice in the top left while a YouTube song or a radio station is on its way.", notices)) {
youtube_music::set_notices(notices);
spotify_dashboard::save_settings();
}
end_card();
// What is lined up: the playlist that is playing, or what Spotify says comes next.
const auto& s = p.streams;
const bool lined_up = s.playing && s.playing_list < s.playlists.size();
begin_card(menu, "music-queue", "QUEUE", lined_up ? s.playlists[s.playing_list].name.c_str() : !p.spotify.empty() ? "Up next on Spotify" : nullptr);
if (lined_up) {
const auto& songs = s.playlists[s.playing_list].songs;
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##music-find", "Find a song or artist", p.find.data(), p.find.size());
ImGui::BeginChild("music-songs", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_None);
// (One press at a time: a song that is loading is not begun again under more presses.)
const bool held = s.loading || (p.pressed >= 0 && ImGui::GetTime() - p.pressed < 1.5);
for (std::size_t i = 0; i < songs.size(); ++i) {
if (!found(p.find, songs[i].artist, songs[i].title)) continue;
ImGui::PushID(static_cast<int>(i));
const bool current = i == s.playing_song;
if (current) ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::blue_hover);
if (ImGui::Selectable(std::format("{:>3} {}", i + 1, songs[i].title).c_str(), current, held ? ImGuiSelectableFlags_Disabled : ImGuiSelectableFlags_None) && !current) {
youtube_music::play_at(s.playing_list, i);
p.pressed = ImGui::GetTime();
p.read_at = -1;
}
if (current) ImGui::PopStyleColor();
ImGui::SameLine(0, px(12));
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(songs[i].artist.c_str());
ImGui::PopStyleColor();
ImGui::PopID();
}
ImGui::EndChild();
} else if (!p.spotify.empty()) {
ImGui::BeginChild("music-spotify", ImVec2(0, std::max(px(160), ImGui::GetContentRegionAvail().y - px(330))), ImGuiChildFlags_None);
for (std::size_t i = 0; i < p.spotify.size(); ++i) {
ImGui::PushStyleColor(ImGuiCol_Text, i ? skate_theme::white : skate_theme::blue_hover);
ImGui::TextUnformatted(std::format("{:>3} {}", i + 1, p.spotify[i].title).c_str());
ImGui::PopStyleColor();
ImGui::SameLine(0, px(12));
ImGui::PushStyleColor(ImGuiCol_Text, skate_theme::grey_text);
ImGui::TextUnformatted(p.spotify[i].artist.c_str());
ImGui::PopStyleColor();
}
ImGui::EndChild();
} else {
note(p.source == "SPOTIFY" ? "To see Spotify's queue, sign in under \"Show your own playlists\" on the setup page (SOURCES tab)." : "Nothing is lined up.");
}
end_card();
}
void sound_tab(SkateMenu& menu, Page& p) {
bool editing{}, changed{};
begin_card(menu, "music-loudness", "LOUDNESS");
field(menu, "Music loudness", "How loud the music you play through ReSkate is in the game.");
if (ImGui::SliderFloat("##music-gain", &p.gain, 0.0f, 400.0f, "%.0f%%", ImGuiSliderFlags_AlwaysClamp)) music_inject::set_gain(p.gain / 100.0f);
editing |= ImGui::IsItemActive();
changed |= ImGui::IsItemDeactivatedAfterEdit();
bool levelling = audio_dsp::levelling();
if (toggle_row(menu, "Keep every song at the same loudness", "Turns quiet songs up and loud ones down.", levelling)) {
audio_dsp::set_levelling(levelling);
changed = true;
}
bool speakers = music_inject::mode() != 3;
if (toggle_row(menu, "Speakers in the world play it too", "Speakers around the map play what you are playing.", speakers)) {
music_inject::set_mode(speakers ? 1 : 3);
changed = true;
}
end_card();
begin_card(menu, "music-equaliser", "EQUALISER", "Up for more of that part of the sound, down for less.");
static constexpr std::array<const char*, audio_dsp::bands> names{"BASS", "LOW MID", "MID", "HIGH MID", "TREBLE"}, where{"60 Hz", "250 Hz", "1 kHz", "4 kHz", "12 kHz"};
// Each band is a column: how much it is set to, a narrow track with a line where 0 dB is
// and a filled bar from that line to the handle, then its name and where it sits.
const float gap = px(10), column = std::min(px(96), (ImGui::GetContentRegionAvail().x - gap * (audio_dsp::bands - 1)) / audio_dsp::bands);
const float track = px(34), tall = px(150);
const auto origin = ImGui::GetCursorScreenPos();
auto* draw = ImGui::GetWindowDrawList();
const auto centred = [&](ImFont* font, float size, float x, float y, ImU32 colour, const char* text) {
const auto extent = font->CalcTextSizeA(size, FLT_MAX, 0, text);
draw->AddText(font, size, ImVec2(x + (column - extent.x) * .5f, y), colour, text);
};
for (int i = 0; i < audio_dsp::bands; ++i) {
const auto at = static_cast<std::size_t>(i);
const float x = origin.x + (column + gap) * static_cast<float>(i);
const float value = p.bands[at];
centred(menu.bold, px(15), x, origin.y, std::fabs(value) < 0.5f ? skate_theme::grey_text : skate_theme::white, std::format("{:+.0f} dB", value).c_str());
const ImVec2 top(x + (column - track) * .5f, origin.y + px(24));
ImGui::PushID(i);
ImGui::SetCursorScreenPos(top);
// (The slider itself is not drawn: only its handle, over what is drawn here.)
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32(0, 0, 0, 0));
if (ImGui::VSliderFloat("##band", ImVec2(track, tall), &p.bands[at], -audio_dsp::most_decibels, audio_dsp::most_decibels, "", ImGuiSliderFlags_AlwaysClamp)) {
p.bands[at] = std::round(p.bands[at]);
audio_dsp::set_band(i, p.bands[at]);
}
ImGui::PopStyleColor(5);
const bool hot = ImGui::IsItemHovered() || ImGui::IsItemActive();
editing |= ImGui::IsItemActive();
changed |= ImGui::IsItemDeactivatedAfterEdit();
if (ImGui::IsItemFocused() && ImGui::GetIO().NavVisible) draw->AddRect(top, ImVec2(top.x + track, top.y + tall), skate_theme::white, 0, 0, px(2));
ImGui::PopID();
const float middle = top.y + tall * .5f, rail = px(6), handle = px(8);
const float level = middle - p.bands[at] / audio_dsp::most_decibels * (tall * .5f - handle * .5f);
const float centre = top.x + track * .5f;
draw->AddRectFilled(ImVec2(centre - rail * .5f, top.y), ImVec2(centre + rail * .5f, top.y + tall), skate_theme::tile_light);
draw->AddRectFilled(ImVec2(centre - rail * .5f, std::min(level, middle)), ImVec2(centre + rail * .5f, std::max(level, middle)), skate_theme::blue);
draw->AddRectFilled(ImVec2(top.x, middle - px(1)), ImVec2(top.x + track, middle + px(1)), skate_theme::grey_text);
draw->AddRectFilled(ImVec2(top.x, level - handle * .5f), ImVec2(top.x + track, level + handle * .5f), hot ? skate_theme::blue_hover : skate_theme::white);
centred(menu.bold, px(13), x, top.y + tall + px(8), skate_theme::white, names[at]);
centred(menu.body, px(13), x, top.y + tall + px(26), skate_theme::grey_text, where[at]);
}
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2((column + gap) * audio_dsp::bands - gap, px(24) + tall + px(46)));
ImGui::Dummy(ImVec2(0, px(6)));
struct Preset {
const char* name;
std::array<float, audio_dsp::bands> bands;
};
static constexpr std::array<Preset, 5> presets{{{"FLAT", {0, 0, 0, 0, 0}}, {"BASS BOOST", {6, 3, 0, 0, 0}}, {"LOUD", {5, 2, -1, 2, 4}}, {"VOCAL", {-2, 0, 3, 3, 0}}, {"BRIGHT", {0, 0, 0, 3, 5}}}};
for (std::size_t i = 0; i < presets.size(); ++i) {
if (i) ImGui::SameLine(0, px(6));
const bool on = presets[i].bands == p.bands;
if (on) ImGui::PushStyleColor(ImGuiCol_Button, skate_theme::blue);
if (ImGui::Button(presets[i].name)) {
p.bands = presets[i].bands;
for (int band = 0; band < audio_dsp::bands; ++band) audio_dsp::set_band(band, p.bands[static_cast<std::size_t>(band)]);
changed = true;
}
if (on) ImGui::PopStyleColor();
}
note("The loudness, levelling and equaliser only change music played through ReSkate, not the game's own.");
end_card();
p.editing = editing;
if (changed) spotify_dashboard::save_settings();
}
void sources_tab(SkateMenu& menu, Page& p) {
begin_card(menu, "music-setup", "SET UP", "Sign in, add playlists, folders and stations.");
note("Sources are set up on a page in your browser. It only works on this PC, while the game is running.");
if (primary_button(menu, "OPEN THE SETUP PAGE IN YOUR BROWSER"))
feedback(menu, spotify_dashboard::open() ? "Opened the setup page in your browser. Alt-Tab to it." : "The setup page could not be opened.");
end_card();
using youtube_music::Kind;
const auto count = [&](Kind kind) { return std::count_if(p.streams.playlists.begin(), p.streams.playlists.end(), [&](const youtube_music::Playlist& playlist) { return playlist.kind == kind; }); };
begin_card(menu, "music-sources", "SOURCES");
info(menu, "Spotify", spotify_device::connected() ? "Connected" : spotify_device::in_use() ? "Remembered, connects by itself" : "Pick \"ReSkate\" in Spotify's device list");
info(menu, "YouTube Music", !p.streams.tools ? "Not set up" : std::format("{} playlist(s)", count(Kind::youtube)));
info(menu, "Local files", count(Kind::files) ? std::format("{} folder(s)", count(Kind::files)) : std::string("No folder added"));
info(menu, "Online radio", count(Kind::radio) ? std::format("{} station(s)", count(Kind::radio)) : std::string("No station added"));
info(menu, "External audio", external_audio::app().empty() ? std::string("No app chosen") : external_audio::app());
if (!p.streams.problem.empty()) warn(p.streams.problem.c_str());
end_card();
if (count(Kind::files)) {
begin_card(menu, "music-folders", "LOCAL FOLDERS", "Rescan after adding files to one.");
for (std::size_t i = 0; i < p.streams.playlists.size(); ++i) {
const auto& playlist = p.streams.playlists[i];
if (playlist.kind != Kind::files) continue;
ImGui::PushID(static_cast<int>(i));
ImGui::BeginDisabled(p.streams.busy);
if (ImGui::Button("RESCAN")) {
youtube_music::add_folder(playlist.url);
feedback(menu, "Reading the folder again.");
p.read_at = -1;
}
ImGui::EndDisabled();
ImGui::SameLine(0, px(10));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(std::format("{} ({} songs)", playlist.name, playlist.songs.size()).c_str());
ImGui::PopID();
}
end_card();
}
}
}
void music_page(SkateMenu& menu, const Model&, const CallbacksV3&) {
auto& p = page();
read(p);
category_tabs(menu, p.tab, {"PLAYER", "SOUND", "SOURCES"}, "music-tabs");
ImGui::PushID(p.tab);
ImGui::BeginChild("music-tab", ImVec2(0, page_body_height(menu)));
if (p.tab == 0) player_tab(menu, p);
else if (p.tab == 1) sound_tab(menu, p);
else sources_tab(menu, p);
ImGui::EndChild();
ImGui::PopID();
}
}
+610
View File
@@ -0,0 +1,610 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReSkate Music</title>
<style>
@font-face{font-family:"RS Heading";src:url("/font/heading.ttf") format("truetype");font-display:swap}
@font-face{font-family:"RS Body";src:url("/font/body.ttf") format("truetype");font-display:swap}
@font-face{font-family:"RS Brush";src:url("/font/brush.ttf") format("truetype");font-display:swap}
:root{
--bg-top:#090b0f;--bg-bottom:#11141b;--tile:#1a1a1a;--tile-2:#2d2d2f;--tile-3:#3d3e42;--line:rgba(255,255,255,.12);
--blue:#0183ff;--blue-hover:#2898ff;--blue-active:#006ed7;--good:#2eb85c;--warn:#ffb10b;--danger:#ff5c5c;
--text:#f5f5f5;--muted:#96989e;--ink:#000;
--heading:"RS Heading","Montserrat","Segoe UI",system-ui,sans-serif;
--body:"RS Body","Montserrat","Segoe UI",system-ui,sans-serif;
--brush:"RS Brush","Permanent Marker","Segoe Print",cursive;
}
*{box-sizing:border-box}
html{background:var(--bg-top)}
body{margin:0;min-height:100vh;color:var(--text);font:15px/1.55 var(--body);
background:linear-gradient(180deg,var(--bg-top),var(--bg-bottom)) fixed}
.shell{max-width:892px;margin:0 auto;padding:0 16px}
body{padding-right:380px}
main{min-width:0;padding:32px 0 140px}
a{color:var(--blue-hover)}
header.top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:22px}
.brand{font:400 46px/1 var(--brush);letter-spacing:.01em;transform:rotate(-2deg);transform-origin:left bottom;margin:0}
.brand span{color:var(--blue)}
.lede{color:var(--muted);margin:10px 0 0;max-width:46ch}
.live{display:inline-flex;align-items:center;gap:8px;font:12px/1 var(--heading);letter-spacing:.08em;text-transform:uppercase;
background:var(--tile);border:1px solid var(--line);padding:9px 12px}
.live i{width:9px;height:9px;border-radius:50%;background:var(--danger)}
.live.on i{background:var(--good)}
/* a source */
.card{background:var(--tile);border-left:4px solid var(--tile-3);margin:0 0 16px;position:relative}
.card.ready{border-left-color:var(--good)}
.card.todo{border-left-color:var(--blue)}
.card.problem{border-left-color:var(--warn)}
.card > summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:14px;padding:16px 18px}
.card > summary::-webkit-details-marker{display:none}
.card h2{font:20px/1.1 var(--heading);text-transform:uppercase;letter-spacing:.03em;margin:0}
.card .what{color:var(--muted);font-size:13.5px;margin-top:3px}
.grow{flex:1;min-width:0}
.pill{font:11px/1 var(--heading);letter-spacing:.09em;text-transform:uppercase;padding:7px 10px;background:var(--tile-3);color:var(--text);white-space:nowrap}
.ready .pill{background:var(--good);color:var(--ink)}
.todo .pill{background:var(--blue);color:var(--ink)}
.problem .pill{background:var(--warn);color:var(--ink)}
.chev{width:10px;height:10px;border-right:3px solid var(--muted);border-bottom:3px solid var(--muted);transform:rotate(45deg);transition:transform .15s;margin:0 4px 4px 0}
details[open] > summary .chev{transform:rotate(-135deg);margin:4px 4px 0 0}
.body{padding:2px 18px 20px}
/* steps */
ol.steps{list-style:none;margin:0;padding:0;counter-reset:step}
ol.steps > li{counter-increment:step;position:relative;padding:12px 0 12px 46px;border-top:1px solid var(--line)}
ol.steps > li::before{content:counter(step);position:absolute;left:0;top:11px;width:30px;height:30px;display:grid;place-items:center;
font:14px/1 var(--heading);background:var(--tile-3);color:var(--text)}
ol.steps > li.now::before{background:var(--blue);color:var(--ink)}
ol.steps > li.now > .title{color:#fff}
ol.steps > li.done::before{content:"\2713";background:var(--good);color:var(--ink)}
ol.steps > li.done > .title{color:var(--muted)}
.title{font:15px/1.35 var(--heading);letter-spacing:.01em}
.help{color:var(--muted);font-size:13.5px;margin:4px 0 0}
.opt{font:10.5px/1 var(--heading);letter-spacing:.09em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line);padding:3px 6px;margin-left:8px;vertical-align:middle}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px}
button,.btn{font:13px/1 var(--heading);letter-spacing:.06em;text-transform:uppercase;border:0;padding:12px 16px;cursor:pointer;
background:var(--tile-3);color:var(--text);text-decoration:none;display:inline-block}
button:hover,.btn:hover{background:#4a4b50}
button.primary,.btn.primary{background:var(--blue);color:var(--ink)}
button.primary:hover,.btn.primary:hover{background:var(--blue-hover)}
button.primary:active{background:var(--blue-active)}
button:disabled{opacity:.5;cursor:default}
button.quiet{background:transparent;border:1px solid var(--line);color:var(--muted)}
button.quiet:hover{color:var(--text);background:rgba(255,255,255,.06)}
:focus-visible{outline:2px solid var(--blue-hover);outline-offset:2px}
input[type=text],select{width:100%;background:#0d0e11;color:var(--text);border:1px solid var(--tile-3);padding:11px 12px;font:14px var(--body)}
input[type=text]:focus,select:focus{border-color:var(--blue);outline:0}
.copy{display:flex;align-items:stretch;margin-top:8px;max-width:520px}
.copy code{flex:1;background:#0d0e11;border:1px solid var(--tile-3);border-right:0;padding:11px 12px;font:13px ui-monospace,Consolas,monospace;overflow:auto;white-space:nowrap}
.note{margin-top:10px;padding:10px 12px;background:rgba(255,177,11,.1);border-left:3px solid var(--warn);color:#ffd98a;font-size:13.5px}
.note.bad{background:rgba(255,92,92,.1);border-left-color:var(--danger);color:#ffb3b3}
.note.ok{background:rgba(46,184,92,.1);border-left-color:var(--good);color:#a9e6bf}
.note:empty{display:none}
ul.list{list-style:none;margin:10px 0 0;padding:0}
ul.list li{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;background:var(--tile-2);margin-top:4px}
ul.list li span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
ul.list small{color:var(--muted);white-space:nowrap}
.bar{height:8px;background:var(--tile-3);margin-top:10px;max-width:420px}
.bar i{display:block;height:100%;background:var(--blue);width:0;transition:width .2s}
.options{margin-top:10px}
.option{background:var(--tile-2);border-left:3px solid var(--tile-3);padding:12px 14px}
.option.inuse{border-left-color:var(--good)}
.option .tag{display:flex;align-items:center;gap:8px;margin-bottom:6px;font:11px/1 var(--heading);letter-spacing:.09em;text-transform:uppercase}
.option .tag b{background:var(--tile-3);padding:5px 8px}
.option .tag .rec{color:var(--blue-hover)}
.option .tag .using{display:none;background:var(--good);color:var(--ink);padding:5px 8px}
.option.inuse .tag .using{display:inline}
.hl{color:var(--ink);background:var(--blue);padding:1px 6px;font-family:var(--heading);font-weight:400;white-space:nowrap}
.or{font:12px/1 var(--heading);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);text-align:center;padding:10px 0}
details.more{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
details.more > summary{cursor:pointer;font:13px/1.3 var(--heading);letter-spacing:.04em;text-transform:uppercase;color:var(--blue-hover)}
.slider{display:flex;align-items:center;gap:14px;margin-top:8px}
.slider input{flex:1;accent-color:var(--blue)}
.slider b{font:15px var(--heading);min-width:52px;text-align:right}
.eq{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;max-width:460px;margin-top:10px;background:var(--tile-2);padding:14px 8px 12px}
.eq .band{display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center}
.eq .band b{font:14px/1 var(--heading)}
.eq .band span{font:11px/1.2 var(--heading);letter-spacing:.06em;text-transform:uppercase}
.eq .band small{color:var(--muted);font-size:12px}
.eq input[type=range]{writing-mode:vertical-lr;direction:rtl;height:132px;width:28px;accent-color:var(--blue)}
.presets button{padding:9px 12px;font-size:12px}
.found li small{display:block;white-space:normal}
.two{display:flex;flex-direction:column;min-width:0}
/* what is lined up, beside the rest as a music player has it */
.side{position:fixed;top:0;right:0;bottom:81px;width:380px;display:flex;flex-direction:column;background:var(--tile);border-left:1px solid var(--line);min-width:0;z-index:1}
.np{position:relative;flex:none;background:var(--tile-2)}
.np img{display:block;width:100%;height:min(34vh,348px);object-fit:cover}
.np img:not([src]){visibility:hidden}
.np .over{position:absolute;left:0;right:0;bottom:0;padding:36px 16px 12px;background:linear-gradient(180deg,transparent,rgba(0,0,0,.88))}
.np .lab{font:11px/1 var(--heading);letter-spacing:.1em;text-transform:uppercase;color:var(--blue-hover);margin-bottom:6px}
.np .t{font:18px/1.2 var(--heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.np .a{color:#d0d1d4;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.qhead{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px 4px;flex:none}
.qhead h2{font:16px/1.1 var(--heading);text-transform:uppercase;letter-spacing:.05em;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.side .help{padding:0 16px 16px}
#qBody{flex:1;min-height:0;display:flex;flex-direction:column}
#qBody .find{padding:8px 16px 10px;flex:none}
ol.queue{list-style:none;margin:0;padding:0 8px 10px;flex:1;min-height:0;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--tile-3) transparent}
ol.queue button{width:100%;display:flex;align-items:center;gap:12px;text-align:left;text-transform:none;letter-spacing:0;background:transparent;padding:6px 8px;font:14px/1.3 var(--body)}
ol.queue button:hover{background:var(--tile-2)}
ol.queue .art{position:relative;width:46px;height:46px;flex:none;background:var(--tile-3);display:grid;place-items:center;color:var(--muted);font:18px/1 var(--heading)}
ol.queue .art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
ol.queue .words{min-width:0;flex:1}
ol.queue b{display:block;font:14px/1.3 var(--heading);font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
ol.queue span.by{display:block;color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
ol.queue i{flex:none;font:11px/1 var(--heading);font-style:normal;color:var(--muted);min-width:22px;text-align:right}
ol.queue li.cur button{background:var(--tile-2);box-shadow:inset 3px 0 0 var(--blue)}
ol.queue li.cur b{color:var(--blue-hover)}
ol.queue li.cur i{color:var(--blue-hover)}
ol.queue.wait button{cursor:progress}
ol.queue.wait li:not(.cur){opacity:.5}
.loading{color:var(--warn)!important}
@keyframes pulse{50%{opacity:.45}}
.loading,ol.queue.wait li.cur{animation:pulse 1.1s ease-in-out infinite}
@media (max-width:1000px){body{padding-right:300px}.side{width:300px}}
@media (max-width:720px){body{padding-right:0}main{padding-bottom:0}.side{position:static;width:auto;margin:0 0 140px;border-left:0;border-top:4px solid var(--blue)}ol.queue{max-height:520px}.np img{height:260px}}
label.check{display:flex;gap:10px;align-items:flex-start;margin-top:14px;cursor:pointer}
label.check input{width:18px;height:18px;margin-top:2px;accent-color:var(--blue)}
/* what is playing, kept in view */
.player{position:fixed;left:0;right:0;bottom:0;height:81px;z-index:2;background:rgba(13,14,17,.96);border-top:1px solid var(--line);backdrop-filter:blur(6px)}
.player .in{padding:12px 20px;display:flex;align-items:center;gap:14px}
.player img{width:56px;height:56px;object-fit:cover;background:var(--tile-2);flex:none}
.player img:not([src]){visibility:hidden}
.player .t{font:15px/1.25 var(--heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.player .a{color:var(--muted);font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.player .ctl{display:flex;gap:6px;flex:none}
.player .ctl button{padding:11px 13px}
@media (max-width:560px){.brand{font-size:36px}.player .ctl button{padding:10px}.card > summary{flex-wrap:wrap}}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="shell">
<main>
<header class="top">
<div>
<h1 class="brand">Re<span>Skate</span> Music</h1>
<p class="lede">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.</p>
</div>
<div class="live" id="live"><i></i><span id="liveText">Connecting to the game</span></div>
</header>
<!-- SPOTIFY -->
<details class="card" id="spotify" open>
<summary><div class="grow"><h2>Spotify</h2><div class="what">Needs Spotify Premium. Sound comes straight from Spotify into the game.</div></div><span class="pill" id="spPill">…</span><span class="chev"></span></summary>
<div class="body">
<ol class="steps">
<li id="spStep1"><div class="title">Pick “ReSkate” in Spotify</div>
<p class="help">Open Spotify on your phone or this PC (same Wi-Fi or network as this PC), open its <b>device list</b>, and choose <b>ReSkate</b>. You only do this once.</p>
<div class="note" id="spDeviceNote"></div>
</li>
<li id="spStep2"><div class="title">Play it in the game</div>
<p class="help">In the game’s music screen, open the <b>Spotify</b> row and pick the <b>Spotify</b> tile. If the row is not there yet, restart the game once.</p>
</li>
<li id="spStep3"><div class="title">Show your own playlists<span class="opt">Optional</span></div>
<p class="help">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.</p>
<div id="spSignedOut">
<ol class="steps" style="margin-top:10px">
<li id="spA"><div class="title">Create the app</div>
<p class="help">Sign in, press <b>Create app</b>, and give it any name and description.</p>
<div class="row"><a class="btn" href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener">Open Spotify’s developer page</a></div>
</li>
<li id="spB"><div class="title">Paste this as its “Redirect URI”</div>
<div class="copy"><code id="redirect">http://127.0.0.1:47824/callback</code><button id="copyRedirect">Copy</button></div>
<p class="help">Tick <b>Web API</b> if it asks which API you use, then save the app.</p>
</li>
<li id="spC"><div class="title">Paste the app’s Client ID here</div>
<p class="help">It is on the app’s page, under <b>Basic information</b>.</p>
<div class="row"><div class="grow"><input type="text" id="client" placeholder="Client ID (32 letters and numbers)" autocomplete="off" spellcheck="false"></div><button id="saveClient" class="primary">Save</button></div>
</li>
<li id="spD"><div class="title">Sign in</div>
<p class="help">A Spotify tab opens. Press <b>Agree</b>, then come back here.</p>
<div class="row"><button id="login" class="primary">Sign in with Spotify</button></div>
</li>
</ol>
</div>
<div id="spSignedIn">
<div class="note ok" id="spWho"></div>
<ul class="list" id="playlists"></ul>
<div class="row"><button id="refresh">Refresh playlists</button><button id="logout" class="quiet">Sign out</button></div>
</div>
<div class="note bad" id="spProblem"></div>
</li>
</ol>
</div>
</details>
<!-- YOUTUBE -->
<details class="card" id="youtube">
<summary><div class="grow"><h2>YouTube Music</h2><div class="what">Paste links to videos or playlists. Nothing to install by hand.</div></div><span class="pill" id="ytPill">…</span><span class="chev"></span></summary>
<div class="body">
<div class="note" id="ytNone"></div>
<ol class="steps" id="ytSteps">
<li id="ytStep1"><div class="title">Get the YouTube tool</div>
<p class="help">Downloads <b>yt-dlp</b> (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.</p>
<div class="row"><button id="ytTools" class="primary">Download and set up</button><span class="help" id="ytProgressText"></span></div>
<div class="bar" id="ytBar"><i></i></div>
</li>
<li id="ytStep2"><div class="title">Add a video or playlist</div>
<p class="help">Copy a link from YouTube or YouTube Music and paste it here. Up to 100 songs each.</p>
<div class="row"><div class="grow"><input type="text" id="ytLink" placeholder="https://music.youtube.com/playlist?list=…" autocomplete="off" spellcheck="false"></div><button id="ytAdd" class="primary">Add</button></div>
<ul class="list" id="ytLists"></ul>
</li>
<li id="ytStep3"><div class="title">Play it in the game</div>
<p class="help">In the game’s music screen, open the <b>YouTube Music</b> row and press a song. Each song takes a few seconds to start. If the row is not there yet, restart the game once.</p>
</li>
</ol>
<div class="note bad" id="ytProblem"></div>
<details class="more" id="ytMore">
<summary>Songs being skipped?</summary>
<p class="help">Private and age-restricted songs only play when YouTube can see you are signed in. There are two ways to do that. <b>Pick one</b>; you do not need both.</p>
<div class="options">
<div class="option" id="ytCk">
<div class="tag"><b>Option A</b><span class="rec">Recommended</span><span class="using">In use</span></div>
<div class="title">Give it a cookies.txt file</div>
<p class="help">Works with every browser. In your browser, signed in to YouTube, install the browser extension <b class="hl">Get cookies.txt LOCALLY</b>, open youtube.com, press its <b>Export</b> button, then choose the file here. It stays on this PC. Treat it like a password.</p>
<div class="row"><input type="file" id="ytCookies" accept=".txt,text/plain"><button id="ytCookiesRemove" class="quiet">Remove it</button></div>
<div class="note" id="ytCookiesText"></div>
</div>
<div class="or">or</div>
<div class="option" id="ytBr">
<div class="tag"><b>Option B</b><span class="using">In use</span></div>
<div class="title">Firefox only: read the sign-in straight from the browser</div>
<p class="help"><b>Only use this if Firefox is the browser you are signed in to YouTube with.</b> There is no file to export. Chrome, Edge, Opera and Brave block this, so for those use Option A.</p>
<div class="row" style="max-width:320px"><select id="ytBrowser" aria-label="Browser to read the YouTube sign-in from"><option value="">Do not use a browser</option><option value="firefox">Firefox</option><option value="edge">Edge (usually blocked)</option><option value="chrome">Chrome (usually blocked)</option><option value="brave">Brave (usually blocked)</option><option value="opera">Opera (usually blocked)</option><option value="vivaldi">Vivaldi (usually blocked)</option></select></div>
</div>
</div>
<p class="help">With a sign-in set, songs take a little longer to start.</p>
<div class="note" id="ytRuntime"></div>
</details>
</div>
</details>
<!-- LOCAL FILES -->
<details class="card" id="local">
<summary><div class="grow"><h2>Local files</h2><div class="what">Music saved on this PC: MP3, M4A, FLAC, Ogg and WAV.</div></div><span class="pill" id="lcPill">…</span><span class="chev"></span></summary>
<div class="body">
<div class="note" id="lcNone"></div>
<ol class="steps" id="lcSteps">
<li id="lcStep1"><div class="title">Add a folder of music</div>
<p class="help">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.</p>
<div class="row"><div class="grow"><input type="text" id="lcPath" placeholder="C:\Users\you\Music\Skate mix" autocomplete="off" spellcheck="false"></div><button id="lcAdd" class="primary">Add</button></div>
<div class="row"><button id="lcMusic">Or use my Music folder</button></div>
<ul class="list" id="lcList"></ul>
</li>
<li id="lcStep2"><div class="title">Play it in the game</div>
<p class="help">In the game’s music screen, open the <b>Local Files</b> row and press a song. Put new files in a folder? Press <b>Rescan</b> next to it. If the row is not there yet, restart the game once.</p>
</li>
</ol>
<div class="note bad" id="lcProblem"></div>
</div>
</details>
<!-- RADIO -->
<details class="card" id="radio">
<summary><div class="grow"><h2>Online radio</h2><div class="what">Live internet radio stations. Each one you add becomes a tile.</div></div><span class="pill" id="rdPill">…</span><span class="chev"></span></summary>
<div class="body">
<div class="note" id="rdNone"></div>
<ol class="steps" id="rdSteps">
<li id="rdStep1"><div class="title">Find a station</div>
<p class="help">Type part of a station’s name. This searches radio-browser.info, a free public directory of stations.</p>
<div class="row"><div class="grow"><input type="text" id="rdQuery" placeholder="SomaFM, BBC, lofi, drum and bass…" autocomplete="off"></div><button id="rdSearch" class="primary">Search</button></div>
<ul class="list found" id="rdFound"></ul>
<details class="more">
<summary>Have a stream’s address instead?</summary>
<p class="help">Paste the address of an MP3 or AAC stream, or of a .pls or .m3u file. Addresses ending in .m3u8 (HLS) are not supported.</p>
<div class="row"><div class="grow"><input type="text" id="rdName" placeholder="Name to show (optional)" autocomplete="off"></div></div>
<div class="row"><div class="grow"><input type="text" id="rdAddress" placeholder="https://…" autocomplete="off" spellcheck="false"></div><button id="rdAdd" class="primary">Add</button></div>
</details>
</li>
<li id="rdStep2"><div class="title">Your stations</div>
<p class="help" id="rdNoneYet">None yet. Press <b>Add</b> next to a station above.</p>
<ul class="list" id="rdList"></ul>
</li>
<li id="rdStep3"><div class="title">Play it in the game</div>
<p class="help">In the game’s music screen, open the <b>Online Radio</b> 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.</p>
</li>
</ol>
<div class="note bad" id="rdProblem"></div>
</div>
</details>
<!-- EXTERNAL -->
<details class="card" id="external">
<summary><div class="grow"><h2>External audio</h2><div class="what">Any app on this PC: a browser, a media player, another music service.</div></div><span class="pill" id="exPill">…</span><span class="chev"></span></summary>
<div class="body">
<ol class="steps">
<li id="exStep1"><div class="title">Start the app playing something</div>
<p class="help">Only apps that are making sound right now show up in the list.</p>
</li>
<li id="exStep2"><div class="title">Choose the app</div>
<div class="row"><div class="grow"><select id="app"></select></div><button id="saveApp" class="primary">Use this app</button><button id="clearApp" class="quiet">Stop using it</button></div>
<div class="note" id="appText"></div>
</li>
<li id="exStep3"><div class="title">Play it in the game</div>
<p class="help">In the game’s music screen, open the <b>External Audio</b> 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.</p>
</li>
</ol>
</div>
</details>
<!-- SOUND -->
<details class="card" id="sound">
<summary><div class="grow"><h2>Sound</h2><div class="what">Loudness, equaliser, and where it plays.</div></div><span class="chev"></span></summary>
<div class="body">
<div class="title" style="margin-top:6px">Loudness</div>
<div class="slider"><input type="range" id="gain" min="0" max="400" step="5" aria-label="Loudness"><b id="gainText">100%</b></div>
<label class="check"><input type="checkbox" id="levelling"><span><span class="title">Keep every song at the same loudness</span><br><span class="help">Turns quiet songs up and loud ones down, so you are not reaching for the volume between songs.</span></span></label>
<div class="title" style="margin-top:18px">Equaliser</div>
<p class="help">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.</p>
<div class="eq" id="eq"></div>
<div class="row presets"><button data-eq="0,0,0,0,0">Flat</button><button data-eq="6,3,0,0,0">Bass boost</button><button data-eq="5,2,-1,2,4">Loud</button><button data-eq="-2,0,3,3,0">Vocal</button><button data-eq="0,0,0,3,5">Bright</button></div>
<label class="check"><input type="checkbox" id="speakers"><span><span class="title">Speakers in the world play it too</span><br><span class="help">Speakers around the map play what you are playing, and grabbing music from one keeps you on your own music.</span></span></label>
</div>
</details>
</main>
<!-- QUEUE -->
<aside class="side" id="queue" aria-label="Now playing and queue">
<div class="np"><img id="npCover" alt=""><div class="over"><div class="lab">Now playing</div><div class="t" id="npTitle">Nothing playing</div><div class="a" id="npArtist"></div></div></div>
<div class="qhead"><h2 id="qName">Queue</h2><span class="pill" id="qPill">…</span></div>
<p class="help" id="qEmpty">Nothing is lined up yet. Start a tile in the game’s music screen and its songs show here.</p>
<div id="qBody">
<div class="find"><input type="text" id="qFilter" placeholder="Find a song or artist" autocomplete="off"></div>
<ol class="queue" id="qList"></ol>
</div>
</aside>
</div>
<div class="player"><div class="in">
<img id="cover" alt="">
<div class="grow"><div class="t" id="title">Nothing playing</div><div class="a" id="artist">Pick a tile in the game’s music screen</div></div>
<div class="ctl"><button data-cmd="previous" aria-label="Previous">Prev</button><button data-cmd="toggle" id="toggle" class="primary">Play</button><button data-cmd="next" aria-label="Next">Next</button></div>
</div></div>
<script>
const $ = id => document.getElementById(id);
let pressed = 0, loading = false, gainHeld = 0, noteHeld = 0, eqHeld = 0, opened = false;
// (Opened as a file, with no game behind it: a made-up state, to look at the page by.)
const demo = location.protocol === 'file:' || location.search === '?demo' ? {
now:{artist:'Carter Cook',title:'Movin\'',playing:true,cover:''},station:true,
device:{connected:true,in_use:true,text:'Spotify device: a Spotify account is on it (zee).'},
account:{has_client:true,signed_in:true,busy:false,user:'zee',problem:'',client:'',playlists:[{name:'Liked Songs',tracks:49},{name:'Sad Drum and Bass',tracks:50}]},
youtube:{available:true,tools:true,busy:false,percent:0,problem:'',browser:'',cookies:false,runtime:'node',playlists:[{index:0,name:'Album - GNX',songs:12}]},
local:{available:true,busy:false,problem:'',folders:[{index:1,name:'Skate mix',songs:3,path:'C:\\Users\\zee\\Music\\Skate mix'}]},
radio:{available:true,busy:false,problem:'',searched:'soma',stations:[{index:2,name:'SomaFM Groove Salad',songs:1,address:'https://ice5.somafm.com/groovesalad-128-mp3'}],
found:[{name:'SomaFM Groove Salad (128k MP3)',address:'https://ice5.somafm.com/groovesalad-128-mp3',icon:'',about:'The United States Of America, MP3 128 kbps'},{name:'SomaFM Drone Zone',address:'https://ice5.somafm.com/dronezone-128-mp3',icon:'',about:'The United States Of America, MP3 128 kbps'}]},
queue:{playlist:1,name:'Skate mix',kind:'local',at:1,songs:[{artist:'Carter Cook',title:'Movin\'',cover:''},{artist:'Cody Bolan',title:'Why',cover:''},{artist:'Behemoth',title:'Say What You Want',cover:''},...Array.from({length:30},(_, i) => ({artist:'Artist ' + (i + 1),title:'Another song ' + (i + 1),cover:''}))]},
external:{app:'',on:false,capture:'',apps:['chrome.exe','vlc.exe']},settings:{gain:100,speakers:true,levelling:true,equaliser:[4,2,0,1,3]}} : null;
async function post(path, body) {
try { await fetch(path, {method:'POST', headers:{'Content-Type':'application/json','X-ReSkate':'1'}, body:JSON.stringify(body || {})}); } catch (e) {}
load();
}
const step = (id, state) => { $(id).className = state; }; // 'done', 'now' or ''
const card = (id, state, pill) => { const c = $(id); c.classList.remove('ready','todo','problem'); if (state) c.classList.add(state); const p = c.querySelector('.pill'); if (p) p.textContent = pill; };
const show = (id, on) => { $(id).style.display = on ? '' : 'none'; };
// Fills a list: each item's name, the small text beside it, and its buttons ([label, what it does]).
// It is only built again when what it shows has changed, so a button is not lost under a press.
function fill(id, items, name, small, buttons) {
const ul = $(id), sig = JSON.stringify(items);
if (ul.dataset.sig === sig) return;
ul.dataset.sig = sig; ul.innerHTML = '';
for (const item of items) {
const li = document.createElement('li'), two = document.createElement('div'), n = document.createElement('span'), r = document.createElement('div');
two.className = 'two'; n.textContent = name(item); two.append(n);
if (small(item)) { const c = document.createElement('small'); c.textContent = small(item); two.append(c); }
r.style.cssText = 'display:flex;gap:8px;align-items:center;flex:none';
for (const [label, act, primary] of buttons) { const b = document.createElement('button'); b.textContent = label; b.className = primary ? 'primary' : 'quiet'; b.onclick = () => act(item); r.append(b); }
li.append(two, r); ul.append(li);
}
}
document.querySelectorAll('[data-cmd]').forEach(b => b.onclick = () => post('/api/command', {command:b.dataset.cmd}));
$('copyRedirect').onclick = async () => { try { await navigator.clipboard.writeText($('redirect').textContent); $('copyRedirect').textContent = 'Copied'; } catch (e) { $('copyRedirect').textContent = 'Select and copy it'; } setTimeout(() => $('copyRedirect').textContent = 'Copy', 2000); };
$('saveClient').onclick = () => post('/api/client', {id:$('client').value.trim()});
$('login').onclick = () => post('/api/login');
$('logout').onclick = () => post('/api/logout');
$('refresh').onclick = () => post('/api/refresh');
$('ytTools').onclick = () => post('/api/youtube/tools');
$('ytAdd').onclick = () => { const link = $('ytLink').value.trim(); if (link) { $('ytLink').value = ''; post('/api/youtube/add', {link}); } };
$('ytLink').onkeydown = e => { if (e.key === 'Enter') $('ytAdd').click(); };
$('ytBrowser').onchange = () => post('/api/youtube/browser', {browser:$('ytBrowser').value});
$('ytCookiesRemove').onclick = () => post('/api/youtube/cookies', {text:''});
$('ytCookies').onchange = async () => {
const file = $('ytCookies').files[0]; if (!file) return;
const text = await file.text(); $('ytCookies').value = '';
let ok = false;
try { ok = (await fetch('/api/youtube/cookies', {method:'POST', headers:{'Content-Type':'application/json','X-ReSkate':'1'}, body:JSON.stringify({text})})).ok; } catch (e) {}
noteHeld = Date.now();
$('ytCookiesText').className = 'note ' + (ok ? 'ok' : 'bad');
$('ytCookiesText').textContent = ok ? 'Saved. Songs that were skipped should play now.' : 'That file is not a cookies.txt with YouTube cookies in it. Export it again while on youtube.com.';
load();
};
$('lcAdd').onclick = () => { const path = $('lcPath').value.trim(); if (path) { $('lcPath').value = ''; post('/api/local/add', {path}); } };
$('lcPath').onkeydown = e => { if (e.key === 'Enter') $('lcAdd').click(); };
$('lcMusic').onclick = () => post('/api/local/add', {path:''});
$('rdSearch').onclick = () => { const query = $('rdQuery').value.trim(); if (query) post('/api/radio/search', {query}); };
$('rdQuery').onkeydown = e => { if (e.key === 'Enter') $('rdSearch').click(); };
$('rdAdd').onclick = () => { const address = $('rdAddress').value.trim(); if (address) { post('/api/radio/add', {name:$('rdName').value.trim(), address, icon:''}); $('rdAddress').value = ''; $('rdName').value = ''; } };
$('rdAddress').onkeydown = e => { if (e.key === 'Enter') $('rdAdd').click(); };
$('qFilter').oninput = () => { if (last) queue(last); };
const eqNames = ['Bass','Low mid','Mid','High mid','Treble'], eqAt = ['60 Hz','250 Hz','1 kHz','4 kHz','12 kHz'];
const eqText = v => (v > 0 ? '+' : '') + v + ' dB';
const eqValues = () => eqNames.map((_, i) => +$('eq' + i).value);
const eqShow = values => values.forEach((v, i) => { $('eq' + i).value = v; $('eqV' + i).textContent = eqText(v); });
eqNames.forEach((name, i) => {
const band = document.createElement('div'), value = document.createElement('b'), input = document.createElement('input'), label = document.createElement('span'), at = document.createElement('small');
band.className = 'band'; value.id = 'eqV' + i; value.textContent = '0 dB';
input.type = 'range'; input.id = 'eq' + i; input.min = -12; input.max = 12; input.step = 1; input.value = 0; input.setAttribute('orient', 'vertical'); input.setAttribute('aria-label', name + ', ' + eqAt[i]);
input.oninput = () => { eqHeld = Date.now(); value.textContent = eqText(+input.value); };
input.onchange = () => post('/api/settings', {equaliser:eqValues()});
label.textContent = name; at.textContent = eqAt[i];
band.append(value, input, label, at); $('eq').append(band);
});
document.querySelectorAll('[data-eq]').forEach(b => b.onclick = () => { const values = b.dataset.eq.split(',').map(Number); eqHeld = Date.now(); eqShow(values); post('/api/settings', {equaliser:values}); });
$('levelling').onchange = () => post('/api/settings', {levelling:$('levelling').checked});
$('saveApp').onclick = () => post('/api/external', {app:$('app').value});
$('clearApp').onclick = () => post('/api/external', {app:''});
$('gain').oninput = () => { gainHeld = Date.now(); $('gainText').textContent = $('gain').value + '%'; };
$('gain').onchange = () => post('/api/settings', {gain:+$('gain').value});
$('speakers').onchange = () => post('/api/settings', {speakers:$('speakers').checked});
// What is lined up: the songs of the playlist that is playing, each with its picture, the one
// playing marked. The list is built once for a playlist (its pictures are not fetched again
// as the song changes); which is playing, and which are found, is set on what is there.
let last = null;
function queue(s) {
const q = s.queue, find = $('qFilter').value.trim().toLowerCase();
show('qEmpty', !q); $('qBody').style.display = q ? '' : 'none';
const spotifyOn = s.station && !q && s.now.title && !s.account.signed_in && (s.device.connected || s.device.in_use);
$('qEmpty').textContent = spotifyOn ? 'To see Spotify’s queue here, finish “Show your own playlists” in the Spotify section (step 3).' : 'Nothing is lined up yet. Start a tile in the game’s music screen and its songs show here.';
$('qPill').textContent = q ? (q.kind === 'radio' ? 'Live' : q.kind === 'spotify' ? 'Up next' : (q.at + 1) + ' / ' + q.songs.length) : 'Empty';
$('qName').textContent = q ? q.name : 'Queue';
const list = $('qList'), sig = q ? JSON.stringify([q.playlist, q.name, q.songs]) : '';
if (list.dataset.sig !== sig) {
list.dataset.sig = sig; list.dataset.at = ''; list.innerHTML = '';
(q ? q.songs : []).forEach((song, i) => {
const li = document.createElement('li'), b = document.createElement('button'), art = document.createElement('div'), words = document.createElement('div'), t = document.createElement('b'), a = document.createElement('span'), n = document.createElement('i');
art.className = 'art'; art.textContent = '♪';
if (song.cover) { const img = document.createElement('img'); img.loading = 'lazy'; img.alt = ''; img.onerror = () => img.remove(); img.src = song.cover; art.append(img); }
words.className = 'words'; a.className = 'by'; t.textContent = song.title; a.textContent = song.artist; words.append(t, a); n.textContent = i + 1;
b.append(art, words, n); b.onclick = () => { if ((last && last.now.loading) || Date.now() - pressed < 1500) return; pressed = Date.now(); list.classList.add('wait'); post('/api/queue/play', {playlist:q.playlist, song:i, spotify:q.kind === 'spotify'}); };
li.append(b); list.append(li);
});
}
if (!q) return;
[...list.children].forEach((li, i) => {
const song = q.songs[i];
li.style.display = find && !(song.title + ' ' + song.artist).toLowerCase().includes(find) ? 'none' : '';
li.className = i === q.at ? 'cur' : '';
const b = li.firstElementChild; if (i === q.at) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
});
// (The song that is playing is brought into view when it changes, not while the list is being read.)
if (list.dataset.at !== String(q.at) && !find) { list.dataset.at = String(q.at); const cur = list.children[q.at]; if (cur) list.scrollTop = Math.max(0, cur.offsetTop - list.offsetTop - 60); }
}
function render(s) {
last = s;
$('live').className = 'live on'; $('liveText').textContent = 'Connected to the game';
// What is playing.
$('title').textContent = s.now.title || 'Nothing playing';
$('artist').textContent = s.now.loading ? 'Loading… ' + (s.now.artist || '') : s.now.title ? (s.now.artist || '') : (s.station ? 'Waiting for the first song' : 'Pick a tile in the game’s music screen');
$('artist').classList.toggle('loading', !!s.now.loading);
$('toggle').textContent = s.now.playing ? 'Pause' : 'Play';
if (s.now.cover) { if ($('cover').dataset.src !== s.now.cover) { $('cover').dataset.src = s.now.cover; $('cover').src = s.now.cover; } }
else { $('cover').removeAttribute('src'); $('cover').dataset.src = ''; }
$('npTitle').textContent = s.now.title || 'Nothing playing'; $('npArtist').textContent = s.now.loading ? 'Loading… ' + (s.now.artist || '') : s.now.title ? (s.now.artist || '') : '';
$('npArtist').classList.toggle('loading', !!s.now.loading); $('qList').classList.toggle('wait', !!s.now.loading || Date.now() - pressed < 1500);
if (s.now.cover) { if ($('npCover').dataset.src !== s.now.cover) { $('npCover').dataset.src = s.now.cover; $('npCover').src = s.now.cover; } }
else { $('npCover').removeAttribute('src'); $('npCover').dataset.src = ''; }
// Spotify.
const d = s.device, a = s.account, off = /^Spotify device: off/.test(d.text), premium = /not Premium/.test(d.text);
const linked = d.connected || d.in_use;
step('spStep1', linked ? 'done' : 'now');
step('spStep2', linked ? (s.station ? 'done' : 'now') : '');
step('spStep3', a.signed_in ? 'done' : '');
$('spDeviceNote').className = 'note' + (premium ? ' bad' : linked ? ' ok' : '');
$('spDeviceNote').textContent = premium ? 'That Spotify account is not Premium. Spotify only lets Premium accounts play to a device, so it was removed. Sign in to Spotify with a Premium account and pick ReSkate again.'
: off ? 'The Spotify device stopped for this session. Restart the game to bring it back.'
: d.connected ? 'Connected' + ((d.text.match(/\(([^)]+)\)/) || [])[1] ? ' as ' + d.text.match(/\(([^)]+)\)/)[1] : '') + '.'
: d.in_use ? 'Remembered from last time. It connects by itself.'
: /starting/.test(d.text) ? 'Starting up…'
: /not running|not built/.test(d.text) ? 'The Spotify device is not available in this build of ReSkate.'
: 'Waiting for you to pick ReSkate in Spotify. If it is not in the list, check both are on the same network and that Windows Firewall allowed the game.';
show('spSignedOut', !a.signed_in); show('spSignedIn', a.signed_in);
step('spA', a.has_client ? 'done' : 'now'); step('spB', a.has_client ? 'done' : ''); step('spC', a.has_client ? 'done' : '');
step('spD', a.has_client ? 'now' : '');
$('login').disabled = !a.has_client || a.busy; $('login').textContent = a.busy ? 'Waiting for Spotify…' : 'Sign in with Spotify';
if (document.activeElement !== $('client') && !$('client').value) $('client').value = a.client || '';
$('spWho').textContent = 'Signed in' + (a.user ? ' as ' + a.user : '') + '. ' + a.playlists.length + ' playlists, shown as tiles in the game.';
$('spProblem').textContent = a.problem || '';
const pl = $('playlists'); pl.innerHTML = '';
for (const p of a.playlists) { const li = document.createElement('li'), n = document.createElement('span'), c = document.createElement('small'); n.textContent = p.name; c.textContent = p.tracks + ' tracks'; li.append(n, c); pl.append(li); }
card('spotify', premium || off ? 'problem' : linked ? 'ready' : 'todo', premium ? 'Needs Premium' : off ? 'Stopped' : linked ? 'Ready' : 'Set up');
// YouTube Music.
const y = s.youtube;
show('ytSteps', y.available); show('ytMore', y.available && y.tools);
$('ytNone').textContent = y.available ? '' : 'This build of ReSkate was made without YouTube Music.';
step('ytStep1', y.tools ? 'done' : 'now'); step('ytStep2', y.tools ? (y.playlists.length ? 'done' : 'now') : ''); step('ytStep3', y.tools && y.playlists.length ? 'now' : '');
show('ytTools', !y.tools); $('ytTools').disabled = y.busy; $('ytTools').textContent = y.busy ? 'Downloading…' : 'Download and set up';
show('ytBar', !y.tools && y.busy); $('ytBar').firstElementChild.style.width = (y.percent || 0) + '%';
$('ytProgressText').textContent = !y.tools && y.busy ? (y.percent ? y.percent + '%' : 'Starting') : y.tools ? 'Installed.' : '';
$('ytAdd').disabled = !y.tools || y.busy; $('ytAdd').textContent = y.tools && y.busy ? 'Adding…' : 'Add'; $('ytLink').disabled = !y.tools;
$('ytProblem').textContent = y.problem || '';
fill('ytLists', y.playlists, p => p.name, p => p.songs + ' songs', [['Remove', p => post('/api/library/remove', {index:p.index})]]);
if (document.activeElement !== $('ytBrowser')) $('ytBrowser').value = y.browser || '';
show('ytCookiesRemove', y.cookies);
$('ytCk').classList.toggle('inuse', !!y.cookies); $('ytBr').classList.toggle('inuse', !!y.browser && !y.cookies);
if (Date.now() - noteHeld > 5000) { $('ytCookiesText').className = 'note' + (y.cookies ? ' ok' : ''); $('ytCookiesText').textContent = y.cookies ? 'A cookies.txt is in use.' : ''; }
$('ytRuntime').textContent = (y.cookies || y.browser) && !y.runtime ? 'Getting one more small tool the sign-in needs… songs will not play until it is here. If this message stays, check your connection.' : '';
if (y.problem && !$('ytMore').open && /sign-in|cookies/.test(y.problem)) $('ytMore').open = true;
card('youtube', !y.available ? '' : y.problem ? 'problem' : y.tools && y.playlists.length ? 'ready' : 'todo', !y.available ? 'Not available' : y.problem ? 'Check' : y.tools && y.playlists.length ? 'Ready' : y.tools ? 'Add a link' : 'Set up');
// Local files.
const l = s.local;
show('lcSteps', l.available); $('lcNone').textContent = l.available ? '' : 'This build of ReSkate was made without local files.';
step('lcStep1', l.folders.length ? 'done' : 'now'); step('lcStep2', l.folders.length ? 'now' : '');
$('lcAdd').disabled = $('lcMusic').disabled = l.busy; $('lcAdd').textContent = l.busy ? 'Working…' : 'Add';
$('lcProblem').textContent = l.problem || '';
fill('lcList', l.folders, f => f.name, f => f.songs + ' songs · ' + f.path, [['Rescan', f => post('/api/local/add', {path:f.path})], ['Remove', f => post('/api/library/remove', {index:f.index})]]);
card('local', !l.available ? '' : l.problem ? 'problem' : l.folders.length ? 'ready' : 'todo', !l.available ? 'Not available' : l.problem ? 'Check' : l.folders.length ? 'Ready' : 'Set up');
// Online radio.
const r = s.radio, have = new Set(r.stations.map(t => t.address));
show('rdSteps', r.available); $('rdNone').textContent = r.available ? '' : 'This build of ReSkate was made without online radio.';
step('rdStep1', r.stations.length ? 'done' : 'now'); step('rdStep2', r.stations.length ? 'done' : ''); step('rdStep3', r.stations.length ? 'now' : '');
$('rdSearch').disabled = $('rdAdd').disabled = r.busy; $('rdSearch').textContent = r.busy ? 'Working…' : 'Search';
$('rdProblem').textContent = r.problem || '';
fill('rdFound', r.found.map(f => ({...f, added:have.has(f.address)})), f => f.name, f => f.about, [['Add', f => { if (!have.has(f.address)) post('/api/radio/add', {name:f.name, address:f.address, icon:f.icon}); }, true]]);
$('rdFound').querySelectorAll('li').forEach((li, i) => { if (have.has(r.found[i].address)) { const b = li.querySelector('button'); b.textContent = 'Added'; b.disabled = true; b.className = 'quiet'; } });
show('rdNoneYet', !r.stations.length);
fill('rdList', r.stations, t => t.name, t => t.address, [['Remove', t => post('/api/library/remove', {index:t.index})]]);
card('radio', !r.available ? '' : r.problem ? 'problem' : r.stations.length ? 'ready' : 'todo', !r.available ? 'Not available' : r.problem ? 'Check' : r.stations.length ? 'Ready' : 'Set up');
queue(s);
// External audio.
const e = s.external;
if (document.activeElement !== $('app')) {
const names = e.apps.includes(e.app) || !e.app ? e.apps : [e.app, ...e.apps];
$('app').innerHTML = '';
if (!names.length) { const o = document.createElement('option'); o.value = ''; o.textContent = 'No app is making sound right now'; $('app').append(o); }
for (const n of names) { const o = document.createElement('option'); o.value = n; o.textContent = n + (e.apps.includes(n) ? '' : ' (not making sound now)'); $('app').append(o); }
if (e.app) $('app').value = e.app;
}
$('saveApp').disabled = !$('app').value; show('clearApp', !!e.app);
step('exStep1', e.apps.length || e.app ? 'done' : 'now'); step('exStep2', e.app ? 'done' : e.apps.length ? 'now' : ''); step('exStep3', e.app ? (e.on ? 'done' : 'now') : '');
const silent = e.on && /^Not capturing|silent/.test(e.capture || '');
$('appText').className = 'note' + (silent ? ' bad' : e.app ? ' ok' : '');
$('appText').textContent = e.app ? ('Using ' + e.app + '.' + (e.on ? ' ' + e.capture + '.' : '')) : '';
card('external', silent ? 'problem' : e.app ? 'ready' : 'todo', silent ? 'No sound' : e.app ? 'Ready' : 'Set up');
// Sound.
if (Date.now() - gainHeld > 1500) { $('gain').value = s.settings.gain; $('gainText').textContent = s.settings.gain + '%'; }
$('speakers').checked = s.settings.speakers;
$('levelling').checked = !!s.settings.levelling;
if (Date.now() - eqHeld > 1500 && s.settings.equaliser) eqShow(s.settings.equaliser);
// (The first time: what still needs doing is opened, what is ready is closed.)
if (!opened) { opened = true; for (const id of ['spotify','youtube','external']) $(id).open = !$(id).classList.contains('ready'); if (!document.querySelector('.card[open]')) $('spotify').open = true; }
}
async function load() {
if (loading) return; loading = true;
try { render(demo || await (await fetch('/api/state')).json()); }
catch (e) { $('live').className = 'live'; $('liveText').textContent = 'The game is not running'; }
loading = false;
}
load(); setInterval(load, 2000);
</script>
</body>
</html>
+188 -116
View File
@@ -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 <WinSock2.h>
#include <WS2tcpip.h>
#include <Windows.h>
#include <shellapi.h>
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <filesystem>
#include <format>
#include <fstream>
#include <string>
#include <thread>
@@ -27,113 +32,46 @@ constexpr unsigned short first_port = 8421;
std::atomic<unsigned short> port{};
std::atomic<bool> started{};
constexpr char page[] = R"html(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReSkate · Spotify</title>
<style>
:root{--bg:#101214;--card:#1a1d21;--line:#2a2f36;--text:#e9edf1;--dim:#98a2ad;--green:#1ed760;--warn:#ffb454}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,Segoe UI,sans-serif}
main{max-width:760px;margin:0 auto;padding:28px 16px 60px}h1{font-size:22px;margin:0 0 4px}p.sub{color:var(--dim);margin:0 0 22px}
section{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;margin:0 0 16px}
h2{font-size:15px;margin:0 0 10px;letter-spacing:.02em}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:0}
.dot{width:10px;height:10px;border-radius:50%;background:#555;flex:none}.dot.on{background:var(--green)}.dot.warn{background:var(--warn)}
.dim{color:var(--dim)}small{color:var(--dim)}
button{background:#2a2f36;color:var(--text);border:1px solid #3a414a;border-radius:8px;padding:8px 14px;font:inherit;cursor:pointer}
button:hover{background:#343a43}button.primary{background:var(--green);border-color:var(--green);color:#04130a;font-weight:600}
input[type=text]{background:#0e1012;color:var(--text);border:1px solid #3a414a;border-radius:8px;padding:8px 10px;font:inherit;width:100%}
input[type=range]{width:100%}select{background:#0e1012;color:var(--text);border:1px solid #3a414a;border-radius:8px;padding:8px 10px;font:inherit;width:100%}
.now{display:flex;gap:14px;align-items:center}.now img{width:72px;height:72px;border-radius:8px;object-fit:cover;background:#0e1012}
.title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
ol{margin:8px 0 0;padding-left:20px;color:var(--dim)}ul{margin:8px 0 0;padding:0;list-style:none}li.pl{display:flex;justify-content:space-between;padding:6px 0;border-top:1px solid var(--line)}
code{background:#0e1012;border:1px solid var(--line);border-radius:6px;padding:1px 6px}
.note{color:var(--warn)}
</style></head><body><main>
<h1>ReSkate · Spotify</h1><p class="sub">Set up Spotify for the in-game music player. This page is only reachable from this computer.</p>
// Something built into the DLL (cmake/Runtime.cmake): the page itself (setup_page.html), and
// the mod's own fonts, which the page is set in.
std::string built_in(const wchar_t* name) {
HMODULE module{};
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast<LPCWSTR>(&built_in), &module)) return {};
const auto resource = FindResourceW(module, name, MAKEINTRESOURCEW(10) /* RT_RCDATA */);
const auto handle = resource ? LoadResource(module, resource) : nullptr;
const auto* data = handle ? static_cast<const char*>(LockResource(handle)) : nullptr;
return data ? std::string(data, SizeofResource(module, resource)) : std::string();
}
<section><h2>Now playing</h2>
<div class="now"><img id="cover" alt=""><div class="grow"><div class="title" id="title">Nothing yet</div><div class="dim" id="artist"></div><small id="tile"></small></div></div>
<div class="row" style="margin-top:12px"><button data-cmd="previous">Previous</button><button data-cmd="toggle" id="toggle">Play</button><button data-cmd="next">Next</button></div>
</section>
<section><h2>Spotify device</h2>
<div class="row"><span class="dot" id="deviceDot"></span><div class="grow" id="deviceText">…</div></div>
<ol id="deviceSteps"><li>Open Spotify on your phone or this computer (same network as this PC).</li><li>Open the device list and pick <b>ReSkate</b>.</li><li>That is remembered: next time it connects by itself.</li></ol>
<small>Needs Spotify Premium. Audio comes straight from Spotify into the game.</small>
</section>
<section><h2>Your playlists</h2>
<div class="row"><span class="dot" id="accountDot"></span><div class="grow" id="accountText">…</div><button id="login" class="primary">Sign in</button><button id="logout">Sign out</button><button id="refresh">Refresh</button></div>
<div id="clientBox" style="margin-top:12px"><small>Listing your playlists uses a Spotify developer app. Create one at developer.spotify.com, add the redirect URI <code>http://127.0.0.1:47824/callback</code>, and paste its client ID here.</small>
<div class="row" style="margin-top:8px"><div class="grow"><input type="text" id="client" placeholder="Client ID" autocomplete="off" spellcheck="false"></div><button id="saveClient">Save</button></div></div>
<div class="note" id="problem"></div>
<ul id="playlists"></ul>
<small>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.</small>
</section>
<section><h2>YouTube Music</h2>
<div id="ytSetup"><small>Plays YouTube videos and playlists through the game. It needs <b>yt-dlp</b>, 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.</small>
<div class="row" style="margin-top:10px"><button id="ytTools" class="primary">Set up YouTube Music</button><small id="ytProgress"></small></div></div>
<div id="ytReady"><small>Paste a link to a YouTube or YouTube Music video or playlist (up to 100 songs each).</small>
<div class="row" style="margin-top:8px"><div class="grow"><input type="text" id="ytLink" placeholder="https://music.youtube.com/playlist?list=…" autocomplete="off" spellcheck="false"></div><button id="ytAdd">Add</button></div>
<ul id="ytLists"></ul>
<div style="margin-top:14px"><b>Private and age-restricted songs</b><br><small>These need your YouTube sign-in as a Netscape <code>cookies.txt</code> exported from your browser. Use an extension like <i>Get cookies.txt LOCALLY</i> 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.</small>
<div class="row" style="margin-top:8px"><input type="file" id="ytCookies" accept=".txt,text/plain"><button id="ytCookiesRemove">Remove cookies.txt</button><small id="ytCookiesText"></small></div><div class="note" id="ytRuntime"></div></div>
<div class="row" style="margin-top:10px"><div style="width:190px">YouTube sign-in from</div><div class="grow"><select id="ytBrowser"><option value="">None</option><option value="firefox">Firefox</option><option value="edge">Edge</option><option value="chrome">Chrome</option><option value="brave">Brave</option><option value="opera">Opera</option><option value="vivaldi">Vivaldi</option></select></div></div>
<small>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.</small><br><small>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.</small></div>
<div class="note" id="ytProblem"></div><small id="ytNone"></small>
</section>
<section><h2>External audio</h2>
<small>Play any app on this computer through the game: pick it here, then choose the <b>External Audio</b> tile in the game's music screen. The app's own sound is turned down while it plays through the game.</small>
<div class="row" style="margin-top:10px"><div class="grow"><select id="app"></select></div><button id="saveApp">Use this app</button></div>
<small id="appText"></small>
</section>
<section><h2>Sound</h2>
<div class="row"><div style="width:130px">Loudness <b id="gainText"></b></div><div class="grow"><input type="range" id="gain" min="0" max="400" step="5"></div></div>
<div class="row" style="margin-top:10px"><label><input type="checkbox" id="speakers"> Speakers in the world play Spotify too</label></div>
</section>
</main>
<script>
const $=id=>document.getElementById(id);let busy=false,gainHeld=0,cookieNote=0;
async function post(path,body){await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-ReSkate':'1'},body:JSON.stringify(body||{})});load()}
document.querySelectorAll('[data-cmd]').forEach(b=>b.onclick=()=>post('/api/command',{command:b.dataset.cmd}));
$('login').onclick=()=>post('/api/login');$('logout').onclick=()=>post('/api/logout');$('refresh').onclick=()=>post('/api/refresh');
$('saveClient').onclick=()=>post('/api/client',{id:$('client').value.trim()});
$('gain').oninput=()=>{gainHeld=Date.now();$('gainText').textContent=$('gain').value+'%'};$('gain').onchange=()=>post('/api/settings',{gain:+$('gain').value});
$('ytTools').onclick=()=>post('/api/youtube/tools');
$('ytAdd').onclick=()=>{const link=$('ytLink').value.trim();if(link){$('ytLink').value='';post('/api/youtube/add',{link})}};
$('ytCookies').onchange=async()=>{const f=$('ytCookies').files[0];if(!f)return;const text=await f.text();$('ytCookies').value='';
const r=await fetch('/api/youtube/cookies',{method:'POST',headers:{'Content-Type':'application/json','X-ReSkate':'1'},body:JSON.stringify({text})});
$('ytCookiesText').textContent=r.ok?'Saved.':'That is not a Netscape cookies.txt with YouTube cookies in it.';cookieNote=Date.now();load()};
$('ytCookiesRemove').onclick=()=>post('/api/youtube/cookies',{text:''});
$('ytBrowser').onchange=()=>post('/api/youtube/browser',{browser:$('ytBrowser').value});
$('saveApp').onclick=()=>post('/api/external',{app:$('app').value});
$('speakers').onchange=()=>post('/api/settings',{speakers:$('speakers').checked});
async function load(){if(busy)return;busy=true;try{const s=await (await fetch('/api/state')).json();
$('title').textContent=s.now.title||'Nothing yet';$('artist').textContent=s.now.artist||'';$('toggle').textContent=s.now.playing?'Pause':'Play';
$('tile').textContent=s.station?'A Spotify tile is on in the game':'No Spotify tile is on in the game: pick one in the music screen to hear it';
if(s.now.cover){if($('cover').dataset.src!==s.now.cover){$('cover').dataset.src=s.now.cover;$('cover').src=s.now.cover}}else{$('cover').removeAttribute('src');$('cover').dataset.src=''}
$('deviceDot').className='dot'+(s.device.connected?' on':' warn');$('deviceText').textContent=s.device.text;$('deviceSteps').style.display=s.device.connected?'none':'';
const a=s.account;$('accountDot').className='dot'+(a.signed_in?' on':'');
$('accountText').textContent=a.signed_in?('Signed in'+(a.user?' as '+a.user:'')+(a.busy?' · working…':'')):(a.has_client?'Not signed in':'No client ID yet');
$('login').style.display=a.signed_in||!a.has_client?'none':'';$('logout').style.display=a.signed_in?'':'none';$('refresh').style.display=a.signed_in?'':'none';
$('clientBox').style.display=a.signed_in?'none':'';if(document.activeElement!==$('client')&&!$('client').value)$('client').value=a.client||'';
$('problem').textContent=a.problem||'';
$('playlists').innerHTML='';for(const p of a.playlists){const li=document.createElement('li');li.className='pl';const n=document.createElement('span');n.textContent=p.name;const c=document.createElement('small');c.textContent=p.tracks+' tracks';li.append(n,c);$('playlists').append(li)}
const y=s.youtube;$('ytNone').textContent=y.available?'':'This ReSkate.dll was built without the part that plays it.';
$('ytSetup').style.display=y.available&&!y.tools?'':'none';$('ytReady').style.display=y.available&&y.tools?'':'none';
$('ytProgress').textContent=y.busy&&!y.tools?('Downloading… '+(y.percent?y.percent+'%':'')):'';$('ytTools').disabled=y.busy;$('ytAdd').disabled=y.busy;$('ytAdd').textContent=y.busy?'Working…':'Add';
$('ytProblem').textContent=y.problem||'';$('ytCookiesRemove').style.display=y.cookies?'':'none';$('ytRuntime').textContent=(y.cookies||y.browser)&&!y.runtime?'Getting what the sign-in needs (a small JavaScript runtime)… songs will not play until it is here. If this stays, check your connection or install Node.js (nodejs.org).':'';if(Date.now()-cookieNote>4000)$('ytCookiesText').textContent=y.cookies?'A cookies.txt is in use.':'';if(document.activeElement!==$('ytBrowser'))$('ytBrowser').value=y.browser||'';$('ytLists').innerHTML='';
y.playlists.forEach((p,i)=>{const li=document.createElement('li');li.className='pl';const n=document.createElement('span');n.textContent=p.name+' · '+p.songs+' songs';const b=document.createElement('button');b.textContent='Remove';b.onclick=()=>post('/api/youtube/remove',{index:i});li.append(n,b);$('ytLists').append(li)});
if(document.activeElement!==$('app')){const e=s.external,names=e.apps.includes(e.app)||!e.app?e.apps:[e.app,...e.apps];$('app').innerHTML='';
for(const n of names){const o=document.createElement('option');o.value=n;o.textContent=n+(e.apps.includes(n)?'':' (not playing sound now)');$('app').append(o)}if(e.app)$('app').value=e.app}
$('appText').textContent=s.external.app?('Using '+s.external.app+(s.external.on?' · on now · '+s.external.capture:'')):'No app picked yet. Only apps that are making sound right now are listed: start it playing first.';
if(Date.now()-gainHeld>1500){$('gain').value=s.settings.gain;$('gainText').textContent=s.settings.gain+'%'}$('speakers').checked=s.settings.speakers;
}catch(e){$('deviceText').textContent='The game is not answering (is it still running?)'}busy=false}
load();setInterval(load,2000);
</script></body></html>)html";
// The sound's settings, as the player left them: <Local AppData>\\ReSkate\\music-settings.json.
std::filesystem::path settings_file() { return redirected_local_app_data().parent_path() / L"music-settings.json"; }
void keep_settings() {
try {
Json json = Json::object();
json["gain"] = static_cast<std::uint64_t>(music_inject::gain() * 100.0f + 0.5f);
json["speakers"] = music_inject::mode() != 3;
json["levelling"] = audio_dsp::levelling();
json["notices"] = youtube_music::notices();
Json bands = Json::array();
for (int i = 0; i < audio_dsp::bands; ++i) bands.push_back(static_cast<std::int64_t>(std::lround(audio_dsp::band(i))));
json["equaliser"] = std::move(bands);
std::ofstream(settings_file(), std::ios::binary | std::ios::trunc) << json.dump(2);
} catch (...) {}
}
void read_settings() {
try {
std::ifstream in(settings_file(), std::ios::binary);
if (!in) return;
const auto json = Json::parse(std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()));
if (json.contains("gain") && json.at("gain").is_number()) music_inject::set_gain(static_cast<float>(std::clamp<std::int64_t>(json.at("gain").get<std::int64_t>(), 0, 400)) / 100.0f);
if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get<bool>() ? 1 : 3);
if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get<bool>());
if (json.contains("notices") && json.at("notices").is_boolean()) youtube_music::set_notices(json.at("notices").get<bool>());
if (json.contains("equaliser") && json.at("equaliser").is_array())
for (std::size_t i = 0; i < json.at("equaliser").size() && i < audio_dsp::bands; ++i)
if (json.at("equaliser").at(i).is_number()) audio_dsp::set_band(static_cast<int>(i), static_cast<float>(std::clamp<std::int64_t>(json.at("equaliser").at(i).get<std::int64_t>(), -12, 12)));
} catch (...) {}
}
std::string state_json() {
Json out = Json::object();
@@ -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<std::uint64_t>(i);
entry["name"] = playlist.name;
entry["songs"] = static_cast<std::uint64_t>(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<std::uint64_t>(tube.playing_list);
queue["name"] = playlist.name;
queue["kind"] = playlist.kind == Kind::files ? "local" : playlist.kind == Kind::radio ? "radio" : "youtube";
queue["at"] = static_cast<std::uint64_t>(tube.playing_song);
Json songs = Json::array();
for (const auto& song : playlist.songs) {
Json entry = Json::object();
entry["artist"] = song.artist;
entry["title"] = song.title;
// Its picture: its video's and its station's from where they are; its file's from here.
entry["cover"] = playlist.kind == Kind::youtube ? "https://i.ytimg.com/vi/" + song.id + "/mqdefault.jpg"
: playlist.kind == Kind::radio ? (playlist.url.starts_with("https://") ? playlist.url : std::string())
: std::format("/api/art/{}/{}?{}", tube.playing_list, songs.size(), song.id);
songs.push_back(std::move(entry));
}
queue["songs"] = std::move(songs);
out["queue"] = std::move(queue);
}
}
// (And Spotify's own queue, when Spotify is what is playing and the player has signed in:
// the song playing, then those Spotify says come after it.)
if (!out.contains("queue") && spotify_station::active() && !youtube_music::on() && !external_audio::on() && account_state.signed_in) {
spotify_web::want_queue();
// (Looked at again at once when the song has changed.)
static std::atomic<std::uint64_t> seen{};
if (seen.exchange(track.revision) != track.revision) spotify_web::want_queue(true);
if (const auto lined_up = spotify_web::queue(); !lined_up.empty()) {
Json queue = Json::object();
queue["playlist"] = static_cast<std::uint64_t>(0);
queue["name"] = "Spotify";
queue["kind"] = "spotify";
queue["at"] = static_cast<std::uint64_t>(0);
Json songs = Json::array();
for (const auto& song : lined_up) {
Json entry = Json::object();
entry["artist"] = song.artist;
entry["title"] = song.title;
entry["cover"] = song.cover;
songs.push_back(std::move(entry));
}
queue["songs"] = std::move(songs);
out["queue"] = std::move(queue);
}
}
Json external = Json::object();
external["app"] = external_audio::app();
@@ -204,6 +223,10 @@ std::string state_json() {
Json settings = Json::object();
settings["gain"] = static_cast<std::uint64_t>(music_inject::gain() * 100.0f + 0.5f);
settings["speakers"] = music_inject::mode() != 3;
settings["levelling"] = audio_dsp::levelling();
Json bands = Json::array();
for (int i = 0; i < audio_dsp::bands; ++i) bands.push_back(static_cast<std::int64_t>(std::lround(audio_dsp::band(i))));
settings["equaliser"] = std::move(bands);
out["settings"] = std::move(settings);
return out.dump();
}
@@ -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<bool>()) {
const auto count = static_cast<int>(std::clamp<std::int64_t>(json.at("song").get<std::int64_t>(), 0, 25));
// (One at a time: a press while it is still going on is not taken.)
static std::atomic<bool> going{};
if (going.exchange(true)) return true;
std::thread([count] {
for (int i = 0; i < count; ++i) {
external_player::send(external_player::Command::next);
Sleep(450);
}
spotify_web::want_queue(true);
going.store(false);
}).detach();
return true;
}
youtube_music::play_at(static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("playlist").get<std::int64_t>(), 0, 1000)),
static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("song").get<std::int64_t>(), 0, 100000)));
} else if (path == "/api/youtube/remove" || path == "/api/library/remove") {
if (!json.contains("index") || !json.at("index").is_number()) return false;
youtube_music::remove(static_cast<std::size_t>(std::clamp<std::int64_t>(json.at("index").get<std::int64_t>(), 0, 1000)));
} else if (path == "/api/external") {
@@ -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<float>(std::clamp<std::int64_t>(json.at("gain").get<std::int64_t>(), 0, 400)) / 100.0f);
if (json.contains("speakers") && json.at("speakers").is_boolean()) music_inject::set_mode(json.at("speakers").get<bool>() ? 1 : 3);
if (json.contains("levelling") && json.at("levelling").is_boolean()) audio_dsp::set_levelling(json.at("levelling").get<bool>());
if (json.contains("equaliser") && json.at("equaliser").is_array())
for (std::size_t i = 0; i < json.at("equaliser").size() && i < audio_dsp::bands; ++i)
if (json.at("equaliser").at(i).is_number()) audio_dsp::set_band(static_cast<int>(i), static_cast<float>(std::clamp<std::int64_t>(json.at("equaliser").at(i).get<std::int64_t>(), -12, 12)));
keep_settings();
} else {
return false;
}
return true;
}
void answer(SOCKET client, const char* status, const char* type, const std::string& body) {
const 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<int>(head.size()), 0);
for (std::size_t sent = 0; sent < body.size();) {
const int did = send(client, body.data() + sent, static_cast<int>(std::min<std::size_t>(body.size() - sent, 65536)), 0);
@@ -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/<playlist>/<song>.
std::size_t playlist{}, song{};
if (sscanf_s(path.c_str(), "/api/art/%zu/%zu", &playlist, &song) != 2) return answer(client, "404 Not Found", "text/plain", "no such picture");
const auto image = youtube_music::art(playlist, song);
const bool png = image.size() > 8 && image[0] == 0x89 && image[1] == 'P', jpeg = image.size() > 8 && image[0] == 0xff && image[1] == 0xd8;
if (!png && !jpeg) return answer(client, "404 Not Found", "text/plain", "no picture", "private, max-age=3600");
return answer(client, "200 OK", png ? "image/png" : "image/jpeg", std::string(image.begin(), image.end()), "private, max-age=3600");
}
if (method == "GET" && path == "/api/state") return answer(client, "200 OK", "application/json", state_json());
if (method == "POST") {
// A change comes from this page alone: its script sends a header that a page of another
@@ -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();
+6 -2
View File
@@ -1,10 +1,11 @@
#pragma once
#include <string>
// 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;
}
+1 -1
View File
@@ -79,7 +79,7 @@ void told(const char* text) {
if (parts.empty()) return;
if (parts[0] == "LOG") {
static std::atomic<int> 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") {
+7 -7
View File
@@ -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<int> said{}; said.fetch_add(1, std::memory_order_relaxed) < 600)
if (static std::atomic<int> 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<std::uintptr_t>(GetModuleHandleW(nullptr));
std::string callers;
for (unsigned i = 0; i < count; ++i) callers += std::format(" +0x{:x}", reinterpret_cast<std::uintptr_t>(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<int> said{}; spotify_station::recent() && said.fetch_add(1, std::memory_order_relaxed) < 600) {
if (static std::atomic<int> 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<std::uintptr_t>(GetModuleHandleW(nullptr));
std::string callers;
for (unsigned i = 0; i < count; ++i) callers += std::format(" +0x{:x}", reinterpret_cast<std::uintptr_t>(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;
}
+7 -7
View File
@@ -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<std::uint64_t>(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 (...) {}
+53 -5
View File
@@ -39,6 +39,8 @@ std::condition_variable wake;
std::deque<std::string> commands; // "login", "logout", "refresh", "play <id>"
std::string client_id, refresh_token, user_name, problem_text;
std::vector<Playlist> known;
std::vector<Queued> lined_up;
std::atomic<std::uint64_t> queue_asked{};
bool is_busy{};
std::atomic<bool> 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<Track> 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<Queued> 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<Playlist> 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 <id>)");
if (client.empty()) return problem("no client id is set (music spotify client <id>)");
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<Playlist> playlists() {
std::lock_guard lock(mutex);
return known;
}
std::vector<Queued> queue() {
std::lock_guard lock(mutex);
return refresh_token.empty() ? std::vector<Queued>{} : 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()};
+11 -2
View File
@@ -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 <id>" in the console), of an
// none of its own built in: the id is a setting ("music spotify client <id>" 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 <Local AppData>\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<Track> 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<Playlist> 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<Queued> queue();
void want_queue(bool now = false);
State state();
}
+458 -60
View File
@@ -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 <Windows.h>
#include <bcrypt.h>
#include <objbase.h>
#include <ShlObj.h>
#include <algorithm>
#include <atomic>
#include <condition_variable>
@@ -17,6 +19,8 @@
#include <format>
#include <fstream>
#include <mutex>
#include <optional>
#include <string_view>
#include <thread>
#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<std::string> commands; // "tools", "add <link>", "covers"
std::vector<Playlist> library;
std::string problem_text, browser_name;
Kind problem_of{};
std::vector<Found> found_stations; // of the last search for radio stations
std::string searched_for;
bool is_busy{}, have_tools{}, have_cookies{};
std::atomic<bool> started{}, is_on{}, paused{};
std::atomic<bool> started{}, is_on{}, paused{}, say_notices{true};
std::atomic<unsigned> 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<std::uint64_t> 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::size_t>(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<Playlist> 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::size_t>(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<std::uint8_t> 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<std::uint8_t>(image->begin(), image->end()) : std::vector<std::uint8_t>{};
// --- 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<wchar_t>(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<std::pair<std::size_t, std::string>> 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<unsigned char>(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<std::filesystem::path> 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<char>(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<std::string> 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> 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<std::int64_t>() != 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<std::int64_t>() > 0)
station.about += " " + std::to_string(entry.at("bitrate").get<std::int64_t>()) + " 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<std::uint8_t> fetched(const std::wstring& address) {
const auto image = https::get_text(address, 2 * 1024 * 1024, 10, agent);
return image ? std::vector<std::uint8_t>(image->begin(), image->end()) : std::vector<std::uint8_t>{};
}
// 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<std::uint8_t> 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<std::uint8_t>{};
#if DINGOSDK_SPOTIFY_DEVICE
std::vector<std::uint8_t> 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<std::uint32_t>(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<Song> songs;
};
std::vector<Wanted> 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<std::size_t>(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<std::ptrdiff_t>(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<double>(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<double>(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<std::size_t>(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<Playlist> playlists() {
std::lock_guard lock(mutex);
return have_tools ? library : std::vector<Playlist>{};
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<std::ptrdiff_t>(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<std::uint8_t> 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
+52 -12
View File
@@ -3,30 +3,44 @@
#include <string>
#include <vector>
// "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 <Local AppData>\ReSkate\bin. What is added is kept in
// <Local AppData>\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<Song> 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<Playlist> playlists;
std::vector<Found> 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<Playlist> 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<std::uint8_t> 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;
+126
View File
@@ -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 <Windows.h>
#include <array>
#include <atomic>
#include <mutex>
#include <string_view>
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<ParseChunk> original_parse{};
// A script that keeps trying must not fill the log.
void refused(std::atomic<unsigned>& 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<char, 200> copy{};
if (!text || memory::peek_cstring(reinterpret_cast<std::uintptr_t>(text), copy.data(), copy.size()) < 0) return {};
std::string result(copy.data());
for (auto& c : result) if (static_cast<unsigned char>(c) < 32 || static_cast<unsigned char>(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<unsigned> 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<const char**>(static_cast<unsigned char*>(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<std::string_view, refusal_count> 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<std::size_t Index> int nothing(void*) {
static std::atomic<unsigned> count{};
refused(count, refusal_names[Index]);
return 0;
}
const std::array<Refusal, refusal_count> refusals{{
{lua::os_execute, &nothing<0>}, {lua::io_popen, &nothing<1>}, {lua::io_open, &nothing<2>},
{lua::io_output, &nothing<3>}, {lua::os_remove, &nothing<4>}, {lua::os_rename, &nothing<5>}}};
bool matches(std::uintptr_t base, const game::build::Fingerprint& contract) noexcept {
std::array<unsigned char, 32> 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<PushFormat>(base + lua::push_format.rva);
std::array<void*, refusal_count + 2> 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<void*>(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<void*>(&load_library), nullptr) &&
prepare(lua::parse_chunk.rva, reinterpret_cast<void*>(&parse_chunk), reinterpret_cast<void**>(&parse)) && parse;
for (std::size_t i = 0; ready && i < refusals.size(); ++i)
ready = prepare(refusals[i].contract.rva, reinterpret_cast<void*>(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
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <cstdint>
#include <string>
// 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);
}
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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 {
+4 -3
View File
@@ -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<const char*, page_count> 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<const char*, page_count> 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;
@@ -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();
+68 -13
View File
@@ -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 |
+4
View File
@@ -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);
+1 -1
View File
@@ -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);
+31
View File
@@ -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"
+3 -1
View File
@@ -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
+28 -6
View File
@@ -1,5 +1,7 @@
//! By hand: runs a program and says how much sound came of its output.
//! cargo run --release --example decode -- <program> <argument>...
//! By hand: plays a stream for a while and says how much sound came of it.
//! cargo run --release --example decode -- run <program> <argument>...
//! cargo run --release --example decode -- file <path>
//! cargo run --release --example decode -- url <address> [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::<Vec<_>>().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<String> = 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);
}
+279 -30
View File
@@ -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 <why>".
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<dyn MediaSource>, 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::<f32>::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<String> {
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<u8>)> {
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::<u8>(), 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<R: Read> {
inner: R,
every: usize,
left: usize,
stream: u64,
titled: Titled,
last: String,
}
impl<R: Read> Station<R> {
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(&note);
if let Some(from) = note.find("StreamTitle='") {
let rest = &note[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<R: Read> Read for Station<R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
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<R: Read> {
inner: R,
stream: u64,
}
impl<R: Read> Read for Watched<R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
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<R: Read> {
inner: R,
pending: Vec<u8>,
pass: usize,
}
impl<R: Read> Read for Adts<R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
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::<Duration>) // (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::<usize>().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() {
+9
View File
@@ -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)
+1
View File
@@ -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
)