aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2024-01-21 03:05:08 +0100
committermetamuffin <metamuffin@disroot.org>2024-01-21 03:05:08 +0100
commit62ef60aca4e39f02cb8de972def0cc366e220542 (patch)
tree2091c42390d703983fc18717c2814ddeec032ea4
parent5177497b3cf376403a8daab2e5ca9408ad2625bd (diff)
downloadjellything-62ef60aca4e39f02cb8de972def0cc366e220542.tar
jellything-62ef60aca4e39f02cb8de972def0cc366e220542.tar.bz2
jellything-62ef60aca4e39f02cb8de972def0cc366e220542.tar.zst
added bad federated lsvp
-rw-r--r--Cargo.lock144
-rw-r--r--server/Cargo.toml1
-rw-r--r--server/src/routes/mod.rs24
-rw-r--r--server/src/routes/playersync.rs55
-rw-r--r--server/src/routes/streamsync.rs11
-rw-r--r--web/script/player/mod.ts33
-rw-r--r--web/script/player/sync.ts92
-rw-r--r--web/style/js-player.css38
8 files changed, 358 insertions, 40 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 5712524..7f9c23f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -428,7 +428,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3"
dependencies = [
- "smallvec",
+ "smallvec 1.11.2",
"target-lexicon",
]
@@ -439,6 +439,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
+name = "chashmap"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff41a3c2c1e39921b9003de14bf0439c7b63a9039637c291e1a64925d8ddfa45"
+dependencies = [
+ "owning_ref",
+ "parking_lot 0.4.8",
+]
+
+[[package]]
name = "chrono"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -547,7 +557,7 @@ dependencies = [
"base64",
"hkdf",
"percent-encoding",
- "rand",
+ "rand 0.8.5",
"sha2",
"subtle",
"time",
@@ -633,7 +643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
- "rand_core",
+ "rand_core 0.6.4",
"typenum",
]
@@ -775,7 +785,7 @@ dependencies = [
"lebe",
"miniz_oxide",
"rayon-core",
- "smallvec",
+ "smallvec 1.11.2",
"zune-inflate",
]
@@ -858,6 +868,12 @@ dependencies = [
]
[[package]]
+name = "fuchsia-cprng"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
+
+[[package]]
name = "futures"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1312,7 +1328,7 @@ dependencies = [
"jellyclient",
"jellycommon",
"log",
- "rand",
+ "rand 0.8.5",
"redb",
"serde",
"serde_json",
@@ -1416,6 +1432,7 @@ dependencies = [
"async-recursion",
"base64",
"bincode",
+ "chashmap",
"chrono",
"env_logger",
"futures",
@@ -1427,7 +1444,7 @@ dependencies = [
"jellytranscoder",
"log",
"markup",
- "rand",
+ "rand 0.8.5",
"rocket",
"rocket_ws",
"serde",
@@ -1451,7 +1468,7 @@ dependencies = [
"jellyclient",
"jellycommon",
"log",
- "rand",
+ "rand 0.8.5",
"reqwest",
"serde",
"serde_json",
@@ -1658,6 +1675,12 @@ dependencies = [
]
[[package]]
+name = "maybe-uninit"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"
+
+[[package]]
name = "memchr"
version = "2.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1936,13 +1959,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
+name = "owning_ref"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdf84f41639e037b484f93433aa3897863b561ed65c6e59c7073d7c561710f37"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "149d8f5b97f3c1133e3cfcd8886449959e856b557ff281e292b733d7c69e005e"
+dependencies = [
+ "owning_ref",
+ "parking_lot_core 0.2.14",
+]
+
+[[package]]
name = "parking_lot"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
"lock_api",
- "parking_lot_core",
+ "parking_lot_core 0.9.9",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4db1a8ccf734a7bce794cc19b3df06ed87ab2f3907036b693c68f56b4d4537fa"
+dependencies = [
+ "libc",
+ "rand 0.4.6",
+ "smallvec 0.6.14",
+ "winapi",
]
[[package]]
@@ -1954,7 +2008,7 @@ dependencies = [
"cfg-if",
"libc",
"redox_syscall",
- "smallvec",
+ "smallvec 1.11.2",
"windows-targets 0.48.5",
]
@@ -1965,7 +2019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -2113,13 +2167,26 @@ dependencies = [
[[package]]
name = "rand"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
+dependencies = [
+ "fuchsia-cprng",
+ "libc",
+ "rand_core 0.3.1",
+ "rdrand",
+ "winapi",
+]
+
+[[package]]
+name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
@@ -2129,11 +2196,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
]
[[package]]
name = "rand_core"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
+dependencies = [
+ "rand_core 0.4.2",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
+
+[[package]]
+name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
@@ -2168,7 +2250,7 @@ dependencies = [
"num-traits",
"once_cell",
"paste",
- "rand",
+ "rand 0.8.5",
"rand_chacha",
"rust_hawktracer",
"rustc_version",
@@ -2215,6 +2297,15 @@ dependencies = [
]
[[package]]
+name = "rdrand"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
+dependencies = [
+ "rand_core 0.3.1",
+]
+
+[[package]]
name = "redb"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2362,9 +2453,9 @@ dependencies = [
"memchr",
"multer",
"num_cpus",
- "parking_lot",
+ "parking_lot 0.12.1",
"pin-project-lite",
- "rand",
+ "rand 0.8.5",
"ref-cast",
"rocket_codegen",
"rocket_http",
@@ -2417,7 +2508,7 @@ dependencies = [
"pin-project-lite",
"ref-cast",
"serde",
- "smallvec",
+ "smallvec 1.11.2",
"stable-pattern",
"state",
"time",
@@ -2681,6 +2772,15 @@ dependencies = [
[[package]]
name = "smallvec"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0"
+dependencies = [
+ "maybe-uninit",
+]
+
+[[package]]
+name = "smallvec"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970"
@@ -2724,6 +2824,12 @@ dependencies = [
]
[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[package]]
name = "state"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2924,7 +3030,7 @@ dependencies = [
"libc",
"mio",
"num_cpus",
- "parking_lot",
+ "parking_lot 0.12.1",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.5",
@@ -3093,7 +3199,7 @@ dependencies = [
"once_cell",
"regex",
"sharded-slab",
- "smallvec",
+ "smallvec 1.11.2",
"thread_local",
"tracing",
"tracing-core",
@@ -3118,7 +3224,7 @@ dependencies = [
"http",
"httparse",
"log",
- "rand",
+ "rand 0.8.5",
"sha1",
"thiserror",
"url",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index a124876..ca8315c 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -21,6 +21,7 @@ rand = "0.8.5"
base64 = "0.21.5"
chrono = { version = "0.4.31", features = ["serde"] }
vte = "0.13.0"
+chashmap = "2.2.2"
argon2 = "0.5.2"
aes-gcm-siv = "0.11.1"
diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs
index e4d05e5..a6a086f 100644
--- a/server/src/routes/mod.rs
+++ b/server/src/routes/mod.rs
@@ -10,8 +10,14 @@ use jellybase::{federation::Federation, CONF, SECRETS};
use log::warn;
use rand::random;
use rocket::{
- catchers, config::SecretKey, fairing::AdHoc, fs::FileServer, get, http::Header, routes, Build,
- Config, Rocket,
+ catchers,
+ config::SecretKey,
+ fairing::AdHoc,
+ fs::FileServer,
+ get,
+ http::Header,
+ response::{self, Responder},
+ routes, Build, Config, Request, Rocket,
};
use std::fs::File;
use stream::r_stream;
@@ -37,11 +43,11 @@ use ui::{
};
use userdata::{r_node_userdata, r_player_progress, r_player_watched};
-use self::streamsync::r_streamsync;
+use self::playersync::{r_streamsync, PlayersyncChannels};
pub mod api;
pub mod stream;
-pub mod streamsync;
+pub mod playersync;
pub mod ui;
pub mod userdata;
@@ -76,6 +82,7 @@ pub fn build_rocket(database: DataAcid, federation: Federation) -> Rocket<Build>
})
.manage(database)
.manage(federation)
+ .manage(PlayersyncChannels::default())
.attach(AdHoc::on_response("set server header", |_req, res| {
res.set_header(Header::new("server", "jellything"));
Box::pin(async {})
@@ -133,3 +140,12 @@ pub fn build_rocket(database: DataAcid, federation: Federation) -> Rocket<Build>
fn r_favicon() -> MyResult<File> {
Ok(File::open(CONF.asset_path.join("favicon.ico"))?)
}
+
+pub struct Cors<T>(pub T);
+impl<'r, T: Responder<'r, 'static>> Responder<'r, 'static> for Cors<T> {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let mut r = self.0.respond_to(request)?;
+ r.adjoin_header(Header::new("access-controll-allow-origin", "*"));
+ Ok(r)
+ }
+}
diff --git a/server/src/routes/playersync.rs b/server/src/routes/playersync.rs
new file mode 100644
index 0000000..2a7d3f6
--- /dev/null
+++ b/server/src/routes/playersync.rs
@@ -0,0 +1,55 @@
+use super::Cors;
+use chashmap::CHashMap;
+use futures::{SinkExt, StreamExt};
+use log::warn;
+use rocket::{get, State};
+use rocket_ws::{stream::DuplexStream, Channel, Message, WebSocket};
+use tokio::sync::broadcast::{self, Sender};
+
+#[derive(Default)]
+pub struct PlayersyncChannels {
+ channels: CHashMap<String, broadcast::Sender<Message>>,
+}
+
+#[get("/playersync/<channel>")]
+pub fn r_streamsync(
+ ws: WebSocket,
+ state: &State<PlayersyncChannels>,
+ channel: &str,
+) -> Cors<Channel<'static>> {
+ let sender = state
+ .channels
+ .get(&channel.to_owned())
+ .map(|x| x.to_owned())
+ .unwrap_or_else(|| {
+ let ch = broadcast::channel(16).0;
+ state.channels.insert(channel.to_owned(), ch.clone());
+ ch
+ });
+ Cors(ws.channel(move |ws| {
+ Box::pin(async move {
+ if let Err(e) = handle_socket(sender, ws).await {
+ warn!("streamsync websocket error: {e:?}")
+ }
+ Ok(())
+ })
+ }))
+}
+
+async fn handle_socket(broadcast: Sender<Message>, mut ws: DuplexStream) -> anyhow::Result<()> {
+ let mut sub = broadcast.subscribe();
+ loop {
+ tokio::select! {
+ message = ws.next() => {
+ if let Some(message) = message {
+ broadcast.send(message?)?;
+ } else {
+ return Ok(())
+ }
+ },
+ message = sub.recv() => {
+ ws.send(message?).await?;
+ }
+ };
+ }
+}
diff --git a/server/src/routes/streamsync.rs b/server/src/routes/streamsync.rs
deleted file mode 100644
index d4a4d7e..0000000
--- a/server/src/routes/streamsync.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-use rocket::get;
-use rocket_ws::{Stream, WebSocket};
-
-#[get("/streamsync")]
-pub fn r_streamsync(ws: WebSocket) -> Stream!['static] {
- Stream! { ws =>
- for await message in ws {
- yield message?;
- }
- }
-}
diff --git a/web/script/player/mod.ts b/web/script/player/mod.ts
index fdb4e4a..6aca36c 100644
--- a/web/script/player/mod.ts
+++ b/web/script/player/mod.ts
@@ -11,6 +11,7 @@ import { EncodingProfile } from "./jhls.d.ts";
import { TrackKind, get_track_kind } from "./mediacaps.ts";
import { Player } from "./player.ts";
import { Popup } from "./popup.ts";
+import { Playersync } from "./sync.ts"
globalThis.addEventListener("DOMContentLoaded", () => {
if (document.body.classList.contains("player")) {
@@ -35,10 +36,12 @@ function initialize_player(el: HTMLElement, node_id: string) {
const logger = new Logger<string>(s => e("p", s))
const player = new Player(node_id, logger)
const show_stats = new OVar(false);
+ const sync_state = new OVar<Playersync | undefined>(undefined)
const toggle_playing = () => player.playing.value ? player.pause() : player.play()
const pri_map = (v: number) => (v / player.duration.value * 100) + "%"
+
let pri_current: HTMLElement;
let pri: HTMLElement;
@@ -49,12 +52,14 @@ function initialize_player(el: HTMLElement, node_id: string) {
const button = e("button", MEDIA_KIND_ICONS[kind][+enabled], {
class: "icon",
onclick: () => {
+ // sync_state.value = new Playersync(player, logger, "test")
enabled = !enabled
button.textContent = MEDIA_KIND_ICONS[kind][+enabled]
}
})
new Popup(button, popups, () =>
e("div", { class: "jsp-track-select-popup" },
+ e("h3", `${kind[0].toUpperCase()}${kind.substring(1)}`),
...(player.tracks ?? [])
.map((track, index) => ({ index, track }))
.filter(({ track }) => get_track_kind(track.kind) == kind)
@@ -74,6 +79,33 @@ function initialize_player(el: HTMLElement, node_id: string) {
)
return button
}
+ const settings_popup = () => {
+ const button = e("button", "settings", { class: "icon" })
+ let channelname: HTMLInputElement;
+ new Popup(button, popups, () => e("div", { class: "jsp-settings-popup" },
+ e("h2", "Settings"),
+ e("div", { class: "jsp-playersync-controls" },
+ e("h3", "Playersync"),
+ sync_state.map(sync => sync
+ ? e("div",
+ e("span", "Sync enabled."),
+ e("button", "Disable", {
+ onclick: () => { sync_state.value?.destroy(); sync_state.value = undefined }
+ }))
+ : e("div",
+ channelname = e("input", { type: "text" }),
+ e("button", "Sync!", {
+ onclick: () => {
+ if (!channelname.value.length) return
+ sync_state.value?.destroy()
+ sync_state.value = new Playersync(player, logger, channelname.value)
+ }
+ }))
+ )
+ )
+ ))
+ return button;
+ }
const controls = e("div", { class: "jsp-controls" },
player.playing.map(playing =>
@@ -105,6 +137,7 @@ function initialize_player(el: HTMLElement, node_id: string) {
track_select("audio"),
track_select("subtitles")
),
+ settings_popup(),
e("button", "fullscreen", {
class: "icon",
onclick() {
diff --git a/web/script/player/sync.ts b/web/script/player/sync.ts
new file mode 100644
index 0000000..a2029ea
--- /dev/null
+++ b/web/script/player/sync.ts
@@ -0,0 +1,92 @@
+import { Logger } from "../jshelper/src/log.ts";
+import { Player } from "./player.ts"
+
+function get_username() {
+ return document.querySelector("nav .account .username")?.textContent ?? "Unknown User"
+}
+
+interface Packet {
+ time?: number,
+ playing?: boolean,
+ join?: string,
+ leave?: string,
+}
+
+export class Playersync {
+ private ws: WebSocket
+ private on_destroy: (() => void)[] = []
+
+ private cancel_pers: undefined | (() => void)
+ set_pers(s?: string) {
+ if (this.cancel_pers) this.cancel_pers(), this.cancel_pers = undefined
+ if (s) this.cancel_pers = this.logger?.log_persistent(s)
+ }
+
+ constructor(private player: Player, private logger: Logger<string>, private channel_name: string) {
+ this.set_pers("Playersync enabling...")
+
+ let [localpart, remotepart] = channel_name.split(":")
+ if (!remotepart?.length) remotepart = window.location.host
+
+ this.ws = new WebSocket(`${window.location.protocol.endsWith("s:") ? "wss" : "ws"}://${remotepart}/playersync/${encodeURIComponent(localpart)}`)
+ this.on_destroy.push(() => this.ws.close())
+
+ this.ws.onopen = () => {
+ this.set_pers()
+ this.logger.log(`Playersync connected.`)
+ this.send({ join: get_username() })
+ }
+ this.ws.onerror = () => {
+ this.set_pers(`Playersync websocket error.`)
+ }
+ this.ws.onclose = () => {
+ this.set_pers(`Playersync websocket closed.`)
+ }
+
+ let last_time = 0;
+ this.ws.onmessage = ev => {
+ const packet: Packet = JSON.parse(ev.data)
+ console.log("playersync recv", packet);
+ if (packet.time !== undefined) {
+ this.player.seek(packet.time)
+ last_time = packet.time
+ }
+ if (packet.playing === true) this.player.play()
+ if (packet.playing === false) this.player.pause()
+ if (packet.join) this.logger.log(`${packet.join} joined.`)
+ if (packet.leave) this.logger.log(`${packet.join} left.`)
+ }
+
+ let cb: () => void
+
+ player.video.addEventListener("play", cb = () => {
+ this.send({ playing: true })
+ })
+ this.on_destroy.push(() => player.video.removeEventListener("play", cb))
+
+ player.video.addEventListener("pause", cb = () => {
+ this.send({ playing: false })
+ })
+ this.on_destroy.push(() => player.video.removeEventListener("pause", cb))
+
+ player.video.addEventListener("seeking", cb = () => {
+ const time = this.player.video.currentTime
+ if (Math.abs(last_time - time) < 0.01) return
+ this.send({ time: this.player.video.currentTime })
+ })
+ this.on_destroy.push(() => player.video.removeEventListener("seeking", cb))
+ }
+
+ destroy() {
+ this.set_pers()
+ this.logger.log("Playersync disabled.")
+ this.on_destroy.forEach(f => f())
+ this.send({ leave: get_username() })
+ }
+
+ send(p: Packet) {
+ console.log("playersync send", p);
+ this.ws.send(JSON.stringify(p))
+ }
+}
+
diff --git a/web/style/js-player.css b/web/style/js-player.css
index 40455ed..8f047f1 100644
--- a/web/style/js-player.css
+++ b/web/style/js-player.css
@@ -52,10 +52,6 @@
.jsp-track-state:hover {
background-color: rgba(113, 113, 113, 0.333);
}
-.jsp-track-select-popup {
- background-color: #303a;
- padding: 1em;
-}
.jsp-pri {
position: relative;
@@ -149,14 +145,16 @@
bottom: var(--csize);
right: 0px;
animation-name: popup-in;
+ animation-delay: 180ms;
animation-duration: 100ms;
- animation-fill-mode: forwards;
+ animation-fill-mode: both;
animation-timing-function: ease-out;
}
.jsp-popup-out {
animation-name: popup-out;
+ animation-delay: 0ms;
animation-duration: 100ms;
- animation-fill-mode: backwards;
+ animation-fill-mode: both;
animation-timing-function: ease-in;
}
@keyframes popup-in {
@@ -179,3 +177,31 @@
opacity: 0;
}
}
+
+.jsp-settings-popup {
+ padding: 1em;
+ min-width: 14em;
+ background-color: rgba(45, 24, 104, 0.548);
+}
+.jsp-track-select-popup {
+ min-width: 14em;
+ background-color: #303a;
+ padding: 1em;
+}
+.jsp-settings-popup h2,
+.jsp-settings-popup h3 {
+ margin-top: 0.1em;
+ margin-bottom: 0.1em;
+}
+
+.jsp-playersync-controls button {
+ background-color: black;
+ border: 2px solid var(--accent-light);
+ font-size: medium;
+ padding: 0.3em;
+ border-radius: 7px;
+}
+.jsp-playersync-controls {
+ padding: 1em;
+ background-color: #0005;
+}