Compare commits

...

40 Commits

Author SHA1 Message Date
Xinrea
0d053a3462 release: bump version to 1.2.0 2024-11-07 02:08:07 +08:00
Xinrea
280e540f4f fix(recorder): using "-" instead of "|" to separate offset and sequence
When using "|" in file name, ffmpeg concat is not able to work. So now
we change it to "-".
Here is an example for new format of segment filename:
18b7af7-89947993.m4s
2024-11-07 01:39:40 +08:00
Xinrea
824cfd23ed fix(recorder): using global offset to find clip segments range
The clip range drift problem is finally solved by calculate
date-time for every segment. With date-time, we can ignore
gaps in m3u8 segments. close #5
2024-11-07 01:16:20 +08:00
Xinrea
695728df2e feat(recorder): add DATE-TIME tag for segments 2024-11-06 20:04:49 +08:00
Xinrea
24deca75d2 fix(recorder_manager): handle cors preflight request for hls server 2024-11-06 20:04:13 +08:00
Xinrea
8a1184f161 fix(recorder): clip using accurate segment length 2024-11-06 12:04:02 +08:00
Xinrea
d61096d1b1 refactor(recorder): calculate segment length in entry-creation 2024-11-06 02:14:42 +08:00
Xinrea
3b9d1be002 fix: use accurate segment length to prevent video time drift 2024-11-04 20:00:38 +08:00
Xinrea
13262f8f10 feat: add history danmu replay (close #16) 2024-11-03 21:24:54 +08:00
Xinrea
9f05fc4954 release: bump version to 1.1.0 2024-10-30 21:32:49 +08:00
Xinrea
3fce06ef63 chore: code format 2024-10-30 21:30:40 +08:00
Xinrea
3d13f69e5c feat: log output to file 2024-10-30 21:28:43 +08:00
Xinrea
deb19c6223 feat: change clip encoding to copy for speed 2024-10-30 21:01:58 +08:00
Xinrea
7466127832 feat: add account using cookie str (close #19) 2024-10-30 20:55:35 +08:00
Xinrea
af982c5fe0 fix: project url on about page 2024-10-30 18:38:25 +08:00
Xinrea
b03f0150d8 release: bump version to 1.0.6 2024-10-26 12:43:07 +08:00
Xinrea
d61ddafb44 fix: remove window effects
Tauri currently does not provide an API to retrieve the active window effects, making it impossible to adjust the window style based on that information. Therefore, we have removed the window effects to maintain UI consistency.

close #18
2024-10-26 12:42:41 +08:00
Xinrea
fd89a197a5 release: bump version to 1.0.5 2024-10-25 21:09:36 +08:00
Xinrea
31fa29ee62 fix: shortkey description 2024-10-25 21:08:32 +08:00
Xinrea
c7e28b2ad6 doc: update readme 2024-10-25 21:06:20 +08:00
Xinrea
bbc1343079 chore: fix some clippy warning 2024-10-25 20:19:24 +08:00
Xinrea
c7d4fb270b feat: add text-shadow for danmaku 2024-10-25 01:03:34 +08:00
Xinrea
fcccdee105 release: bump version to 1.0.4 2024-10-24 01:04:36 +08:00
Xinrea
887072f6c7 chore: remove quick-clip for now 2024-10-24 01:04:02 +08:00
Xinrea
1932edba21 feat: remove live-window fullscreen button on windows 2024-10-24 01:01:43 +08:00
Xinrea
0c15415822 feat: delete related cache folder when removing recorder 2024-10-24 01:01:04 +08:00
Xinrea
b8dc0870b5 feat: adjust cover-text style 2024-10-22 02:00:24 +08:00
Xinrea
9d0ad2ae45 feat: hide danmaku when playback is not keeping up with live stream 2024-10-22 01:43:44 +08:00
Xinrea
7278b9f48c fix: remove unused overflow-button 2024-10-22 01:39:22 +08:00
Xinrea
1aee95492a fix: only enable danmaku in live 2024-10-22 01:34:48 +08:00
Xinrea
0cff889f4b release: bump version to 1.0.3 2024-10-21 04:58:03 +08:00
Xinrea
9cd05362ac chore: update dependency ffmpeg-sidecar to 1.2.0 2024-10-21 04:57:24 +08:00
Xinrea
269eccc7ef feat: add playback rate select 2024-10-21 03:43:38 +08:00
Xinrea
aafd02090b feat: live_window title using formatted timestamp to show date 2024-10-21 03:21:53 +08:00
Xinrea
e0e43dbfa4 fix: prevent danmaku overflow from showing scrollbar 2024-10-21 02:39:49 +08:00
Xinrea
37c358a48b fix: video preview modal background 2024-10-21 02:27:07 +08:00
Xinrea
2b81f7a106 release: bump version to 1.0.2 2024-10-20 22:44:39 +08:00
Xinrea
cb9b606cb4 feat: save profile when text changed 2024-10-20 22:43:47 +08:00
Xinrea
c1879c6527 fix: post button 2024-10-20 22:41:15 +08:00
Xinrea
035c54b2fd fix: using player.seekRange() to get video total length 2024-10-20 22:37:23 +08:00
31 changed files with 1361 additions and 668 deletions

View File

@@ -1,12 +1,64 @@
# Bilibili ShadowReplay
# BiliBili ShadowReplay
![icon](doc/header.png)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/xinrea/bili-shadowreplay/main.yml)
![GitHub Release](https://img.shields.io/github/v/release/xinrea/bili-shadowreplay)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xinrea/bili-shadowreplay/total)
BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
> [!NOTE]
> 由于软件在快速开发中,截图说明可能有变动,仅供参考
![rooms](doc/summary.png)
## 总览
![rooms](doc/summary.png)
显示直播缓存的占用以及缓存所在磁盘的使用情况。
## 直播间管理
![clip](doc/rooms.png)
显示当前缓存的直播间列表,在添加前需要在账号页面添加至少一个账号(主账号)用于直播流以及用户信息的获取。
操作菜单包含打开直播流、查看历史记录以及删除等操作。其中历史记录以列表形式展示,可以进行回放以及删除。
![archives](doc/archives.png)
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
## 消息管理
![messages](doc/messages.png)
执行的各种操作都会留下消息记录,方便查看过去进行的操作。
## 账号管理
![accounts](doc/accounts.png)
程序需要至少一个账号用于直播流以及用户信息的获取,可以在此页面添加账号。目前添加账号仅支持 B 站手机 App 扫码添加。
你可以添加多个账号,但只有一个账号会被标记为主账号,主账号用于直播流的获取。所有账号都可在切片投稿或是观看直播流发送弹幕时自由选择,详情见 [预览窗口](#预览窗口)。
## 预览窗口
![livewindow](doc/livewindow.png)
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
## 设置
![settings](doc/settings.png)
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
> [!WARNING]
> 程序仍在开发中, Rlease 中提供的下载版本为历史遗留版本, 不保证能够正常使用
![rooms](doc/rooms.png)
## 介绍
Bilibili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
![clip](doc/clip.png)
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。缓存切换开始和结束均会在消息管理中有记录。

BIN
doc/accounts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

BIN
doc/archives.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

BIN
doc/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
doc/livewindow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

BIN
doc/messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

BIN
doc/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

BIN
doc/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "bili-shadowreplay",
"private": true,
"version": "1.0.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",

35
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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)

View File

@@ -11,13 +11,15 @@ use db::{AccountRow, Database, MessageRow, RecordRow, VideoRow};
use recorder::bilibili::errors::BiliClientError;
use recorder::bilibili::profile::Profile;
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
use recorder::danmu::DanmuEntry;
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 +368,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 +390,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 +486,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 +540,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 {
@@ -592,6 +620,15 @@ async fn send_danmaku(
Ok(())
}
#[tauri::command]
async fn get_danmu_record(
state: tauri::State<'_, State>,
room_id: u64,
ts: u64,
) -> Result<Vec<DanmuEntry>, String> {
Ok(state.recorder_manager.get_danmu(room_id, ts).await?)
}
#[derive(serde::Serialize)]
struct AccountInfo {
pub primary_uid: u64,
@@ -707,12 +744,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 +858,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);
@@ -879,6 +918,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
get_disk_info,
send_danmaku,
update_notify,
get_danmu_record,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,9 +1,12 @@
pub mod bilibili;
pub mod danmu;
use async_std::{fs, stream::StreamExt};
use bilibili::{errors::BiliClientError, RoomInfo};
use bilibili::{BiliClient, UserInfo};
use chrono::prelude::*;
use custom_error::custom_error;
use danmu::{DanmuEntry, DanmuStorage};
use dashmap::DashMap;
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
use ffmpeg_sidecar::{
command::FfmpegCommand,
@@ -12,10 +15,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};
@@ -25,8 +28,9 @@ use crate::Config;
#[derive(Clone)]
pub struct TsEntry {
pub url: String,
pub offset: u64,
pub sequence: u64,
pub _length: f64,
pub length: f64,
pub size: u64,
}
@@ -55,6 +59,8 @@ pub struct BiliRecorder {
header: Arc<RwLock<Option<TsEntry>>>,
stream_type: Arc<RwLock<StreamType>>,
cache_size: Arc<RwLock<u64>>,
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
m3u8_cache: DashMap<u64, String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -133,6 +139,8 @@ impl BiliRecorder {
header: Arc::new(RwLock::new(None)),
stream_type: Arc::new(RwLock::new(stream_type)),
cache_size: Arc::new(RwLock::new(0)),
danmu_storage: Arc::new(RwLock::new(None)),
m3u8_cache: DashMap::new(),
};
log::info!("Recorder for room {} created.", room_id);
Ok(recorder)
@@ -144,6 +152,7 @@ impl BiliRecorder {
self.ts_entries.lock().await.clear();
*self.header.write().await = None;
*self.timestamp.write().await = 0;
*self.danmu_storage.write().await = None;
}
async fn check_status(&self) -> bool {
@@ -165,16 +174,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.
@@ -273,8 +291,20 @@ impl BiliRecorder {
while let Some(msg) = rx.recv().await {
if let WsStreamMessageType::DanmuMsg(msg) = msg {
self.app_handle
.emit(&format!("danmu:{}", room), msg.msg.clone())
.emit(
&format!("danmu:{}", room),
DanmuEntry {
ts: msg.timestamp,
content: msg.msg.clone(),
},
)
.unwrap();
if *self.live_status.read().await {
// save danmu
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
storage.add_line(msg.timestamp, &msg.msg).await;
}
}
}
}
Ok(())
@@ -362,7 +392,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 +419,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() {
@@ -398,11 +438,18 @@ impl BiliRecorder {
// make sure work_dir is created
fs::create_dir_all(&work_dir).await.unwrap();
}
// danmau file
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
self.danmu_storage
.write()
.await
.replace(DanmuStorage::new(&danmu_file_path).await);
let full_header_url = self.ts_url(&header_url).await?;
let mut header = TsEntry {
url: full_header_url.clone(),
offset: 0,
sequence: 0,
_length: 0.0,
length: 0.0,
size: 0,
};
let file_name = header_url.split('/').last().unwrap();
@@ -435,27 +482,51 @@ impl BiliRecorder {
sequence += 1;
continue;
}
let mut ts_entry = TsEntry {
url: ts.uri,
sequence,
_length: ts.duration as f64,
size: 0,
};
let client = self.client.clone();
let ts_url = self.ts_url(&ts_entry.url).await?;
ts_entry.url = ts_url.clone();
let mut offset_hex: String = "".into();
let mut seg_offset: u64 = 0;
for tag in ts.unknown_tags {
if tag.tag == "BILI-AUX" {
if let Some(rest) = tag.rest {
let parts: Vec<&str> = rest.split('|').collect();
if parts.len() == 0 {
continue;
}
offset_hex = parts.get(0).unwrap().to_string();
seg_offset = u64::from_str_radix(&offset_hex, 16).unwrap();
}
break;
}
}
let ts_url = self.ts_url(&ts.uri).await?;
if ts_url.is_empty() {
continue;
}
// encode segment offset into filename
let mut entries = self.ts_entries.lock().await;
let file_name =
format!("{}-{}", &offset_hex, ts_url.split('/').last().unwrap());
let mut ts_length = 1.0;
// calculate entry length using offset
// the default #EXTINF is 1.0, which is not accurate
if !entries.is_empty() {
ts_length = (seg_offset - entries.last().unwrap().offset) as f64 / 1000.0;
}
let ts_entry = TsEntry {
url: file_name.clone(),
offset: seg_offset,
sequence,
length: ts_length,
size: 0,
};
let client = self.client.clone();
let work_dir = work_dir.clone();
let cache_size_clone = self.cache_size.clone();
handles.push(tokio::task::spawn(async move {
let ts_url_clone = ts_url.clone();
let file_name = ts_url_clone.split('/').last().unwrap();
let file_name_clone = file_name.clone();
match client
.read()
.await
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name_clone))
.await
{
Ok(size) => {
@@ -466,7 +537,6 @@ impl BiliRecorder {
}
}
}));
let mut entries = self.ts_entries.lock().await;
entries.push(ts_entry);
*self.last_sequence.write().await = sequence;
let mut total_length = self.ts_length.write().await;
@@ -475,7 +545,7 @@ impl BiliRecorder {
}
join_all(handles).await.into_iter().for_each(|e| {
if let Err(e) = e {
log::error!("download ts failed: {:?}", e);
log::error!("Download ts failed: {:?}", e);
}
});
// currently we take every segement's length as 1.0s.
@@ -535,7 +605,7 @@ impl BiliRecorder {
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create archive clip for range [{}, {}]", x, y);
log::info!("Create archive clip for range [{}, {}]", x, y);
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
@@ -546,19 +616,20 @@ impl BiliRecorder {
file_list += &format!("{}/h{}.m4s", work_dir, ts);
file_list += "|";
// add body entries
let mut offset = 0.0;
// seconds to ms
let begin = (x * 1000.0) as u64;
let end = (y * 1000.0) as u64;
let offset = entries.first().unwrap().offset;
if !entries.is_empty() {
for e in entries {
if offset < x {
offset += 1.0;
if e.offset - offset < begin {
continue;
}
file_list += &format!("{}/{}", work_dir, e.url);
file_list += "|";
if offset > y {
if e.offset - offset > end {
break;
}
offset += 1.0;
}
}
@@ -572,7 +643,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())
@@ -594,29 +665,25 @@ impl BiliRecorder {
y: f64,
output_path: &str,
) -> Result<String, RecorderError> {
log::info!("create live clip for range [{}, {}]", x, y);
log::info!("Create live clip for range [{}, {}]", x, y);
let mut to_combine = Vec::new();
let header_copy = self.header.read().await.clone();
let entry_copy = self.ts_entries.lock().await.clone();
if entry_copy.is_empty() {
return Err(RecorderError::EmptyCache);
}
let mut start = x;
let mut end = y;
if start > end {
std::mem::swap(&mut start, &mut end);
}
let mut offset = 0.0;
let begin = (x * 1000.0) as u64;
let end = (y * 1000.0) as u64;
let offset = entry_copy.first().unwrap().offset;
// TODO using binary search
for e in entry_copy.iter() {
if (offset as f64) < start {
offset += 1.0;
if e.offset - offset < begin {
continue;
}
to_combine.push(e);
if (offset as f64) >= end {
if e.offset - offset > end {
break;
}
offset += 1.0;
}
if *self.stream_type.read().await == StreamType::FMP4 {
// add header to vec
@@ -629,7 +696,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 += "|";
@@ -643,10 +713,10 @@ impl BiliRecorder {
self.room_id,
title,
Utc::now().format("%m%d%H%M%S"),
end - start
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())
@@ -672,6 +742,9 @@ impl BiliRecorder {
}
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
if self.m3u8_cache.contains_key(&timestamp) {
return self.m3u8_cache.get(&timestamp).unwrap().clone();
}
let mut m3u8_content = "#EXTM3U\n".to_string();
m3u8_content += "#EXT-X-VERSION:6\n";
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
@@ -681,22 +754,35 @@ 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;
}
let mut last_sequence = entries.first().unwrap().sequence;
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
for e in entries {
let current_seq = e.sequence;
if current_seq - last_sequence > 1 {
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
last_sequence = current_seq;
m3u8_content += "#EXTINF:1,\n";
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
let ts = timestamp + e.offset / 1000;
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", e.length);
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
last_sequence = current_seq;
}
m3u8_content += "#EXT-X-ENDLIST";
// cache this
self.m3u8_cache.insert(timestamp, m3u8_content.clone());
m3u8_content
}
@@ -721,18 +807,54 @@ impl BiliRecorder {
if !etype.is_file() {
continue;
}
if let Some(file_ext) = e.path().extension() {
let file_ext = file_ext.to_str().unwrap().to_string();
// need to exclude other files, such as danmu file
if file_ext != "m4s" {
continue;
}
} else {
continue;
}
let file_name = e.file_name().to_str().unwrap().to_string();
if file_name.starts_with("h") {
continue;
}
let meta_info: &str = file_name.split('.').next().unwrap();
let infos: Vec<&str> = meta_info.split('-').collect();
let offset: u64;
let sequence: u64;
// BREAKCHANGE do not support legacy files that not named with offset
if infos.len() == 1 {
continue;
} else {
if let Ok(parsed_offset) = u64::from_str_radix(infos.get(0).unwrap(), 16) {
offset = parsed_offset;
} else {
continue;
}
sequence = infos.get(1).unwrap().parse().unwrap();
}
ret.push(TsEntry {
url: file_name.clone(),
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
_length: 1.0,
offset,
sequence,
length: 1.0,
size: e.metadata().await.unwrap().len(),
});
}
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
if ret.is_empty() {
return ret;
}
let mut last_offset = ret.first().unwrap().offset;
for (i, entry) in ret.iter_mut().enumerate() {
if i == 0 {
continue;
}
entry.length = (entry.offset - last_offset) as f64 / 1000.0;
last_offset = entry.offset;
}
ret
}
@@ -757,16 +879,23 @@ impl BiliRecorder {
}
let entries = self.ts_entries.lock().await.clone();
if entries.is_empty() {
m3u8_content += "#EXT-X-OFFSET:0\n";
return m3u8_content;
}
let timestamp = *self.timestamp.read().await;
let mut last_sequence = entries.first().unwrap().sequence;
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
for entry in entries.iter() {
if entry.sequence - last_sequence > 1 {
// discontinuity happens
m3u8_content += "#EXT-X-DISCONTINUITY\n"
}
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
let ts = timestamp + entry.offset / 1000;
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
m3u8_content += &format!("#EXTINF:{:.2},\n", entry.length,);
last_sequence = entry.sequence;
m3u8_content += "#EXTINF:1,\n";
let file_name = entry.url.split('/').last().unwrap();
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
m3u8_content += &format!("{}\n", local_url);
@@ -777,4 +906,30 @@ impl BiliRecorder {
}
m3u8_content
}
pub async fn get_danmu_record(&self, ts: u64) -> Vec<DanmuEntry> {
if ts == *self.timestamp.read().await {
// just return current cache content
match self.danmu_storage.read().await.as_ref() {
Some(storage) => {
return storage.get_entries().await;
}
None => {
return Vec::new();
}
}
} else {
// load disk cache
let cache_file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache,
self.room_id,
ts,
"danmu.txt"
);
log::info!("loading danmu cache from {}", cache_file_path);
let storage = DanmuStorage::new(&cache_file_path).await;
return storage.get_entries().await;
}
}
}

View File

@@ -0,0 +1,66 @@
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use tokio::{
fs::{File, OpenOptions},
io::{AsyncBufReadExt, BufReader},
sync::RwLock,
};
#[derive(Clone, Serialize)]
pub struct DanmuEntry {
pub ts: u64,
pub content: String,
}
pub struct DanmuStorage {
cache: RwLock<Vec<DanmuEntry>>,
file: RwLock<File>,
}
impl DanmuStorage {
pub async fn new(file_path: &str) -> DanmuStorage {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(file_path)
.await
.expect("create danmu.txt failed");
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut preload_cache: Vec<DanmuEntry> = Vec::new();
while let Ok(Some(line)) = lines.next_line().await {
let parts: Vec<&str> = line.split(':').collect();
let ts: u64 = parts[0].parse().unwrap();
let content = parts[1].to_string();
preload_cache.push(DanmuEntry { ts, content })
}
let file = OpenOptions::new()
.append(true)
.create(true)
.open(file_path)
.await
.expect("create danmu.txt failed");
return DanmuStorage {
cache: RwLock::new(preload_cache),
file: RwLock::new(file),
};
}
pub async fn add_line(&self, ts: u64, content: &str) {
self.cache.write().await.push(DanmuEntry {
ts,
content: content.to_string(),
});
let _ = self
.file
.write()
.await
.write(format!("{}:{}\n", ts, content).as_bytes())
.await;
}
pub async fn get_entries(&self) -> Vec<DanmuEntry> {
self.cache.read().await.clone()
}
}

View File

@@ -1,10 +1,12 @@
use crate::db::{AccountRow, Database, RecordRow};
use crate::recorder::bilibili::UserInfo;
use crate::recorder::danmu::DanmuEntry;
use crate::recorder::RecorderError;
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
use crate::Config;
use custom_error::custom_error;
use dashmap::DashMap;
use hyper::Method;
use hyper::{
service::{make_service_fn, service_fn},
Body, Request, Response, Server,
@@ -70,7 +72,6 @@ impl From<RecorderManagerError> for String {
}
impl RecorderManager {
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
RecorderManager {
app_handle,
@@ -122,6 +123,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(())
}
@@ -224,6 +228,18 @@ impl RecorderManager {
}
}
pub async fn get_danmu(
&self,
room_id: u64,
live_id: u64,
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
if let Some(recorder) = self.recorders.get(&room_id) {
Ok(recorder.get_danmu_record(live_id).await)
} else {
Err(RecorderManagerError::NotFound { room_id })
}
}
async fn start_hls_server(
&self,
listener: TcpListener,
@@ -238,6 +254,18 @@ impl RecorderManager {
let recorders = recorders.clone();
let config = config.clone();
async move {
// handle cors preflight request
if req.method() == Method::OPTIONS {
return Ok::<_, Infallible>(
Response::builder()
.status(200)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body(Body::empty())
.unwrap(),
);
}
let cache_path = config.read().await.cache.clone();
let path = req.uri().path();
let path_segs: Vec<&str> = path.split('/').collect();
@@ -280,7 +308,7 @@ impl RecorderManager {
} else {
// try to find requested ts file in recorder's cache
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
let ts_file = format!("{}/{}", cache_path, path);
let ts_file = format!("{}/{}", cache_path, path.replace("%7C", "|"));
let recorder = recorders.get(&room_id);
if recorder.is_none() {
return Ok::<_, Infallible>(

View File

@@ -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"
}
]
}

View File

@@ -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>

View File

@@ -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:

View File

@@ -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>

View File

@@ -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>

View File

@@ -3,12 +3,38 @@
import { listen } from "@tauri-apps/api/event";
import type { AccountInfo, AccountItem } from "./db";
interface DanmuEntry {
ts: number;
content: string;
}
export let port;
export let room_id;
export let ts;
export let start = 0;
export let end = 0;
let show_detail = false;
let global_offset = 0;
// TODO get custom tag from shaka player instead of manual parsing
async function meta_parse() {
fetch(`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`)
.then((response) => response.text())
.then((m3u8Content) => {
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
const match = m3u8Content.match(offsetRegex);
if (match && match[1]) {
global_offset = parseInt(match[1], 10);
} else {
console.warn("No #EXT-X-OFFSET found");
}
})
.catch((error) => {
console.error("Error fetching M3U8 file:", error);
});
}
async function init() {
const video = document.getElementById("video") as HTMLVideoElement;
const ui = video["ui"];
@@ -26,6 +52,14 @@
// Attach player and UI to the window to make it easy to access in the JS console.
(window as any).player = player;
(window as any).ui = ui;
player.addEventListener("ended", async () => {
location.reload();
});
player.addEventListener("manifestloaded", (event) => {
console.log("Manifest loaded:", event);
});
try {
await player.load(
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
@@ -39,15 +73,9 @@
location.reload();
}
}
player.addEventListener("ended", async () => {
location.reload();
});
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",
@@ -66,55 +94,104 @@
`;
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 = "";
}
}
});
// add to shaka-spacer
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
let danmu_enabled = true;
// get danmaku record
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
roomId: room_id,
ts: ts,
})) as DanmuEntry[];
console.log("danmu loaded:", danmu_records.length);
// history danmaku sender
setInterval(() => {
if (video.paused) {
return;
}
if (danmu_records.length == 0) {
return;
}
// using live source
if (isLive() && get_total() - video.currentTime <= 5) {
return;
}
const cur = Math.floor(
(video.currentTime + global_offset / 1000 + ts) * 1000,
);
console.log(new Date(cur).toString());
let danmus = danmu_records.filter((v) => {
return v.ts >= cur - 1000 && v.ts < cur;
});
danmus.forEach((v) => danmu_handler(v.content));
}, 1000);
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 = "";
}
}
});
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
// listen to danmaku event
listen("danmu:" + room_id, (event: { payload: DanmuEntry }) => {
// add into records
danmu_records.push(event.payload);
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
return;
}
danmu_handler(event.payload.content);
});
}
// create a danmaku toggle button
const danmakuToggle = document.createElement("button");
danmakuToggle.innerText = "弹幕已开启";
@@ -134,15 +211,6 @@
: "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);
// 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%";
@@ -151,7 +219,7 @@
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "40";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
@@ -162,12 +230,7 @@
// 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;
}
function danmu_handler(content: string) {
const danmaku = document.createElement("p");
danmaku.style.position = "absolute";
@@ -199,7 +262,8 @@
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.innerText = event.payload;
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = content;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
@@ -207,22 +271,47 @@
danmaku.addEventListener("transitionend", () => {
overlay.removeChild(danmaku);
});
}
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";
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 +324,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;
}
@@ -331,6 +400,9 @@
}
requestAnimationFrame(updateSeekbar);
}
meta_parse();
// receive tauri emit
document.addEventListener("shaka-ui-loaded", init);
@@ -361,8 +433,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>

View File

@@ -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>