Remote playback control for any local player — inspired by "Spotify Connect".

Initial Commit

+925
+1
.gitignore
··· 1 + target/
+29
Cargo.toml
··· 1 + [package] 2 + name = "connect" 3 + version = "0.1.0" 4 + authors = ["Tsiry Sandratraina <tsiry.sndr@rocksky.app>"] 5 + edition = "2024" 6 + license = "Apache-2.0" 7 + 8 + [dependencies] 9 + tungstenite = { version = "0.26.2", features = ["rustls"] } 10 + tokio-tungstenite = { version = "0.26.2", features = [ 11 + "tokio-rustls", 12 + "rustls-tls-webpki-roots", 13 + ] } 14 + futures-util = "0.3.31" 15 + tokio-stream = "0.1.17" 16 + tokio = { version = "1.45.1", features = ["full"] } 17 + dirs = "6.0.0" 18 + serde = { version = "1.0.217", features = ["derive"] } 19 + serde_json = "1.0.139" 20 + owo-colors = "4.2.1" 21 + anyhow = "1.0.98" 22 + async-trait = "0.1.88" 23 + reqwest = { version = "0.12.15", features = [ 24 + "rustls-tls", 25 + "json", 26 + ], default-features = false } 27 + jsonrpsee = { version = "0.25.1", features = ["client", "tokio"] } 28 + http = "1.3.1" 29 + base64 = "0.22.1"
+201
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + APPENDIX: How to apply the Apache License to your work. 179 + 180 + To apply the Apache License to your work, attach the following 181 + boilerplate notice, with the fields enclosed by brackets "[]" 182 + replaced with your own identifying information. (Don't include 183 + the brackets!) The text should be enclosed in the appropriate 184 + comment syntax for the file format. We also recommend that a 185 + file or class name and description of purpose be included on the 186 + same "printed page" as the copyright notice for easier 187 + identification within third-party archives. 188 + 189 + Copyright 2025 Tsiry Sandratraina 190 + 191 + Licensed under the Apache License, Version 2.0 (the "License"); 192 + you may not use this file except in compliance with the License. 193 + You may obtain a copy of the License at 194 + 195 + http://www.apache.org/licenses/LICENSE-2.0 196 + 197 + Unless required by applicable law or agreed to in writing, software 198 + distributed under the License is distributed on an "AS IS" BASIS, 199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 + See the License for the specific language governing permissions and 201 + limitations under the License.
+9
README.md
··· 1 + # Rocksky Connect 🔌 2 + 3 + Remote playback control for any local player — inspired by "Spotify Connect". 4 + 5 + Rocksky Connect lets you link your local music player to the Rocksky Web UI https://rocksky.app, enabling seamless remote playback control from anywhere. It's like Spotify Connect, but designed to work with any player that integrates with Rocksky. 6 + 7 + 🐲 **Work in progress:** Rocksky Connect is still under active development — features and compatibility may change frequently. 8 + 9 + **Note:** Only [Kodi](https://kodi.tv) is currently working with Rocksky Connect. Other players will be supported in the future.
+55
src/main.rs
··· 1 + use std::thread; 2 + 3 + use owo_colors::OwoColorize; 4 + use websocket::connect_to_rocksky_websocket; 5 + 6 + pub mod players; 7 + pub mod websocket; 8 + 9 + #[tokio::main] 10 + async fn main() -> Result<(), Box<dyn std::error::Error>> { 11 + let home = dirs::home_dir().unwrap(); 12 + let token_file = home.join(".rocksky").join("token.json"); 13 + 14 + if !token_file.exists() { 15 + println!( 16 + "Please run {} to authenticate with Rocksky before connecting to the WebSocket", 17 + "`rocksky login`".magenta() 18 + ); 19 + return Ok(()); 20 + } 21 + 22 + let token = std::fs::read_to_string(token_file)?; 23 + let token: serde_json::Value = serde_json::from_str(&token)?; 24 + let token = token 25 + .get("token") 26 + .and_then(|t| t.as_str()) 27 + .ok_or("Token not found")? 28 + .to_string(); 29 + 30 + thread::spawn(move || { 31 + let rt = tokio::runtime::Runtime::new().unwrap(); 32 + rt.block_on(async move { 33 + let delay = 3; 34 + 35 + loop { 36 + match connect_to_rocksky_websocket(token.clone()).await { 37 + Ok(_) => { 38 + println!("WebSocket session ended cleanly"); 39 + } 40 + Err(e) => { 41 + eprintln!("WebSocket session error: {}", e); 42 + } 43 + } 44 + 45 + println!("Reconnecting in {} seconds...", delay); 46 + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; 47 + } 48 + }) 49 + }); 50 + 51 + // Keep the main thread alive to allow the WebSocket to run 52 + loop { 53 + std::thread::park(); 54 + } 55 + }
+41
src/players/jellyfin.rs
··· 1 + use super::Player; 2 + use async_trait::async_trait; 3 + 4 + use anyhow::Error; 5 + use tokio::sync::mpsc::Sender; 6 + pub struct JellyfinPlayer {} 7 + 8 + pub fn new() -> JellyfinPlayer { 9 + JellyfinPlayer {} 10 + } 11 + 12 + #[async_trait] 13 + impl Player for JellyfinPlayer { 14 + async fn play(&self) -> Result<(), Error> { 15 + Ok(()) 16 + } 17 + 18 + async fn pause(&self) -> Result<(), Error> { 19 + Ok(()) 20 + } 21 + 22 + async fn next(&self) -> Result<(), Error> { 23 + Ok(()) 24 + } 25 + 26 + async fn previous(&self) -> Result<(), Error> { 27 + Ok(()) 28 + } 29 + 30 + async fn seek(&self, _position: u64) -> Result<(), Error> { 31 + Ok(()) 32 + } 33 + 34 + async fn broadcast_now_playing(&self, _tx: Sender<String>) -> Result<(), Error> { 35 + Ok(()) 36 + } 37 + 38 + async fn broadcast_status(&self, _tx: Sender<String>) -> Result<(), Error> { 39 + Ok(()) 40 + } 41 + }
+246
src/players/kodi.rs
··· 1 + use std::{env, time::Duration}; 2 + 3 + use super::Player; 4 + use anyhow::Error; 5 + use async_trait::async_trait; 6 + use base64::{engine::general_purpose::STANDARD, Engine as _}; 7 + use jsonrpsee::{ 8 + core::{ 9 + client::ClientT, 10 + params::{ArrayParams, ObjectParams}, 11 + }, 12 + http_client::{HttpClient, HttpClientBuilder}, 13 + rpc_params, 14 + }; 15 + use reqwest::header::HeaderMap; 16 + use serde_json::{json, Value}; 17 + use tokio::sync::mpsc::Sender; 18 + 19 + #[derive(Clone)] 20 + pub struct KodiPlayer { 21 + client: HttpClient, 22 + player_id: usize, 23 + } 24 + 25 + pub fn new() -> Result<KodiPlayer, Error> { 26 + let user = env::var("KODI_USER")?; 27 + let password = env::var("KODI_PASSWORD")?; 28 + let mut headers = HeaderMap::new(); 29 + headers.insert( 30 + http::header::AUTHORIZATION, 31 + format!( 32 + "Basic {}", 33 + STANDARD.encode(format!("{}:{}", user, password).as_bytes()) 34 + ) 35 + .parse() 36 + .unwrap(), 37 + ); 38 + 39 + let kodi_url = 40 + env::var("KODI_URL").unwrap_or_else(|_| "http://localhost:8080/jsonrpc".to_string()); 41 + 42 + let client = HttpClientBuilder::default() 43 + .set_headers(headers) 44 + .build(kodi_url)?; 45 + 46 + Ok(KodiPlayer { 47 + client, 48 + player_id: 0, 49 + }) 50 + } 51 + 52 + impl KodiPlayer { 53 + pub async fn get_properties(&self, properties: Vec<&str>) -> Result<Value, Error> { 54 + let mut params = ObjectParams::new(); 55 + params.insert("properties", properties)?; 56 + 57 + let response = self 58 + .client 59 + .request::<Value, ObjectParams>("Application.GetProperties", params) 60 + .await?; 61 + Ok(response) 62 + } 63 + 64 + pub async fn get_active_players(&self) -> Result<Value, Error> { 65 + let response = self 66 + .client 67 + .request::<Value, ArrayParams>("Application.GetActivePlayers", rpc_params![]) 68 + .await?; 69 + Ok(response) 70 + } 71 + 72 + pub async fn set_player_id(&mut self, player_id: usize) -> Result<Self, Error> { 73 + self.player_id = player_id; 74 + Ok(self.clone()) 75 + } 76 + } 77 + 78 + #[async_trait] 79 + impl Player for KodiPlayer { 80 + async fn play(&self) -> Result<(), Error> { 81 + let mut params = ObjectParams::new(); 82 + params.insert("playerid", self.player_id)?; 83 + let _response = self 84 + .client 85 + .request::<Value, ObjectParams>("Player.PlayPause", params) 86 + .await?; 87 + 88 + Ok(()) 89 + } 90 + 91 + async fn pause(&self) -> Result<(), Error> { 92 + let mut params = ObjectParams::new(); 93 + params.insert("playerid", self.player_id)?; 94 + let _response = self 95 + .client 96 + .request::<Value, ObjectParams>("Player.PlayPause", params) 97 + .await?; 98 + Ok(()) 99 + } 100 + 101 + async fn next(&self) -> Result<(), Error> { 102 + let mut params = ObjectParams::new(); 103 + params.insert("playerid", self.player_id)?; 104 + params.insert("to", "next")?; 105 + let _response = self 106 + .client 107 + .request::<Value, ObjectParams>("Player.GoTo", params) 108 + .await?; 109 + Ok(()) 110 + } 111 + 112 + async fn previous(&self) -> Result<(), Error> { 113 + let mut params = ObjectParams::new(); 114 + params.insert("playerid", self.player_id)?; 115 + params.insert("to", "previous")?; 116 + let _response = self 117 + .client 118 + .request::<Value, ObjectParams>("Player.GoTo", params) 119 + .await?; 120 + Ok(()) 121 + } 122 + 123 + async fn seek(&self, position: u64) -> Result<(), Error> { 124 + let mut params = ObjectParams::new(); 125 + params.insert("playerid", self.player_id)?; 126 + params.insert("value", position)?; 127 + let _response = self 128 + .client 129 + .request::<Value, ObjectParams>("Player.Seek", params) 130 + .await?; 131 + Ok(()) 132 + } 133 + 134 + async fn broadcast_now_playing(&self, tx: Sender<String>) -> Result<(), Error> { 135 + loop { 136 + let mut params = ObjectParams::new(); 137 + params.insert("playerid", self.player_id)?; 138 + params.insert( 139 + "properties", 140 + vec!["title", "artist", "album", "duration", "file"], 141 + )?; 142 + 143 + let current_track = self 144 + .client 145 + .request::<Value, ObjectParams>("Player.GetItem", params) 146 + .await?; 147 + 148 + let mut params = ObjectParams::new(); 149 + params.insert("playerid", self.player_id)?; 150 + params.insert( 151 + "properties", 152 + vec!["time", "totaltime", "percentage", "speed"], 153 + )?; 154 + 155 + let progress = self 156 + .client 157 + .request::<Value, ObjectParams>("Player.GetProperties", params) 158 + .await?; 159 + 160 + println!("{:#?}", progress); 161 + 162 + let hours = progress 163 + .get("time") 164 + .and_then(|time| time.get("hours")) 165 + .and_then(Value::as_u64) 166 + .unwrap_or(0); 167 + let minutes = progress 168 + .get("time") 169 + .and_then(|time| time.get("minutes")) 170 + .and_then(Value::as_u64) 171 + .unwrap_or(0); 172 + let seconds = progress 173 + .get("time") 174 + .and_then(|time| time.get("seconds")) 175 + .and_then(Value::as_u64) 176 + .unwrap_or(0); 177 + let milliseconds = progress 178 + .get("time") 179 + .and_then(|time| time.get("milliseconds")) 180 + .and_then(Value::as_u64) 181 + .unwrap_or(0); 182 + 183 + tx.send( 184 + json!({ 185 + "type": "track", 186 + "title": current_track 187 + .get("item") 188 + .and_then(|item| item.get("title")) 189 + .and_then(Value::as_str) 190 + .unwrap_or("Unknown Title"), 191 + "artist": current_track 192 + .get("item") 193 + .and_then(|item| item.get("artist")) 194 + .and_then(Value::as_array) 195 + .map(|arr| arr.iter().map(Value::as_str).map(|x| x.unwrap()).collect::<Vec<_>>().join(", ")) 196 + .unwrap_or("Unknown Artist".into()), 197 + // "album_artist": "", 198 + "album": current_track 199 + .get("item") 200 + .and_then(|item| item.get("album")) 201 + .and_then(Value::as_str) 202 + .unwrap_or("Unknown Album"), 203 + "length": current_track 204 + .get("item") 205 + .and_then(|item| item.get("duration")) 206 + .and_then(Value::as_u64) 207 + .unwrap_or(0) * 1000, // Convert to milliseconds 208 + "elapsed": ((hours * 3600) + (minutes * 60) + seconds) * 1000 + milliseconds, 209 + }) 210 + .to_string(), 211 + ).await?; 212 + 213 + tokio::time::sleep(Duration::from_secs(3)).await; 214 + } 215 + } 216 + 217 + async fn broadcast_status(&self, tx: Sender<String>) -> Result<(), Error> { 218 + loop { 219 + let mut params = ObjectParams::new(); 220 + params.insert("playerid", self.player_id).unwrap(); 221 + params.insert("properties", vec!["speed"]).unwrap(); 222 + 223 + let response = self 224 + .client 225 + .request::<Value, ObjectParams>("Player.GetProperties", params) 226 + .await?; 227 + 228 + tx.send( 229 + json!({ 230 + "type": "status", 231 + "status": match response.get("speed") { 232 + Some(Value::Number(speed)) => match speed.as_i64() { 233 + Some(0) => 2, 234 + Some(_) => 1, 235 + None => 2, 236 + }, 237 + _ => 2, 238 + }, 239 + }) 240 + .to_string(), 241 + ) 242 + .await?; 243 + tokio::time::sleep(Duration::from_secs(3)).await; 244 + } 245 + } 246 + }
+49
src/players/mod.rs
··· 1 + use anyhow::Error; 2 + use async_trait::async_trait; 3 + use owo_colors::OwoColorize; 4 + use tokio::sync::mpsc::Sender; 5 + 6 + pub mod jellyfin; 7 + pub mod kodi; 8 + pub mod mopidy; 9 + pub mod mpd; 10 + pub mod mpris; 11 + pub mod vlc; 12 + 13 + pub const SUPPORTED_PLAYERS: [&str; 6] = ["jellyfin", "kodi", "mopidy", "mpd", "mpris", "vlc"]; 14 + 15 + #[async_trait] 16 + pub trait Player { 17 + async fn play(&self) -> Result<(), Error>; 18 + async fn pause(&self) -> Result<(), Error>; 19 + async fn next(&self) -> Result<(), Error>; 20 + async fn previous(&self) -> Result<(), Error>; 21 + async fn seek(&self, position: u64) -> Result<(), Error>; 22 + async fn broadcast_now_playing(&self, tx: Sender<String>) -> Result<(), Error>; 23 + async fn broadcast_status(&self, tx: Sender<String>) -> Result<(), Error>; 24 + } 25 + 26 + pub fn get_current_player() -> Result<Box<dyn Player + Send + Sync>, Error> { 27 + let player_type = std::env::var("ROCKSKY_PLAYER"); 28 + if player_type.is_err() { 29 + return Err(Error::msg(format!( 30 + "{} environment variable not set", 31 + "ROCKSKY_PLAYER".green() 32 + ))); 33 + } 34 + 35 + let player_type = player_type.unwrap(); 36 + 37 + match player_type.as_str() { 38 + "jellyfin" => Ok(Box::new(jellyfin::new())), 39 + "kodi" => Ok(Box::new(kodi::new()?)), 40 + "mopidy" => Ok(Box::new(mopidy::new())), 41 + "mpd" => Ok(Box::new(mpd::new())), 42 + "mpris" => Ok(Box::new(mpris::new())), 43 + "vlc" => Ok(Box::new(vlc::new())), 44 + _ => Err(Error::msg(format!( 45 + "Unsupported player type: {}", 46 + player_type.magenta() 47 + ))), 48 + } 49 + }
+41
src/players/mopidy.rs
··· 1 + use super::Player; 2 + use anyhow::Error; 3 + use async_trait::async_trait; 4 + use tokio::sync::mpsc::Sender; 5 + 6 + pub struct MopidyPlayer {} 7 + 8 + pub fn new() -> MopidyPlayer { 9 + MopidyPlayer {} 10 + } 11 + 12 + #[async_trait] 13 + impl Player for MopidyPlayer { 14 + async fn play(&self) -> Result<(), Error> { 15 + Ok(()) 16 + } 17 + 18 + async fn pause(&self) -> Result<(), Error> { 19 + Ok(()) 20 + } 21 + 22 + async fn next(&self) -> Result<(), Error> { 23 + Ok(()) 24 + } 25 + 26 + async fn previous(&self) -> Result<(), Error> { 27 + Ok(()) 28 + } 29 + 30 + async fn seek(&self, _position: u64) -> Result<(), Error> { 31 + Ok(()) 32 + } 33 + 34 + async fn broadcast_now_playing(&self, _tx: Sender<String>) -> Result<(), Error> { 35 + Ok(()) 36 + } 37 + 38 + async fn broadcast_status(&self, _tx: Sender<String>) -> Result<(), Error> { 39 + Ok(()) 40 + } 41 + }
+41
src/players/mpd.rs
··· 1 + use super::Player; 2 + use anyhow::Error; 3 + use async_trait::async_trait; 4 + use tokio::sync::mpsc::Sender; 5 + 6 + pub struct MpdPlayer {} 7 + 8 + pub fn new() -> MpdPlayer { 9 + MpdPlayer {} 10 + } 11 + 12 + #[async_trait] 13 + impl Player for MpdPlayer { 14 + async fn play(&self) -> Result<(), Error> { 15 + Ok(()) 16 + } 17 + 18 + async fn pause(&self) -> Result<(), Error> { 19 + Ok(()) 20 + } 21 + 22 + async fn next(&self) -> Result<(), Error> { 23 + Ok(()) 24 + } 25 + 26 + async fn previous(&self) -> Result<(), Error> { 27 + Ok(()) 28 + } 29 + 30 + async fn seek(&self, _position: u64) -> Result<(), Error> { 31 + Ok(()) 32 + } 33 + 34 + async fn broadcast_now_playing(&self, _tx: Sender<String>) -> Result<(), Error> { 35 + Ok(()) 36 + } 37 + 38 + async fn broadcast_status(&self, _tx: Sender<String>) -> Result<(), Error> { 39 + Ok(()) 40 + } 41 + }
+41
src/players/mpris.rs
··· 1 + use super::Player; 2 + use anyhow::Error; 3 + use async_trait::async_trait; 4 + use tokio::sync::mpsc::Sender; 5 + 6 + pub struct MprisPlayer {} 7 + 8 + pub fn new() -> MprisPlayer { 9 + MprisPlayer {} 10 + } 11 + 12 + #[async_trait] 13 + impl Player for MprisPlayer { 14 + async fn play(&self) -> Result<(), Error> { 15 + Ok(()) 16 + } 17 + 18 + async fn pause(&self) -> Result<(), Error> { 19 + Ok(()) 20 + } 21 + 22 + async fn next(&self) -> Result<(), Error> { 23 + Ok(()) 24 + } 25 + 26 + async fn previous(&self) -> Result<(), Error> { 27 + Ok(()) 28 + } 29 + 30 + async fn seek(&self, _position: u64) -> Result<(), Error> { 31 + Ok(()) 32 + } 33 + 34 + async fn broadcast_now_playing(&self, _tx: Sender<String>) -> Result<(), Error> { 35 + Ok(()) 36 + } 37 + 38 + async fn broadcast_status(&self, _tx: Sender<String>) -> Result<(), Error> { 39 + Ok(()) 40 + } 41 + }
+41
src/players/vlc.rs
··· 1 + use super::Player; 2 + use anyhow::Error; 3 + use async_trait::async_trait; 4 + use tokio::sync::mpsc::Sender; 5 + 6 + pub struct VlcPlayer {} 7 + 8 + pub fn new() -> VlcPlayer { 9 + VlcPlayer {} 10 + } 11 + 12 + #[async_trait] 13 + impl Player for VlcPlayer { 14 + async fn play(&self) -> Result<(), Error> { 15 + Ok(()) 16 + } 17 + 18 + async fn pause(&self) -> Result<(), Error> { 19 + Ok(()) 20 + } 21 + 22 + async fn next(&self) -> Result<(), Error> { 23 + Ok(()) 24 + } 25 + 26 + async fn previous(&self) -> Result<(), Error> { 27 + Ok(()) 28 + } 29 + 30 + async fn seek(&self, _position: u64) -> Result<(), Error> { 31 + Ok(()) 32 + } 33 + 34 + async fn broadcast_now_playing(&self, _tx: Sender<String>) -> Result<(), Error> { 35 + Ok(()) 36 + } 37 + 38 + async fn broadcast_status(&self, _tx: Sender<String>) -> Result<(), Error> { 39 + Ok(()) 40 + } 41 + }
+130
src/websocket.rs
··· 1 + use std::{env, sync::Arc}; 2 + 3 + use anyhow::Error; 4 + use futures_util::{SinkExt, StreamExt}; 5 + use owo_colors::OwoColorize; 6 + use serde_json::{json, Value}; 7 + use tokio::sync::Mutex; 8 + use tokio_tungstenite::connect_async; 9 + 10 + use crate::players::{get_current_player, Player}; 11 + 12 + pub async fn connect_to_rocksky_websocket(token: String) -> Result<(), Error> { 13 + let rocksky_ws = 14 + env::var("ROCKSKY_WS").unwrap_or_else(|_| "wss://api.rocksky.app/ws".to_string()); 15 + let (ws_stream, _) = connect_async(&rocksky_ws).await?; 16 + println!("Connected to {}", rocksky_ws); 17 + 18 + let (mut write, mut read) = ws_stream.split(); 19 + let device_id = Arc::new(Mutex::new(String::new())); 20 + 21 + write 22 + .send( 23 + json!({ 24 + "type": "register", 25 + "clientName": "Rockbox", 26 + "token": token 27 + }) 28 + .to_string() 29 + .into(), 30 + ) 31 + .await?; 32 + 33 + let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(32); 34 + let tx_clone = tx.clone(); 35 + 36 + tokio::spawn(async move { 37 + let player: Box<dyn Player + Send + Sync> = get_current_player().map_err(|err| { 38 + println!("Error getting current player: {}", err); 39 + err 40 + })?; 41 + player 42 + .broadcast_now_playing(tx_clone) 43 + .await 44 + .unwrap_or_else(|err| eprintln!("Error broadcasting now playing: {}", err)); 45 + Ok::<(), Error>(()) 46 + }); 47 + 48 + tokio::spawn(async move { 49 + let player: Box<dyn Player + Send + Sync> = get_current_player().map_err(|err| { 50 + println!("Error getting current player: {}", err); 51 + err 52 + })?; 53 + player 54 + .broadcast_status(tx) 55 + .await 56 + .unwrap_or_else(|err| eprintln!("Error broadcasting status: {}", err)); 57 + Ok::<(), Error>(()) 58 + }); 59 + 60 + { 61 + let device_id = Arc::clone(&device_id); 62 + let token = token.clone(); 63 + tokio::spawn(async move { 64 + while let Some(msg) = rx.recv().await { 65 + println!("Sending message: {}", msg); 66 + let id = device_id.lock().await.clone(); 67 + if let Err(err) = write 68 + .send( 69 + json!({ 70 + "type": "message", 71 + "data": serde_json::from_str::<Value>(&msg).unwrap(), 72 + "device_id": id, 73 + "token": token 74 + }) 75 + .to_string() 76 + .into(), 77 + ) 78 + .await 79 + { 80 + eprintln!("Send error: {}", err); 81 + break; 82 + } 83 + } 84 + }); 85 + } 86 + 87 + while let Some(msg) = read.next().await { 88 + let msg = match msg { 89 + Ok(m) => m.to_string(), 90 + Err(e) => { 91 + eprintln!("Read error: {}", e); 92 + break; 93 + } 94 + }; 95 + 96 + let msg: Value = serde_json::from_str(&msg)?; 97 + if let Some(id) = msg["deviceId"].as_str() { 98 + println!("Device ID: {}", id); 99 + *device_id.lock().await = id.to_string(); 100 + } 101 + 102 + if let Some("command") = msg["type"].as_str() { 103 + if let Some(cmd) = msg["action"].as_str() { 104 + println!("Received command: {}", cmd); 105 + 106 + let player: Box<dyn Player> = get_current_player()?; 107 + 108 + if let Some("command") = msg["type"].as_str() { 109 + if let Some(cmd) = msg["action"].as_str() { 110 + match cmd { 111 + "play" => player.play().await?, 112 + "pause" => player.pause().await?, 113 + "next" => player.next().await?, 114 + "previous" => player.previous().await?, 115 + "seek" => player.seek(msg["position"].as_u64().unwrap_or(0)).await?, 116 + _ => { 117 + eprintln!("Unknown command: {}", cmd.magenta()); 118 + continue; 119 + } 120 + } 121 + } else { 122 + println!("No action specified in command message, ignoring."); 123 + } 124 + } 125 + } 126 + } 127 + } 128 + 129 + Ok(()) 130 + }