This commit is contained in:
2026-08-06 10:38:07 -07:00
parent ac80cd1db4
commit 97a3f1be48
29 changed files with 1008 additions and 115 deletions
+4
View File
@@ -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),
+23
View File
@@ -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();
+23
View File
@@ -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,
+26
View File
@@ -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);
+69
View File
@@ -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);