From 4e4ff152e281205c4d4d7d107023e465d1bfb4f5 Mon Sep 17 00:00:00 2001 From: DreadHeadHippy <155098676+DreadHeadHippy@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:26:16 -0400 Subject: [PATCH] Music: link a Plex account, with now-playing, album art and transport controls --- Extension/Music/external_player.cpp | 61 +++- Extension/Music/plex_web.cpp | 496 ++++++++++++++++++++++++++ Extension/Music/plex_web.h | 40 +++ Extension/Music/setup_page.html | 28 ++ Extension/Music/spotify_dashboard.cpp | 14 + Runtime/runtime_init.cpp | 2 + cmake/Runtime.cmake | 1 + 7 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 Extension/Music/plex_web.cpp create mode 100644 Extension/Music/plex_web.h diff --git a/Extension/Music/external_player.cpp b/Extension/Music/external_player.cpp index 3e5fcda..8ae0336 100644 --- a/Extension/Music/external_player.cpp +++ b/Extension/Music/external_player.cpp @@ -1,4 +1,5 @@ #include "external_player.h" +#include "plex_web.h" #include "spotify_device.h" #include "youtube_music.h" #include @@ -68,11 +69,53 @@ std::vector read_artwork(const streams::IRandomAccessStreamReferen reader.ReadBytes(bytes); return bytes; } +// What Plex's own server says, laid over what Windows gives: the server's title, artist, album +// and cover are the ones the player's library has, which a player's media session often shortens +// or leaves without a cover. Used for Plex's own apps, and for any other that is playing the song +// the server says an account of the player is. +bool plex_is(const std::string& app) { + std::string lower(app); + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return lower.find("plex") != std::string::npos; +} +bool same_text(const std::string& a, const std::string& b) { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); +} +void lay_plex(Track& track, const Track& before, bool force) { + const auto plex = plex_web::now_playing(); + if (!plex.valid || plex.title.empty()) return; + if (!force && !same_text(plex.title, track.title)) return; + track.title = plex.title; + track.artist = plex.artist; + track.album = plex.album; + track.playing = plex.playing; + if (!plex.art.empty()) { + track.artwork = plex.art; + track.artwork_revision = plex.art == before.artwork ? before.artwork_revision : before.artwork_revision + 1; + } +} // One look at the player: the track, with its cover read again only when the song changed. Track look(const control::GlobalSystemMediaTransportControlsSessionManager& manager, const Track& before) { Track track; const auto session = pick(manager); - if (!session) return track; + if (!session) { + // Plex's player has no media session of its own to read (a Plex app on another device): + // the server's word is all there is, and its transport is the server's. + bool plex_wanted{}; + { + std::lock_guard lock(app_mutex); + plex_wanted = wanted_app.find("plex") != std::string::npos; + } + if (plex_wanted) { + track.app = "plex"; + lay_plex(track, before, true); + if (!track.title.empty()) { + track.available = true; + track.can_next = track.can_previous = track.can_pause = track.can_play = true; + } + } + return track; + } track.available = true; track.app = winrt::to_string(session.SourceAppUserModelId()); track.spotify = spotify_app(track.app); @@ -101,6 +144,7 @@ Track look(const control::GlobalSystemMediaTransportControlsSessionManager& mana } catch (const winrt::hresult_error&) {} track.artwork_revision = track.artwork == before.artwork ? before.artwork_revision : before.artwork_revision + 1; } + if (!track.spotify) lay_plex(track, before, plex_is(track.app)); return track; } bool same(const Track& a, const Track& b) { @@ -110,7 +154,20 @@ bool same(const Track& a, const Track& b) { } void run_command(const control::GlobalSystemMediaTransportControlsSessionManager& manager, Command command, const std::string& app) { const auto session = pick(manager, &app); - if (!session) return; + if (!session) { + // No media session for a Plex player: the server is asked to drive it. + if (app.find("plex") != std::string::npos) { + using plex_web::Action; + switch (command) { + case Command::play: plex_web::control(Action::play); break; + case Command::pause: plex_web::control(Action::pause); break; + case Command::toggle: plex_web::control(plex_web::now_playing().playing ? Action::pause : Action::play); break; + case Command::next: plex_web::control(Action::next); break; + case Command::previous: plex_web::control(Action::previous); break; + } + } + return; + } switch (command) { case Command::play: session.TryPlayAsync().get(); break; case Command::pause: session.TryPauseAsync().get(); break; diff --git a/Extension/Music/plex_web.cpp b/Extension/Music/plex_web.cpp new file mode 100644 index 0000000..7a8e1da --- /dev/null +++ b/Extension/Music/plex_web.cpp @@ -0,0 +1,496 @@ +#include "plex_web.h" +#include "Engine/Core/Json/json.h" +#include "Engine/Core/Log/logging.h" +#include "Extension/Boot/user_data_redirect.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "winhttp") +#pragma comment(lib, "bcrypt") +#pragma comment(lib, "crypt32") +#pragma comment(lib, "shell32") + +namespace dingosdk::plex_web { +namespace { +constexpr wchar_t agent[] = L"ReSkate"; +constexpr wchar_t product[] = L"ReSkate"; +constexpr std::uint64_t approval_wait_ms = 5 * 60 * 1000; + +std::mutex mutex; // everything below it +std::condition_variable wake; +std::deque commands; // "login", "logout" +std::string auth_token, client_id, user_name, problem_text; +bool is_busy{}; +std::atomic started{}; + +void say(logging::Level level, const std::string& text) noexcept { + try { + logging::log(level, logging::Channel::music, "Plex account: {}", text); + } catch (...) {} +} +void problem(const std::string& text) { + { + std::lock_guard lock(mutex); + problem_text = text; + } + if (!text.empty()) say(logging::Level::warning, text); +} + +std::wstring wide(const std::string& text) { + if (text.empty()) return {}; + std::wstring out(static_cast(MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), nullptr, 0)), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.data(), static_cast(text.size()), out.data(), static_cast(out.size())); + return out; +} +std::string escaped(const std::string& text) { + std::string out; + for (const unsigned char c : text) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out.push_back(static_cast(c)); + else out += std::format("%{:02X}", c); + } + return out; +} +std::string random_hex(std::size_t bytes) { + std::vector data(bytes); + if (BCryptGenRandom(nullptr, data.data(), static_cast(data.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) return {}; + std::string out; + for (const auto b : data) out += std::format("{:02x}", b); + return out; +} + +// The token, kept so that only this Windows account can read it. +std::string sealed(const std::string& text) { + DATA_BLOB in{static_cast(text.size()), reinterpret_cast(const_cast(text.data()))}, out{}; + if (text.empty() || !CryptProtectData(&in, L"ReSkate Plex", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return {}; + std::string hex; + for (DWORD i = 0; i < out.cbData; ++i) hex += std::format("{:02x}", out.pbData[i]); + LocalFree(out.pbData); + return hex; +} +std::string unsealed(const std::string& hex) { + std::vector bytes; + for (std::size_t i = 0; i + 1 < hex.size(); i += 2) { + const auto digit = [](char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : 0; }; + bytes.push_back(static_cast(digit(hex[i]) * 16 + digit(hex[i + 1]))); + } + DATA_BLOB in{static_cast(bytes.size()), bytes.data()}, out{}; + if (bytes.empty() || !CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return {}; + std::string text(reinterpret_cast(out.pbData), out.cbData); + LocalFree(out.pbData); + return text; +} +std::filesystem::path kept_file() { return redirected_local_app_data().parent_path() / L"plex.json"; } +void keep() { + try { + Json json = Json::object(); + { + std::lock_guard lock(mutex); + json["client_id"] = client_id; + json["token"] = sealed(auth_token); + json["user"] = user_name; + } + std::error_code ignored; + std::filesystem::create_directories(kept_file().parent_path(), ignored); + std::ofstream(kept_file(), std::ios::binary | std::ios::trunc) << json.dump(2); + } catch (...) {} +} +void read_kept() { + try { + std::ifstream in(kept_file(), std::ios::binary); + if (in) { + const auto json = Json::parse(std::string(std::istreambuf_iterator(in), std::istreambuf_iterator())); + std::lock_guard lock(mutex); + if (json.contains("client_id") && json.at("client_id").is_string()) client_id = json.at("client_id").string(); + if (json.contains("token") && json.at("token").is_string()) auth_token = unsealed(json.at("token").string()); + if (json.contains("user") && json.at("user").is_string()) user_name = json.at("user").string(); + } + } catch (...) {} + bool fresh{}; + { + std::lock_guard lock(mutex); + if (client_id.empty()) { + client_id = "reskate-" + random_hex(12); + fresh = true; + } + } + if (fresh) keep(); +} + +struct Reply { + unsigned long status{}; + std::string body; +}; +struct Handle { + HINTERNET h{}; + ~Handle() { if (h) WinHttpCloseHandle(h); } +}; +// A call to plex.tv or to the player's server, as Plex's API asks for one: JSON back, and who is asking in its headers. +Reply request(const wchar_t* method, const std::wstring& host, INTERNET_PORT port, bool secure, const std::wstring& path, const std::string& token = {}, const std::wstring& extra = {}) { + Reply reply; + std::wstring headers = L"Accept: application/json\r\nX-Plex-Product: " + std::wstring(product) + L"\r\nX-Plex-Client-Identifier: " + wide(client_identifier()) + + L"\r\nX-Plex-Device-Name: ReSkate\r\nX-Plex-Platform: Windows\r\n" + extra; + if (!token.empty()) headers += L"X-Plex-Token: " + wide(token) + L"\r\n"; + Handle session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)}; + if (!session.h) return reply; + WinHttpSetTimeouts(session.h, 5000, 5000, 10000, 10000); + Handle connection{WinHttpConnect(session.h, host.c_str(), port, 0)}; + Handle call{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0) : nullptr}; + if (!call.h || !WinHttpSendRequest(call.h, headers.c_str(), static_cast(-1), WINHTTP_NO_REQUEST_DATA, 0, 0, 0) || !WinHttpReceiveResponse(call.h, nullptr)) return reply; + DWORD status{}, size = sizeof(status); + WinHttpQueryHeaders(call.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &size, WINHTTP_NO_HEADER_INDEX); + reply.status = status; + char buffer[8192]; + for (DWORD got{}; WinHttpReadData(call.h, buffer, sizeof(buffer), &got) && got && reply.body.size() < 4 * 1024 * 1024;) reply.body.append(buffer, got); + return reply; +} +Reply request(const wchar_t* method, const std::wstring& path, const std::string& token = {}) { + return request(method, L"plex.tv", INTERNET_DEFAULT_HTTPS_PORT, true, path, token); +} + +// Who the token belongs to. False when plex.tv refuses it (true, with no name, when it could not be asked). +bool check_token(const std::string& token, std::string& name, bool& refused) { + refused = false; + const auto reply = request(L"GET", L"/api/v2/user", token); + if (reply.status == 401 || reply.status == 403) { + refused = true; + return false; + } + if (reply.status != 200) return false; + try { + const auto json = Json::parse(reply.body); + for (const auto* key : {"username", "title", "email"}) + if (json.contains(key) && json.at(key).is_string() && !json.at(key).string().empty()) { + name = json.at(key).string(); + break; + } + } catch (...) {} + return true; +} + +void sign_in() { + problem({}); + const auto made = request(L"POST", L"/api/v2/pins?strong=true"); + std::uint64_t id{}; + std::string code; + try { + const auto json = Json::parse(made.body); + if ((made.status != 200 && made.status != 201) || !json.contains("id") || !json.contains("code")) return problem(std::format("Plex would not start a sign-in ({})", made.status)); + id = json.at("id").get(); + code = json.at("code").string(); + } catch (...) { + return problem("Plex would not start a sign-in"); + } + const auto address = "https://app.plex.tv/auth#?clientID=" + escaped(client_identifier()) + "&code=" + escaped(code) + "&context%5Bdevice%5D%5Bproduct%5D=ReSkate"; + ShellExecuteW(nullptr, L"open", wide(address).c_str(), nullptr, nullptr, SW_SHOWNORMAL); + say(logging::Level::info, "waiting for the sign-in in the browser."); + const auto until = GetTickCount64() + approval_wait_ms; + std::string granted; + while (GetTickCount64() < until) { + Sleep(2000); + const auto polled = request(L"GET", L"/api/v2/pins/" + std::to_wstring(id) + L"?code=" + wide(escaped(code))); + if (polled.status == 404) return problem("the sign-in expired; try again"); + if (polled.status != 200) continue; + try { + const auto json = Json::parse(polled.body); + if (json.contains("authToken") && json.at("authToken").is_string() && !json.at("authToken").string().empty()) { + granted = json.at("authToken").string(); + break; + } + } catch (...) {} + } + if (granted.empty()) return problem("the sign-in was not finished in the browser"); + std::string name; + bool refused{}; + check_token(granted, name, refused); + { + std::lock_guard lock(mutex); + auth_token = granted; + user_name = name; + problem_text.clear(); + } + keep(); + say(logging::Level::info, "signed in."); +} + +// The player's server, found through plex.tv (worker's alone, but for `session_now`). +struct Server { + std::wstring host; + INTERNET_PORT port{}; + bool secure{}; + std::string token; +}; +Server server; +bool have_server{}; +std::uint64_t server_tried_at{}; +std::uint64_t polled_at{}; +std::string art_thumb, session_client; +std::atomic wanted_at{}; +NowPlaying session_now; + +std::uint64_t tick() { return GetTickCount64(); } + +bool parse_uri(const std::string& uri, Server& out) { + const auto scheme = uri.find("://"); + if (scheme == std::string::npos) return false; + out.secure = uri.compare(0, scheme, "https") == 0; + auto rest = uri.substr(scheme + 3); + if (const auto slash = rest.find('/'); slash != std::string::npos) rest.resize(slash); + unsigned port = out.secure ? 443 : 80; + if (const auto colon = rest.rfind(':'); colon != std::string::npos) { + try { + port = static_cast(std::stoul(rest.substr(colon + 1))); + } catch (...) { + return false; + } + rest.resize(colon); + } + if (rest.empty() || port == 0 || port > 65535) return false; + out.host = wide(rest); + out.port = static_cast(port); + return true; +} +Reply to_server(const wchar_t* method, const std::string& path, const std::wstring& extra = {}) { + return request(method, server.host, server.port, server.secure, wide(path), server.token, extra); +} + +// Looks for a server the account owns that answers: its local address first, then the others, a relay last. +bool find_server() { + server_tried_at = tick(); + const auto reply = request(L"GET", L"/api/v2/resources?includeHttps=1&includeRelay=1", token()); + if (reply.status != 200) return false; + try { + const auto json = Json::parse(reply.body); + for (int pass = 0; pass < 3; ++pass) + for (std::size_t i = 0; i < json.size(); ++i) { + const auto& resource = json.at(i); + if (!resource.contains("provides") || resource.at("provides").string().find("server") == std::string::npos) continue; + if (!resource.contains("connections")) continue; + const auto access = resource.contains("accessToken") && resource.at("accessToken").is_string() ? resource.at("accessToken").string() : token(); + const auto& connections = resource.at("connections"); + for (std::size_t c = 0; c < connections.size(); ++c) { + const auto& connection = connections.at(c); + const bool local = connection.contains("local") && connection.at("local").get(); + const bool relay = connection.contains("relay") && connection.at("relay").get(); + if (pass != (local ? 0 : relay ? 2 : 1)) continue; + Server candidate; + candidate.token = access; + if (!connection.contains("uri") || !parse_uri(connection.at("uri").string(), candidate)) continue; + if (request(L"GET", candidate.host, candidate.port, candidate.secure, L"/identity", access).status != 200) continue; + server = std::move(candidate); + have_server = true; + say(logging::Level::info, "found the Plex server."); + return true; + } + } + } catch (...) {} + return false; +} + +void poll() { + if (token().empty()) return; + if (!have_server && (tick() - server_tried_at < 30000 || !find_server())) return; + const auto reply = to_server(L"GET", "/status/sessions"); + if (reply.status != 200) { + have_server = false; // found again next time + return; + } + std::string name; + { + std::lock_guard lock(mutex); + name = user_name; + } + NowPlaying found; + std::string thumb, client; + try { + const auto json = Json::parse(reply.body); + if (json.contains("MediaContainer") && json.at("MediaContainer").contains("Metadata")) { + const auto& list = json.at("MediaContainer").at("Metadata"); + int matches{}, chosen{-1}; + std::size_t music{}; + int only{-1}; + for (std::size_t i = 0; i < list.size(); ++i) { + const auto& item = list.at(i); + if (item.value("type", "") != "track") continue; + ++music; + only = static_cast(i); + const auto who = item.contains("User") ? item.at("User").value("title", "") : std::string(); + if (!name.empty() && _stricmp(who.c_str(), name.c_str()) == 0 && matches++ == 0) chosen = static_cast(i); + } + if (chosen < 0 && music == 1) chosen = only; + if (chosen >= 0) { + const auto& item = list.at(static_cast(chosen)); + found.valid = true; + found.title = item.value("title", ""); + found.artist = item.value("grandparentTitle", ""); + found.album = item.value("parentTitle", ""); + for (const auto* key : {"parentThumb", "thumb", "grandparentThumb"}) + if (item.contains(key) && item.at(key).is_string() && !item.at(key).string().empty()) { + thumb = item.at(key).string(); + break; + } + if (item.contains("Player")) { + found.playing = item.at("Player").value("state", "") == "playing"; + client = item.at("Player").value("machineIdentifier", ""); + } + } + } + } catch (...) {} + std::vector art; + if (found.valid && !thumb.empty() && thumb != art_thumb) { + const auto picture = to_server(L"GET", "/photo/:/transcode?width=600&height=600&minSize=1&upscale=1&url=" + escaped(thumb)); + if (picture.status == 200 && !picture.body.empty()) { + art.assign(picture.body.begin(), picture.body.end()); + art_thumb = thumb; + } + } else if (!found.valid) { + art_thumb.clear(); + } + std::lock_guard lock(mutex); + found.art = session_now.art; + found.art_revision = session_now.art_revision; + if (!art.empty()) { + found.art = std::move(art); + ++found.art_revision; + } else if (!found.valid || thumb.empty()) { + found.art.clear(); + } + session_now = std::move(found); + session_client = client; + polled_at = tick(); +} + +void send_control(const std::string& action) { + std::string client; + { + std::lock_guard lock(mutex); + client = session_client; + } + if (client.empty() || !have_server) return; + to_server(L"GET", "/player/playback/" + action + "?type=music&commandID=" + std::to_string(tick() & 0xFFFFFF), L"X-Plex-Target-Client-Identifier: " + wide(client) + L"\r\n"); +} + +void run() noexcept { + read_kept(); + try { + std::string saved; + { + std::lock_guard lock(mutex); + saved = auth_token; + } + if (!saved.empty()) { + std::string name; + bool refused{}; + if (check_token(saved, name, refused)) { + std::lock_guard lock(mutex); + if (!name.empty()) user_name = name; + } else if (refused) { + { + std::lock_guard lock(mutex); + auth_token.clear(); + user_name.clear(); + } + keep(); + problem("the saved sign-in is no longer valid; sign in again"); + } + } + } catch (...) {} + for (;;) { + std::string command; + { + std::unique_lock lock(mutex); + is_busy = false; + wake.wait_for(lock, std::chrono::milliseconds(1500), [] { return !commands.empty(); }); + if (!commands.empty()) { + command = std::move(commands.front()); + commands.pop_front(); + is_busy = command == "login"; + } + } + try { + if (command == "login") sign_in(); + else if (command == "logout") { + { + std::lock_guard lock(mutex); + auth_token.clear(); + user_name.clear(); + problem_text.clear(); + session_now = {}; + } + have_server = false; + art_thumb.clear(); + keep(); + } else if (command.starts_with("ctl ")) { + send_control(command.substr(4)); + Sleep(300); + poll(); + } else if (tick() - wanted_at.load() < 15000) { + poll(); + } + } catch (...) {} + } +} +void ask(std::string command) { + start(); + { + std::lock_guard lock(mutex); + std::erase(commands, command); + commands.push_back(std::move(command)); + } + wake.notify_one(); +} +} // namespace + +void start() noexcept { + if (started.exchange(true)) return; + try { + std::thread(run).detach(); + } catch (...) {} +} +void login() { ask("login"); } +void logout() { ask("logout"); } +State state() { + std::lock_guard lock(mutex); + return {!auth_token.empty(), is_busy, user_name, problem_text}; +} +std::string token() { + std::lock_guard lock(mutex); + return auth_token; +} +NowPlaying now_playing() { + start(); + wanted_at.store(tick()); + std::lock_guard lock(mutex); + NowPlaying copy = session_now; + copy.valid = copy.valid && !auth_token.empty() && tick() - polled_at < 10000; + return copy; +} +bool control(Action action) { + { + std::lock_guard lock(mutex); + if (auth_token.empty() || !session_now.valid || session_client.empty()) return false; + } + switch (action) { + case Action::play: ask("ctl play"); break; + case Action::pause: ask("ctl pause"); break; + case Action::next: ask("ctl skipNext"); break; + case Action::previous: ask("ctl skipPrevious"); break; + } + return true; +} +std::string client_identifier() { + std::lock_guard lock(mutex); + return client_id; +} +} diff --git a/Extension/Music/plex_web.h b/Extension/Music/plex_web.h new file mode 100644 index 0000000..515932f --- /dev/null +++ b/Extension/Music/plex_web.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include +#include + +// The player's Plex account, signed in the way Plex's own apps do it: a PIN is asked of plex.tv, +// the player approves it in their browser (app.plex.tv/auth), and plex.tv hands back a token. +// Nothing is typed into the game or this page. The token is kept encrypted for their Windows +// account in \ReSkate\plex.json, with the random id this install goes by. +namespace dingosdk::plex_web { +struct State { + bool signed_in{}, busy{}; + std::string user, problem; +}; +// Reads what was kept and checks that the sign-in still holds. Once, on a thread of its own, +// which does everything below. +void start() noexcept; +void login(); // opens the browser, and waits for the approval there +void logout(); // forgets the sign-in +State state(); + +// What the player's own Plex server says they are playing (its /status/sessions), with the +// album's picture fetched from the server. The server is found through plex.tv; the poll runs +// only while `now_playing` is being asked for. +struct NowPlaying { + bool valid{}; // a music session of this account was seen a moment ago + bool playing{}; + std::string title, artist, album; // UTF-8 + std::vector art; // JPEG bytes from the server, or empty until fetched + std::uint64_t art_revision{}; // changes whenever the picture does +}; +NowPlaying now_playing(); +enum class Action { play, pause, next, previous }; +// Asks the Plex player of that session to do it, through the server. Any thread; false when there is no session. +bool control(Action action); +// The token of the sign-in, for what talks to the player's server; empty when signed out. +std::string token(); +// This install's client identifier, which Plex wants on every request. +std::string client_identifier(); +} diff --git a/Extension/Music/setup_page.html b/Extension/Music/setup_page.html index 9f8aebc..0644a5b 100644 --- a/Extension/Music/setup_page.html +++ b/Extension/Music/setup_page.html @@ -218,6 +218,22 @@ label.check input{width:18px;height:18px;margin-top:2px;accent-color:var(--blue) + +
+

Plex

Link your Plex account so your music and its album art come through.
…
+
+
    +
  1. Sign in with Plex
    +

    A Plex tab opens. Sign in if it asks, press Allow, then come back here. Your password is never shown to ReSkate.

    +

    Then play music in Plexamp (or any Plex app) and pick it under External Audio below. The game shows the song, artist and album art from your Plex server, and the game’s play, pause and next buttons drive it.

    +
    +
    +
    +
  2. +
+
+
+

YouTube Music

Paste links to videos or playlists. Nothing to install by hand.
…
@@ -375,6 +391,7 @@ 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}]}, + plex:{signed_in:false,busy:false,user:'',problem:''}, 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'}], @@ -411,6 +428,8 @@ $('saveClient').onclick = () => post('/api/client', {id:$('client').value.trim() $('login').onclick = () => post('/api/login'); $('logout').onclick = () => post('/api/logout'); $('refresh').onclick = () => post('/api/refresh'); +$('plLogin').onclick = () => post('/api/plex/login'); +$('plLogout').onclick = () => post('/api/plex/logout'); $('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(); }; @@ -530,6 +549,15 @@ function render(s) { 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'); + // Plex. + const pl2 = s.plex || {}; + step('plStep1', pl2.signed_in ? 'done' : 'now'); + show('plSignedOut', !pl2.signed_in); show('plSignedIn', !!pl2.signed_in); + $('plLogin').disabled = !!pl2.busy; $('plLogin').textContent = pl2.busy ? 'Waiting for Plex…' : 'Sign in with Plex'; + $('plWho').textContent = 'Signed in' + (pl2.user ? ' as ' + pl2.user : '') + '.'; + $('plProblem').textContent = pl2.problem || ''; + card('plex', pl2.problem ? 'problem' : pl2.signed_in ? 'ready' : 'todo', pl2.problem ? 'Problem' : pl2.signed_in ? 'Linked' : 'Set up'); + // YouTube Music. const y = s.youtube; show('ytSteps', y.available); show('ytMore', y.available && y.tools); diff --git a/Extension/Music/spotify_dashboard.cpp b/Extension/Music/spotify_dashboard.cpp index a3a1901..3329893 100644 --- a/Extension/Music/spotify_dashboard.cpp +++ b/Extension/Music/spotify_dashboard.cpp @@ -10,6 +10,7 @@ #include "spotify_device.h" #include "spotify_station.h" #include "spotify_web.h" +#include "plex_web.h" #include "Engine/Core/Json/json.h" #include "Engine/Core/Log/logging.h" #include "Extension/Boot/user_data_redirect.h" @@ -106,6 +107,15 @@ std::string state_json() { } account["playlists"] = std::move(playlists); out["account"] = std::move(account); + { + const auto plex_state = plex_web::state(); + Json plex = Json::object(); + plex["signed_in"] = plex_state.signed_in; + plex["busy"] = plex_state.busy; + plex["user"] = plex_state.user; + plex["problem"] = plex_state.problem; + out["plex"] = std::move(plex); + } { const auto tube = youtube_music::state(); Json youtube = Json::object(); @@ -257,6 +267,10 @@ bool change(const std::string& path, const std::string& body) { spotify_web::login(); } else if (path == "/api/logout") { spotify_web::logout(); + } else if (path == "/api/plex/login") { + plex_web::login(); + } else if (path == "/api/plex/logout") { + plex_web::logout(); } else if (path == "/api/refresh") { spotify_web::refresh(); } else if (path == "/api/youtube/tools") { diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 45cc317..29581f2 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -14,6 +14,7 @@ #include "Extension/World/stack_check_report.h" #include "Extension/Music/music_inject.h" #include "Extension/Music/spotify_web.h" +#include "Extension/Music/plex_web.h" #include "Extension/Music/spotify_device.h" #include "Extension/Music/spotify_dashboard.h" #include "Extension/Music/youtube_music.h" @@ -340,6 +341,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { (void)dingosdk::spotify_rows::install(r.base); dingosdk::spotify_device::start(); // (the game as a device in the player's Spotify app) dingosdk::spotify_web::start(); + dingosdk::plex_web::start(); dingosdk::youtube_music::start(); // (the player's YouTube playlists, read before the music screen is built) dingosdk::spotify_dashboard::start(); // (the page in the browser that sets Spotify up) // (the player's playlists, fetched before the music screen is built) const bool fixed_stop_provider = (global_offline_hook && progression_guard_armed) && diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 5c6c910..ab44fd8 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -79,6 +79,7 @@ add_library(dingosdk_runtime SHARED Extension/Music/spotify_station.cpp Extension/Music/spotify_cover.cpp Extension/Music/spotify_web.cpp + Extension/Music/plex_web.cpp Extension/Music/spotify_device.cpp Extension/Music/spotify_dashboard.cpp Extension/Music/external_audio.cpp