mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-29 03:46:50 +01:00
Three outside sources can now be played through the game's own music
player. Each has its own row in the music screen, shown only when it is
set up, with tiles that behave like the game's playlists: track name,
cover, pause, next, and pressing a row to start that track.
How the sound gets in
- The game's song decoder is hooked. While one of these tiles is on, the
audio of the game song behind it (its "carrier") is replaced, block by
block, with the outside source's audio. The game's volume, fades and
pausing still apply.
- Rows are songs of our own registered with the game's music registry
(copies of a real song's asset with their own artist and title), so no
game song is borrowed or renamed to list a track.
Spotify
- The game is a Spotify Connect device ("ReSkate"), built on librespot
and linked into ReSkate.dll (SpotifyHelper/, Rust). Audio, track info
and controls come straight from Spotify. Needs Premium.
- Optional sign-in through the Web API lists the player's playlists and
Liked Songs as tiles with their tracks.
- librespot can no longer end the process (it does so on a free account
and on playback errors); only its thread is stopped.
YouTube Music
- Videos and playlists added by link. yt-dlp is fetched on request into
<Local AppData>\ReSkate\bin and checked against its published sums;
decoding is done in the DLL, so no ffmpeg.
- Private and age-restricted songs: cookies.txt or a browser's sign-in,
with QuickJS fetched as the JavaScript runtime yt-dlp then needs
(Node.js is used instead when installed).
External Audio
- Any app's sound, captured per process from Windows, with the app's own
output turned down while it plays through the game.
Setup page
- "spotify setup" opens http://127.0.0.1:8421/: device status, sign-ins,
YouTube links and tools, the external app, loudness. Loopback only;
changes are accepted from its own page only.
Also
- Speakers in the world play the tile's audio; grabbing one keeps the
player on their tile.
- Playlists added while the game runs are sent to the music screen live.
- MUSIC-SETUP.md: setup guide for players.
Building the Spotify device and the YouTube decoder needs cargo; without
it ReSkate.dll builds without them.
290 lines
14 KiB
Rust
290 lines
14 KiB
Rust
//! ReSkate's Spotify Connect device (librespot), built into ReSkate.dll.
|
|
//!
|
|
//! The game is a device in the player's Spotify app ("ReSkate"), and what Spotify plays on it
|
|
//! goes to the game (Extension/Music/spotify_device.cpp), which starts this once:
|
|
//! `sound` is given the sound as it is to be heard: 32-bit float samples, stereo, 44100 Hz.
|
|
//! It returns when the game has room for more, which is what keeps Spotify at the
|
|
//! speed of the game.
|
|
//! `told` is given a line for each thing the game is told, with tabs between its parts:
|
|
//! READY the device is in Spotify's list (on this network)
|
|
//! CONNECTED <user> a Spotify account is using it
|
|
//! DISCONNECTED
|
|
//! TRACK <uri> <artist> <title> <cover address> <milliseconds>
|
|
//! STATE playing|paused|stopped
|
|
//! LOG <text> this library's own log
|
|
//! ENDED it has stopped for good
|
|
//! `reskate_spotify_ask` takes a line for each thing the game asks:
|
|
//! activate play here, taking over from wherever Spotify plays
|
|
//! play | pause | next | prev
|
|
//! load <context uri> [<track uri>] a playlist or album, at a track of it
|
|
//! shuffle <context uri> the same, in no order and over again
|
|
//! tracks <index> <track uri>... these tracks, at one of them
|
|
//! quit
|
|
//!
|
|
//! Who the account is comes from the Spotify app: picking the device there hands it over, and
|
|
//! it is kept (the cache folder) so the device is there the next time without being picked.
|
|
|
|
pub mod stream;
|
|
|
|
use std::ffi::{c_char, CStr, CString};
|
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use futures_util::StreamExt;
|
|
use librespot_connect::{ConnectConfig, LoadContextOptions, LoadRequest, LoadRequestOptions, Options, PlayingTrack, Spirc};
|
|
use librespot_core::authentication::Credentials;
|
|
use librespot_core::cache::Cache;
|
|
use librespot_core::config::SessionConfig;
|
|
use librespot_core::session::Session;
|
|
use librespot_discovery::{DeviceType, Discovery};
|
|
use librespot_metadata::audio::UniqueFields;
|
|
use librespot_playback::audio_backend::{Sink, SinkError, SinkResult};
|
|
use librespot_playback::config::{Bitrate, PlayerConfig};
|
|
use librespot_playback::convert::Converter;
|
|
use librespot_playback::decoder::AudioPacket;
|
|
use librespot_playback::mixer::softmixer::SoftMixer;
|
|
use librespot_playback::mixer::{Mixer, MixerConfig};
|
|
use librespot_playback::player::{Player, PlayerEvent};
|
|
use sha1::{Digest, Sha1};
|
|
use tokio::sync::mpsc;
|
|
|
|
type Sound = extern "C" fn(samples: *const f32, frames: u32);
|
|
type Told = extern "C" fn(line: *const c_char);
|
|
|
|
static SOUND: OnceLock<Sound> = OnceLock::new();
|
|
static TOLD: OnceLock<Told> = OnceLock::new();
|
|
static ASK: OnceLock<mpsc::UnboundedSender<String>> = OnceLock::new();
|
|
|
|
/// What the game is told: a line.
|
|
fn tell(parts: &[&str]) {
|
|
let clean: Vec<String> = parts.iter().map(|part| part.replace(['\t', '\r', '\n', '\0'], " ")).collect();
|
|
if let (Some(told), Ok(line)) = (TOLD.get(), CString::new(clean.join("\t"))) {
|
|
told(line.as_ptr());
|
|
}
|
|
}
|
|
|
|
/// This library's own log, told to the game as well.
|
|
struct Log;
|
|
impl log::Log for Log {
|
|
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
|
metadata.level() <= log::Level::Info
|
|
}
|
|
fn log(&self, record: &log::Record) {
|
|
if self.enabled(record.metadata()) {
|
|
tell(&["LOG", &format!("{}: {}", record.level(), record.args())]);
|
|
}
|
|
}
|
|
fn flush(&self) {}
|
|
}
|
|
|
|
/// The sound goes to the game as it comes.
|
|
struct GameSink;
|
|
impl Sink for GameSink {
|
|
fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {
|
|
let samples = packet.samples().map_err(|error| SinkError::OnWrite(error.to_string()))?;
|
|
let converted = converter.f64_to_f32(samples);
|
|
if let Some(sound) = SOUND.get() {
|
|
sound(converted.as_ptr(), (converted.len() / 2) as u32);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn told(event: PlayerEvent) {
|
|
match event {
|
|
PlayerEvent::TrackChanged { audio_item } => {
|
|
let artist = match &audio_item.unique_fields {
|
|
UniqueFields::Track { artists, .. } => artists.first().map(|artist| artist.name.clone()).unwrap_or_default(),
|
|
UniqueFields::Episode { show_name, .. } => show_name.clone(),
|
|
_ => String::new(),
|
|
};
|
|
let cover = audio_item.covers.iter().max_by_key(|cover| cover.width).map(|cover| cover.url.clone()).unwrap_or_default();
|
|
tell(&["TRACK", &audio_item.uri, &artist, &audio_item.name, &cover, &audio_item.duration_ms.to_string()]);
|
|
}
|
|
PlayerEvent::Playing { .. } => tell(&["STATE", "playing"]),
|
|
PlayerEvent::Paused { .. } => tell(&["STATE", "paused"]),
|
|
PlayerEvent::Stopped { .. } => tell(&["STATE", "stopped"]),
|
|
PlayerEvent::SessionConnected { user_name, .. } => tell(&["CONNECTED", &user_name]),
|
|
PlayerEvent::SessionDisconnected { .. } => tell(&["DISCONNECTED"]),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
async fn run(name: String, cache_folder: Option<PathBuf>, mut asked: mpsc::UnboundedReceiver<String>) {
|
|
let device_id = hex::encode(Sha1::digest(name.as_bytes()));
|
|
let mut session_config = SessionConfig::default();
|
|
session_config.device_id = device_id.clone();
|
|
let cache = cache_folder.and_then(|folder| Cache::new(Some(folder.clone()), Some(folder), None::<PathBuf>, None).ok());
|
|
|
|
// In Spotify's list of devices on this network: picking it there hands over the account.
|
|
let mut discovery = match Discovery::builder(device_id, session_config.client_id.clone()).name(name.clone()).device_type(DeviceType::Computer).launch() {
|
|
Ok(discovery) => {
|
|
tell(&["READY"]);
|
|
Some(discovery)
|
|
}
|
|
Err(error) => {
|
|
log::error!("the device could not be put in Spotify's list: {error}");
|
|
None
|
|
}
|
|
};
|
|
|
|
let mut credentials: Option<Credentials> = cache.as_ref().and_then(|cache| cache.credentials());
|
|
loop {
|
|
// Until there is an account: one kept from before, or one handed over now.
|
|
let account = match credentials.take() {
|
|
Some(account) => account,
|
|
None => loop {
|
|
tokio::select! {
|
|
found = async { match discovery.as_mut() { Some(discovery) => discovery.next().await, None => std::future::pending().await } } => {
|
|
if let Some(account) = found { break account; }
|
|
}
|
|
line = asked.recv() => match line.as_deref() {
|
|
Some("quit") | None => return,
|
|
_ => {}
|
|
},
|
|
}
|
|
},
|
|
};
|
|
|
|
let session = Session::new(session_config.clone(), cache.clone());
|
|
let mixer: Arc<dyn Mixer> = match SoftMixer::open(MixerConfig::default()) {
|
|
Ok(mixer) => Arc::new(mixer),
|
|
Err(error) => {
|
|
log::error!("no mixer: {error}");
|
|
return;
|
|
}
|
|
};
|
|
let player_config = PlayerConfig { bitrate: Bitrate::Bitrate320, ..PlayerConfig::default() };
|
|
let player = Player::new(player_config, session.clone(), mixer.get_soft_volume(), || Box::new(GameSink));
|
|
let mut events = player.get_player_event_channel();
|
|
let connect_config = ConnectConfig { name: name.clone(), device_type: DeviceType::Computer, initial_volume: u16::MAX, ..ConnectConfig::default() };
|
|
let (spirc, task) = match Spirc::new(connect_config, session.clone(), account, player.clone(), mixer).await {
|
|
Ok(made) => made,
|
|
Err(error) => {
|
|
// (An account kept from before that Spotify no longer takes: it is asked for anew.)
|
|
log::error!("Spotify did not take the account: {error}");
|
|
tell(&["DISCONNECTED"]);
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
continue;
|
|
}
|
|
};
|
|
// (With the account taken, the device is the player's to use.)
|
|
tell(&["CONNECTED", &session.username()]);
|
|
tokio::pin!(task);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut task => {
|
|
tell(&["DISCONNECTED"]);
|
|
break;
|
|
}
|
|
event = events.recv() => match event {
|
|
Some(event) => told(event),
|
|
None => break,
|
|
},
|
|
found = async { match discovery.as_mut() { Some(discovery) => discovery.next().await, None => std::future::pending().await } } => {
|
|
// Another account was handed over: this one is let go of.
|
|
if let Some(account) = found {
|
|
credentials = Some(account);
|
|
let _ = spirc.shutdown();
|
|
}
|
|
}
|
|
line = asked.recv() => {
|
|
let Some(line) = line else { return };
|
|
let mut words = line.split_whitespace();
|
|
let result = match words.next() {
|
|
Some("activate") => spirc.activate(),
|
|
Some("play") => spirc.play(),
|
|
Some("pause") => spirc.pause(),
|
|
Some("next") => spirc.next(),
|
|
Some("prev") => spirc.prev(),
|
|
Some("load") => match words.next() {
|
|
Some(context) => {
|
|
let playing_track = words.next().map(|track| PlayingTrack::Uri(track.to_string()));
|
|
// (Loading is for the device that is playing: this is made it first.)
|
|
let _ = spirc.activate();
|
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
|
spirc.load(LoadRequest::from_context_uri(context.to_string(), LoadRequestOptions { start_playing: true, seek_to: 0, context_options: None, playing_track }))
|
|
}
|
|
None => Ok(()),
|
|
},
|
|
Some("shuffle") => match words.next() {
|
|
Some(context) => {
|
|
let _ = spirc.activate();
|
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
|
let context_options = Some(LoadContextOptions::Options(Options { shuffle: true, repeat: true, repeat_track: false }));
|
|
spirc.load(LoadRequest::from_context_uri(context.to_string(), LoadRequestOptions { start_playing: true, seek_to: 0, context_options, playing_track: None }))
|
|
}
|
|
None => Ok(()),
|
|
},
|
|
Some("tracks") => {
|
|
let index = words.next().and_then(|index| index.parse::<u32>().ok()).unwrap_or(0);
|
|
let tracks: Vec<String> = words.map(str::to_string).collect();
|
|
if tracks.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
let _ = spirc.activate();
|
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
|
spirc.load(LoadRequest::from_tracks(tracks, LoadRequestOptions { start_playing: true, seek_to: 0, context_options: None, playing_track: Some(PlayingTrack::Index(index)) }))
|
|
}
|
|
}
|
|
Some("quit") => {
|
|
let _ = spirc.shutdown();
|
|
return;
|
|
}
|
|
_ => Ok(()),
|
|
};
|
|
if let Err(error) = result {
|
|
log::warn!("'{line}' was not done: {error}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn text(pointer: *const c_char) -> Option<String> {
|
|
if pointer.is_null() {
|
|
return None;
|
|
}
|
|
// SAFETY: the game passes a string that ends in a zero and outlives the call.
|
|
unsafe { CStr::from_ptr(pointer) }.to_str().ok().map(str::to_string)
|
|
}
|
|
|
|
/// Starts the device, once, on threads of its own. `name` is what it is called in Spotify,
|
|
/// `cache` the folder the account is kept in (may be null). Returns 1 when it was started.
|
|
#[no_mangle]
|
|
pub extern "C" fn reskate_spotify_start(name: *const c_char, cache: *const c_char, sound: Sound, told: Told) -> i32 {
|
|
let (ask, asked) = mpsc::unbounded_channel::<String>();
|
|
if ASK.set(ask).is_err() {
|
|
return 0;
|
|
}
|
|
let _ = SOUND.set(sound);
|
|
let _ = TOLD.set(told);
|
|
let _ = log::set_logger(&Log).map(|()| log::set_max_level(log::LevelFilter::Info));
|
|
let name = text(name).unwrap_or_else(|| "ReSkate".to_string());
|
|
let cache = text(cache).map(PathBuf::from);
|
|
let started = std::thread::Builder::new().name("ReSkate Spotify".to_string()).spawn(move || {
|
|
// (Nothing that goes wrong here is to take the game with it.)
|
|
let outcome = catch_unwind(AssertUnwindSafe(|| match tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build() {
|
|
Ok(runtime) => runtime.block_on(run(name, cache, asked)),
|
|
Err(error) => log::error!("no runtime: {error}"),
|
|
}));
|
|
if outcome.is_err() {
|
|
tell(&["LOG", "ERROR: the Spotify device stopped on a fault of its own"]);
|
|
}
|
|
tell(&["ENDED"]);
|
|
});
|
|
i32::from(started.is_ok())
|
|
}
|
|
|
|
/// Asks the device for something (see the top of this file).
|
|
#[no_mangle]
|
|
pub extern "C" fn reskate_spotify_ask(line: *const c_char) {
|
|
if let (Some(ask), Some(line)) = (ASK.get(), text(line)) {
|
|
let _ = ask.send(line.trim().to_string());
|
|
}
|
|
}
|