Merge pull request #33 from Stremio/feat/discord-rich-presence
Some checks are pending
Continuous integration / test (push) Waiting to run

Feat/discord rich presence
This commit is contained in:
Владимир Борисов 2026-06-15 17:39:30 +03:00 committed by GitHub
commit d98d21bfb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 245 additions and 1 deletions

38
Cargo.lock generated
View file

@ -406,6 +406,21 @@ dependencies = [
"subtle",
]
[[package]]
name = "discord-rich-presence"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ead3c5edc7e048c317c6fc4a7e24aff0c7e4c136918e2ba38106a385b2cc53a5"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror",
"uuid 0.8.2",
]
[[package]]
name = "encoding_rs"
version = "0.8.34"
@ -1513,6 +1528,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "serde_test"
version = "1.0.176"
@ -1604,6 +1630,7 @@ dependencies = [
"bitflags 2.4.2",
"chrono",
"clap",
"discord-rich-presence",
"flume",
"libmpv2",
"libmpv2-sys",
@ -1621,7 +1648,7 @@ dependencies = [
"sha2",
"url",
"urlencoding",
"uuid",
"uuid 1.20.0",
"webview2",
"webview2-sys",
"whoami",
@ -1965,6 +1992,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.12",
]
[[package]]
name = "uuid"
version = "1.20.0"

View file

@ -4,6 +4,7 @@ version = "5.0.22"
edition = "2018"
[dependencies]
discord-rich-presence = "1"
once_cell = "1.19"
native-windows-gui = { git = "https://github.com/Stremio/native-windows-gui", features = [
"high-dpi",

View file

@ -28,6 +28,7 @@ use crate::stremio_app::{
PipeServer,
};
use super::discord::DiscordRpc;
use super::stremio_server::StremioServer;
#[derive(Default, NwgUi)]
@ -256,7 +257,10 @@ impl MainWindow {
let hide_splash_sender = self.hide_splash_notice.sender();
let focus_sender = self.focus_notice.sender();
let autoupdater_setup_mutex = self.autoupdater_setup_file.clone();
let discord_rpc = DiscordRpc::new(web_tx.clone());
let requested_fullscreen = self.requested_fullscreen.clone();
thread::spawn(move || loop {
if let Some(msg) = web_rx
.recv()
@ -401,6 +405,44 @@ impl MainWindow {
}
}
}
Some("discord-connect") => {
if let Err(e) = discord_rpc.connect() {
eprintln!("Discord connect error: {}", e);
web_tx_web.send(RPCResponse::discord_status(false)).ok();
}
}
Some("discord-disconnect") => {
if let Err(e) = discord_rpc.disconnect() {
eprintln!("Discord disconnect error: {}", e);
}
web_tx_web.send(RPCResponse::discord_status(false)).ok();
}
Some("discord-set-activity") => {
if let Some(params) = msg.get_params() {
let state = params.get("state").and_then(|v| v.as_str()).unwrap_or("");
let details =
params.get("details").and_then(|v| v.as_str()).unwrap_or("");
let image = params.get("image").and_then(|v| v.as_str());
let start_timestamp =
params.get("startTimestamp").and_then(|v| v.as_i64());
let end_timestamp = params.get("endTimestamp").and_then(|v| v.as_i64());
if let Err(e) = discord_rpc.set_activity(
state,
details,
image,
start_timestamp,
end_timestamp,
) {
eprintln!("Discord set activity error: {}", e);
}
}
}
Some("discord-clear-activity") => {
if let Err(e) = discord_rpc.clear_activity() {
eprintln!("Discord clear activity error: {}", e);
}
}
Some(player_command) if player_command.starts_with("mpv-") => {
let resp_json = serde_json::to_string(
&msg.args.expect("Cannot have method without args"),

159
src/stremio_app/discord.rs Normal file
View file

@ -0,0 +1,159 @@
use std::sync::mpsc::{self, Sender};
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use crate::stremio_app::ipc::RPCResponse;
const DISCORD_APP_ID: &str = "1452620752263319665";
enum DiscordCommand {
Connect,
Disconnect,
SetActivity {
state: String,
details: String,
large_image: Option<String>,
start_timestamp: Option<i64>,
end_timestamp: Option<i64>,
},
ClearActivity,
}
pub struct DiscordRpc {
commands: Sender<DiscordCommand>,
}
impl DiscordRpc {
pub fn new(status_tx: flume::Sender<String>) -> Self {
let (commands, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut client: Option<DiscordIpcClient> = None;
for command in receiver {
match command {
DiscordCommand::Connect => {
if client.is_some() {
send_status(&status_tx, true);
continue;
}
let mut next_client = DiscordIpcClient::new(DISCORD_APP_ID);
match next_client.connect() {
Ok(()) => {
client = Some(next_client);
send_status(&status_tx, true);
}
Err(error) => {
eprintln!("Discord connect error: {error}");
send_status(&status_tx, false);
}
}
}
DiscordCommand::Disconnect => {
if let Some(mut current_client) = client.take() {
if let Err(error) = current_client.close() {
eprintln!("Discord disconnect error: {error}");
}
}
}
DiscordCommand::SetActivity {
state,
details,
large_image,
start_timestamp,
end_timestamp,
} => {
let Some(current_client) = client.as_mut() else {
continue;
};
let mut payload = activity::Activity::new()
.activity_type(activity::ActivityType::Watching)
.state(&state)
.details(&details);
payload = payload.assets(
activity::Assets::new()
.large_image(large_image.as_deref().unwrap_or("stremio_logo"))
.large_text("Stremio"),
);
let timestamps = match (start_timestamp, end_timestamp) {
(Some(start), Some(end)) => {
Some(activity::Timestamps::new().start(start).end(end))
}
(Some(start), None) => Some(activity::Timestamps::new().start(start)),
(None, Some(end)) => Some(activity::Timestamps::new().end(end)),
(None, None) => None,
};
if let Some(timestamps) = timestamps {
payload = payload.timestamps(timestamps);
}
if let Err(error) = current_client.set_activity(payload) {
eprintln!("Discord set activity error: {error}");
client = None;
send_status(&status_tx, false);
}
}
DiscordCommand::ClearActivity => {
let Some(current_client) = client.as_mut() else {
continue;
};
if let Err(error) = current_client.clear_activity() {
eprintln!("Discord clear activity error: {error}");
client = None;
send_status(&status_tx, false);
}
}
}
}
if let Some(mut current_client) = client {
let _ = current_client.close();
}
});
Self { commands }
}
pub fn connect(&self) -> Result<(), String> {
self.send(DiscordCommand::Connect)
}
pub fn disconnect(&self) -> Result<(), String> {
self.send(DiscordCommand::Disconnect)
}
pub fn set_activity(
&self,
state: &str,
details: &str,
large_image: Option<&str>,
start_timestamp: Option<i64>,
end_timestamp: Option<i64>,
) -> Result<(), String> {
self.send(DiscordCommand::SetActivity {
state: state.to_string(),
details: details.to_string(),
large_image: large_image.map(ToString::to_string),
start_timestamp,
end_timestamp,
})
}
pub fn clear_activity(&self) -> Result<(), String> {
self.send(DiscordCommand::ClearActivity)
}
fn send(&self, command: DiscordCommand) -> Result<(), String> {
self.commands
.send(command)
.map_err(|e| format!("Failed to send Discord command: {e}"))
}
}
fn send_status(status_tx: &flume::Sender<String>, connected: bool) {
status_tx.send(RPCResponse::discord_status(connected)).ok();
}

View file

@ -105,6 +105,11 @@ impl RPCResponse {
pub fn update_available() -> String {
Self::response_message(Some(json!(["autoupdater-show-notif"])))
}
pub fn discord_status(connected: bool) -> String {
Self::response_message(Some(json!(["discord-status", {
"connected": connected,
}])))
}
pub fn media_key(action: &str) -> String {
Self::response_message(Some(json!(["media-key", action])))
}

View file

@ -1,5 +1,6 @@
pub mod app;
pub use app::MainWindow;
pub mod discord;
pub mod ipc;
pub mod stremio_player;
pub mod stremio_server;