master
cysamurai 2 months ago
parent eded921144
commit 9b1198c550

@ -290,6 +290,7 @@ dependencies = [
name = "broadcast-client"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"tauri",
@ -463,8 +464,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]

@ -18,3 +18,4 @@ tauri-plugin-opener = "2"
tauri-plugin-store = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["clock"] }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 B

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 617 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 860 B

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 35 KiB

@ -1,8 +1,8 @@
use std::{
fs::{create_dir_all, OpenOptions},
fs::{create_dir_all, read_dir, remove_file, OpenOptions},
io::Read,
net::TcpListener,
io::Write,
net::TcpListener,
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
@ -12,17 +12,22 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use chrono::Local;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
const SOCKET_PORT: u16 = 9501;
const SOCKET_STATUS_EVENT: &str = "socket-status";
const SOCKET_CALL_EVENT: &str = "socket-call-message";
const LOG_FILE_NAME: &str = "socket-service.log";
const LOG_FILE_PREFIX: &str = "socket-service-";
const LOG_FILE_EXT: &str = ".log";
const LOG_FILE_MAX_BYTES: u64 = 5 * 1024 * 1024;
const LOG_RETENTION_DAYS: u64 = 7;
#[derive(Default)]
struct SocketServiceState {
runtime: Mutex<Option<SocketServiceRuntime>>,
log_file: Mutex<Option<PathBuf>>,
}
struct SocketServiceRuntime {
@ -86,6 +91,11 @@ pub fn run() {
"open_sync_config" => {
let _ = ensure_config_window(app);
}
"minimize_main" => {
if let Some(main_window) = app.get_webview_window("main") {
let _ = main_window.minimize();
}
}
"quit_app" => app.exit(0),
_ => {}
})
@ -120,8 +130,16 @@ fn show_context_menu(window: tauri::Window) -> Result<(), String> {
None::<&str>,
)
.map_err(|error| format!("创建菜单项失败: {error}"))?;
let minimize_item = MenuItem::with_id(
window.app_handle(),
"minimize_main",
"最小化",
true,
None::<&str>,
)
.map_err(|error| format!("创建菜单项失败: {error}"))?;
let menu = Menu::with_items(window.app_handle(), &[&config_item, &quit_item])
let menu = Menu::with_items(window.app_handle(), &[&config_item, &minimize_item, &quit_item])
.map_err(|error| format!("创建菜单失败: {error}"))?;
window
@ -362,13 +380,22 @@ fn try_dispatch_one_message(app: &tauri::AppHandle, text: &str) -> bool {
}
fn append_socket_log<S: AsRef<str>>(app: &tauri::AppHandle, level: &str, message: S) {
let Ok(log_file_path) = resolve_log_file_path(app) else {
let Ok(log_dir) = app.path().app_config_dir() else {
return;
};
if let Some(parent) = log_file_path.parent() {
let _ = create_dir_all(parent);
}
let _ = create_dir_all(&log_dir);
cleanup_expired_logs(&log_dir);
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let line = format!("[{}][{}] {}\n", now_ms, level, message.as_ref());
let Ok(log_file_path) = resolve_log_file_path(app, line.len() as u64, &log_dir) else {
return;
};
let Ok(mut file) = OpenOptions::new()
.create(true)
@ -378,18 +405,85 @@ fn append_socket_log<S: AsRef<str>>(app: &tauri::AppHandle, level: &str, message
return;
};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let line = format!("[{}][{}] {}\n", now_ms, level, message.as_ref());
let _ = file.write_all(line.as_bytes());
}
fn resolve_log_file_path(app: &tauri::AppHandle) -> tauri::Result<PathBuf> {
let log_dir = app.path().app_log_dir()?;
Ok(log_dir.join(LOG_FILE_NAME))
fn resolve_log_file_path(
app: &tauri::AppHandle,
incoming_bytes: u64,
log_dir: &PathBuf,
) -> tauri::Result<PathBuf> {
let state = app.state::<SocketServiceState>();
let mut guard = state
.log_file
.lock()
.map_err(|_| tauri::Error::FailedToReceiveMessage)?;
if let Some(current) = guard.as_ref() {
let keep_current = std::fs::metadata(current)
.map(|meta| meta.len().saturating_add(incoming_bytes) <= LOG_FILE_MAX_BYTES)
.unwrap_or(false);
if keep_current {
return Ok(current.clone());
}
}
let next = create_next_log_file_path(log_dir);
*guard = Some(next.clone());
Ok(next)
}
fn create_next_log_file_path(log_dir: &PathBuf) -> PathBuf {
let base = format!(
"{}{}{}",
LOG_FILE_PREFIX,
Local::now().format("%Y%m%d-%H%M%S-%3f"),
LOG_FILE_EXT
);
let mut path = log_dir.join(&base);
let mut index = 1;
while path.exists() {
let candidate = format!(
"{}{}-{}{}",
LOG_FILE_PREFIX,
Local::now().format("%Y%m%d-%H%M%S-%3f"),
index,
LOG_FILE_EXT
);
path = log_dir.join(candidate);
index += 1;
}
path
}
fn cleanup_expired_logs(log_dir: &PathBuf) {
let Ok(entries) = read_dir(log_dir) else {
return;
};
let expire_before = SystemTime::now()
.checked_sub(Duration::from_secs(LOG_RETENTION_DAYS * 24 * 60 * 60))
.unwrap_or(SystemTime::UNIX_EPOCH);
for entry in entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !file_name.starts_with(LOG_FILE_PREFIX) || !file_name.ends_with(LOG_FILE_EXT) {
continue;
}
let should_delete = entry
.metadata()
.and_then(|meta| meta.modified())
.map(|modified| modified < expire_before)
.unwrap_or(false);
if should_delete {
let _ = remove_file(path);
}
}
}
fn truncate_for_log(source: &str, max_chars: usize) -> String {

@ -24,14 +24,17 @@
/>
</div>
<WindowAreasLayer :slices="segmentAreaSlices" />
<SubtitleAreasLayer :slices="segmentSubtitleSlices" />
</section>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Segment, Tick } from "../models/ruler";
import type { SubtitleAreaSlice } from "../services/subtitleAreaSliceService";
import type { WindowAreaSlice } from "../services/windowAreaSliceService";
import RulerTicks from "./RulerTicks.vue";
import SubtitleAreasLayer from "./SubtitleAreasLayer.vue";
import WindowAreasLayer from "./WindowAreasLayer.vue";
const props = defineProps<{
@ -40,9 +43,14 @@ const props = defineProps<{
totalWidth: number;
showRuler: boolean;
windowAreaSlices: WindowAreaSlice[];
subtitleAreaSlices: SubtitleAreaSlice[];
}>();
const segmentAreaSlices = computed(() =>
props.windowAreaSlices.filter((item) => item.segmentIndex === props.segment.index),
);
const segmentSubtitleSlices = computed(() =>
props.subtitleAreaSlices.filter((item) => item.segmentIndex === props.segment.index),
);
</script>

@ -0,0 +1,50 @@
<template>
<div class="subtitle-areas-layer">
<section
v-for="slice in slices"
:key="`${slice.area.id}-${slice.segmentIndex}-${slice.renderLeft}`"
class="subtitle-area"
:style="{
left: `${slice.renderLeft}px`,
top: `${slice.renderTop}px`,
width: `${slice.renderWidth}px`,
height: `${slice.renderHeight}px`,
}"
>
<div
class="subtitle-area-inner"
:style="{
width: `${slice.area.width}px`,
height: `${slice.area.height}px`,
transform: `translateX(${-slice.clipOffset}px)`,
}"
>
<div
class="subtitle-text"
:style="{
color: slice.area.textStyle.color,
fontSize: `${slice.area.textStyle.fontSize}px`,
fontWeight: slice.area.textStyle.fontWeight,
animationDuration: `${computeDuration(slice.area)}s`,
}"
>
{{ slice.area.text }}
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { SubtitleAreaSlice } from "../services/subtitleAreaSliceService";
defineProps<{
slices: SubtitleAreaSlice[];
}>();
function computeDuration(area: SubtitleAreaSlice["area"]) {
const speed = area.speed > 0 ? area.speed : 80;
// speed px/s
return Math.max(2, (area.width * 2) / speed);
}
</script>

@ -19,50 +19,115 @@
transform: `translateX(${-slice.clipOffset}px)`,
}"
>
<div class="window-no-region">
<div v-if="slice.area.isClockWindow" class="clock-window">
<span
class="window-no-text"
:class="{ circle: slice.area.windowNumberCircle }"
:style="{
fontSize: `${slice.area.windowNumberStyle.fontSize}px`,
color: slice.area.windowNumberStyle.color,
fontWeight: slice.area.windowNumberStyle.fontWeight,
}"
>
{{ slice.area.windowNumber }}
</span>
</div>
<div class="window-text-region">
<div
class="window-text-line"
:style="{
fontSize: `${slice.area.staticTextStyle.fontSize}px`,
color: slice.area.staticTextStyle.color,
fontWeight: slice.area.staticTextStyle.fontWeight,
}"
>
{{ slice.area.staticText }}
</div>
<div
class="window-text-line"
class="clock-text"
:style="{
fontSize: `${slice.area.dynamicTextStyle.fontSize}px`,
color: slice.area.dynamicTextStyle.color,
fontWeight: slice.area.dynamicTextStyle.fontWeight,
}"
>
{{ slice.area.dynamicText }}
</div>
{{ nowTime }}
</span>
</div>
<template v-else>
<div class="window-no-region">
<span
class="window-no-text"
:class="{ circle: slice.area.windowNumberCircle }"
:style="resolveWindowNumberStyle(slice)"
>
{{ slice.area.windowNumber }}
</span>
</div>
<div class="window-text-region">
<div
class="window-text-line"
:style="{
fontSize: `${slice.area.staticTextStyle.fontSize}px`,
color: slice.area.staticTextStyle.color,
fontWeight: slice.area.staticTextStyle.fontWeight,
}"
>
{{ slice.area.staticText }}
</div>
<div
class="window-text-line"
:style="{
fontSize: `${slice.area.dynamicTextStyle.fontSize}px`,
color: slice.area.dynamicTextStyle.color,
fontWeight: slice.area.dynamicTextStyle.fontWeight,
}"
>
{{ slice.area.dynamicText }}
</div>
</div>
</template>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import type { ChildWindowAreaConfig } from "../models/config";
import type { WindowAreaSlice } from "../services/windowAreaSliceService";
defineProps<{
slices: WindowAreaSlice[];
}>();
const nowMs = ref(Date.now());
let timerId: number | null = null;
const nowTime = computed(() => {
const date = new Date(nowMs.value);
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
});
function resolveWindowNumberStyle(slice: WindowAreaSlice) {
const base = {
fontSize: `${slice.area.windowNumberStyle.fontSize}px`,
color: slice.area.windowNumberStyle.color,
fontWeight: slice.area.windowNumberStyle.fontWeight,
};
if (!slice.area.windowNumberCircle) {
return base;
}
return {
...base,
...resolveCircleStyle(slice.area),
};
}
function resolveCircleStyle(area: ChildWindowAreaConfig) {
const style = area.windowNumberCircleStyle ?? { size: 36, borderWidth: 1, borderRadius: 18 };
const size = Number.isFinite(style.size) && style.size > 0 ? Math.floor(style.size) : 36;
const borderWidth =
Number.isFinite(style.borderWidth) && style.borderWidth > 0 ? Math.floor(style.borderWidth) : 1;
const borderRadius =
Number.isFinite(style.borderRadius) && style.borderRadius >= 0 ? Math.floor(style.borderRadius) : 18;
return {
width: `${size}px`,
height: `${size}px`,
borderWidth: `${borderWidth}px`,
borderRadius: `${borderRadius}px`,
};
}
onMounted(() => {
timerId = window.setInterval(() => {
nowMs.value = Date.now();
}, 1000);
});
onUnmounted(() => {
if (timerId !== null) {
window.clearInterval(timerId);
timerId = null;
}
});
</script>

@ -14,16 +14,24 @@ export interface TextStyleConfig {
fontWeight: number;
}
export interface CircleStyleConfig {
size: number;
borderWidth: number;
borderRadius: number;
}
// 子div窗口区域配置模型。
export interface ChildWindowAreaConfig {
id: string;
windowId: number;
isClockWindow: boolean;
width: number;
height: number;
x: number;
y: number;
windowNumber: string;
windowNumberCircle: boolean;
windowNumberCircleStyle: CircleStyleConfig;
windowNumberStyle: TextStyleConfig;
staticText: string;
staticTextStyle: TextStyleConfig;
@ -31,6 +39,18 @@ export interface ChildWindowAreaConfig {
dynamicTextStyle: TextStyleConfig;
}
// 滚动字幕区域配置。
export interface SubtitleAreaConfig {
id: string;
width: number;
height: number;
x: number;
y: number;
text: string;
textStyle: TextStyleConfig;
speed: number;
}
// 广播渲染配置模型。
export interface BroadcastConfig {
totalWidth: number;
@ -38,6 +58,7 @@ export interface BroadcastConfig {
showRuler: boolean;
segments: SegmentConfigItem[];
windowAreas: ChildWindowAreaConfig[];
subtitleAreas: SubtitleAreaConfig[];
}
// 应用启动时使用的默认配置。
@ -47,4 +68,5 @@ export const DEFAULT_BROADCAST_CONFIG: BroadcastConfig = {
showRuler: true,
segments: [],
windowAreas: [],
subtitleAreas: [],
};

@ -4,10 +4,12 @@ export const DEFAULT_TOTAL_WIDTH = 800;
export const DEFAULT_SEGMENT_HEIGHT = 64;
// 小刻度间隔(单位 px
export const MINOR_TICK_GAP = 10;
// 中刻度间隔(单位 px
export const MID_TICK_GAP = 50;
// 大刻度间隔(单位 px
export const MAJOR_TICK_GAP = 100;
export type TickType = "minor" | "major";
export type TickType = "minor" | "mid" | "major";
export interface Tick {
x: number;

@ -1,6 +1,12 @@
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
import { load, type Store } from "@tauri-apps/plugin-store";
import type { BroadcastConfig, ChildWindowAreaConfig, TextStyleConfig } from "../models/config";
import type {
BroadcastConfig,
ChildWindowAreaConfig,
CircleStyleConfig,
SubtitleAreaConfig,
TextStyleConfig,
} from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import {
normalizeSegmentConfigItem,
@ -41,6 +47,28 @@ function normalizeTextStyle(raw: unknown, fallback: TextStyleConfig): TextStyleC
};
}
function normalizeCircleStyle(raw: unknown, fallback: CircleStyleConfig): CircleStyleConfig {
const source = (raw ?? {}) as Partial<CircleStyleConfig>;
return {
size:
typeof source.size === "number" && Number.isFinite(source.size) && source.size > 0
? Math.floor(source.size)
: fallback.size,
borderWidth:
typeof source.borderWidth === "number" &&
Number.isFinite(source.borderWidth) &&
source.borderWidth > 0
? Math.floor(source.borderWidth)
: fallback.borderWidth,
borderRadius:
typeof source.borderRadius === "number" &&
Number.isFinite(source.borderRadius) &&
source.borderRadius >= 0
? Math.floor(source.borderRadius)
: fallback.borderRadius,
};
}
function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig {
const source = (raw ?? {}) as Partial<ChildWindowAreaConfig>;
return {
@ -49,6 +77,7 @@ function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig
typeof source.windowId === "number" && Number.isFinite(source.windowId) && source.windowId > 0
? Math.floor(source.windowId)
: index + 1,
isClockWindow: source.isClockWindow === true,
width: normalizeFontSize(source.width, 220),
height: normalizeFontSize(source.height, 48),
x: typeof source.x === "number" && Number.isFinite(source.x) ? Math.max(0, Math.floor(source.x)) : 0,
@ -58,6 +87,11 @@ function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig
? source.windowNumber
: String(index + 1),
windowNumberCircle: source.windowNumberCircle === true,
windowNumberCircleStyle: normalizeCircleStyle(source.windowNumberCircleStyle, {
size: 36,
borderWidth: 1,
borderRadius: 18,
}),
windowNumberStyle: normalizeTextStyle(source.windowNumberStyle, {
fontSize: 16,
color: "#ffffff",
@ -78,6 +112,27 @@ function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig
};
}
function normalizeSubtitleArea(raw: unknown, index: number): SubtitleAreaConfig {
const source = (raw ?? {}) as Partial<SubtitleAreaConfig>;
return {
id: typeof source.id === "string" && source.id.trim() ? source.id : `subtitle-${index + 1}`,
width: normalizeFontSize(source.width, 320),
height: normalizeFontSize(source.height, 32),
x: typeof source.x === "number" && Number.isFinite(source.x) ? Math.max(0, Math.floor(source.x)) : 0,
y: typeof source.y === "number" && Number.isFinite(source.y) ? Math.max(0, Math.floor(source.y)) : 0,
text: typeof source.text === "string" ? source.text : "滚动字幕示例",
textStyle: normalizeTextStyle(source.textStyle, {
fontSize: 16,
color: "#ffffff",
fontWeight: 500,
}),
speed:
typeof source.speed === "number" && Number.isFinite(source.speed) && source.speed > 0
? source.speed
: 80,
};
}
/**
*
*/
@ -90,6 +145,7 @@ function normalizeConfig(raw: unknown): BroadcastConfig {
const screenWidth = normalizeScreenWidth(window.screen.width || 1920);
const segmentsRaw = Array.isArray(source.segments) ? source.segments : [];
const windowAreasRaw = Array.isArray(source.windowAreas) ? source.windowAreas : [];
const subtitleAreasRaw = Array.isArray(source.subtitleAreas) ? source.subtitleAreas : [];
return {
totalWidth: Math.min(totalWidth, screenWidth),
segmentHeight,
@ -99,6 +155,7 @@ function normalizeConfig(raw: unknown): BroadcastConfig {
normalizeSegmentConfigItem(item, index, screenWidth, segmentHeight),
),
windowAreas: windowAreasRaw.map((item, index) => normalizeWindowArea(item, index)),
subtitleAreas: subtitleAreasRaw.map((item, index) => normalizeSubtitleArea(item, index)),
};
}

@ -0,0 +1,51 @@
import type { SubtitleAreaConfig } from "../models/config";
import type { Segment } from "../models/ruler";
export interface SubtitleAreaSlice {
area: SubtitleAreaConfig;
segmentIndex: number;
renderLeft: number;
renderTop: number;
renderWidth: number;
renderHeight: number;
clipOffset: number;
}
/**
*
*/
export function buildSubtitleAreaSlices(
areas: SubtitleAreaConfig[],
segments: Segment[],
): SubtitleAreaSlice[] {
const slices: SubtitleAreaSlice[] = [];
for (const area of areas) {
const areaStart = area.x;
const areaEnd = area.x + area.width;
for (const segment of segments) {
const segmentStart = segment.sourceX;
const segmentEnd = segmentStart + segment.sliceWidth;
const visibleStart = Math.max(areaStart, segmentStart);
const visibleEnd = Math.min(areaEnd, segmentEnd);
const visibleWidth = visibleEnd - visibleStart;
if (visibleWidth <= 0) {
continue;
}
slices.push({
area,
segmentIndex: segment.index,
renderLeft: visibleStart - segmentStart,
renderTop: area.y,
renderWidth: visibleWidth,
renderHeight: area.height,
clipOffset: Math.max(0, segmentStart - areaStart),
});
}
}
return slices;
}

@ -1,6 +1,7 @@
import {
DEFAULT_TOTAL_WIDTH,
MAJOR_TICK_GAP,
MID_TICK_GAP,
MINOR_TICK_GAP,
type Tick,
} from "../models/ruler";
@ -22,6 +23,8 @@ export function buildTicks(totalWidth: number = DEFAULT_TOTAL_WIDTH): Tick[] {
for (let x = 0; x <= safeTotalWidth; x += MINOR_TICK_GAP) {
if (x % MAJOR_TICK_GAP === 0) {
ticks.push({ x, type: "major", label: String(x) });
} else if (x % MAJOR_TICK_GAP === MID_TICK_GAP) {
ticks.push({ x, type: "mid" });
} else {
ticks.push({ x, type: "minor" });
}

@ -54,6 +54,11 @@ body,
opacity: 0.85;
}
.tick.mid {
height: 24px;
opacity: 0.9;
}
.tick.major {
height: 34px;
background: #c4d2ea;
@ -231,6 +236,13 @@ body,
margin-top: 14px;
}
.config-top-status {
display: flex;
align-items: center;
min-height: 28px;
margin-bottom: 10px;
}
.btn {
border: 1px solid #c8c8c8;
background: #fff;
@ -358,6 +370,18 @@ body,
display: flex;
}
.clock-window {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.clock-text {
line-height: 1;
}
.window-no-region {
flex: 1;
display: flex;
@ -391,3 +415,42 @@ body,
justify-content: center;
text-align: center;
}
.subtitle-areas-layer {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.subtitle-area {
position: absolute;
overflow: hidden;
background: transparent;
}
.subtitle-area-inner {
position: relative;
overflow: hidden;
}
.subtitle-text {
position: absolute;
top: 50%;
white-space: nowrap;
transform: translateY(-50%);
animation-name: subtitle-move-right;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes subtitle-move-right {
0% {
left: -100%;
}
100% {
left: 100%;
}
}

@ -12,6 +12,7 @@
:total-width="totalWidth"
:show-ruler="showRuler"
:window-area-slices="windowAreaSlices"
:subtitle-area-slices="subtitleAreaSlices"
/>
</main>
</template>
@ -26,6 +27,7 @@ import { useRulerTicks } from "../composables/useRulerTicks";
import { useScreenInfo } from "../composables/useScreenInfo";
import type { ChildWindowAreaConfig } from "../models/config";
import { buildSegmentsFromConfig } from "../services/segmentService";
import { buildSubtitleAreaSlices } from "../services/subtitleAreaSliceService";
import { buildWindowAreaSlices } from "../services/windowAreaSliceService";
const { config } = useBroadcastConfig();
@ -67,6 +69,9 @@ const containerHeight = computed(() => {
});
const { ticks } = useRulerTicks(totalWidth);
const windowAreaSlices = computed(() => buildWindowAreaSlices(windowAreas.value, segments.value));
const subtitleAreaSlices = computed(() =>
buildSubtitleAreaSlices(config.value.subtitleAreas, segments.value),
);
/**
* 右键触发原生菜单配置窗口 / 退出

@ -1,6 +1,11 @@
<template>
<main class="config-root config-page">
<el-page-header content="配置同步屏窗口" />
<div class="config-top-status">
<span class="socket-status" :class="socketRunning ? 'running' : 'stopped'">
<i class="socket-dot" />
Socket {{ socketRunning ? "运行中" : "未启动" }} (9501)
</span>
</div>
<el-collapse v-model="activePanels" class="config-collapse">
<el-collapse-item name="base">
@ -96,6 +101,11 @@
<el-input-number v-model="area.windowId" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="时钟窗口">
<el-switch v-model="area.isClockWindow" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="宽度">
<el-input-number v-model="area.width" :min="1" />
@ -145,6 +155,21 @@
<el-input-number v-model="area.windowNumberStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆圈大小">
<el-input-number v-model="area.windowNumberCircleStyle.size" :min="1" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="边框粗细">
<el-input-number v-model="area.windowNumberCircleStyle.borderWidth" :min="1" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="圆角半径">
<el-input-number v-model="area.windowNumberCircleStyle.borderRadius" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">文本区域占比 2.5静态/动态=1:1</el-divider>
@ -196,6 +221,83 @@
</el-card>
</div>
</el-collapse-item>
<el-collapse-item name="subtitles">
<template #title>
<div class="card-header row-between">
<span>滚动字幕区域</span>
<el-button type="primary" plain size="small" @click.stop="addSubtitleArea">
添加滚动字幕
</el-button>
</div>
</template>
<div class="panel-scroll area-list">
<el-card
v-for="(subtitle, index) in draft.subtitleAreas"
:key="subtitle.id"
class="area-item"
shadow="never"
>
<template #header>
<div class="row-between">
<strong>字幕区域 {{ index + 1 }}</strong>
<el-button type="danger" link @click="removeSubtitleArea(index)"></el-button>
</div>
</template>
<el-form label-width="120px" size="small">
<el-row :gutter="12">
<el-col :span="6">
<el-form-item label="宽度">
<el-input-number v-model="subtitle.width" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="高度">
<el-input-number v-model="subtitle.height" :min="1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="X">
<el-input-number v-model="subtitle.x" :min="0" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Y">
<el-input-number v-model="subtitle.y" :min="0" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="字幕文本">
<el-input v-model="subtitle.text" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="字号">
<el-input-number v-model="subtitle.textStyle.fontSize" :min="1" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="颜色">
<el-input v-model="subtitle.textStyle.color" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="粗细">
<el-input-number v-model="subtitle.textStyle.fontWeight" :min="100" :step="100" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="滚动速度">
<el-input-number v-model="subtitle.speed" :min="1" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</el-collapse-item>
</el-collapse>
<div class="actions-row">
@ -206,10 +308,6 @@
<el-button type="danger" plain :disabled="!socketRunning" @click="stopSocketService">
停止 Socket 服务
</el-button>
<span class="socket-status" :class="socketRunning ? 'running' : 'stopped'">
<i class="socket-dot" />
Socket {{ socketRunning ? "运行中" : "未启动" }} (9501)
</span>
<span class="save-hint">{{ saveMessage }}</span>
</div>
</main>
@ -220,7 +318,12 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { currentMonitor } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { BroadcastConfig, ChildWindowAreaConfig, SegmentConfigItem } from "../models/config";
import type {
BroadcastConfig,
ChildWindowAreaConfig,
SegmentConfigItem,
SubtitleAreaConfig,
} from "../models/config";
import { DEFAULT_BROADCAST_CONFIG } from "../models/config";
import { useBroadcastConfig } from "../composables/useBroadcastConfig";
import {
@ -239,7 +342,7 @@ interface SocketStatusPayload {
const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920));
const { config, patchConfig } = useBroadcastConfig();
const saveMessage = ref("修改后请点击“保存配置”。");
const activePanels = ref(["base", "segments", "areas"]);
const activePanels = ref(["base", "segments", "areas", "subtitles"]);
const socketRunning = ref(false);
// store -> draft
const syncingFromStore = ref(false);
@ -267,12 +370,20 @@ function cloneSegments(segments: SegmentConfigItem[]) {
function cloneWindowAreas(areas: ChildWindowAreaConfig[]) {
return areas.map((item) => ({
...item,
windowNumberCircleStyle: { ...item.windowNumberCircleStyle },
windowNumberStyle: { ...item.windowNumberStyle },
staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle },
}));
}
function cloneSubtitleAreas(areas: SubtitleAreaConfig[]) {
return areas.map((item) => ({
...item,
textStyle: { ...item.textStyle },
}));
}
function normalizeStyle(style: { fontSize: number; color: string; fontWeight: number }) {
return {
fontSize: Number.isFinite(style.fontSize) && style.fontSize > 0 ? Math.floor(style.fontSize) : 14,
@ -286,12 +397,18 @@ function createDefaultWindowArea(index: number): ChildWindowAreaConfig {
return {
id: `area-${Date.now()}-${index}`,
windowId: index + 1,
isClockWindow: false,
width: 220,
height: 48,
x: 0,
y: index * 50,
windowNumber: String(index + 1),
windowNumberCircle: false,
windowNumberCircleStyle: {
size: 36,
borderWidth: 1,
borderRadius: 18,
},
windowNumberStyle: {
fontSize: 16,
color: "#ffffff",
@ -312,6 +429,23 @@ function createDefaultWindowArea(index: number): ChildWindowAreaConfig {
};
}
function createDefaultSubtitleArea(index: number): SubtitleAreaConfig {
return {
id: `subtitle-${Date.now()}-${index}`,
width: 420,
height: 28,
x: 0,
y: 0,
text: "欢迎使用同步屏系统",
textStyle: {
fontSize: 16,
color: "#ffffff",
fontWeight: 500,
},
speed: 80,
};
}
/**
* 添加一个新分段默认放在当前末段之后
*/
@ -350,6 +484,16 @@ function removeWindowArea(index: number) {
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function addSubtitleArea() {
draft.subtitleAreas.push(createDefaultSubtitleArea(draft.subtitleAreas.length));
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
function removeSubtitleArea(index: number) {
draft.subtitleAreas.splice(index, 1);
saveMessage.value = "已修改,点击“保存配置”后生效。";
}
async function startSocketService() {
try {
const result = await invoke<SocketStatusPayload>("start_socket_service");
@ -379,8 +523,10 @@ async function saveConfig() {
const segments = draft.segments.map((item, index) =>
normalizeSegmentConfigItem(item, index, screenWidth.value, segmentHeight),
);
const windowAreas = draft.windowAreas.map((area, index) => ({
id: area.id || `area-${index + 1}`,
const windowAreas = draft.windowAreas.map((area, index) => {
const circleStyle = area.windowNumberCircleStyle ?? { size: 36, borderWidth: 1, borderRadius: 18 };
return {
id: area.id || `area-${index + 1}`,
windowId:
Number.isFinite(area.windowId) && area.windowId > 0 ? Math.floor(area.windowId) : index + 1,
width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 220,
@ -388,12 +534,35 @@ async function saveConfig() {
x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0,
y: Number.isFinite(area.y) ? Math.max(0, Math.floor(area.y)) : 0,
windowNumber: area.windowNumber || String(index + 1),
isClockWindow: area.isClockWindow === true,
windowNumberCircle: area.windowNumberCircle === true,
windowNumberCircleStyle: {
size: Number.isFinite(circleStyle.size) && circleStyle.size > 0 ? Math.floor(circleStyle.size) : 36,
borderWidth:
Number.isFinite(circleStyle.borderWidth) && circleStyle.borderWidth > 0
? Math.floor(circleStyle.borderWidth)
: 1,
borderRadius:
Number.isFinite(circleStyle.borderRadius) && circleStyle.borderRadius >= 0
? Math.floor(circleStyle.borderRadius)
: 18,
},
windowNumberStyle: normalizeStyle(area.windowNumberStyle),
staticText: area.staticText || "静态文本",
staticTextStyle: normalizeStyle(area.staticTextStyle),
dynamicText: area.dynamicText || "动态文本",
dynamicTextStyle: normalizeStyle(area.dynamicTextStyle),
dynamicTextStyle: normalizeStyle(area.dynamicTextStyle),
};
});
const subtitleAreas = draft.subtitleAreas.map((area, index) => ({
id: area.id || `subtitle-${index + 1}`,
width: Number.isFinite(area.width) && area.width > 0 ? Math.floor(area.width) : 420,
height: Number.isFinite(area.height) && area.height > 0 ? Math.floor(area.height) : 28,
x: Number.isFinite(area.x) ? Math.max(0, Math.floor(area.x)) : 0,
y: Number.isFinite(area.y) ? Math.max(0, Math.floor(area.y)) : 0,
text: area.text || "欢迎使用同步屏系统",
textStyle: normalizeStyle(area.textStyle),
speed: Number.isFinite(area.speed) && area.speed > 0 ? area.speed : 80,
}));
await patchConfig({
totalWidth,
@ -401,6 +570,7 @@ async function saveConfig() {
showRuler: draft.showRuler,
segments,
windowAreas,
subtitleAreas,
});
saveMessage.value = "保存成功,已写入配置文件并实时应用。";
}
@ -414,6 +584,7 @@ watch(
draft.showRuler = next.showRuler;
draft.segments = cloneSegments(next.segments);
draft.windowAreas = cloneWindowAreas(next.windowAreas);
draft.subtitleAreas = cloneSubtitleAreas(next.subtitleAreas);
syncingFromStore.value = false;
},
{ immediate: true, deep: true },
@ -427,10 +598,15 @@ watch(
segments: draft.segments.map((item) => ({ ...item })),
windowAreas: draft.windowAreas.map((item) => ({
...item,
windowNumberCircleStyle: { ...item.windowNumberCircleStyle },
windowNumberStyle: { ...item.windowNumberStyle },
staticTextStyle: { ...item.staticTextStyle },
dynamicTextStyle: { ...item.dynamicTextStyle },
})),
subtitleAreas: draft.subtitleAreas.map((item) => ({
...item,
textStyle: { ...item.textStyle },
})),
}),
() => {
if (!syncingFromStore.value) {

@ -53,3 +53,13 @@
##7.第五阶段更新需求
7.1. 添加日志模块用于记录socket服务启动、停止、消息接收、消息处理等操作。日志文件使用XGD规则保存到本地
##8.第六阶段更新需求
8.1. 修改标尺每100px之间再加入50px的刻度线比10px的刻度线高一点比100px的刻度线低一点.
8.2. 添加:同步屏窗口右键菜单加入一项最小化,点击后同步屏窗口最小化,在系统任务栏中点击同步屏窗口图标,恢复同步屏窗口。
8.3. 添加窗口区域配置中加一个勾选项切换为时钟窗口如果切换为时钟窗口则不显示窗口号区域和文本区域只显示hhh:mm格式的时间时间文字样式由动态文本区域样式决定。
8.4. 添加:添加一个折叠面板,用于滚动字幕区域的添加和配置,滚动字幕的区域和可以设置区域的宽度、高度、左顶点起始坐标、其中滚动字幕的文本、文本字体大小颜色,文本滚动速度,设置完成之后文本会在滚动字幕区域从左往右持续滚动。
##9.补充细节修改
9.1. 修改配置文件和日志的保存位置在不同的路径下日志文件生成时文件名拼接上日期和时间限制单个日志文件大小日志最多保存7天。
9.2. 修改:窗口号区域的圆圈边框增加可以修改边框大小、边框粗细、圆角半径。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save