670 lines
23 KiB
Rust
670 lines
23 KiB
Rust
//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches.
|
|
//!
|
|
//! Settings are sanitized before persistence or projection. Catalog refreshes
|
|
//! replace the in-memory snapshot only after a complete valid response, so a
|
|
//! transient upstream failure preserves the last known gift images, prices and
|
|
//! emoticon URLs instead of breaking live rendering.
|
|
|
|
use std::{collections::HashMap, sync::Arc};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use tokio::sync::RwLock;
|
|
use uuid::Uuid;
|
|
|
|
use crate::typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness};
|
|
|
|
/// Stable identifier for a renderer theme.
|
|
///
|
|
/// This enum is deliberately shared by defaults, validation and serialization:
|
|
/// persisted component settings can therefore never reference a theme that the
|
|
/// deployed frontend does not know how to render. Add a variant only together
|
|
/// with its frontend theme definition and stylesheet.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum OverlayThemeId {
|
|
#[default]
|
|
JadeScroll,
|
|
MoonlitWater,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OverlaySettings {
|
|
/// Visual theme selected for this tenant-owned component instance.
|
|
#[serde(default)]
|
|
pub theme_id: OverlayThemeId,
|
|
#[serde(default)]
|
|
pub font_family: FontFamilyId,
|
|
#[serde(default = "default_font_brightness")]
|
|
pub font_brightness: u16,
|
|
/// Optional per-component overrides. `None` keeps the selected theme's
|
|
/// palette, which makes future theme changes immediately visible.
|
|
#[serde(default)]
|
|
pub viewer_color: Option<String>,
|
|
#[serde(default)]
|
|
pub danmaku_color: Option<String>,
|
|
#[serde(default = "default_font_scale")]
|
|
pub font_scale: u16,
|
|
/// Relative weight of theme borders, rules, and ornamental edge artwork.
|
|
#[serde(default = "default_decoration_line_weight")]
|
|
pub decoration_line_weight: u16,
|
|
pub show_danmaku: bool,
|
|
pub show_enter: bool,
|
|
pub show_gift: bool,
|
|
pub show_superchat: bool,
|
|
pub show_guard: bool,
|
|
pub show_like: bool,
|
|
pub show_share: bool,
|
|
pub max_visible: u8,
|
|
pub collapse_after_seconds: u16,
|
|
#[serde(default = "default_unfold_duration_ms")]
|
|
pub unfold_duration_ms: u16,
|
|
pub motion_intensity: u8,
|
|
#[serde(default = "default_particle_count")]
|
|
pub particle_count: u8,
|
|
#[serde(default = "default_particle_speed")]
|
|
pub particle_speed: u16,
|
|
pub low_performance_mode: bool,
|
|
/// Bilibili gift-panel prices are in thousandths of a yuan.
|
|
pub high_value_threshold: i64,
|
|
pub featured_value_threshold: i64,
|
|
}
|
|
|
|
impl Default for OverlaySettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
theme_id: OverlayThemeId::default(),
|
|
font_family: FontFamilyId::default(),
|
|
font_brightness: default_font_brightness(),
|
|
viewer_color: None,
|
|
danmaku_color: None,
|
|
font_scale: default_font_scale(),
|
|
decoration_line_weight: default_decoration_line_weight(),
|
|
show_danmaku: true,
|
|
show_enter: true,
|
|
show_gift: true,
|
|
show_superchat: true,
|
|
show_guard: true,
|
|
show_like: false,
|
|
show_share: false,
|
|
max_visible: 5,
|
|
collapse_after_seconds: 12,
|
|
unfold_duration_ms: default_unfold_duration_ms(),
|
|
motion_intensity: 70,
|
|
particle_count: default_particle_count(),
|
|
particle_speed: default_particle_speed(),
|
|
low_performance_mode: false,
|
|
high_value_threshold: 10_000,
|
|
featured_value_threshold: 100_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OverlaySettings {
|
|
pub fn sanitize(mut self) -> Self {
|
|
self.max_visible = self.max_visible.clamp(1, 12);
|
|
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
|
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
|
|
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
|
|
self.font_scale = self.font_scale.clamp(50, 300);
|
|
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
|
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
|
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
|
self.motion_intensity = self.motion_intensity.min(100);
|
|
self.particle_count = self.particle_count.min(12);
|
|
self.particle_speed = self.particle_speed.clamp(25, 300);
|
|
self.high_value_threshold = self.high_value_threshold.max(0);
|
|
self.featured_value_threshold =
|
|
self.featured_value_threshold.max(self.high_value_threshold);
|
|
self
|
|
}
|
|
}
|
|
|
|
fn sanitize_optional_hex_color(value: Option<String>) -> Option<String> {
|
|
let value = value?.trim().to_ascii_uppercase();
|
|
(value.len() == 7
|
|
&& value.starts_with('#')
|
|
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
|
|
.then_some(value)
|
|
}
|
|
|
|
fn default_font_scale() -> u16 {
|
|
140
|
|
}
|
|
|
|
fn default_decoration_line_weight() -> u16 {
|
|
160
|
|
}
|
|
|
|
fn default_unfold_duration_ms() -> u16 {
|
|
1_000
|
|
}
|
|
|
|
fn default_particle_count() -> u8 {
|
|
8
|
|
}
|
|
|
|
fn default_particle_speed() -> u16 {
|
|
100
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GiftMeta {
|
|
pub id: Option<i64>,
|
|
pub name: String,
|
|
pub coin_type: String,
|
|
pub battery_value: i64,
|
|
pub unit_price: i64,
|
|
pub image_url: Option<String>,
|
|
pub animation_url: Option<String>,
|
|
pub effect_type: Option<String>,
|
|
pub stay_time: Option<i64>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct GiftCatalog {
|
|
by_id: Arc<RwLock<HashMap<i64, GiftMeta>>>,
|
|
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
|
|
}
|
|
|
|
/// Account-keyed catalog handles shared by the live provider and control API.
|
|
/// Each account receives a distinct [`GiftCatalog`], so switching or refreshing
|
|
/// one room can never replace another tenant's gift metadata.
|
|
#[derive(Clone, Default)]
|
|
pub struct GiftCatalogRegistry {
|
|
catalogs: Arc<RwLock<HashMap<Uuid, GiftCatalog>>>,
|
|
}
|
|
|
|
impl GiftCatalogRegistry {
|
|
pub async fn catalog(&self, owner_id: Uuid) -> GiftCatalog {
|
|
if let Some(catalog) = self.catalogs.read().await.get(&owner_id).cloned() {
|
|
return catalog;
|
|
}
|
|
let mut catalogs = self.catalogs.write().await;
|
|
catalogs.entry(owner_id).or_default().clone()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct EmoticonMeta {
|
|
pub emoji: String,
|
|
pub unique: Option<String>,
|
|
pub url: String,
|
|
pub width: Option<u32>,
|
|
pub height: Option<u32>,
|
|
pub is_dynamic: bool,
|
|
pub bulge_display: bool,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct EmoticonCatalog {
|
|
by_unique: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
|
|
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
|
|
}
|
|
|
|
// Parser return aliases keep the atomic cache-replacement contract visible:
|
|
// both lookup maps and their source count are produced before either lock is
|
|
// updated, so readers never observe a half-refreshed catalog.
|
|
type GiftCatalogSnapshot = (HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
|
|
type EmoticonCatalogSnapshot = (
|
|
HashMap<String, EmoticonMeta>,
|
|
HashMap<String, EmoticonMeta>,
|
|
usize,
|
|
);
|
|
|
|
impl EmoticonCatalog {
|
|
pub async fn len(&self) -> usize {
|
|
self.by_unique.read().await.len()
|
|
}
|
|
|
|
pub async fn is_empty(&self) -> bool {
|
|
self.len().await == 0
|
|
}
|
|
|
|
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
|
|
if let Some(unique) = unique.filter(|value| !value.is_empty())
|
|
&& let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
|
|
{
|
|
return Some(emoticon);
|
|
}
|
|
self.by_emoji.read().await.get(emoji).cloned()
|
|
}
|
|
|
|
pub async fn refresh(
|
|
&self,
|
|
room_id: &str,
|
|
cookie: &str,
|
|
timeout_seconds: u64,
|
|
) -> Result<usize, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
|
.build()
|
|
.map_err(|error| error.to_string())?;
|
|
let response = client
|
|
.get("https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons")
|
|
.query(&[("platform", "pc"), ("room_id", room_id)])
|
|
.header(reqwest::header::COOKIE, cookie)
|
|
.header(reqwest::header::REFERER, "https://live.bilibili.com/")
|
|
.header(
|
|
reqwest::header::USER_AGENT,
|
|
"Mozilla/5.0 lxc-streamutils/1.0",
|
|
)
|
|
.send()
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
if !response.status().is_success() {
|
|
return Err(format!("emoticon API HTTP {}", response.status()));
|
|
}
|
|
let payload: Value = response.json().await.map_err(|error| error.to_string())?;
|
|
if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 {
|
|
return Err(format!(
|
|
"emoticon API error: {}",
|
|
payload
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
));
|
|
}
|
|
let (unique, emoji, count) = parse_emoticon_catalog(&payload)?;
|
|
*self.by_unique.write().await = unique;
|
|
*self.by_emoji.write().await = emoji;
|
|
Ok(count)
|
|
}
|
|
}
|
|
|
|
impl GiftCatalog {
|
|
pub async fn len(&self) -> usize {
|
|
self.by_id.read().await.len()
|
|
}
|
|
|
|
pub async fn is_empty(&self) -> bool {
|
|
self.len().await == 0
|
|
}
|
|
|
|
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
|
if let Some(id) = id
|
|
&& let Some(gift) = self.by_id.read().await.get(&id).cloned()
|
|
{
|
|
return Some(gift);
|
|
}
|
|
self.by_name
|
|
.read()
|
|
.await
|
|
.get(&normalize_name(name))
|
|
.cloned()
|
|
}
|
|
|
|
/// Return a stable, deduplicated control-console view ordered by price and
|
|
/// name. ID-backed entries are preferred because names are not unique.
|
|
pub async fn list(&self) -> Vec<GiftMeta> {
|
|
let mut gifts: Vec<_> = self.by_id.read().await.values().cloned().collect();
|
|
if gifts.is_empty() {
|
|
gifts.extend(self.by_name.read().await.values().cloned());
|
|
}
|
|
gifts.sort_by(|left, right| {
|
|
left.unit_price
|
|
.cmp(&right.unit_price)
|
|
.then_with(|| left.name.cmp(&right.name))
|
|
});
|
|
gifts
|
|
}
|
|
|
|
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
let response = client
|
|
.get("https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList")
|
|
// Despite the endpoint name, the current API only includes
|
|
// gift_config for platform=pc. platform=web may return code=0
|
|
// with an empty room_gift_list and no catalog.
|
|
.query(&[("platform", "pc"), ("room_id", room_id)])
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !response.status().is_success() {
|
|
return Err(format!("gift panel HTTP {}", response.status()));
|
|
}
|
|
let payload: Value = response.json().await.map_err(|e| e.to_string())?;
|
|
if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 {
|
|
return Err(format!(
|
|
"gift panel API error: {}",
|
|
payload
|
|
.get("message")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
));
|
|
}
|
|
let (ids, names, count) = parse_catalog(&payload)?;
|
|
*self.by_id.write().await = ids;
|
|
*self.by_name.write().await = names;
|
|
Ok(count)
|
|
}
|
|
}
|
|
|
|
fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
|
let list = payload
|
|
.pointer("/data/gift_config/base_config/list")
|
|
.and_then(Value::as_array)
|
|
.ok_or("gift panel response has no gift list")?;
|
|
let mut ids = HashMap::new();
|
|
let mut names = HashMap::new();
|
|
for item in list {
|
|
let Some(name) = item.get("name").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let id = item.get("id").and_then(Value::as_i64);
|
|
let unit_price = item.get("price").and_then(Value::as_i64).unwrap_or(0);
|
|
let gift = GiftMeta {
|
|
id,
|
|
name: name.to_owned(),
|
|
coin_type: item
|
|
.get("coin_type")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("gold")
|
|
.to_owned(),
|
|
battery_value: gift_price_to_batteries(unit_price),
|
|
unit_price,
|
|
image_url: string_field(item, "img_basic"),
|
|
animation_url: string_field(item, "gif"),
|
|
effect_type: item.get("effect").and_then(|value| match value {
|
|
Value::String(value) if !value.is_empty() => Some(value.clone()),
|
|
Value::Number(value) => Some(value.to_string()),
|
|
_ => None,
|
|
}),
|
|
stay_time: item.get("stay_time").and_then(Value::as_i64),
|
|
};
|
|
if let Some(id) = id {
|
|
ids.insert(id, gift.clone());
|
|
}
|
|
names.insert(normalize_name(name), gift);
|
|
}
|
|
if ids.is_empty() && names.is_empty() {
|
|
return Err("gift panel returned an empty catalog".into());
|
|
}
|
|
Ok((ids, names, list.len()))
|
|
}
|
|
|
|
/// Bilibili's gift panel and live messages express paid gift prices in gold
|
|
/// coins rather than batteries. One battery is 100 gold coins (and ¥0.1).
|
|
pub fn gift_price_to_batteries(raw_price: i64) -> i64 {
|
|
raw_price.max(0) / 100
|
|
}
|
|
|
|
fn string_field(value: &Value, name: &str) -> Option<String> {
|
|
value
|
|
.get(name)
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
|
|
let packages = payload
|
|
.pointer("/data/data")
|
|
.and_then(Value::as_array)
|
|
.ok_or("emoticon response has no package list")?;
|
|
let mut by_unique = HashMap::new();
|
|
let mut by_emoji = HashMap::new();
|
|
let mut count = 0;
|
|
for item in packages
|
|
.iter()
|
|
.filter_map(|package| package.get("emoticons").and_then(Value::as_array))
|
|
.flatten()
|
|
{
|
|
let emoji = item
|
|
.get("emoji")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
let unique = item
|
|
.get("emoticon_unique")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned);
|
|
let Some(url) = item
|
|
.get("url")
|
|
.and_then(Value::as_str)
|
|
.and_then(normalize_image_url)
|
|
else {
|
|
continue;
|
|
};
|
|
let emoticon = EmoticonMeta {
|
|
emoji: emoji.clone(),
|
|
unique: unique.clone(),
|
|
url,
|
|
width: number_as_u32(item.get("width")),
|
|
height: number_as_u32(item.get("height")),
|
|
is_dynamic: truthy(item.get("is_dynamic")),
|
|
bulge_display: truthy(item.get("bulge_display")),
|
|
};
|
|
if let Some(unique) = unique {
|
|
by_unique.insert(unique, emoticon.clone());
|
|
}
|
|
if !emoji.is_empty() {
|
|
by_emoji.insert(emoji, emoticon);
|
|
}
|
|
count += 1;
|
|
}
|
|
if count == 0 {
|
|
return Err("emoticon API returned an empty catalog".into());
|
|
}
|
|
Ok((by_unique, by_emoji, count))
|
|
}
|
|
|
|
pub fn normalize_image_url(url: &str) -> Option<String> {
|
|
let url = url.trim();
|
|
if let Some(rest) = url.strip_prefix("http://") {
|
|
Some(format!("https://{rest}"))
|
|
} else if url.starts_with("https://") {
|
|
Some(url.to_owned())
|
|
} else if url.starts_with("//") {
|
|
Some(format!("https:{url}"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn number_as_u32(value: Option<&Value>) -> Option<u32> {
|
|
value.and_then(|value| {
|
|
value
|
|
.as_u64()
|
|
.and_then(|value| u32::try_from(value).ok())
|
|
.or_else(|| value.as_str()?.parse().ok())
|
|
})
|
|
}
|
|
|
|
fn truthy(value: Option<&Value>) -> bool {
|
|
value.is_some_and(|value| {
|
|
value.as_bool().unwrap_or(false)
|
|
|| value.as_i64().is_some_and(|value| value != 0)
|
|
|| value.as_str().is_some_and(|value| value == "1")
|
|
})
|
|
}
|
|
fn normalize_name(name: &str) -> String {
|
|
name.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
.to_lowercase()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn gift_catalog_registry_isolates_account_snapshots() {
|
|
let registry = GiftCatalogRegistry::default();
|
|
let first = registry.catalog(Uuid::new_v4()).await;
|
|
let second = registry.catalog(Uuid::new_v4()).await;
|
|
first.by_id.write().await.insert(
|
|
1,
|
|
GiftMeta {
|
|
id: Some(1),
|
|
name: "小花花".into(),
|
|
coin_type: "gold".into(),
|
|
battery_value: 1,
|
|
unit_price: 100,
|
|
image_url: None,
|
|
animation_url: None,
|
|
effect_type: None,
|
|
stay_time: None,
|
|
},
|
|
);
|
|
assert_eq!(first.list().await.len(), 1);
|
|
assert!(second.list().await.is_empty());
|
|
}
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn parses_current_gift_panel_fields_and_indexes_by_id_and_name() {
|
|
let payload = json!({"data":{"gift_config":{"base_config":{"list":[{
|
|
"id":42,"name":"青玉灯","price":30000,"coin_type":"gold","img_basic":"https://example.test/gift.png",
|
|
"gif":"https://example.test/gift.gif","effect":2,"stay_time":3
|
|
}]}}}});
|
|
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
|
|
assert_eq!(count, 1);
|
|
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
|
|
assert_eq!(ids.get(&42).expect("id index").battery_value, 300);
|
|
assert_eq!(
|
|
names
|
|
.get("青玉灯")
|
|
.expect("name index")
|
|
.effect_type
|
|
.as_deref(),
|
|
Some("2")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn converts_raw_gold_coin_prices_to_batteries() {
|
|
assert_eq!(gift_price_to_batteries(100), 1);
|
|
assert_eq!(gift_price_to_batteries(15_000), 150);
|
|
assert_eq!(gift_price_to_batteries(-1), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_an_empty_catalog_without_replacing_the_cache() {
|
|
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
|
|
assert!(parse_catalog(&payload).is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parses_emoticon_packages_and_normalizes_image_urls() {
|
|
let payload = json!({"code":0,"data":{"data":[{"emoticons":[{
|
|
"emoji":"[热]",
|
|
"emoticon_unique":"emoji_278",
|
|
"url":"http://i0.hdslb.com/bfs/live/hot.png",
|
|
"width":20,
|
|
"height":20,
|
|
"is_dynamic":1,
|
|
"bulge_display":0
|
|
}]}]}});
|
|
let (by_unique, by_emoji, count) =
|
|
parse_emoticon_catalog(&payload).expect("emoticon catalog");
|
|
assert_eq!(count, 1);
|
|
assert_eq!(
|
|
by_unique.get("emoji_278").expect("unique index").url,
|
|
"https://i0.hdslb.com/bfs/live/hot.png"
|
|
);
|
|
assert!(by_emoji.get("[热]").expect("emoji index").is_dynamic);
|
|
|
|
let catalog = EmoticonCatalog {
|
|
by_unique: Arc::new(RwLock::new(by_unique)),
|
|
by_emoji: Arc::new(RwLock::new(by_emoji)),
|
|
};
|
|
assert!(catalog.get(Some("emoji_278"), "missing").await.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn settings_are_bounded_before_persistence() {
|
|
let settings = OverlaySettings {
|
|
font_scale: 999,
|
|
font_brightness: 1,
|
|
viewer_color: Some(" #a1b2c3 ".into()),
|
|
danmaku_color: Some("not-css".into()),
|
|
decoration_line_weight: 999,
|
|
max_visible: 99,
|
|
collapse_after_seconds: 1,
|
|
unfold_duration_ms: 9_000,
|
|
motion_intensity: 200,
|
|
particle_count: 255,
|
|
particle_speed: 0,
|
|
high_value_threshold: -1,
|
|
featured_value_threshold: -2,
|
|
..OverlaySettings::default()
|
|
}
|
|
.sanitize();
|
|
assert_eq!(settings.font_scale, 300);
|
|
assert_eq!(settings.font_brightness, 70);
|
|
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
|
|
assert_eq!(settings.danmaku_color, None);
|
|
assert_eq!(settings.decoration_line_weight, 300);
|
|
assert_eq!(settings.max_visible, 12);
|
|
assert_eq!(settings.collapse_after_seconds, 2);
|
|
assert_eq!(settings.unfold_duration_ms, 5_000);
|
|
assert_eq!(settings.motion_intensity, 100);
|
|
assert_eq!(settings.particle_count, 12);
|
|
assert_eq!(settings.particle_speed, 25);
|
|
assert_eq!(settings.featured_value_threshold, 0);
|
|
|
|
let settings = OverlaySettings {
|
|
particle_speed: u16::MAX,
|
|
..OverlaySettings::default()
|
|
}
|
|
.sanitize();
|
|
assert_eq!(settings.particle_speed, 300);
|
|
}
|
|
|
|
#[test]
|
|
fn old_saved_settings_receive_new_field_defaults() {
|
|
let mut value = json!(OverlaySettings::default());
|
|
let object = value.as_object_mut().expect("settings object");
|
|
object.insert("title".into(), json!("旧版弹幕栏标题"));
|
|
object.remove("fontScale");
|
|
object.remove("fontFamily");
|
|
object.remove("fontBrightness");
|
|
object.remove("viewerColor");
|
|
object.remove("danmakuColor");
|
|
object.remove("decorationLineWeight");
|
|
object.remove("unfoldDurationMs");
|
|
object.remove("particleCount");
|
|
object.remove("particleSpeed");
|
|
object.remove("themeId");
|
|
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
|
|
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
|
|
assert_eq!(settings.font_family, FontFamilyId::FangSong);
|
|
assert_eq!(settings.font_brightness, 130);
|
|
assert_eq!(settings.viewer_color, None);
|
|
assert_eq!(settings.danmaku_color, None);
|
|
assert_eq!(settings.font_scale, 140);
|
|
assert_eq!(settings.decoration_line_weight, 160);
|
|
assert_eq!(settings.unfold_duration_ms, 1_000);
|
|
assert_eq!(settings.particle_count, 8);
|
|
assert_eq!(settings.particle_speed, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_theme_ids_are_rejected() {
|
|
let mut value = json!(OverlaySettings::default());
|
|
value["themeId"] = json!("theme-that-is-not-installed");
|
|
let error = serde_json::from_value::<OverlaySettings>(value).expect_err("unknown theme");
|
|
assert!(error.to_string().contains("unknown variant"));
|
|
}
|
|
|
|
#[test]
|
|
fn moonlit_water_theme_is_a_valid_persisted_identifier() {
|
|
let mut value = json!(OverlaySettings::default());
|
|
value["themeId"] = json!("moonlit-water");
|
|
let settings: OverlaySettings = serde_json::from_value(value).expect("moonlit theme");
|
|
assert_eq!(settings.theme_id, OverlayThemeId::MoonlitWater);
|
|
assert_eq!(
|
|
serde_json::to_value(settings).unwrap()["themeId"],
|
|
"moonlit-water"
|
|
);
|
|
}
|
|
}
|