Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f05fc4954 | ||
|
|
3fce06ef63 | ||
|
|
3d13f69e5c | ||
|
|
deb19c6223 | ||
|
|
7466127832 | ||
|
|
af982c5fe0 | ||
|
|
b03f0150d8 | ||
|
|
d61ddafb44 | ||
|
|
fd89a197a5 | ||
|
|
31fa29ee62 | ||
|
|
c7e28b2ad6 | ||
|
|
bbc1343079 | ||
|
|
c7d4fb270b | ||
|
|
fcccdee105 | ||
|
|
887072f6c7 | ||
|
|
1932edba21 | ||
|
|
0c15415822 | ||
|
|
b8dc0870b5 | ||
|
|
9d0ad2ae45 | ||
|
|
7278b9f48c | ||
|
|
1aee95492a | ||
|
|
0cff889f4b | ||
|
|
9cd05362ac | ||
|
|
269eccc7ef | ||
|
|
aafd02090b | ||
|
|
e0e43dbfa4 | ||
|
|
37c358a48b | ||
|
|
2b81f7a106 | ||
|
|
cb9b606cb4 | ||
|
|
c1879c6527 | ||
|
|
035c54b2fd |
72
README.md
@@ -1,12 +1,64 @@
|
||||
# Bilibili ShadowReplay
|
||||
# BiliBili ShadowReplay
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
|
||||
> [!NOTE]
|
||||
> 由于软件在快速开发中,截图说明可能有变动,仅供参考
|
||||
|
||||

|
||||
|
||||
## 总览
|
||||
|
||||

|
||||
|
||||
显示直播缓存的占用以及缓存所在磁盘的使用情况。
|
||||
|
||||
## 直播间管理
|
||||
|
||||

|
||||
|
||||
显示当前缓存的直播间列表,在添加前需要在账号页面添加至少一个账号(主账号)用于直播流以及用户信息的获取。
|
||||
操作菜单包含打开直播流、查看历史记录以及删除等操作。其中历史记录以列表形式展示,可以进行回放以及删除。
|
||||
|
||||

|
||||
|
||||
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
|
||||
|
||||
## 消息管理
|
||||
|
||||

|
||||
|
||||
执行的各种操作都会留下消息记录,方便查看过去进行的操作。
|
||||
|
||||
## 账号管理
|
||||
|
||||

|
||||
|
||||
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。目前添加账号仅支持 B 站手机 App 扫码添加。
|
||||
|
||||
你可以添加多个账号,但只有一个账号会被标记为主账号,主账号用于直播流的获取。所有账号都可在切片投稿或是观看直播流发送弹幕时自由选择,详情见 [预览窗口](#预览窗口)。
|
||||
|
||||
## 预览窗口
|
||||
|
||||

|
||||
|
||||
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
|
||||
|
||||
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
|
||||
|
||||
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
|
||||
|
||||
## 设置
|
||||
|
||||

|
||||
|
||||
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
|
||||
|
||||
> [!WARNING]
|
||||
> 程序仍在开发中, Rlease 中提供的下载版本为历史遗留版本, 不保证能够正常使用
|
||||
|
||||

|
||||
|
||||
## 介绍
|
||||
|
||||
Bilibili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
|
||||

|
||||
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。缓存切换开始和结束均会在消息管理中有记录。
|
||||
|
||||
BIN
doc/accounts.png
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
doc/archives.png
Normal file
|
After Width: | Height: | Size: 574 KiB |
BIN
doc/clip.png
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 24 KiB |
BIN
doc/header.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
doc/icon.png
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 18 KiB |
BIN
doc/livewindow.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
doc/main.png
|
Before Width: | Height: | Size: 127 KiB |
BIN
doc/messages.png
Normal file
|
After Width: | Height: | Size: 638 KiB |
BIN
doc/output.png
|
Before Width: | Height: | Size: 28 KiB |
BIN
doc/rooms.png
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 678 KiB |
BIN
doc/setting.png
|
Before Width: | Height: | Size: 136 KiB |
BIN
doc/settings.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
doc/summary.png
Normal file
|
After Width: | Height: | Size: 547 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
35
src-tauri/Cargo.lock
generated
@@ -1440,11 +1440,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sidecar"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bec830aba6cd3da7621c58e8f06727e3b750e8383bcd934d789f2b9c3c4ea595"
|
||||
checksum = "d67d09bdb90406a420b30ba06d464a976c9642081c2ecdf09e35ec80bd7eb9b1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest 0.12.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2293,6 +2294,22 @@ dependencies = [
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.4.1",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.8"
|
||||
@@ -4019,7 +4036,7 @@ dependencies = [
|
||||
"http 0.2.9",
|
||||
"http-body 0.4.5",
|
||||
"hyper 0.14.25",
|
||||
"hyper-tls",
|
||||
"hyper-tls 0.5.0",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -4043,15 +4060,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.7"
|
||||
version = "0.12.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
|
||||
checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.6",
|
||||
@@ -4060,11 +4078,13 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper 1.4.1",
|
||||
"hyper-rustls",
|
||||
"hyper-tls 0.6.0",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -4078,6 +4098,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
@@ -5161,7 +5182,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.12.7",
|
||||
"reqwest 0.12.8",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5311,7 +5332,7 @@ dependencies = [
|
||||
"data-url",
|
||||
"http 1.1.0",
|
||||
"regex",
|
||||
"reqwest 0.12.7",
|
||||
"reqwest 0.12.8",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -22,7 +22,7 @@ sysinfo = "0.32.0"
|
||||
m3u8-rs = "5.0.3"
|
||||
async-std = "1.12.0"
|
||||
futures = "0.3.28"
|
||||
ffmpeg-sidecar = "1.1"
|
||||
ffmpeg-sidecar = "1.2.0"
|
||||
chrono = { version = "0.4.24", features = ["serde"] }
|
||||
toml = "0.7.3"
|
||||
custom_error = "1.9.2"
|
||||
|
||||
@@ -5004,6 +5004,171 @@
|
||||
"type": "string",
|
||||
"const": "http:deny-fetch-send"
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n",
|
||||
"type": "string",
|
||||
"const": "notification:default"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch"
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel"
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions"
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel"
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted"
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels"
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify"
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state"
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types"
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener"
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active"
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission"
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show"
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch"
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel"
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions"
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel"
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel"
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active"
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending"
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted"
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels"
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify"
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state"
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types"
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener"
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active"
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission"
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show"
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n",
|
||||
"type": "string",
|
||||
|
||||
@@ -329,7 +329,6 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct VideoRow {
|
||||
@@ -351,21 +350,34 @@ pub struct VideoRow {
|
||||
impl Database {
|
||||
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&lock)
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn update_video(&self, video_id: i64, status: i64, bvid: &str, title: &str, desc: &str, tags: &str, area: u64) -> Result<(), DatabaseError> {
|
||||
pub async fn update_video(
|
||||
&self,
|
||||
video_id: i64,
|
||||
status: i64,
|
||||
bvid: &str,
|
||||
title: &str,
|
||||
desc: &str,
|
||||
tags: &str,
|
||||
area: u64,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
|
||||
.bind(status)
|
||||
|
||||
@@ -12,12 +12,13 @@ use recorder::bilibili::errors::BiliClientError;
|
||||
use recorder::bilibili::profile::Profile;
|
||||
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
|
||||
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tauri::utils::config::WindowEffectsConfig;
|
||||
use tauri::{Manager, Theme, WindowEvent};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -366,7 +367,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
// Copy old cache to new cache
|
||||
log::info!("Start copy old cache to new cache");
|
||||
state.db.new_message("缓存目录切换", "缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用").await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"缓存目录切换",
|
||||
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
|
||||
)
|
||||
.await?;
|
||||
if let Err(e) = copy_dir_all(&old_cache_path, &cache_path) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
@@ -382,7 +389,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_notify(state: tauri::State<'_, State>, live_start_notify: bool, live_end_notify: bool, clip_notify: bool, post_notify: bool) -> Result<(), ()> {
|
||||
async fn update_notify(
|
||||
state: tauri::State<'_, State>,
|
||||
live_start_notify: bool,
|
||||
live_end_notify: bool,
|
||||
clip_notify: bool,
|
||||
post_notify: bool,
|
||||
) -> Result<(), ()> {
|
||||
state.config.write().await.live_start_notify = live_start_notify;
|
||||
state.config.write().await.live_end_notify = live_end_notify;
|
||||
state.config.write().await.clip_notify = clip_notify;
|
||||
@@ -472,7 +485,14 @@ async fn clip_range(
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.clip_notify {
|
||||
state.app_handle.notification().builder().title("BiliShadowReplay - 切片完成").body(format!("生成了房间 {} 的切片: {}", room_id, filename)).show().unwrap();
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 切片完成")
|
||||
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(video)
|
||||
}
|
||||
@@ -519,7 +539,14 @@ async fn upload_procedure(
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.post_notify {
|
||||
state.app_handle.notification().builder().title("BiliShadowReplay - 投稿成功").body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid)).show().unwrap();
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 投稿成功")
|
||||
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(ret.bvid)
|
||||
} else {
|
||||
@@ -707,12 +734,19 @@ async fn delete_video(state: tauri::State<'_, State>, id: i64) -> Result<(), Str
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Setup log
|
||||
simplelog::CombinedLogger::init(vec![simplelog::TermLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
simplelog::TerminalMode::Mixed,
|
||||
simplelog::ColorChoice::Auto,
|
||||
)])
|
||||
simplelog::CombinedLogger::init(vec![
|
||||
simplelog::TermLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
simplelog::TerminalMode::Mixed,
|
||||
simplelog::ColorChoice::Auto,
|
||||
),
|
||||
simplelog::WriteLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
File::create("bsr.log").unwrap(),
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
// Setup ffmpeg
|
||||
@@ -814,12 +848,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(account) = account {
|
||||
for room in initial_rooms {
|
||||
if let Err(e) = recorder_manager_clone
|
||||
.add_recorder(
|
||||
&webid,
|
||||
&db_clone,
|
||||
&account,
|
||||
room.room_id,
|
||||
)
|
||||
.add_recorder(&webid, &db_clone, &account, room.room_id)
|
||||
.await
|
||||
{
|
||||
log::error!("error when adding initial rooms: {}", e);
|
||||
|
||||
@@ -12,10 +12,10 @@ use ffmpeg_sidecar::{
|
||||
use futures::future::join_all;
|
||||
use m3u8_rs::Playlist;
|
||||
use regex::Regex;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
@@ -165,16 +165,25 @@ impl BiliRecorder {
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 直播开始")
|
||||
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
|
||||
}
|
||||
} else {
|
||||
if self.config.read().await.live_end_notify {
|
||||
self.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 直播结束")
|
||||
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
|
||||
.body(format!(
|
||||
"{} 开启了直播:{}",
|
||||
self.user_info.read().await.user_name,
|
||||
room_info.room_title
|
||||
))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
} else if self.config.read().await.live_end_notify {
|
||||
self.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 直播结束")
|
||||
.body(format!(
|
||||
"{} 的直播结束了",
|
||||
self.user_info.read().await.user_name
|
||||
))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
// if stream is confirmed to be closed, live stream cache is cleaned.
|
||||
@@ -362,7 +371,12 @@ impl BiliRecorder {
|
||||
async fn update_entries(&self) -> Result<(), RecorderError> {
|
||||
let parsed = self.get_playlist().await;
|
||||
let mut timestamp = *self.timestamp.read().await;
|
||||
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
|
||||
let mut work_dir = format!(
|
||||
"{}/{}/{}/",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp
|
||||
);
|
||||
// Check header if None
|
||||
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
|
||||
{
|
||||
@@ -384,7 +398,12 @@ impl BiliRecorder {
|
||||
)
|
||||
.await?;
|
||||
// now work dir is confirmed
|
||||
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
|
||||
work_dir = format!(
|
||||
"{}/{}/{}/",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp
|
||||
);
|
||||
// if folder is exisited, need to load previous data into cache
|
||||
if let Ok(meta) = fs::metadata(&work_dir).await {
|
||||
if meta.is_dir() {
|
||||
@@ -572,7 +591,7 @@ impl BiliRecorder {
|
||||
y - x
|
||||
);
|
||||
log::info!("{}", file_name);
|
||||
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
|
||||
let args = format!("-i concat:{} -c copy", file_list);
|
||||
FfmpegCommand::new()
|
||||
.args(args.split(' '))
|
||||
.output(file_name.clone())
|
||||
@@ -608,12 +627,12 @@ impl BiliRecorder {
|
||||
}
|
||||
let mut offset = 0.0;
|
||||
for e in entry_copy.iter() {
|
||||
if (offset as f64) < start {
|
||||
if offset < start {
|
||||
offset += 1.0;
|
||||
continue;
|
||||
}
|
||||
to_combine.push(e);
|
||||
if (offset as f64) >= end {
|
||||
if offset >= end {
|
||||
break;
|
||||
}
|
||||
offset += 1.0;
|
||||
@@ -629,7 +648,10 @@ impl BiliRecorder {
|
||||
let file_name = e.url.split('/').last().unwrap();
|
||||
let file_path = format!(
|
||||
"{}/{}/{}/{}",
|
||||
self.config.read().await.cache, self.room_id, timestamp, file_name
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp,
|
||||
file_name
|
||||
);
|
||||
file_list += &file_path;
|
||||
file_list += "|";
|
||||
@@ -646,7 +668,7 @@ impl BiliRecorder {
|
||||
end - start
|
||||
);
|
||||
log::info!("{}", file_name);
|
||||
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
|
||||
let args = format!("-i concat:{} -c copy", file_list);
|
||||
FfmpegCommand::new()
|
||||
.args(args.split(' '))
|
||||
.output(file_name.clone())
|
||||
@@ -681,7 +703,12 @@ impl BiliRecorder {
|
||||
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
|
||||
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
|
||||
// add entries from read_dir
|
||||
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
|
||||
let work_dir = format!(
|
||||
"{}/{}/{}",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp
|
||||
);
|
||||
let entries = self.get_fs_entries(&work_dir).await;
|
||||
if entries.is_empty() {
|
||||
return m3u8_content;
|
||||
|
||||
@@ -70,7 +70,6 @@ impl From<RecorderManagerError> for String {
|
||||
}
|
||||
|
||||
impl RecorderManager {
|
||||
|
||||
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
||||
RecorderManager {
|
||||
app_handle,
|
||||
@@ -122,6 +121,9 @@ impl RecorderManager {
|
||||
if recorder.is_none() {
|
||||
return Err(RecorderManagerError::NotFound { room_id });
|
||||
}
|
||||
// remove related cache folder
|
||||
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
|
||||
tokio::fs::remove_dir_all(cache_folder).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,9 @@
|
||||
"title": "BiliBili ShadowReplay",
|
||||
"width": 1300,
|
||||
"height": 600,
|
||||
"transparent": true,
|
||||
"transparent": false,
|
||||
"decorations": false,
|
||||
"theme": "Light",
|
||||
"windowEffects": {
|
||||
"effects": ["tabbed", "mica"]
|
||||
}
|
||||
"theme": "Light"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
139
src/App.svelte
@@ -1,84 +1,85 @@
|
||||
<script lang="ts">
|
||||
import Room from "./lib/Room.svelte";
|
||||
import BSidebar from "./lib/BSidebar.svelte";
|
||||
import Summary from "./lib/Summary.svelte";
|
||||
import Setting from "./lib/Setting.svelte";
|
||||
import Account from "./lib/Account.svelte";
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import Messages from "./lib/Messages.svelte";
|
||||
import About from "./lib/About.svelte";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
let active = "#总览";
|
||||
let room_count = 0;
|
||||
let message_cnt = 0;
|
||||
let use_titlebar = platform() == "windows";
|
||||
import Room from "./lib/Room.svelte";
|
||||
import BSidebar from "./lib/BSidebar.svelte";
|
||||
import Summary from "./lib/Summary.svelte";
|
||||
import Setting from "./lib/Setting.svelte";
|
||||
import Account from "./lib/Account.svelte";
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import Messages from "./lib/Messages.svelte";
|
||||
import About from "./lib/About.svelte";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
let active = "#总览";
|
||||
let room_count = 0;
|
||||
let message_cnt = 0;
|
||||
let use_titlebar = platform() == "windows";
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if use_titlebar}
|
||||
<TitleBar />
|
||||
{/if}
|
||||
<div class="wrap">
|
||||
<div class="sidebar">
|
||||
<BSidebar bind:activeUrl={active} {room_count} {message_cnt} />
|
||||
</div>
|
||||
<div class="content">
|
||||
<!-- switch component by active -->
|
||||
<div class="page" class:visible={active == "#总览"}>
|
||||
<Summary />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#直播间"}>
|
||||
<Room bind:room_count />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#消息"}>
|
||||
<Messages bind:message_cnt />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#账号"}>
|
||||
<Account />
|
||||
</div>
|
||||
<!-- <div class="page" class:visible={active == "#自动化"}>
|
||||
{#if use_titlebar}
|
||||
<TitleBar />
|
||||
{/if}
|
||||
<div class="wrap">
|
||||
<div class="sidebar">
|
||||
<BSidebar bind:activeUrl={active} {room_count} {message_cnt} />
|
||||
</div>
|
||||
<div class="content">
|
||||
<!-- switch component by active -->
|
||||
<div class="page" class:visible={active == "#总览"}>
|
||||
<Summary />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#直播间"}>
|
||||
<Room bind:room_count />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#消息"}>
|
||||
<Messages bind:message_cnt />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#账号"}>
|
||||
<Account />
|
||||
</div>
|
||||
<!-- <div class="page" class:visible={active == "#自动化"}>
|
||||
<div>自动化[开发中]</div>
|
||||
</div> -->
|
||||
<div class="page" class:visible={active == "#设置"}>
|
||||
<Setting />
|
||||
</div>
|
||||
<div class="page" class:visible={active == "#关于"}>
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
<div class="page" class:visible={active == "#设置"}>
|
||||
<Setting />
|
||||
</div>
|
||||
<div class="page" class:visible={active == "#关于"}>
|
||||
<About />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1 !important;
|
||||
max-height: fit-content !important;
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
.visible {
|
||||
opacity: 1 !important;
|
||||
max-height: fit-content !important;
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
.page {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateX(100%);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
}
|
||||
.page {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateX(100%);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100vh;
|
||||
}
|
||||
.content {
|
||||
height: 100vh;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,10 +122,21 @@
|
||||
(a: RecordItem) => {
|
||||
console.log(a);
|
||||
archive = a;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title}`);
|
||||
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
|
||||
},
|
||||
);
|
||||
|
||||
function update_title(str: string) {
|
||||
appWindow.setTitle(
|
||||
`[${room_id}][${format_ts(ts)}]${archive.title} - ${str}`,
|
||||
);
|
||||
}
|
||||
|
||||
function format_ts(ts: number) {
|
||||
const date = new Date(ts * 1000);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async function get_video_list() {
|
||||
videos = (
|
||||
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
||||
@@ -159,30 +170,34 @@
|
||||
}
|
||||
loading = true;
|
||||
let new_cover = generateCover();
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成中`);
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
loading = false;
|
||||
update_title(`切片生成中`);
|
||||
try {
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
update_title(`切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
alert("Err generating clip: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function do_post() {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿上传中`);
|
||||
update_title(`投稿上传中`);
|
||||
loading = true;
|
||||
// render cover with text
|
||||
const ecapture = document.getElementById("capture");
|
||||
@@ -201,13 +216,13 @@
|
||||
})
|
||||
.then(async () => {
|
||||
loading = false;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿成功`);
|
||||
update_title(`投稿成功`);
|
||||
video_selected = 0;
|
||||
await get_video_list();
|
||||
})
|
||||
.catch((e) => {
|
||||
loading = false;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿失败`);
|
||||
update_title(`投稿失败`);
|
||||
alert(e);
|
||||
});
|
||||
}
|
||||
@@ -217,9 +232,9 @@
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除中`);
|
||||
update_title(`删除中`);
|
||||
await invoke("delete_video", { id: video_selected });
|
||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除成功`);
|
||||
update_title(`删除成功`);
|
||||
loading = false;
|
||||
video_selected = 0;
|
||||
video = null;
|
||||
@@ -246,7 +261,7 @@
|
||||
<TitleBar dark />
|
||||
{/if}
|
||||
<div class="flex flex-row">
|
||||
<div class="w-3/4">
|
||||
<div class="w-3/4 overflow-hidden">
|
||||
<Player bind:start bind:end {port} {room_id} {ts} />
|
||||
<Modal title="预览" bind:open={preview} autoclose>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
@@ -254,11 +269,11 @@
|
||||
</Modal>
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[49]"
|
||||
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[39]"
|
||||
>
|
||||
<div
|
||||
id="post-panel"
|
||||
class="mt-6 overflow-auto p-6"
|
||||
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
|
||||
class:titlebar={use_titlebar}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
@@ -271,7 +286,7 @@
|
||||
>
|
||||
<div id="capture" class="cover-wrap relative cursor-pointer">
|
||||
<div
|
||||
class="cover-text absolute py-2 px-8"
|
||||
class="cover-text absolute py-1 px-8"
|
||||
class:play-icon={false}
|
||||
>
|
||||
{cover_text}
|
||||
@@ -309,30 +324,64 @@
|
||||
</div>
|
||||
<Hr />
|
||||
<Label class="mt-4">标题</Label>
|
||||
<Input size="sm" bind:value={profile.title} />
|
||||
<Input
|
||||
size="sm"
|
||||
bind:value={profile.title}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label class="mt-2">封面文本</Label>
|
||||
<Textarea bind:value={cover_text} />
|
||||
<Label class="mt-2">描述</Label>
|
||||
<Textarea bind:value={profile.desc} />
|
||||
<Textarea
|
||||
bind:value={profile.desc}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label class="mt-2">标签</Label>
|
||||
<Input size="sm" bind:value={profile.tag} />
|
||||
<Input
|
||||
size="sm"
|
||||
bind:value={profile.tag}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label class="mt-2">动态</Label>
|
||||
<Textarea bind:value={profile.dynamic} />
|
||||
<Textarea
|
||||
bind:value={profile.dynamic}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label class="mt-2">视频分区</Label>
|
||||
<Input size="sm" value="动画 - 综合" disabled />
|
||||
<Label class="mt-2">投稿账号</Label>
|
||||
<Select size="sm" items={accounts} bind:value={uid_selected} />
|
||||
{#if video}
|
||||
<div class="flex mt-4 justify-center w-full">
|
||||
<Button on:click={do_post} disabled={loading}>
|
||||
{#if loading}
|
||||
<Spinner class="me-3" size="4" />
|
||||
{/if}
|
||||
投稿
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if video}
|
||||
<div class="flex mt-4 justify-center w-full">
|
||||
<Button on:click={do_post} disabled={loading}>
|
||||
{#if loading}
|
||||
<Spinner class="me-3" size="4" />
|
||||
{/if}
|
||||
投稿
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -351,6 +400,7 @@
|
||||
.cover-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
color: rgb(255, 127, 0);
|
||||
text-shadow:
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
站直播流,并生成视频投稿的工具。
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
项目地址: <a href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
项目地址: <a
|
||||
target="_blank"
|
||||
href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
>https://github.com/Xinrea/bili-shadowreplay</a
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
TableBodyCell,
|
||||
Modal,
|
||||
ButtonGroup,
|
||||
SpeedDial,
|
||||
Listgroup,
|
||||
ListgroupItem,
|
||||
Textarea,
|
||||
Hr,
|
||||
} from "flowbite-svelte";
|
||||
import Image from "./Image.svelte";
|
||||
import QRCode from "qrcode";
|
||||
@@ -32,6 +37,9 @@
|
||||
let oauth_key = "";
|
||||
let check_interval = null;
|
||||
|
||||
let manualModal = false;
|
||||
let cookie_str = "";
|
||||
|
||||
async function handle_qr() {
|
||||
if (check_interval) {
|
||||
clearInterval(check_interval);
|
||||
@@ -52,7 +60,7 @@
|
||||
async function check_qr() {
|
||||
let qr_status: { code: number; cookies: string } = await invoke(
|
||||
"get_qr_status",
|
||||
{ qrcodeKey: oauth_key }
|
||||
{ qrcodeKey: oauth_key },
|
||||
);
|
||||
if (qr_status.code == 0) {
|
||||
clearInterval(check_interval);
|
||||
@@ -61,6 +69,20 @@
|
||||
addModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add_cookie() {
|
||||
if (cookie_str == "") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("add_account", { cookies: cookie_str });
|
||||
await update_accounts();
|
||||
cookie_str = "";
|
||||
manualModal = false;
|
||||
} catch (e) {
|
||||
alert("Err adding cookie:" + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12 h-full overflow-auto">
|
||||
@@ -116,16 +138,23 @@
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<Button
|
||||
pill={true}
|
||||
class="!p-2"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}><UserAddSolid class="w-8 h-8" /></Button
|
||||
>
|
||||
</div>
|
||||
<SpeedDial defaultClass="absolute end-6 bottom-6" placement="top-end">
|
||||
<Listgroup active>
|
||||
<ListgroupItem
|
||||
class="flex gap-2 md:px-5"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}>扫码添加</ListgroupItem
|
||||
>
|
||||
<ListgroupItem
|
||||
class="flex gap-2 md:px-5"
|
||||
on:click={() => {
|
||||
manualModal = true;
|
||||
}}>手动添加</ListgroupItem
|
||||
>
|
||||
</Listgroup>
|
||||
</SpeedDial>
|
||||
|
||||
<Modal
|
||||
title="请使用 BiliBili App 扫码登录"
|
||||
@@ -137,3 +166,20 @@
|
||||
<canvas id="qr" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="请粘贴 BiliBili 账号 Cookie"
|
||||
bind:open={manualModal}
|
||||
size="sm"
|
||||
autoclose
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center h-full">
|
||||
<Textarea bind:value={cookie_str} />
|
||||
<Button
|
||||
class="mt-4"
|
||||
on:click={() => {
|
||||
add_cookie();
|
||||
}}>添加</Button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
(window as any).ui = ui;
|
||||
try {
|
||||
await player.load(
|
||||
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
|
||||
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`
|
||||
);
|
||||
// This runs if the asynchronous load is successful.
|
||||
console.log("The video has now been loaded!");
|
||||
@@ -44,13 +44,10 @@
|
||||
});
|
||||
|
||||
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
|
||||
document.querySelector(
|
||||
".shaka-back-to-overflow-button .material-icons-round",
|
||||
).innerHTML = "arrow_back_ios_new";
|
||||
|
||||
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
|
||||
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
|
||||
const shakaBottomControls = document.querySelector(
|
||||
".shaka-bottom-controls.shaka-no-propagation",
|
||||
".shaka-bottom-controls.shaka-no-propagation"
|
||||
);
|
||||
const selfSeekbar = document.createElement("div");
|
||||
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
|
||||
@@ -66,163 +63,187 @@
|
||||
`;
|
||||
shakaBottomControls.appendChild(selfSeekbar);
|
||||
|
||||
// add a account select
|
||||
const accountSelect = document.createElement("select");
|
||||
accountSelect.style.height = "30px";
|
||||
accountSelect.style.minWidth = "100px";
|
||||
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
accountSelect.style.color = "white";
|
||||
accountSelect.style.border = "1px solid gray";
|
||||
accountSelect.style.padding = "0 10px";
|
||||
accountSelect.style.boxSizing = "border-box";
|
||||
accountSelect.style.fontSize = "1em";
|
||||
|
||||
// get accounts from tauri
|
||||
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
||||
account_info.accounts.forEach((account) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = account.uid.toString();
|
||||
option.text = account.name;
|
||||
accountSelect.appendChild(option);
|
||||
});
|
||||
// add a danmaku send input
|
||||
const danmakuInput = document.createElement("input");
|
||||
danmakuInput.type = "text";
|
||||
danmakuInput.placeholder = "回车发送弹幕";
|
||||
danmakuInput.style.width = "50%";
|
||||
danmakuInput.style.height = "30px";
|
||||
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
danmakuInput.style.color = "white";
|
||||
danmakuInput.style.border = "1px solid gray";
|
||||
danmakuInput.style.padding = "0 10px";
|
||||
danmakuInput.style.boxSizing = "border-box";
|
||||
danmakuInput.style.fontSize = "1em";
|
||||
danmakuInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = danmakuInput.value;
|
||||
if (value) {
|
||||
// get account uid from select
|
||||
const uid = parseInt(accountSelect.value);
|
||||
await invoke("send_danmaku", {
|
||||
uid,
|
||||
roomId: room_id,
|
||||
ts,
|
||||
message: value,
|
||||
});
|
||||
danmakuInput.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let danmu_enabled = true;
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
danmakuToggle.style.height = "30px";
|
||||
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
danmakuToggle.style.color = "white";
|
||||
danmakuToggle.style.border = "1px solid gray";
|
||||
danmakuToggle.style.padding = "0 10px";
|
||||
danmakuToggle.style.boxSizing = "border-box";
|
||||
danmakuToggle.style.fontSize = "1em";
|
||||
danmakuToggle.addEventListener("click", async () => {
|
||||
danmu_enabled = !danmu_enabled;
|
||||
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
|
||||
// clear background color
|
||||
danmakuToggle.style.backgroundColor = danmu_enabled
|
||||
? "rgba(0, 128, 255, 0.5)"
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
|
||||
// add to shaka-spacer
|
||||
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
|
||||
if (isLive()) {
|
||||
// add a account select
|
||||
const accountSelect = document.createElement("select");
|
||||
accountSelect.style.height = "30px";
|
||||
accountSelect.style.minWidth = "100px";
|
||||
accountSelect.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
accountSelect.style.color = "white";
|
||||
accountSelect.style.border = "1px solid gray";
|
||||
accountSelect.style.padding = "0 10px";
|
||||
accountSelect.style.boxSizing = "border-box";
|
||||
accountSelect.style.fontSize = "1em";
|
||||
|
||||
// get accounts from tauri
|
||||
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
||||
account_info.accounts.forEach((account) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = account.uid.toString();
|
||||
option.text = account.name;
|
||||
accountSelect.appendChild(option);
|
||||
});
|
||||
// add a danmaku send input
|
||||
const danmakuInput = document.createElement("input");
|
||||
danmakuInput.type = "text";
|
||||
danmakuInput.placeholder = "回车发送弹幕";
|
||||
danmakuInput.style.width = "50%";
|
||||
danmakuInput.style.height = "30px";
|
||||
danmakuInput.style.backgroundColor = "rgba(0, 0, 0, 0)";
|
||||
danmakuInput.style.color = "white";
|
||||
danmakuInput.style.border = "1px solid gray";
|
||||
danmakuInput.style.padding = "0 10px";
|
||||
danmakuInput.style.boxSizing = "border-box";
|
||||
danmakuInput.style.fontSize = "1em";
|
||||
danmakuInput.addEventListener("keydown", async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = danmakuInput.value;
|
||||
if (value) {
|
||||
// get account uid from select
|
||||
const uid = parseInt(accountSelect.value);
|
||||
await invoke("send_danmaku", {
|
||||
uid,
|
||||
roomId: room_id,
|
||||
ts,
|
||||
message: value,
|
||||
});
|
||||
danmakuInput.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let danmu_enabled = true;
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
danmakuToggle.style.height = "30px";
|
||||
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
danmakuToggle.style.color = "white";
|
||||
danmakuToggle.style.border = "1px solid gray";
|
||||
danmakuToggle.style.padding = "0 10px";
|
||||
danmakuToggle.style.boxSizing = "border-box";
|
||||
danmakuToggle.style.fontSize = "1em";
|
||||
danmakuToggle.addEventListener("click", async () => {
|
||||
danmu_enabled = !danmu_enabled;
|
||||
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
|
||||
// clear background color
|
||||
danmakuToggle.style.backgroundColor = danmu_enabled
|
||||
? "rgba(0, 128, 255, 0.5)"
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "30";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
overlay.style.paddingTop = "10%";
|
||||
// place overlay to the top of the video
|
||||
video.parentElement.appendChild(overlay);
|
||||
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
|
||||
// listen to danmaku event
|
||||
listen("danmu:" + room_id, (event: { payload: string }) => {
|
||||
// if not enabled or playback is not keep up with live, ignore the danmaku
|
||||
if (!danmu_enabled || get_total() - video.currentTime > 5) {
|
||||
return;
|
||||
}
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
|
||||
// Calculate a random position for the danmaku
|
||||
let topPosition;
|
||||
let attempts = 0;
|
||||
do {
|
||||
topPosition = Math.random() * 30;
|
||||
attempts++;
|
||||
} while (
|
||||
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
|
||||
attempts < 10
|
||||
);
|
||||
|
||||
// Record the position
|
||||
danmakuPositions.push(topPosition);
|
||||
if (danmakuPositions.length > 10) {
|
||||
danmakuPositions.shift(); // Keep the last 10 positions
|
||||
}
|
||||
|
||||
danmaku.style.top = `${topPosition}%`;
|
||||
danmaku.style.right = "0";
|
||||
danmaku.style.color = "white";
|
||||
danmaku.style.fontSize = "1.2em";
|
||||
danmaku.style.whiteSpace = "nowrap";
|
||||
danmaku.style.transform = "translateX(100%)";
|
||||
danmaku.style.transition = "transform 10s linear";
|
||||
danmaku.style.pointerEvents = "none";
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
danmaku.style.zIndex = "500";
|
||||
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
|
||||
danmaku.innerText = event.payload;
|
||||
overlay.appendChild(danmaku);
|
||||
requestAnimationFrame(() => {
|
||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||
});
|
||||
danmaku.addEventListener("transitionend", () => {
|
||||
overlay.removeChild(danmaku);
|
||||
});
|
||||
});
|
||||
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
}
|
||||
|
||||
// create a playback rate select to of shaka-spacer
|
||||
const playbackRateSelect = document.createElement("select");
|
||||
playbackRateSelect.style.height = "30px";
|
||||
playbackRateSelect.style.minWidth = "60px";
|
||||
playbackRateSelect.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
|
||||
playbackRateSelect.style.color = "white";
|
||||
playbackRateSelect.style.border = "1px solid gray";
|
||||
playbackRateSelect.style.padding = "0 10px";
|
||||
playbackRateSelect.style.boxSizing = "border-box";
|
||||
playbackRateSelect.style.fontSize = "1em";
|
||||
playbackRateSelect.style.right = "10px";
|
||||
playbackRateSelect.style.position = "absolute";
|
||||
playbackRateSelect.innerHTML = `
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="1">1x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
<option value="5">5x</option>
|
||||
`;
|
||||
// default playback rate is 1
|
||||
playbackRateSelect.value = "1";
|
||||
playbackRateSelect.addEventListener("change", () => {
|
||||
const rate = parseFloat(playbackRateSelect.value);
|
||||
video.playbackRate = rate;
|
||||
});
|
||||
|
||||
shakaSpacer.appendChild(playbackRateSelect);
|
||||
|
||||
// shaka-spacer should be flex-direction: column
|
||||
shakaSpacer.style.flexDirection = "column";
|
||||
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "40";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
overlay.style.paddingTop = "10%";
|
||||
// place overlay to the top of the video
|
||||
video.parentElement.appendChild(overlay);
|
||||
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
|
||||
// listen to danmaku event
|
||||
listen("danmu:" + room_id, (event: { payload: string }) => {
|
||||
console.log("danmu", event.payload);
|
||||
if (!danmu_enabled) {
|
||||
return;
|
||||
}
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
|
||||
// Calculate a random position for the danmaku
|
||||
let topPosition;
|
||||
let attempts = 0;
|
||||
do {
|
||||
topPosition = Math.random() * 30;
|
||||
attempts++;
|
||||
} while (
|
||||
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
|
||||
attempts < 10
|
||||
);
|
||||
|
||||
// Record the position
|
||||
danmakuPositions.push(topPosition);
|
||||
if (danmakuPositions.length > 10) {
|
||||
danmakuPositions.shift(); // Keep the last 10 positions
|
||||
}
|
||||
|
||||
danmaku.style.top = `${topPosition}%`;
|
||||
danmaku.style.right = "0";
|
||||
danmaku.style.color = "white";
|
||||
danmaku.style.fontSize = "1.2em";
|
||||
danmaku.style.whiteSpace = "nowrap";
|
||||
danmaku.style.transform = "translateX(100%)";
|
||||
danmaku.style.transition = "transform 10s linear";
|
||||
danmaku.style.pointerEvents = "none";
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
danmaku.style.zIndex = "500";
|
||||
danmaku.innerText = event.payload;
|
||||
overlay.appendChild(danmaku);
|
||||
requestAnimationFrame(() => {
|
||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||
});
|
||||
danmaku.addEventListener("transitionend", () => {
|
||||
overlay.removeChild(danmaku);
|
||||
});
|
||||
});
|
||||
|
||||
function isLive() {
|
||||
let total = video.duration;
|
||||
if (total == Infinity || total >= 4294967296) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return player.isLive();
|
||||
}
|
||||
|
||||
function get_total() {
|
||||
let total = video.duration;
|
||||
if (total == Infinity || total >= 4294967296) {
|
||||
total = (Date.now() - player.getPresentationStartTimeAsDate()) / 1000;
|
||||
}
|
||||
return total;
|
||||
return player.seekRange().end;
|
||||
}
|
||||
// add keydown event listener for '[' and ']' to control range
|
||||
document.addEventListener("keydown", async (e) => {
|
||||
@@ -235,34 +256,14 @@
|
||||
}
|
||||
switch (e.key) {
|
||||
case "[":
|
||||
if (isLive()) {
|
||||
start = parseFloat(
|
||||
(
|
||||
(player.getPlayheadTimeAsDate() -
|
||||
player.getPresentationStartTimeAsDate()) /
|
||||
1000
|
||||
).toFixed(2),
|
||||
);
|
||||
} else {
|
||||
start = parseFloat(video.currentTime.toFixed(2));
|
||||
}
|
||||
start = parseFloat(video.currentTime.toFixed(2));
|
||||
if (end < start) {
|
||||
end = get_total();
|
||||
}
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "]":
|
||||
if (isLive()) {
|
||||
end = parseFloat(
|
||||
(
|
||||
(player.getPlayheadTimeAsDate() -
|
||||
player.getPresentationStartTimeAsDate()) /
|
||||
1000
|
||||
).toFixed(2),
|
||||
);
|
||||
} else {
|
||||
end = parseFloat(video.currentTime.toFixed(2));
|
||||
}
|
||||
end = parseFloat(video.currentTime.toFixed(2));
|
||||
if (start > end) {
|
||||
start = 0;
|
||||
}
|
||||
@@ -316,7 +317,7 @@
|
||||
const second_point = end / total;
|
||||
// set background color for self-defined seekbar between first_point and second_point using linear-gradient
|
||||
const seekbarContainer = selfSeekbar.querySelector(
|
||||
".shaka-seek-bar-container.self-defined",
|
||||
".shaka-seek-bar-container.self-defined"
|
||||
) as HTMLElement;
|
||||
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
|
||||
first_point * 100
|
||||
@@ -361,8 +362,8 @@
|
||||
<p><kbd>]</kbd>设定选区结束</p>
|
||||
<p><kbd>q</kbd>跳转到选区开始</p>
|
||||
<p><kbd>e</kbd>跳转到选区结束</p>
|
||||
<p><kbd>Alt</kbd><kbd>←</kbd>前进</p>
|
||||
<p><kbd>Alt</kbd><kbd>→</kbd>后退</p>
|
||||
<p><kbd>←</kbd>前进</p>
|
||||
<p><kbd>→</kbd>后退</p>
|
||||
<p><kbd>c</kbd>清除选区</p>
|
||||
<p><kbd>m</kbd>静音</p>
|
||||
</span>
|
||||
|
||||
@@ -1,347 +1,333 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Badge,
|
||||
SpeedDial,
|
||||
SpeedDialButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableBodyCell,
|
||||
TableBodyRow,
|
||||
TableHead,
|
||||
TableHeadCell,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
Button,
|
||||
CheckboxButton,
|
||||
ButtonGroup,
|
||||
Modal,
|
||||
Label,
|
||||
Select,
|
||||
Checkbox,
|
||||
Input,
|
||||
Helper,
|
||||
Tooltip,
|
||||
} from "flowbite-svelte";
|
||||
import {
|
||||
ChevronDownOutline,
|
||||
PlusOutline,
|
||||
ExclamationCircleOutline,
|
||||
} from "flowbite-svelte-icons";
|
||||
import type { RecorderList } from "./interface";
|
||||
import Image from "./Image.svelte";
|
||||
import type { RecordItem } from "./db";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Badge,
|
||||
SpeedDial,
|
||||
SpeedDialButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableBodyCell,
|
||||
TableBodyRow,
|
||||
TableHead,
|
||||
TableHeadCell,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
Button,
|
||||
CheckboxButton,
|
||||
ButtonGroup,
|
||||
Modal,
|
||||
Label,
|
||||
Select,
|
||||
Checkbox,
|
||||
Input,
|
||||
Helper,
|
||||
Tooltip,
|
||||
} from "flowbite-svelte";
|
||||
import {
|
||||
ChevronDownOutline,
|
||||
PlusOutline,
|
||||
ExclamationCircleOutline,
|
||||
} from "flowbite-svelte-icons";
|
||||
import type { RecorderList } from "./interface";
|
||||
import Image from "./Image.svelte";
|
||||
import type { RecordItem } from "./db";
|
||||
|
||||
export let room_count = 0;
|
||||
let summary: RecorderList = {
|
||||
count: 0,
|
||||
recorders: [],
|
||||
};
|
||||
export let room_count = 0;
|
||||
let summary: RecorderList = {
|
||||
count: 0,
|
||||
recorders: [],
|
||||
};
|
||||
|
||||
async function update_summary() {
|
||||
summary = (await invoke("get_recorder_list")) as RecorderList;
|
||||
room_count = summary.count;
|
||||
}
|
||||
update_summary();
|
||||
setInterval(update_summary, 1000);
|
||||
|
||||
function format_time(time: number) {
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.floor(time % 60);
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// modals
|
||||
let deleteModal = false;
|
||||
let deleteRoom = 0;
|
||||
|
||||
let quickClipModal = false;
|
||||
let quickClipRoom = 0;
|
||||
let quickClipSelected = 0;
|
||||
let quickClipOptions = [
|
||||
{ value: 10, name: "10 秒" },
|
||||
{ value: 30, name: "30 秒" },
|
||||
{ value: 60, name: "60 秒" },
|
||||
];
|
||||
|
||||
let addModal = false;
|
||||
let addRoom = "";
|
||||
let addValid = false;
|
||||
let addErrorMsg = "";
|
||||
|
||||
let archiveModal = false;
|
||||
let archiveRoom = null;
|
||||
let archives: RecordItem[] = [];
|
||||
async function showArchives(room_id: number) {
|
||||
archives = await invoke("get_archives", { roomId: room_id });
|
||||
archiveModal = true;
|
||||
console.log(archives);
|
||||
}
|
||||
function format_ts(ts_string: string) {
|
||||
const date = new Date(ts_string);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
function format_duration(duration: number) {
|
||||
const hours = Math.floor(duration / 3600)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const minutes = Math.floor((duration % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const seconds = (duration % 60).toString().padStart(2, "0");
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
function format_size(size: number) {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
} else if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(2)} KiB`;
|
||||
} else if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
|
||||
} else {
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
|
||||
}
|
||||
}
|
||||
function calc_bitrate(size: number, duration: number) {
|
||||
return ((size * 8) / duration / 1024).toFixed(0);
|
||||
async function update_summary() {
|
||||
summary = (await invoke("get_recorder_list")) as RecorderList;
|
||||
room_count = summary.count;
|
||||
}
|
||||
update_summary();
|
||||
setInterval(update_summary, 1000);
|
||||
|
||||
function format_time(time: number) {
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.floor(time % 60);
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// modals
|
||||
let deleteModal = false;
|
||||
let deleteRoom = 0;
|
||||
|
||||
let quickClipModal = false;
|
||||
let quickClipRoom = 0;
|
||||
let quickClipSelected = 0;
|
||||
let quickClipOptions = [
|
||||
{ value: 10, name: "10 秒" },
|
||||
{ value: 30, name: "30 秒" },
|
||||
{ value: 60, name: "60 秒" },
|
||||
];
|
||||
|
||||
let addModal = false;
|
||||
let addRoom = "";
|
||||
let addValid = false;
|
||||
let addErrorMsg = "";
|
||||
|
||||
let archiveModal = false;
|
||||
let archiveRoom = null;
|
||||
let archives: RecordItem[] = [];
|
||||
async function showArchives(room_id: number) {
|
||||
archives = await invoke("get_archives", { roomId: room_id });
|
||||
archiveModal = true;
|
||||
console.log(archives);
|
||||
}
|
||||
function format_ts(ts_string: string) {
|
||||
const date = new Date(ts_string);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
function format_duration(duration: number) {
|
||||
const hours = Math.floor(duration / 3600)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const minutes = Math.floor((duration % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const seconds = (duration % 60).toString().padStart(2, "0");
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
function format_size(size: number) {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
} else if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(2)} KiB`;
|
||||
} else if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
|
||||
} else {
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB`;
|
||||
}
|
||||
}
|
||||
function calc_bitrate(size: number, duration: number) {
|
||||
return ((size * 8) / duration / 1024).toFixed(0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12 h-full overflow-auto">
|
||||
<Table hoverable={true} divClass="relative max-h-full" shadow>
|
||||
<TableHead>
|
||||
<TableHeadCell>房间号</TableHeadCell>
|
||||
<TableHeadCell>标题</TableHeadCell>
|
||||
<TableHeadCell>用户</TableHeadCell>
|
||||
<TableHeadCell>状态</TableHeadCell>
|
||||
<TableHeadCell>缓存时长</TableHeadCell>
|
||||
<TableHeadCell>
|
||||
<span class="sr-only">Edit</span>
|
||||
</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each summary.recorders as room}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell>{room.room_id}</TableBodyCell>
|
||||
<TableBodyCell>{room.room_info.room_title}</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<div class="pr-4">
|
||||
<Image
|
||||
iclass="rounded-full w-12 inline"
|
||||
src={room.user_info.user_avatar_url}
|
||||
/>
|
||||
<span>
|
||||
{room.user_info.user_name}
|
||||
</span>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
{#if room.live_status}
|
||||
<Badge color="green">直播中</Badge>
|
||||
{:else}
|
||||
<Badge color="dark">未直播</Badge>
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell
|
||||
>{format_time(room.total_length)}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell>
|
||||
<Button size="sm" color="dark"
|
||||
>操作<ChevronDownOutline
|
||||
class="w-6 h-6 ms-2 text-white dark:text-white"
|
||||
/></Button
|
||||
>
|
||||
<Dropdown>
|
||||
{#if room.live_status}
|
||||
<DropdownItem
|
||||
on:click={async () => {
|
||||
await invoke("open_live", {
|
||||
roomId: room.room_id,
|
||||
ts: room.current_ts,
|
||||
});
|
||||
}}>打开直播流</DropdownItem
|
||||
>
|
||||
<DropdownItem
|
||||
<Table hoverable={true} divClass="relative max-h-full" shadow>
|
||||
<TableHead>
|
||||
<TableHeadCell>房间号</TableHeadCell>
|
||||
<TableHeadCell>标题</TableHeadCell>
|
||||
<TableHeadCell>用户</TableHeadCell>
|
||||
<TableHeadCell>状态</TableHeadCell>
|
||||
<TableHeadCell>缓存时长</TableHeadCell>
|
||||
<TableHeadCell>
|
||||
<span class="sr-only">Edit</span>
|
||||
</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each summary.recorders as room}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell>{room.room_id}</TableBodyCell>
|
||||
<TableBodyCell>{room.room_info.room_title}</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<div class="pr-4">
|
||||
<Image
|
||||
iclass="rounded-full w-12 inline"
|
||||
src={room.user_info.user_avatar_url}
|
||||
/>
|
||||
<span>
|
||||
{room.user_info.user_name}
|
||||
</span>
|
||||
</div>
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
{#if room.live_status}
|
||||
<Badge color="green">直播中</Badge>
|
||||
{:else}
|
||||
<Badge color="dark">未直播</Badge>
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>{format_time(room.total_length)}</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<Button size="sm" color="dark"
|
||||
>操作<ChevronDownOutline
|
||||
class="w-6 h-6 ms-2 text-white dark:text-white"
|
||||
/></Button
|
||||
>
|
||||
<Dropdown>
|
||||
{#if room.live_status}
|
||||
<DropdownItem
|
||||
on:click={async () => {
|
||||
await invoke("open_live", {
|
||||
roomId: room.room_id,
|
||||
ts: room.current_ts,
|
||||
});
|
||||
}}>打开直播流</DropdownItem
|
||||
>
|
||||
<!-- <DropdownItem
|
||||
on:click={() => {
|
||||
quickClipRoom = room.room_id;
|
||||
quickClipSelected = 30;
|
||||
quickClipModal = true;
|
||||
}}>快速切片</DropdownItem
|
||||
>
|
||||
{/if}
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
archiveRoom = room;
|
||||
showArchives(room.room_id);
|
||||
}}>查看历史记录</DropdownItem
|
||||
>
|
||||
<DropdownItem
|
||||
class="text-red-500"
|
||||
on:click={() => {
|
||||
deleteRoom = room.room_id;
|
||||
deleteModal = true;
|
||||
}}>移除直播间</DropdownItem
|
||||
>
|
||||
</Dropdown>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<Button
|
||||
pill={true}
|
||||
class="!p-2"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
}}><PlusOutline class="w-8 h-8" /></Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={deleteModal} size="xs" autoclose>
|
||||
<div class="text-center">
|
||||
<ExclamationCircleOutline
|
||||
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
||||
/>
|
||||
<h3
|
||||
class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
确定要移除这个直播间吗?
|
||||
</h3>
|
||||
<Button
|
||||
color="red"
|
||||
class="me-2"
|
||||
on:click={async () => {
|
||||
await invoke("remove_recorder", { roomId: deleteRoom });
|
||||
}}>确定</Button
|
||||
>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title="快速切片" bind:open={quickClipModal} size="xs" autoclose>
|
||||
<Label>
|
||||
选择切片时长
|
||||
<Select
|
||||
class="mt-2"
|
||||
items={quickClipOptions}
|
||||
bind:value={quickClipSelected}
|
||||
/>
|
||||
</Label>
|
||||
<Checkbox>生成后启动上传流程</Checkbox>
|
||||
<Checkbox>生成后打开文件所在目录</Checkbox>
|
||||
<div class="text-center">
|
||||
<Button color="red" class="me-2">确定</Button>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增直播间" bind:open={addModal} size="xs" autoclose>
|
||||
<Label color={addErrorMsg ? "red" : "gray"}>
|
||||
房间号
|
||||
<Input
|
||||
bind:value={addRoom}
|
||||
color={addErrorMsg ? "red" : "base"}
|
||||
on:change={() => {
|
||||
if (!addRoom) {
|
||||
addErrorMsg = "";
|
||||
addValid = false;
|
||||
return;
|
||||
}
|
||||
// TODO preload room info
|
||||
const room_id = Number(addRoom);
|
||||
if (Number.isInteger(room_id) && room_id > 0) {
|
||||
addErrorMsg = "";
|
||||
addValid = true;
|
||||
} else {
|
||||
addErrorMsg = "房间号格式错误,请检查输入";
|
||||
addValid = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if addErrorMsg}
|
||||
<Helper class="mt-2" color="red">
|
||||
<span class="font-medium">{addErrorMsg}</span>
|
||||
</Helper>
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="text-center">
|
||||
<Button
|
||||
color="red"
|
||||
class="me-2"
|
||||
disabled={!addValid}
|
||||
> -->
|
||||
{/if}
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
|
||||
async (e) => {
|
||||
await message(
|
||||
"请检查房间号是否有效:" + e,
|
||||
"添加失败",
|
||||
);
|
||||
},
|
||||
);
|
||||
}}>确定</Button
|
||||
>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
archiveRoom = room;
|
||||
showArchives(room.room_id);
|
||||
}}>查看历史记录</DropdownItem
|
||||
>
|
||||
<DropdownItem
|
||||
class="text-red-500"
|
||||
on:click={() => {
|
||||
deleteRoom = room.room_id;
|
||||
deleteModal = true;
|
||||
}}>移除直播间</DropdownItem
|
||||
>
|
||||
</Dropdown>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Modal title="直播间记录" bind:open={archiveModal} size="lg">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeadCell>直播时间</TableHeadCell>
|
||||
<TableHeadCell>标题</TableHeadCell>
|
||||
<TableHeadCell>时长</TableHeadCell>
|
||||
<TableHeadCell>缓存</TableHeadCell>
|
||||
<TableHeadCell>操作</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each archives as archive}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell
|
||||
>{format_ts(archive.created_at)}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell>{archive.title}</TableBodyCell>
|
||||
<TableBodyCell
|
||||
>{format_duration(archive.length)}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell>
|
||||
<span>{format_size(archive.size)}</span>
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
on:click={() => {
|
||||
invoke("open_live", {
|
||||
roomId: archiveRoom.room_id,
|
||||
ts: archive.live_id,
|
||||
});
|
||||
}}>编辑切片</Button
|
||||
>
|
||||
<Button
|
||||
color="red"
|
||||
on:click={() => {
|
||||
invoke("delete_archive", {
|
||||
roomId: archiveRoom.room_id,
|
||||
ts: archive.live_id,
|
||||
}).then(async () => {
|
||||
archives = await invoke(
|
||||
"get_archives",
|
||||
{
|
||||
roomId: archiveRoom.room_id,
|
||||
},
|
||||
);
|
||||
});
|
||||
}}>移除</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<Button
|
||||
pill={true}
|
||||
class="!p-2"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
}}><PlusOutline class="w-8 h-8" /></Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={deleteModal} size="xs" autoclose>
|
||||
<div class="text-center">
|
||||
<ExclamationCircleOutline
|
||||
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
||||
/>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||
确定要移除这个直播间吗?
|
||||
</h3>
|
||||
<Button
|
||||
color="red"
|
||||
class="me-2"
|
||||
on:click={async () => {
|
||||
await invoke("remove_recorder", { roomId: deleteRoom });
|
||||
}}>确定</Button
|
||||
>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title="快速切片" bind:open={quickClipModal} size="xs" autoclose>
|
||||
<Label>
|
||||
选择切片时长
|
||||
<Select
|
||||
class="mt-2"
|
||||
items={quickClipOptions}
|
||||
bind:value={quickClipSelected}
|
||||
/>
|
||||
</Label>
|
||||
<Checkbox>生成后启动上传流程</Checkbox>
|
||||
<Checkbox>生成后打开文件所在目录</Checkbox>
|
||||
<div class="text-center">
|
||||
<Button color="red" class="me-2">确定</Button>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增直播间" bind:open={addModal} size="xs" autoclose>
|
||||
<Label color={addErrorMsg ? "red" : "gray"}>
|
||||
房间号
|
||||
<Input
|
||||
bind:value={addRoom}
|
||||
color={addErrorMsg ? "red" : "base"}
|
||||
on:change={() => {
|
||||
if (!addRoom) {
|
||||
addErrorMsg = "";
|
||||
addValid = false;
|
||||
return;
|
||||
}
|
||||
// TODO preload room info
|
||||
const room_id = Number(addRoom);
|
||||
if (Number.isInteger(room_id) && room_id > 0) {
|
||||
addErrorMsg = "";
|
||||
addValid = true;
|
||||
} else {
|
||||
addErrorMsg = "房间号格式错误,请检查输入";
|
||||
addValid = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if addErrorMsg}
|
||||
<Helper class="mt-2" color="red">
|
||||
<span class="font-medium">{addErrorMsg}</span>
|
||||
</Helper>
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="text-center">
|
||||
<Button
|
||||
color="red"
|
||||
class="me-2"
|
||||
disabled={!addValid}
|
||||
on:click={() => {
|
||||
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
|
||||
async (e) => {
|
||||
await message("请检查房间号是否有效:" + e, "添加失败");
|
||||
}
|
||||
);
|
||||
}}>确定</Button
|
||||
>
|
||||
<Button color="alternative">取消</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal title="直播间记录" bind:open={archiveModal} size="lg">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeadCell>直播时间</TableHeadCell>
|
||||
<TableHeadCell>标题</TableHeadCell>
|
||||
<TableHeadCell>时长</TableHeadCell>
|
||||
<TableHeadCell>缓存</TableHeadCell>
|
||||
<TableHeadCell>操作</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each archives as archive}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell>{format_ts(archive.created_at)}</TableBodyCell>
|
||||
<TableBodyCell>{archive.title}</TableBodyCell>
|
||||
<TableBodyCell>{format_duration(archive.length)}</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<span>{format_size(archive.size)}</span>
|
||||
</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
on:click={() => {
|
||||
invoke("open_live", {
|
||||
roomId: archiveRoom.room_id,
|
||||
ts: archive.live_id,
|
||||
});
|
||||
}}>编辑切片</Button
|
||||
>
|
||||
<Button
|
||||
color="red"
|
||||
on:click={() => {
|
||||
invoke("delete_archive", {
|
||||
roomId: archiveRoom.room_id,
|
||||
ts: archive.live_id,
|
||||
}).then(async () => {
|
||||
archives = await invoke("get_archives", {
|
||||
roomId: archiveRoom.room_id,
|
||||
});
|
||||
});
|
||||
}}>移除</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||