装饰
This commit is contained in:
@@ -130,6 +130,8 @@ struct OverlayFileConfig {
|
||||
theme_id: Option<OverlayThemeId>,
|
||||
font_family: Option<FontFamilyId>,
|
||||
font_brightness: Option<u16>,
|
||||
viewer_color: Option<String>,
|
||||
danmaku_color: Option<String>,
|
||||
font_scale: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
@@ -328,6 +330,8 @@ fn overlay_defaults(file: OverlayFileConfig) -> 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),
|
||||
viewer_color: file.viewer_color.or(default.viewer_color),
|
||||
danmaku_color: file.danmaku_color.or(default.danmaku_color),
|
||||
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),
|
||||
|
||||
@@ -24,6 +24,10 @@ pub const GIFT_MENU_KIND: &str = "gift_menu";
|
||||
pub const GIFT_MENU_NAME: &str = "礼物菜单";
|
||||
const MAX_MENU_ITEMS: usize = 100;
|
||||
|
||||
const fn default_page_interval_ms() -> u16 {
|
||||
6_000
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GiftMenuThemeId {
|
||||
@@ -83,6 +87,10 @@ pub struct GiftMenuSettings {
|
||||
pub visible_rows: u8,
|
||||
pub row_height: u16,
|
||||
pub scroll_speed_pixels_per_second: u16,
|
||||
/// Delay between pages for non-scrolling themes. Kept separate from the
|
||||
/// pixel speed so switching themes never destroys either preference.
|
||||
#[serde(default = "default_page_interval_ms")]
|
||||
pub page_interval_ms: u16,
|
||||
pub highlight_duration_ms: u16,
|
||||
pub font_scale: u16,
|
||||
pub motion_intensity: u8,
|
||||
@@ -99,6 +107,7 @@ impl Default for GiftMenuSettings {
|
||||
visible_rows: 4,
|
||||
row_height: 88,
|
||||
scroll_speed_pixels_per_second: 28,
|
||||
page_interval_ms: default_page_interval_ms(),
|
||||
highlight_duration_ms: 3_800,
|
||||
font_scale: 100,
|
||||
motion_intensity: 78,
|
||||
@@ -128,6 +137,7 @@ impl GiftMenuSettings {
|
||||
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.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000);
|
||||
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
|
||||
self.font_scale = self.font_scale.clamp(50, 220);
|
||||
self.motion_intensity = self.motion_intensity.min(100);
|
||||
@@ -372,6 +382,7 @@ mod tests {
|
||||
visible_rows: 255,
|
||||
row_height: 1,
|
||||
scroll_speed_pixels_per_second: u16::MAX,
|
||||
page_interval_ms: u16::MAX,
|
||||
font_scale: 1,
|
||||
font_brightness: 1,
|
||||
..GiftMenuSettings::default()
|
||||
@@ -382,10 +393,22 @@ mod tests {
|
||||
assert_eq!(sanitized["visibleRows"], 20);
|
||||
assert_eq!(sanitized["rowHeight"], 44);
|
||||
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
|
||||
assert_eq!(sanitized["pageIntervalMs"], 30_000);
|
||||
assert_eq!(sanitized["fontScale"], 50);
|
||||
assert_eq!(sanitized["fontBrightness"], 70);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_the_default_page_interval() {
|
||||
let definition = GiftMenuDefinition;
|
||||
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
|
||||
settings.as_object_mut().unwrap().remove("pageIntervalMs");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
|
||||
let specific = Uuid::new_v4();
|
||||
|
||||
@@ -106,6 +106,10 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/v1/components/{id}/song-requests",
|
||||
get(list_song_requests),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{id}/song-requests/clear",
|
||||
post(clear_song_requests),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{id}/song-requests/{request_id}/promote",
|
||||
post(promote_song_request),
|
||||
@@ -875,6 +879,25 @@ async fn cancel_song_request(
|
||||
change_song_request(state, headers, id, request_id, true).await
|
||||
}
|
||||
|
||||
async fn clear_song_requests(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
let component = state.repository.get_component(session.user.id, id).await?;
|
||||
require_song_component(&component)?;
|
||||
let cancelled_count = state
|
||||
.song_requests
|
||||
.clear_active(&component, &session.user.room_id)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"cancelledCount": cancelled_count,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn change_song_request(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -38,6 +38,12 @@ pub struct OverlaySettings {
|
||||
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,
|
||||
pub show_danmaku: bool,
|
||||
@@ -68,6 +74,8 @@ impl Default for OverlaySettings {
|
||||
theme_id: OverlayThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
viewer_color: None,
|
||||
danmaku_color: None,
|
||||
font_scale: default_font_scale(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
@@ -93,6 +101,8 @@ 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.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
||||
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
||||
@@ -106,6 +116,14 @@ impl OverlaySettings {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -557,6 +575,8 @@ mod tests {
|
||||
let settings = OverlaySettings {
|
||||
font_scale: 999,
|
||||
font_brightness: 1,
|
||||
viewer_color: Some(" #a1b2c3 ".into()),
|
||||
danmaku_color: Some("not-css".into()),
|
||||
max_visible: 99,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
@@ -570,6 +590,8 @@ mod tests {
|
||||
.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.max_visible, 12);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
@@ -594,6 +616,8 @@ mod tests {
|
||||
object.remove("fontScale");
|
||||
object.remove("fontFamily");
|
||||
object.remove("fontBrightness");
|
||||
object.remove("viewerColor");
|
||||
object.remove("danmakuColor");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
@@ -602,6 +626,8 @@ mod tests {
|
||||
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.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
|
||||
@@ -38,6 +38,12 @@ pub struct SongRequestSettings {
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
/// Optional text-color overrides. `None` follows the selected theme so a
|
||||
/// later theme switch can update the palette without stale saved colors.
|
||||
#[serde(default)]
|
||||
pub requester_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub song_title_color: Option<String>,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_scroll_speed")]
|
||||
@@ -61,6 +67,8 @@ impl Default for SongRequestSettings {
|
||||
theme_id: OverlayThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
requester_color: None,
|
||||
song_title_color: None,
|
||||
font_scale: default_font_scale(),
|
||||
scroll_speed_pixels_per_second: default_scroll_speed(),
|
||||
edge_pause_seconds: default_edge_pause(),
|
||||
@@ -75,6 +83,8 @@ 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.requester_color = sanitize_optional_hex_color(self.requester_color);
|
||||
self.song_title_color = sanitize_optional_hex_color(self.song_title_color);
|
||||
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);
|
||||
@@ -84,6 +94,14 @@ impl SongRequestSettings {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const fn default_font_scale() -> u16 {
|
||||
100
|
||||
}
|
||||
@@ -465,6 +483,53 @@ impl SongRequestService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Cancel every active request in one tenant-scoped transaction.
|
||||
///
|
||||
/// A single revision represents the whole reset. Publishing one reset
|
||||
/// event also avoids exposing a half-cleared queue to OBS clients while a
|
||||
/// long queue is being processed.
|
||||
pub async fn clear_active(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
room_id: &str,
|
||||
) -> Result<u64, SongRequestError> {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, component.owner_id).await?;
|
||||
lock_state(&transaction, component).await?;
|
||||
let cancelled_count = transaction
|
||||
.execute(
|
||||
"UPDATE song_requests SET status='cancelled',queue_position=0,finished_at=now() \
|
||||
WHERE owner_user_id=$1 AND component_instance_id=$2 \
|
||||
AND status IN ('current','queued')",
|
||||
&[&component.owner_id, &component.id],
|
||||
)
|
||||
.await?;
|
||||
if cancelled_count == 0 {
|
||||
transaction.commit().await?;
|
||||
return Ok(0);
|
||||
}
|
||||
let revision = bump_revision(&transaction, component.id).await?;
|
||||
transaction.commit().await?;
|
||||
self.hub.publish(
|
||||
component.id,
|
||||
Arc::new(ComponentMessage::new(
|
||||
component,
|
||||
room_id,
|
||||
"song.queue.changed",
|
||||
json!({
|
||||
"revision": revision,
|
||||
"operation": "cleared",
|
||||
"itemId": null,
|
||||
"item": null,
|
||||
"current": null,
|
||||
"cancelledCount": cancelled_count,
|
||||
}),
|
||||
)),
|
||||
);
|
||||
Ok(cancelled_count)
|
||||
}
|
||||
|
||||
async fn request_song(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
@@ -1031,6 +1096,8 @@ mod tests {
|
||||
let bounded = SongRequestSettings {
|
||||
font_scale: 999,
|
||||
font_brightness: u16::MAX,
|
||||
requester_color: Some(" #a1b2c3 ".into()),
|
||||
song_title_color: Some("transparent".into()),
|
||||
scroll_speed_pixels_per_second: 0,
|
||||
edge_pause_seconds: 200,
|
||||
max_queue_size: u32::MAX,
|
||||
@@ -1041,6 +1108,8 @@ mod tests {
|
||||
.sanitize();
|
||||
assert_eq!(bounded.font_scale, 250);
|
||||
assert_eq!(bounded.font_brightness, 180);
|
||||
assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3"));
|
||||
assert_eq!(bounded.song_title_color, None);
|
||||
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
|
||||
assert_eq!(bounded.edge_pause_seconds, 15);
|
||||
assert_eq!(bounded.max_queue_size, 10_000);
|
||||
|
||||
Reference in New Issue
Block a user