新主题
This commit is contained in:
@@ -14,6 +14,7 @@ use sha2::{Digest, Sha256};
|
||||
use crate::{
|
||||
credentials::normalize_cookiecloud_host,
|
||||
overlay::{OverlaySettings, OverlayThemeId},
|
||||
typography::FontFamilyId,
|
||||
};
|
||||
|
||||
/// Process-level configuration. Tenant-owned room, CookieCloud and component
|
||||
@@ -127,6 +128,8 @@ struct EmoticonsConfig {
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OverlayFileConfig {
|
||||
theme_id: Option<OverlayThemeId>,
|
||||
font_family: Option<FontFamilyId>,
|
||||
font_brightness: Option<u16>,
|
||||
font_scale: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
@@ -323,6 +326,8 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
let default = OverlaySettings::default();
|
||||
OverlaySettings {
|
||||
theme_id: file.theme_id.unwrap_or(default.theme_id),
|
||||
font_family: file.font_family.unwrap_or(default.font_family),
|
||||
font_brightness: file.font_brightness.unwrap_or(default.font_brightness),
|
||||
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
||||
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
||||
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
||||
|
||||
@@ -51,6 +51,10 @@ impl LiveEventKind {
|
||||
pub struct PlatformViewer {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
/// Optional public avatar supplied by the live platform. Older event
|
||||
/// formats omit it, so renderers must keep a local fallback.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
@@ -331,6 +335,7 @@ mod tests {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
components::{ComponentDefinition, ComponentError, EventSubscription},
|
||||
domain::LiveEventKind,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GIFT_EFFECT_KIND: &str = "gift_effect";
|
||||
@@ -21,6 +22,7 @@ pub const GIFT_EFFECT_NAME: &str = "礼物星雨";
|
||||
pub enum GiftEffectThemeId {
|
||||
#[default]
|
||||
JadeStarfall,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
@@ -45,6 +47,10 @@ impl MeteorTierSettings {
|
||||
pub struct GiftEffectSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GiftEffectThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
pub high_value_threshold: i64,
|
||||
pub featured_value_threshold: i64,
|
||||
pub normal: MeteorTierSettings,
|
||||
@@ -61,6 +67,8 @@ impl Default for GiftEffectSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GiftEffectThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
high_value_threshold: 10_000,
|
||||
featured_value_threshold: 100_000,
|
||||
normal: MeteorTierSettings {
|
||||
@@ -90,6 +98,7 @@ impl Default for GiftEffectSettings {
|
||||
impl GiftEffectSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.high_value_threshold = self.high_value_threshold.max(0);
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.featured_value_threshold =
|
||||
self.featured_value_threshold.max(self.high_value_threshold);
|
||||
self.normal = self.normal.sanitize();
|
||||
@@ -155,11 +164,13 @@ mod tests {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["normal"]["count"] = Value::from(0);
|
||||
settings["fontBrightness"] = Value::from(999);
|
||||
settings["featured"]["size"] = Value::from(9_999);
|
||||
settings["highValueThreshold"] = Value::from(50_000);
|
||||
settings["featuredValueThreshold"] = Value::from(10_000);
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["normal"]["count"], 1);
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["featured"]["size"], 480);
|
||||
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
||||
}
|
||||
@@ -175,4 +186,13 @@ mod tests {
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{
|
||||
},
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
|
||||
overlay::normalize_image_url,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GIFT_MENU_KIND: &str = "gift_menu";
|
||||
@@ -28,6 +29,7 @@ const MAX_MENU_ITEMS: usize = 100;
|
||||
pub enum GiftMenuThemeId {
|
||||
#[default]
|
||||
JadeBanquet,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
|
||||
@@ -73,6 +75,10 @@ pub struct GiftMenuSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GiftMenuThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
#[serde(default)]
|
||||
pub items: Vec<GiftMenuItem>,
|
||||
pub visible_rows: u8,
|
||||
pub row_height: u16,
|
||||
@@ -87,6 +93,8 @@ impl Default for GiftMenuSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GiftMenuThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
items: Vec::new(),
|
||||
visible_rows: 4,
|
||||
row_height: 88,
|
||||
@@ -117,6 +125,7 @@ impl GiftMenuSettings {
|
||||
}
|
||||
}
|
||||
self.visible_rows = self.visible_rows.clamp(1, 20);
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.row_height = self.row_height.clamp(44, 240);
|
||||
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
|
||||
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
|
||||
@@ -314,6 +323,7 @@ mod tests {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +373,7 @@ mod tests {
|
||||
row_height: 1,
|
||||
scroll_speed_pixels_per_second: u16::MAX,
|
||||
font_scale: 1,
|
||||
font_brightness: 1,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
let sanitized = definition
|
||||
@@ -372,6 +383,7 @@ mod tests {
|
||||
assert_eq!(sanitized["rowHeight"], 44);
|
||||
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
|
||||
assert_eq!(sanitized["fontScale"], 50);
|
||||
assert_eq!(sanitized["fontBrightness"], 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -507,4 +519,13 @@ mod tests {
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftMenuDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,7 +993,11 @@ async fn component_test_event(
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
let component = state.repository.get_component(session.user.id, id).await?;
|
||||
let viewer = |uid: String, name: String| PlatformViewer { uid, name };
|
||||
let viewer = |uid: String, name: String| PlatformViewer {
|
||||
uid,
|
||||
name,
|
||||
avatar_url: None,
|
||||
};
|
||||
let payload = match body {
|
||||
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
|
||||
viewer: viewer(uid, name),
|
||||
|
||||
@@ -22,3 +22,4 @@ pub mod rate_limit;
|
||||
pub mod realtime;
|
||||
pub mod repository;
|
||||
pub mod song_request;
|
||||
pub mod typography;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! modeled by `libilibili` are interpreted only inside this provider boundary;
|
||||
//! unknown events expose sanitized command names, never raw packets or secrets.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use libilibili::{
|
||||
@@ -491,6 +491,7 @@ fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
|
||||
viewer: PlatformViewer {
|
||||
uid: message.uid.to_string(),
|
||||
name: message.username,
|
||||
avatar_url: None,
|
||||
},
|
||||
name: message.gift_name,
|
||||
quantity: bounded_i32(message.num).max(1),
|
||||
@@ -534,6 +535,7 @@ fn normalize_danmaku(message: &DanmuMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: danmaku_avatar(message),
|
||||
},
|
||||
emoticons: parse_danmaku_emoticons(message, &text),
|
||||
text,
|
||||
@@ -629,6 +631,7 @@ fn normalize_gift(message: &GiftMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
// Blind-box SEND_GIFT events describe the revealed prize in the
|
||||
// ordinary gift fields. The `blind_gift.original_*` fields identify
|
||||
@@ -674,6 +677,7 @@ fn normalize_combo(message: &ComboSendMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
name: message
|
||||
.data
|
||||
@@ -701,6 +705,7 @@ fn normalize_like(message: &LikeClickMessage) -> Option<ProviderEvent> {
|
||||
.uname
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("UID {uid}")),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -726,6 +731,7 @@ fn normalize_superchat(message: &SuperChatMessage) -> Option<ProviderEvent> {
|
||||
viewer: PlatformViewer {
|
||||
uid: uid.to_string(),
|
||||
name,
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
},
|
||||
message: message.data.message.clone(),
|
||||
price: bounded_i64(message.data.price).unwrap_or(i64::MAX),
|
||||
@@ -737,6 +743,7 @@ fn normalize_interaction(message: &InteractWordMessage) -> Option<ProviderEvent>
|
||||
let viewer = PlatformViewer {
|
||||
uid: message.data.uid.to_string(),
|
||||
name: message.data.uname.clone(),
|
||||
avatar_url: avatar_from_extra(&message.data.extra),
|
||||
};
|
||||
match message.data.msg_type {
|
||||
1 => Some(ProviderEvent::Enter { viewer }),
|
||||
@@ -761,16 +768,73 @@ fn sanitized_command(command: Option<&str>) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Recover the public avatar from both legacy and current Bilibili sender
|
||||
/// layouts. `libilibili` deliberately preserves unknown DANMU_MSG entries, so
|
||||
/// this adapter can adopt new sender envelopes without exposing raw packets to
|
||||
/// the rest of the application.
|
||||
fn danmaku_avatar(message: &DanmuMessage) -> Option<String> {
|
||||
message
|
||||
.metadata
|
||||
.extra
|
||||
.values()
|
||||
.chain(message.sender.extra.values())
|
||||
.chain(message.unknown_info.values())
|
||||
.chain(message.extra.values())
|
||||
.find_map(avatar_from_value)
|
||||
.or_else(|| {
|
||||
message.extension.as_ref().and_then(|extension| {
|
||||
avatar_from_extra(&extension.extra).or_else(|| {
|
||||
extension
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||
.as_ref()
|
||||
.and_then(avatar_from_value)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn avatar_from_extra(extra: &HashMap<String, Value>) -> Option<String> {
|
||||
["face", "avatar", "avatar_url"]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
extra
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_image_url)
|
||||
})
|
||||
.or_else(|| extra.values().find_map(avatar_from_value))
|
||||
}
|
||||
|
||||
fn avatar_from_value(value: &Value) -> Option<String> {
|
||||
[
|
||||
"/sender_uinfo/base/face",
|
||||
"/user/base/face",
|
||||
"/user_info/face",
|
||||
"/base/face",
|
||||
"/face",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|path| {
|
||||
value
|
||||
.pointer(path)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_image_url)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
let command = raw.get("cmd")?.as_str()?.split(':').next()?.to_owned();
|
||||
let data = raw.get("data").unwrap_or(raw);
|
||||
let viewer = |uid: &Value, name: &Value| {
|
||||
let viewer = |uid: &Value, name: &Value, avatar: Option<&Value>| {
|
||||
Some(PlatformViewer {
|
||||
uid: uid
|
||||
.as_i64()
|
||||
.map(|id| id.to_string())
|
||||
.or_else(|| uid.as_str().map(str::to_owned))?,
|
||||
name: name.as_str()?.to_owned(),
|
||||
avatar_url: avatar.and_then(Value::as_str).and_then(normalize_image_url),
|
||||
})
|
||||
};
|
||||
let data_viewer = |value: &Value| {
|
||||
@@ -780,6 +844,10 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
.get("uname")
|
||||
.or_else(|| value.pointer("/sender_uinfo/base/name"))
|
||||
.or_else(|| value.pointer("/user_info/uname"))?,
|
||||
value
|
||||
.get("face")
|
||||
.or_else(|| value.pointer("/sender_uinfo/base/face"))
|
||||
.or_else(|| value.pointer("/user_info/face")),
|
||||
)
|
||||
};
|
||||
match command.as_str() {
|
||||
@@ -1239,6 +1307,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_avatar_from_current_danmaku_sender_envelope() {
|
||||
let mut metadata = vec![Value::Null; 16];
|
||||
metadata[15] = json!({
|
||||
"user": {
|
||||
"base": {
|
||||
"face": "//i0.hdslb.com/bfs/face/avatar.jpg"
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut info = vec![Value::Null; 3];
|
||||
info[0] = Value::Array(metadata);
|
||||
info[1] = json!("晚上好");
|
||||
info[2] = json!([12345, "观众"]);
|
||||
let message = normalize_command(parse_command(json!({
|
||||
"cmd": "DANMU_MSG",
|
||||
"info": info
|
||||
})))
|
||||
.unwrap();
|
||||
|
||||
match message {
|
||||
ProviderEvent::Danmaku { viewer, .. } => assert_eq!(
|
||||
viewer.avatar_url.as_deref(),
|
||||
Some("https://i0.hdslb.com/bfs/face/avatar.jpg")
|
||||
),
|
||||
_ => panic!("expected danmaku"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_events_do_not_forward_raw_payload() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
|
||||
@@ -12,6 +12,8 @@ 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:
|
||||
@@ -23,6 +25,7 @@ use uuid::Uuid;
|
||||
pub enum OverlayThemeId {
|
||||
#[default]
|
||||
JadeScroll,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -31,6 +34,10 @@ 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,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
pub show_danmaku: bool,
|
||||
@@ -59,6 +66,8 @@ impl Default for OverlaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: OverlayThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
font_scale: default_font_scale(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
@@ -83,6 +92,7 @@ impl Default for OverlaySettings {
|
||||
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.font_scale = self.font_scale.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);
|
||||
@@ -546,6 +556,7 @@ mod tests {
|
||||
fn settings_are_bounded_before_persistence() {
|
||||
let settings = OverlaySettings {
|
||||
font_scale: 999,
|
||||
font_brightness: 1,
|
||||
max_visible: 99,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
@@ -558,6 +569,7 @@ mod tests {
|
||||
}
|
||||
.sanitize();
|
||||
assert_eq!(settings.font_scale, 300);
|
||||
assert_eq!(settings.font_brightness, 70);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
@@ -580,12 +592,16 @@ mod tests {
|
||||
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("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.font_scale, 140);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
@@ -599,4 +615,16 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +402,7 @@ mod tests {
|
||||
viewer: PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
text: "晚上好".into(),
|
||||
segments: Vec::new(),
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload, PlatformViewer},
|
||||
overlay::OverlayThemeId,
|
||||
realtime::EventHub,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const SONG_REQUEST_KIND: &str = "song_request";
|
||||
@@ -33,6 +34,10 @@ const SNAPSHOT_PAGE_SIZE: usize = 100;
|
||||
pub struct SongRequestSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: OverlayThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_scroll_speed")]
|
||||
@@ -54,6 +59,8 @@ impl Default for SongRequestSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: OverlayThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
font_scale: default_font_scale(),
|
||||
scroll_speed_pixels_per_second: default_scroll_speed(),
|
||||
edge_pause_seconds: default_edge_pause(),
|
||||
@@ -67,6 +74,7 @@ impl Default for SongRequestSettings {
|
||||
impl SongRequestSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.font_scale = self.font_scale.clamp(50, 250);
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.clamp(5, 200);
|
||||
self.edge_pause_seconds = self.edge_pause_seconds.min(15);
|
||||
self.max_queue_size = self.max_queue_size.min(10_000);
|
||||
@@ -1022,6 +1030,7 @@ mod tests {
|
||||
|
||||
let bounded = SongRequestSettings {
|
||||
font_scale: 999,
|
||||
font_brightness: u16::MAX,
|
||||
scroll_speed_pixels_per_second: 0,
|
||||
edge_pause_seconds: 200,
|
||||
max_queue_size: u32::MAX,
|
||||
@@ -1031,6 +1040,7 @@ mod tests {
|
||||
}
|
||||
.sanitize();
|
||||
assert_eq!(bounded.font_scale, 250);
|
||||
assert_eq!(bounded.font_brightness, 180);
|
||||
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
|
||||
assert_eq!(bounded.edge_pause_seconds, 15);
|
||||
assert_eq!(bounded.max_queue_size, 10_000);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Shared, validated typography settings for all built-in OBS components.
|
||||
//!
|
||||
//! Font IDs map to fixed frontend font stacks rather than accepting arbitrary
|
||||
//! CSS. This keeps settings portable between OBS hosts and prevents untrusted
|
||||
//! component settings from becoming a CSS injection boundary.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FontFamilyId {
|
||||
Song,
|
||||
#[default]
|
||||
FangSong,
|
||||
Kai,
|
||||
}
|
||||
|
||||
pub const fn default_font_brightness() -> u16 {
|
||||
130
|
||||
}
|
||||
|
||||
pub fn sanitize_font_brightness(value: u16) -> u16 {
|
||||
value.clamp(70, 180)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn font_ids_are_stable_and_brightness_is_bounded() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&FontFamilyId::FangSong).unwrap(),
|
||||
"\"fang-song\""
|
||||
);
|
||||
assert_eq!(sanitize_font_brightness(1), 70);
|
||||
assert_eq!(sanitize_font_brightness(u16::MAX), 180);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user