fix guard effects
This commit is contained in:
@@ -142,7 +142,7 @@ function JadeGuardVoyage({
|
|||||||
const level = guardLevel(effect.payload.guardName)
|
const level = guardLevel(effect.payload.guardName)
|
||||||
const guard = translate(`gift_menu.guard.${level}`)
|
const guard = translate(`gift_menu.guard.${level}`)
|
||||||
const viewer =
|
const viewer =
|
||||||
effect.payload.viewer?.uid || effect.payload.viewer?.name || translate('common.viewer')
|
effect.payload.viewer?.name || effect.payload.viewer?.uid || translate('common.viewer')
|
||||||
const title = settings.titleTemplate.replaceAll('{guard}', guard)
|
const title = settings.titleTemplate.replaceAll('{guard}', guard)
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
//! modeled by `libilibili` are interpreted only inside this provider boundary;
|
//! modeled by `libilibili` are interpreted only inside this provider boundary;
|
||||||
//! unknown events expose sanitized command names, never raw packets or secrets.
|
//! unknown events expose sanitized command names, never raw packets or secrets.
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use libilibili::{
|
use libilibili::{
|
||||||
@@ -36,6 +40,7 @@ use crate::{
|
|||||||
|
|
||||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
|
||||||
const RECONNECT_BACKOFF: Duration = Duration::from_secs(3);
|
const RECONNECT_BACKOFF: Duration = Duration::from_secs(3);
|
||||||
|
const GUARD_DUPLICATE_WINDOW: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct BilibiliProvider {
|
pub struct BilibiliProvider {
|
||||||
@@ -266,6 +271,7 @@ impl LiveProvider for BilibiliProvider {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
self.initial_catalogs(&context.room_id).await;
|
self.initial_catalogs(&context.room_id).await;
|
||||||
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
|
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
|
||||||
|
let mut guard_deduplicator = GuardEventDeduplicator::default();
|
||||||
|
|
||||||
let room_id = context
|
let room_id = context
|
||||||
.room_id
|
.room_id
|
||||||
@@ -346,6 +352,9 @@ impl LiveProvider for BilibiliProvider {
|
|||||||
let Some(raw) = normalize_upstream(upstream) else {
|
let Some(raw) = normalize_upstream(upstream) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if !guard_deduplicator.should_emit(&raw, Instant::now()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !received_event {
|
if !received_event {
|
||||||
received_event = true;
|
received_event = true;
|
||||||
info!(room_id = %context.room_id, "libilibili listener received its first live event");
|
info!(room_id = %context.room_id, "libilibili listener received its first live event");
|
||||||
@@ -472,6 +481,49 @@ enum ProviderEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
struct GuardEventFingerprint {
|
||||||
|
viewer_uid: String,
|
||||||
|
level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct GuardEventDeduplicator {
|
||||||
|
recent: HashMap<GuardEventFingerprint, Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuardEventDeduplicator {
|
||||||
|
fn should_emit(&mut self, event: &ProviderEvent, now: Instant) -> bool {
|
||||||
|
let ProviderEvent::Guard { viewer, name, .. } = event else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
self.recent
|
||||||
|
.retain(|_, seen| now.saturating_duration_since(*seen) <= GUARD_DUPLICATE_WINDOW);
|
||||||
|
let fingerprint = GuardEventFingerprint {
|
||||||
|
viewer_uid: viewer.uid.clone(),
|
||||||
|
level: guard_level_key(name),
|
||||||
|
};
|
||||||
|
if self.recent.contains_key(&fingerprint) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.recent.insert(fingerprint, now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard_level_key(name: &str) -> String {
|
||||||
|
let normalized = name.trim().to_lowercase();
|
||||||
|
if normalized.contains("总督") || normalized.contains("governor") {
|
||||||
|
"governor".to_owned()
|
||||||
|
} else if normalized.contains("提督") || normalized.contains("admiral") {
|
||||||
|
"admiral".to_owned()
|
||||||
|
} else if normalized.contains("舰长") || normalized.contains("captain") {
|
||||||
|
"captain".to_owned()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_upstream(event: UpstreamLiveEvent) -> Option<ProviderEvent> {
|
fn normalize_upstream(event: UpstreamLiveEvent) -> Option<ProviderEvent> {
|
||||||
let UpstreamLiveEvent::Command(command) = event else {
|
let UpstreamLiveEvent::Command(command) = event else {
|
||||||
return None;
|
return None;
|
||||||
@@ -507,11 +559,17 @@ fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
|
|||||||
command,
|
command,
|
||||||
raw,
|
raw,
|
||||||
error: _,
|
error: _,
|
||||||
} => normalize_raw(&raw).or_else(|| {
|
} => {
|
||||||
|
if ignored_raw_command(&raw) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
normalize_raw(&raw).or_else(|| {
|
||||||
Some(ProviderEvent::Unknown {
|
Some(ProviderEvent::Unknown {
|
||||||
command: sanitized_command(command.as_deref()),
|
command: sanitized_command(command.as_deref()),
|
||||||
})
|
})
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
LiveCommand::OnlineRankCount(message) => unknown(message.cmd),
|
LiveCommand::OnlineRankCount(message) => unknown(message.cmd),
|
||||||
LiveCommand::WatchedChange(message) => unknown(message.cmd),
|
LiveCommand::WatchedChange(message) => unknown(message.cmd),
|
||||||
LiveCommand::StopLiveRoomList(message) => unknown(message.cmd),
|
LiveCommand::StopLiveRoomList(message) => unknown(message.cmd),
|
||||||
@@ -839,9 +897,13 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
|||||||
};
|
};
|
||||||
let data_viewer = |value: &Value| {
|
let data_viewer = |value: &Value| {
|
||||||
viewer(
|
viewer(
|
||||||
value.get("uid")?,
|
value
|
||||||
|
.get("uid")
|
||||||
|
.or_else(|| value.pointer("/sender_uinfo/uid"))
|
||||||
|
.or_else(|| value.pointer("/user_info/uid"))?,
|
||||||
value
|
value
|
||||||
.get("uname")
|
.get("uname")
|
||||||
|
.or_else(|| value.get("username"))
|
||||||
.or_else(|| value.pointer("/sender_uinfo/base/name"))
|
.or_else(|| value.pointer("/sender_uinfo/base/name"))
|
||||||
.or_else(|| value.pointer("/user_info/uname"))?,
|
.or_else(|| value.pointer("/user_info/uname"))?,
|
||||||
value
|
value
|
||||||
@@ -890,21 +952,9 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
"GUARD_BUY" => Some(ProviderEvent::Guard {
|
"GUARD_BUY" | "USER_TOAST_MSG" | "USER_TOAST_MSG_V2" => {
|
||||||
viewer: data_viewer(data)?,
|
normalize_raw_guard(data, &data_viewer)
|
||||||
name: data
|
}
|
||||||
.get("gift_name")
|
|
||||||
.or_else(|| data.get("giftName"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("舰长")
|
|
||||||
.to_owned(),
|
|
||||||
quantity: data
|
|
||||||
.get("num")
|
|
||||||
.and_then(value_i64)
|
|
||||||
.map(bounded_signed_i32)
|
|
||||||
.unwrap_or(1),
|
|
||||||
price: data.get("price").and_then(value_i64).unwrap_or(0),
|
|
||||||
}),
|
|
||||||
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
|
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
|
||||||
viewer: data_viewer(data)?,
|
viewer: data_viewer(data)?,
|
||||||
message: data
|
message: data
|
||||||
@@ -925,6 +975,68 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ignored_raw_command(raw: &Value) -> bool {
|
||||||
|
let command = raw
|
||||||
|
.get("cmd")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|value| value.split(':').next());
|
||||||
|
let data = raw.get("data").unwrap_or(raw);
|
||||||
|
matches!(command, Some("USER_TOAST_MSG" | "USER_TOAST_MSG_V2"))
|
||||||
|
&& data
|
||||||
|
.pointer("/option/source")
|
||||||
|
.or_else(|| data.get("source"))
|
||||||
|
.and_then(value_i64)
|
||||||
|
== Some(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_raw_guard(
|
||||||
|
data: &Value,
|
||||||
|
data_viewer: &impl Fn(&Value) -> Option<PlatformViewer>,
|
||||||
|
) -> Option<ProviderEvent> {
|
||||||
|
let guard_level = data
|
||||||
|
.pointer("/guard_info/guard_level")
|
||||||
|
.or_else(|| data.get("guard_level"))
|
||||||
|
.and_then(value_i64);
|
||||||
|
let name = data
|
||||||
|
.pointer("/gift_info/gift_name")
|
||||||
|
.or_else(|| data.get("gift_name"))
|
||||||
|
.or_else(|| data.get("giftName"))
|
||||||
|
.or_else(|| data.get("role_name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| guard_name_from_level(guard_level).map(ToOwned::to_owned))
|
||||||
|
.unwrap_or_else(|| "舰长".to_owned());
|
||||||
|
let quantity = data
|
||||||
|
.pointer("/pay_info/num")
|
||||||
|
.or_else(|| data.get("num"))
|
||||||
|
.and_then(value_i64)
|
||||||
|
.map(bounded_signed_i32)
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
let price = data
|
||||||
|
.pointer("/pay_info/price")
|
||||||
|
.or_else(|| data.get("price"))
|
||||||
|
.and_then(value_i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(0);
|
||||||
|
Some(ProviderEvent::Guard {
|
||||||
|
viewer: data_viewer(data)?,
|
||||||
|
name,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard_name_from_level(level: Option<i64>) -> Option<&'static str> {
|
||||||
|
match level {
|
||||||
|
Some(1) => Some("总督"),
|
||||||
|
Some(2) => Some("提督"),
|
||||||
|
Some(3) => Some("舰长"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn value_i64(value: &Value) -> Option<i64> {
|
fn value_i64(value: &Value) -> Option<i64> {
|
||||||
value
|
value
|
||||||
.as_i64()
|
.as_i64()
|
||||||
@@ -1478,6 +1590,118 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_nested_guard_buy_with_username() {
|
||||||
|
let event = normalize_command(parse_command(json!({
|
||||||
|
"cmd":"GUARD_BUY",
|
||||||
|
"data":{
|
||||||
|
"uid":789,
|
||||||
|
"username":"上舰观众",
|
||||||
|
"guard_level":3,
|
||||||
|
"num":1,
|
||||||
|
"price":198000,
|
||||||
|
"gift_id":10003,
|
||||||
|
"gift_name":"舰长",
|
||||||
|
"start_time":1_755_000_000,
|
||||||
|
"end_time":1_755_000_000
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match event {
|
||||||
|
ProviderEvent::Guard {
|
||||||
|
viewer,
|
||||||
|
name,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
} => {
|
||||||
|
assert_eq!(viewer.uid, "789");
|
||||||
|
assert_eq!(viewer.name, "上舰观众");
|
||||||
|
assert_eq!(name, "舰长");
|
||||||
|
assert_eq!(quantity, 1);
|
||||||
|
assert_eq!(price, 198_000);
|
||||||
|
}
|
||||||
|
_ => panic!("expected guard purchase"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_current_user_toast_guard_and_ignores_mirror_source() {
|
||||||
|
let command = |source| {
|
||||||
|
parse_command(json!({
|
||||||
|
"cmd":"USER_TOAST_MSG_V2",
|
||||||
|
"data":{
|
||||||
|
"sender_uinfo":{
|
||||||
|
"uid":987,
|
||||||
|
"base":{
|
||||||
|
"name":"续舰观众",
|
||||||
|
"face":"//i0.hdslb.com/bfs/face/guard.jpg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guard_info":{
|
||||||
|
"guard_level":2,
|
||||||
|
"start_time":1_755_000_001,
|
||||||
|
"end_time":1_755_000_001
|
||||||
|
},
|
||||||
|
"pay_info":{"num":2,"price":1_998_000,"unit":"月"},
|
||||||
|
"gift_info":{"gift_id":10002},
|
||||||
|
"option":{"source":source},
|
||||||
|
"toast_msg":"续费大航海"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = normalize_command(command(0)).unwrap();
|
||||||
|
match event {
|
||||||
|
ProviderEvent::Guard {
|
||||||
|
viewer,
|
||||||
|
name,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
} => {
|
||||||
|
assert_eq!(viewer.uid, "987");
|
||||||
|
assert_eq!(viewer.name, "续舰观众");
|
||||||
|
assert_eq!(
|
||||||
|
viewer.avatar_url.as_deref(),
|
||||||
|
Some("https://i0.hdslb.com/bfs/face/guard.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(name, "提督");
|
||||||
|
assert_eq!(quantity, 2);
|
||||||
|
assert_eq!(price, 1_998_000);
|
||||||
|
}
|
||||||
|
_ => panic!("expected guard purchase"),
|
||||||
|
}
|
||||||
|
assert!(normalize_command(command(2)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_guard_notifications_ignore_accounting_differences() {
|
||||||
|
let guard = |name: &str, quantity, price| ProviderEvent::Guard {
|
||||||
|
viewer: PlatformViewer {
|
||||||
|
uid: "987".into(),
|
||||||
|
name: "上舰观众".into(),
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
name: name.into(),
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
};
|
||||||
|
let first = guard("舰长", 1, 198_000);
|
||||||
|
let duplicate = guard("舰长月卡", 12, 1_998_000);
|
||||||
|
let different_level = guard("提督", 1, 1_998_000);
|
||||||
|
let later = guard("舰长", 1, 198_000);
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut deduplicator = GuardEventDeduplicator::default();
|
||||||
|
|
||||||
|
assert!(deduplicator.should_emit(&first, now));
|
||||||
|
assert!(!deduplicator.should_emit(&duplicate, now + Duration::from_secs(1)));
|
||||||
|
assert!(deduplicator.should_emit(&different_level, now + Duration::from_secs(1)));
|
||||||
|
assert!(deduplicator.should_emit(
|
||||||
|
&later,
|
||||||
|
now + GUARD_DUPLICATE_WINDOW + Duration::from_millis(1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalizes_typed_like_clicks_with_a_viewer() {
|
fn normalizes_typed_like_clicks_with_a_viewer() {
|
||||||
let event = normalize_command(parse_command(json!({
|
let event = normalize_command(parse_command(json!({
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ token、`publicId` 和 WebSocket 广播通道。它只订阅 `live.guard.buy`,
|
|||||||
## 青玉星落
|
## 青玉星落
|
||||||
|
|
||||||
舰长、提督或总督分别选择 `captain.webm`、`admiral.webm` 或 `general.webm`
|
舰长、提督或总督分别选择 `captain.webm`、`admiral.webm` 或 `general.webm`
|
||||||
全屏播放。视频约 8 秒,首尾各淡入/淡出 0.5 秒;第 4 秒开始在中央横向展开行书卷轴,依次显示可编辑的等级标题、开通或续费用户 UID,以及可编辑的收尾文字。标题模板可使用
|
全屏播放。视频约 8 秒,首尾各淡入/淡出 0.5 秒;第 4 秒开始在中央横向展开行书卷轴,依次显示可编辑的等级标题、开通或续费用户名,以及可编辑的收尾文字。标题模板可使用
|
||||||
`{guard}` 插入规范化的“舰长/提督/总督”,默认三行为“{guard}启航”、UID、“相伴前行”。
|
`{guard}`
|
||||||
|
插入规范化的“舰长/提督/总督”,默认三行为“{guard}启航”、用户名、“相伴前行”。用户名缺失时才回退显示 UID。
|
||||||
|
|
||||||
大航海事件进入浏览器内存中的有界 FIFO,当前约八秒的视频或月夜庆祝完整结束后才播放下一项,不会覆盖正在播放的用户。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不持久化,OBS 断线重连后只接收新事件。
|
大航海事件进入浏览器内存中的有界 FIFO,当前约八秒的视频或月夜庆祝完整结束后才播放下一项,不会覆盖正在播放的用户。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不持久化,OBS 断线重连后只接收新事件。
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -122,7 +122,9 @@ close 结束连接。
|
|||||||
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
||||||
|
|
||||||
`guard_effect` 只订阅
|
`guard_effect` 只订阅
|
||||||
`live.guard.buy`。舰长、提督和总督事件进入该组件独立的广播通道;它不接收普通礼物或礼物连击,也不维护状态快照。
|
`live.guard.buy`。舰长、提督和总督事件进入该组件独立的广播通道;它不接收普通礼物或礼物连击,也不维护状态快照。服务端会将 Bilibili 的
|
||||||
|
`GUARD_BUY`、`USER_TOAST_MSG` 与当前的 `USER_TOAST_MSG_V2` 统一规范化为该事件;V2 的 `source = 2`
|
||||||
|
镜像通知会被忽略,同一购买产生的重叠通知也会在短时间窗口内去重。
|
||||||
|
|
||||||
`gift_effect` 与 `guard_effect`
|
`gift_effect` 与 `guard_effect`
|
||||||
的浏览器渲染器分别维护有界 FIFO,并且每次只播放一个 active 特效。动画完成后按到达顺序推进下一项;这个队列仅用于 OBS 视觉节流,不是可靠业务队列,也不会在重连后恢复。
|
的浏览器渲染器分别维护有界 FIFO,并且每次只播放一个 active 特效。动画完成后按到达顺序推进下一项;这个队列仅用于 OBS 视觉节流,不是可靠业务队列,也不会在重连后恢复。
|
||||||
|
|||||||
+2
-2
@@ -448,7 +448,7 @@ name = "简体中文"
|
|||||||
"guard.settings.title_template" = "第一行"
|
"guard.settings.title_template" = "第一行"
|
||||||
"guard.settings.title_template_description" = "使用 {guard} 插入舰长、提督或总督;默认“{guard}启航”。"
|
"guard.settings.title_template_description" = "使用 {guard} 插入舰长、提督或总督;默认“{guard}启航”。"
|
||||||
"guard.settings.closing_text" = "第三行"
|
"guard.settings.closing_text" = "第三行"
|
||||||
"guard.settings.closing_text_description" = "显示在用户 UID 下方;默认“相伴前行”。"
|
"guard.settings.closing_text_description" = "显示在用户名下方;默认“相伴前行”。"
|
||||||
"guard.settings.queue_capacity" = "最大等待大航海数"
|
"guard.settings.queue_capacity" = "最大等待大航海数"
|
||||||
"gift.settings.queue_capacity" = "最大等待礼物数"
|
"gift.settings.queue_capacity" = "最大等待礼物数"
|
||||||
"gift.preview.title" = "全屏礼物特效预览"
|
"gift.preview.title" = "全屏礼物特效预览"
|
||||||
@@ -978,7 +978,7 @@ name = "English"
|
|||||||
"guard.settings.title_template" = "First line"
|
"guard.settings.title_template" = "First line"
|
||||||
"guard.settings.title_template_description" = "Use {guard} to insert the Guard, Admiral, or Governor tier; the default is “{guard}启航”."
|
"guard.settings.title_template_description" = "Use {guard} to insert the Guard, Admiral, or Governor tier; the default is “{guard}启航”."
|
||||||
"guard.settings.closing_text" = "Third line"
|
"guard.settings.closing_text" = "Third line"
|
||||||
"guard.settings.closing_text_description" = "Shown below the viewer UID; the default is “相伴前行”."
|
"guard.settings.closing_text_description" = "Shown below the viewer name; the default is “相伴前行”."
|
||||||
"guard.settings.queue_capacity" = "Maximum queued memberships"
|
"guard.settings.queue_capacity" = "Maximum queued memberships"
|
||||||
"gift.settings.queue_capacity" = "Maximum queued gifts"
|
"gift.settings.queue_capacity" = "Maximum queued gifts"
|
||||||
"gift.preview.title" = "Full-screen gift effect preview"
|
"gift.preview.title" = "Full-screen gift effect preview"
|
||||||
|
|||||||
Reference in New Issue
Block a user