Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d053a3462 | ||
|
|
280e540f4f | ||
|
|
824cfd23ed | ||
|
|
695728df2e | ||
|
|
24deca75d2 | ||
|
|
8a1184f161 | ||
|
|
d61096d1b1 | ||
|
|
3b9d1be002 | ||
|
|
13262f8f10 | ||
|
|
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 |
72
README.md
@@ -1,12 +1,64 @@
|
|||||||
# Bilibili ShadowReplay
|
# BiliBili ShadowReplay
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

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

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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",
|
"name": "bili-shadowreplay",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.2",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
35
src-tauri/Cargo.lock
generated
@@ -1440,11 +1440,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ffmpeg-sidecar"
|
name = "ffmpeg-sidecar"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bec830aba6cd3da7621c58e8f06727e3b750e8383bcd934d789f2b9c3c4ea595"
|
checksum = "d67d09bdb90406a420b30ba06d464a976c9642081c2ecdf09e35ec80bd7eb9b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"reqwest 0.12.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2293,6 +2294,22 @@ dependencies = [
|
|||||||
"tokio-native-tls",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
@@ -4019,7 +4036,7 @@ dependencies = [
|
|||||||
"http 0.2.9",
|
"http 0.2.9",
|
||||||
"http-body 0.4.5",
|
"http-body 0.4.5",
|
||||||
"hyper 0.14.25",
|
"hyper 0.14.25",
|
||||||
"hyper-tls",
|
"hyper-tls 0.5.0",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
@@ -4043,15 +4060,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.7"
|
version = "0.12.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
|
checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.4.6",
|
"h2 0.4.6",
|
||||||
@@ -4060,11 +4078,13 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.4.1",
|
"hyper 1.4.1",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls 0.6.0",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -4078,6 +4098,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -5161,7 +5182,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.12.7",
|
"reqwest 0.12.8",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -5311,7 +5332,7 @@ dependencies = [
|
|||||||
"data-url",
|
"data-url",
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.7",
|
"reqwest 0.12.8",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ sysinfo = "0.32.0"
|
|||||||
m3u8-rs = "5.0.3"
|
m3u8-rs = "5.0.3"
|
||||||
async-std = "1.12.0"
|
async-std = "1.12.0"
|
||||||
futures = "0.3.28"
|
futures = "0.3.28"
|
||||||
ffmpeg-sidecar = "1.1"
|
ffmpeg-sidecar = "1.2.0"
|
||||||
chrono = { version = "0.4.24", features = ["serde"] }
|
chrono = { version = "0.4.24", features = ["serde"] }
|
||||||
toml = "0.7.3"
|
toml = "0.7.3"
|
||||||
custom_error = "1.9.2"
|
custom_error = "1.9.2"
|
||||||
|
|||||||
@@ -5004,6 +5004,171 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "http:deny-fetch-send"
|
"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",
|
"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",
|
"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);
|
// 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)]
|
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||||
pub struct VideoRow {
|
pub struct VideoRow {
|
||||||
@@ -351,21 +350,34 @@ pub struct VideoRow {
|
|||||||
impl Database {
|
impl Database {
|
||||||
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
|
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
|
||||||
let lock = self.db.read().await.clone().unwrap();
|
let lock = self.db.read().await.clone().unwrap();
|
||||||
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
|
Ok(
|
||||||
|
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
|
||||||
.bind(room_id as i64)
|
.bind(room_id as i64)
|
||||||
.fetch_all(&lock)
|
.fetch_all(&lock)
|
||||||
.await?)
|
.await?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
|
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
|
||||||
let lock = self.db.read().await.clone().unwrap();
|
let lock = self.db.read().await.clone().unwrap();
|
||||||
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
|
Ok(
|
||||||
|
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&lock)
|
.fetch_one(&lock)
|
||||||
.await?)
|
.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();
|
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")
|
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
|
||||||
.bind(status)
|
.bind(status)
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ use db::{AccountRow, Database, MessageRow, RecordRow, VideoRow};
|
|||||||
use recorder::bilibili::errors::BiliClientError;
|
use recorder::bilibili::errors::BiliClientError;
|
||||||
use recorder::bilibili::profile::Profile;
|
use recorder::bilibili::profile::Profile;
|
||||||
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
|
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
|
||||||
|
use recorder::danmu::DanmuEntry;
|
||||||
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
|
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use std::fs::File;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::utils::config::WindowEffectsConfig;
|
use tauri::utils::config::WindowEffectsConfig;
|
||||||
use tauri::{Manager, Theme, WindowEvent};
|
use tauri::{Manager, Theme, WindowEvent};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
use tokio::sync::RwLock;
|
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));
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
// Copy old cache to new cache
|
// Copy old cache to new cache
|
||||||
log::info!("Start 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) {
|
if let Err(e) = copy_dir_all(&old_cache_path, &cache_path) {
|
||||||
log::error!("Copy old cache to new cache error: {}", e);
|
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]
|
#[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_start_notify = live_start_notify;
|
||||||
state.config.write().await.live_end_notify = live_end_notify;
|
state.config.write().await.live_end_notify = live_end_notify;
|
||||||
state.config.write().await.clip_notify = clip_notify;
|
state.config.write().await.clip_notify = clip_notify;
|
||||||
@@ -472,7 +486,14 @@ async fn clip_range(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if state.config.read().await.clip_notify {
|
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)
|
Ok(video)
|
||||||
}
|
}
|
||||||
@@ -519,7 +540,14 @@ async fn upload_procedure(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if state.config.read().await.post_notify {
|
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)
|
Ok(ret.bvid)
|
||||||
} else {
|
} else {
|
||||||
@@ -592,6 +620,15 @@ async fn send_danmaku(
|
|||||||
Ok(())
|
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)]
|
#[derive(serde::Serialize)]
|
||||||
struct AccountInfo {
|
struct AccountInfo {
|
||||||
pub primary_uid: u64,
|
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>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Setup log
|
// Setup log
|
||||||
simplelog::CombinedLogger::init(vec![simplelog::TermLogger::new(
|
simplelog::CombinedLogger::init(vec![
|
||||||
|
simplelog::TermLogger::new(
|
||||||
simplelog::LevelFilter::Info,
|
simplelog::LevelFilter::Info,
|
||||||
simplelog::Config::default(),
|
simplelog::Config::default(),
|
||||||
simplelog::TerminalMode::Mixed,
|
simplelog::TerminalMode::Mixed,
|
||||||
simplelog::ColorChoice::Auto,
|
simplelog::ColorChoice::Auto,
|
||||||
)])
|
),
|
||||||
|
simplelog::WriteLogger::new(
|
||||||
|
simplelog::LevelFilter::Info,
|
||||||
|
simplelog::Config::default(),
|
||||||
|
File::create("bsr.log").unwrap(),
|
||||||
|
),
|
||||||
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Setup ffmpeg
|
// Setup ffmpeg
|
||||||
@@ -814,12 +858,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Ok(account) = account {
|
if let Ok(account) = account {
|
||||||
for room in initial_rooms {
|
for room in initial_rooms {
|
||||||
if let Err(e) = recorder_manager_clone
|
if let Err(e) = recorder_manager_clone
|
||||||
.add_recorder(
|
.add_recorder(&webid, &db_clone, &account, room.room_id)
|
||||||
&webid,
|
|
||||||
&db_clone,
|
|
||||||
&account,
|
|
||||||
room.room_id,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::error!("error when adding initial rooms: {}", e);
|
log::error!("error when adding initial rooms: {}", e);
|
||||||
@@ -879,6 +918,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
get_disk_info,
|
get_disk_info,
|
||||||
send_danmaku,
|
send_danmaku,
|
||||||
update_notify,
|
update_notify,
|
||||||
|
get_danmu_record,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
pub mod bilibili;
|
pub mod bilibili;
|
||||||
|
pub mod danmu;
|
||||||
use async_std::{fs, stream::StreamExt};
|
use async_std::{fs, stream::StreamExt};
|
||||||
use bilibili::{errors::BiliClientError, RoomInfo};
|
use bilibili::{errors::BiliClientError, RoomInfo};
|
||||||
use bilibili::{BiliClient, UserInfo};
|
use bilibili::{BiliClient, UserInfo};
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use custom_error::custom_error;
|
use custom_error::custom_error;
|
||||||
|
use danmu::{DanmuEntry, DanmuStorage};
|
||||||
|
use dashmap::DashMap;
|
||||||
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
|
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
|
||||||
use ffmpeg_sidecar::{
|
use ffmpeg_sidecar::{
|
||||||
command::FfmpegCommand,
|
command::FfmpegCommand,
|
||||||
@@ -12,10 +15,10 @@ use ffmpeg_sidecar::{
|
|||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use m3u8_rs::Playlist;
|
use m3u8_rs::Playlist;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use tauri_plugin_notification::NotificationExt;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
@@ -25,8 +28,9 @@ use crate::Config;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TsEntry {
|
pub struct TsEntry {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
pub offset: u64,
|
||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
pub _length: f64,
|
pub length: f64,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +59,8 @@ pub struct BiliRecorder {
|
|||||||
header: Arc<RwLock<Option<TsEntry>>>,
|
header: Arc<RwLock<Option<TsEntry>>>,
|
||||||
stream_type: Arc<RwLock<StreamType>>,
|
stream_type: Arc<RwLock<StreamType>>,
|
||||||
cache_size: Arc<RwLock<u64>>,
|
cache_size: Arc<RwLock<u64>>,
|
||||||
|
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
|
||||||
|
m3u8_cache: DashMap<u64, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
@@ -133,6 +139,8 @@ impl BiliRecorder {
|
|||||||
header: Arc::new(RwLock::new(None)),
|
header: Arc::new(RwLock::new(None)),
|
||||||
stream_type: Arc::new(RwLock::new(stream_type)),
|
stream_type: Arc::new(RwLock::new(stream_type)),
|
||||||
cache_size: Arc::new(RwLock::new(0)),
|
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);
|
log::info!("Recorder for room {} created.", room_id);
|
||||||
Ok(recorder)
|
Ok(recorder)
|
||||||
@@ -144,6 +152,7 @@ impl BiliRecorder {
|
|||||||
self.ts_entries.lock().await.clear();
|
self.ts_entries.lock().await.clear();
|
||||||
*self.header.write().await = None;
|
*self.header.write().await = None;
|
||||||
*self.timestamp.write().await = 0;
|
*self.timestamp.write().await = 0;
|
||||||
|
*self.danmu_storage.write().await = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_status(&self) -> bool {
|
async fn check_status(&self) -> bool {
|
||||||
@@ -165,16 +174,25 @@ impl BiliRecorder {
|
|||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.title("BiliShadowReplay - 直播开始")
|
.title("BiliShadowReplay - 直播开始")
|
||||||
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
|
.body(format!(
|
||||||
|
"{} 开启了直播:{}",
|
||||||
|
self.user_info.read().await.user_name,
|
||||||
|
room_info.room_title
|
||||||
|
))
|
||||||
|
.show()
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
} else {
|
} else if self.config.read().await.live_end_notify {
|
||||||
if self.config.read().await.live_end_notify {
|
|
||||||
self.app_handle
|
self.app_handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.title("BiliShadowReplay - 直播结束")
|
.title("BiliShadowReplay - 直播结束")
|
||||||
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
|
.body(format!(
|
||||||
}
|
"{} 的直播结束了",
|
||||||
|
self.user_info.read().await.user_name
|
||||||
|
))
|
||||||
|
.show()
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if stream is confirmed to be closed, live stream cache is cleaned.
|
// 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 {
|
while let Some(msg) = rx.recv().await {
|
||||||
if let WsStreamMessageType::DanmuMsg(msg) = msg {
|
if let WsStreamMessageType::DanmuMsg(msg) = msg {
|
||||||
self.app_handle
|
self.app_handle
|
||||||
.emit(&format!("danmu:{}", room), msg.msg.clone())
|
.emit(
|
||||||
|
&format!("danmu:{}", room),
|
||||||
|
DanmuEntry {
|
||||||
|
ts: msg.timestamp,
|
||||||
|
content: msg.msg.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.unwrap();
|
.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(())
|
Ok(())
|
||||||
@@ -362,7 +392,12 @@ impl BiliRecorder {
|
|||||||
async fn update_entries(&self) -> Result<(), RecorderError> {
|
async fn update_entries(&self) -> Result<(), RecorderError> {
|
||||||
let parsed = self.get_playlist().await;
|
let parsed = self.get_playlist().await;
|
||||||
let mut timestamp = *self.timestamp.read().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
|
// Check header if None
|
||||||
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
|
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
|
||||||
{
|
{
|
||||||
@@ -384,7 +419,12 @@ impl BiliRecorder {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// now work dir is confirmed
|
// 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 folder is exisited, need to load previous data into cache
|
||||||
if let Ok(meta) = fs::metadata(&work_dir).await {
|
if let Ok(meta) = fs::metadata(&work_dir).await {
|
||||||
if meta.is_dir() {
|
if meta.is_dir() {
|
||||||
@@ -398,11 +438,18 @@ impl BiliRecorder {
|
|||||||
// make sure work_dir is created
|
// make sure work_dir is created
|
||||||
fs::create_dir_all(&work_dir).await.unwrap();
|
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 full_header_url = self.ts_url(&header_url).await?;
|
||||||
let mut header = TsEntry {
|
let mut header = TsEntry {
|
||||||
url: full_header_url.clone(),
|
url: full_header_url.clone(),
|
||||||
|
offset: 0,
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
_length: 0.0,
|
length: 0.0,
|
||||||
size: 0,
|
size: 0,
|
||||||
};
|
};
|
||||||
let file_name = header_url.split('/').last().unwrap();
|
let file_name = header_url.split('/').last().unwrap();
|
||||||
@@ -435,27 +482,51 @@ impl BiliRecorder {
|
|||||||
sequence += 1;
|
sequence += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut ts_entry = TsEntry {
|
let mut offset_hex: String = "".into();
|
||||||
url: ts.uri,
|
let mut seg_offset: u64 = 0;
|
||||||
sequence,
|
for tag in ts.unknown_tags {
|
||||||
_length: ts.duration as f64,
|
if tag.tag == "BILI-AUX" {
|
||||||
size: 0,
|
if let Some(rest) = tag.rest {
|
||||||
};
|
let parts: Vec<&str> = rest.split('|').collect();
|
||||||
let client = self.client.clone();
|
if parts.len() == 0 {
|
||||||
let ts_url = self.ts_url(&ts_entry.url).await?;
|
continue;
|
||||||
ts_entry.url = ts_url.clone();
|
}
|
||||||
|
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() {
|
if ts_url.is_empty() {
|
||||||
continue;
|
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 work_dir = work_dir.clone();
|
||||||
let cache_size_clone = self.cache_size.clone();
|
let cache_size_clone = self.cache_size.clone();
|
||||||
handles.push(tokio::task::spawn(async move {
|
handles.push(tokio::task::spawn(async move {
|
||||||
let ts_url_clone = ts_url.clone();
|
let file_name_clone = file_name.clone();
|
||||||
let file_name = ts_url_clone.split('/').last().unwrap();
|
|
||||||
match client
|
match client
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
|
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name_clone))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(size) => {
|
Ok(size) => {
|
||||||
@@ -466,7 +537,6 @@ impl BiliRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
let mut entries = self.ts_entries.lock().await;
|
|
||||||
entries.push(ts_entry);
|
entries.push(ts_entry);
|
||||||
*self.last_sequence.write().await = sequence;
|
*self.last_sequence.write().await = sequence;
|
||||||
let mut total_length = self.ts_length.write().await;
|
let mut total_length = self.ts_length.write().await;
|
||||||
@@ -475,7 +545,7 @@ impl BiliRecorder {
|
|||||||
}
|
}
|
||||||
join_all(handles).await.into_iter().for_each(|e| {
|
join_all(handles).await.into_iter().for_each(|e| {
|
||||||
if let Err(e) = 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.
|
// currently we take every segement's length as 1.0s.
|
||||||
@@ -535,7 +605,7 @@ impl BiliRecorder {
|
|||||||
y: f64,
|
y: f64,
|
||||||
output_path: &str,
|
output_path: &str,
|
||||||
) -> Result<String, RecorderError> {
|
) -> 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 work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
|
||||||
let entries = self.get_fs_entries(&work_dir).await;
|
let entries = self.get_fs_entries(&work_dir).await;
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
@@ -546,19 +616,20 @@ impl BiliRecorder {
|
|||||||
file_list += &format!("{}/h{}.m4s", work_dir, ts);
|
file_list += &format!("{}/h{}.m4s", work_dir, ts);
|
||||||
file_list += "|";
|
file_list += "|";
|
||||||
// add body entries
|
// 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() {
|
if !entries.is_empty() {
|
||||||
for e in entries {
|
for e in entries {
|
||||||
if offset < x {
|
if e.offset - offset < begin {
|
||||||
offset += 1.0;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
file_list += &format!("{}/{}", work_dir, e.url);
|
file_list += &format!("{}/{}", work_dir, e.url);
|
||||||
file_list += "|";
|
file_list += "|";
|
||||||
if offset > y {
|
if e.offset - offset > end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offset += 1.0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +643,7 @@ impl BiliRecorder {
|
|||||||
y - x
|
y - x
|
||||||
);
|
);
|
||||||
log::info!("{}", file_name);
|
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()
|
FfmpegCommand::new()
|
||||||
.args(args.split(' '))
|
.args(args.split(' '))
|
||||||
.output(file_name.clone())
|
.output(file_name.clone())
|
||||||
@@ -594,29 +665,25 @@ impl BiliRecorder {
|
|||||||
y: f64,
|
y: f64,
|
||||||
output_path: &str,
|
output_path: &str,
|
||||||
) -> Result<String, RecorderError> {
|
) -> 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 mut to_combine = Vec::new();
|
||||||
let header_copy = self.header.read().await.clone();
|
let header_copy = self.header.read().await.clone();
|
||||||
let entry_copy = self.ts_entries.lock().await.clone();
|
let entry_copy = self.ts_entries.lock().await.clone();
|
||||||
if entry_copy.is_empty() {
|
if entry_copy.is_empty() {
|
||||||
return Err(RecorderError::EmptyCache);
|
return Err(RecorderError::EmptyCache);
|
||||||
}
|
}
|
||||||
let mut start = x;
|
let begin = (x * 1000.0) as u64;
|
||||||
let mut end = y;
|
let end = (y * 1000.0) as u64;
|
||||||
if start > end {
|
let offset = entry_copy.first().unwrap().offset;
|
||||||
std::mem::swap(&mut start, &mut end);
|
// TODO using binary search
|
||||||
}
|
|
||||||
let mut offset = 0.0;
|
|
||||||
for e in entry_copy.iter() {
|
for e in entry_copy.iter() {
|
||||||
if (offset as f64) < start {
|
if e.offset - offset < begin {
|
||||||
offset += 1.0;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
to_combine.push(e);
|
to_combine.push(e);
|
||||||
if (offset as f64) >= end {
|
if e.offset - offset > end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offset += 1.0;
|
|
||||||
}
|
}
|
||||||
if *self.stream_type.read().await == StreamType::FMP4 {
|
if *self.stream_type.read().await == StreamType::FMP4 {
|
||||||
// add header to vec
|
// add header to vec
|
||||||
@@ -629,7 +696,10 @@ impl BiliRecorder {
|
|||||||
let file_name = e.url.split('/').last().unwrap();
|
let file_name = e.url.split('/').last().unwrap();
|
||||||
let file_path = format!(
|
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 += &file_path;
|
||||||
file_list += "|";
|
file_list += "|";
|
||||||
@@ -643,10 +713,10 @@ impl BiliRecorder {
|
|||||||
self.room_id,
|
self.room_id,
|
||||||
title,
|
title,
|
||||||
Utc::now().format("%m%d%H%M%S"),
|
Utc::now().format("%m%d%H%M%S"),
|
||||||
end - start
|
y - x
|
||||||
);
|
);
|
||||||
log::info!("{}", file_name);
|
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()
|
FfmpegCommand::new()
|
||||||
.args(args.split(' '))
|
.args(args.split(' '))
|
||||||
.output(file_name.clone())
|
.output(file_name.clone())
|
||||||
@@ -672,6 +742,9 @@ impl BiliRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
|
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
|
||||||
|
if self.m3u8_cache.contains_key(×tamp) {
|
||||||
|
return self.m3u8_cache.get(×tamp).unwrap().clone();
|
||||||
|
}
|
||||||
let mut m3u8_content = "#EXTM3U\n".to_string();
|
let mut m3u8_content = "#EXTM3U\n".to_string();
|
||||||
m3u8_content += "#EXT-X-VERSION:6\n";
|
m3u8_content += "#EXT-X-VERSION:6\n";
|
||||||
m3u8_content += "#EXT-X-TARGETDURATION:1\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);
|
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
|
||||||
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
|
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
|
||||||
// add entries from read_dir
|
// 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;
|
let entries = self.get_fs_entries(&work_dir).await;
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return m3u8_content;
|
return m3u8_content;
|
||||||
}
|
}
|
||||||
let mut last_sequence = entries.first().unwrap().sequence;
|
let mut last_sequence = entries.first().unwrap().sequence;
|
||||||
|
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let current_seq = e.sequence;
|
let current_seq = e.sequence;
|
||||||
if current_seq - last_sequence > 1 {
|
if current_seq - last_sequence > 1 {
|
||||||
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
||||||
}
|
}
|
||||||
last_sequence = current_seq;
|
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
|
||||||
m3u8_content += "#EXTINF:1,\n";
|
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);
|
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
|
||||||
|
|
||||||
|
last_sequence = current_seq;
|
||||||
}
|
}
|
||||||
m3u8_content += "#EXT-X-ENDLIST";
|
m3u8_content += "#EXT-X-ENDLIST";
|
||||||
|
// cache this
|
||||||
|
self.m3u8_cache.insert(timestamp, m3u8_content.clone());
|
||||||
m3u8_content
|
m3u8_content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,18 +807,54 @@ impl BiliRecorder {
|
|||||||
if !etype.is_file() {
|
if !etype.is_file() {
|
||||||
continue;
|
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();
|
let file_name = e.file_name().to_str().unwrap().to_string();
|
||||||
if file_name.starts_with("h") {
|
if file_name.starts_with("h") {
|
||||||
continue;
|
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 {
|
ret.push(TsEntry {
|
||||||
url: file_name.clone(),
|
url: file_name.clone(),
|
||||||
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
|
offset,
|
||||||
_length: 1.0,
|
sequence,
|
||||||
|
length: 1.0,
|
||||||
size: e.metadata().await.unwrap().len(),
|
size: e.metadata().await.unwrap().len(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
|
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
|
ret
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,16 +879,23 @@ impl BiliRecorder {
|
|||||||
}
|
}
|
||||||
let entries = self.ts_entries.lock().await.clone();
|
let entries = self.ts_entries.lock().await.clone();
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
|
m3u8_content += "#EXT-X-OFFSET:0\n";
|
||||||
return m3u8_content;
|
return m3u8_content;
|
||||||
}
|
}
|
||||||
|
let timestamp = *self.timestamp.read().await;
|
||||||
let mut last_sequence = entries.first().unwrap().sequence;
|
let mut last_sequence = entries.first().unwrap().sequence;
|
||||||
|
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
|
||||||
for entry in entries.iter() {
|
for entry in entries.iter() {
|
||||||
if entry.sequence - last_sequence > 1 {
|
if entry.sequence - last_sequence > 1 {
|
||||||
// discontinuity happens
|
// discontinuity happens
|
||||||
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
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;
|
last_sequence = entry.sequence;
|
||||||
m3u8_content += "#EXTINF:1,\n";
|
|
||||||
let file_name = entry.url.split('/').last().unwrap();
|
let file_name = entry.url.split('/').last().unwrap();
|
||||||
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
|
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
|
||||||
m3u8_content += &format!("{}\n", local_url);
|
m3u8_content += &format!("{}\n", local_url);
|
||||||
@@ -777,4 +906,30 @@ impl BiliRecorder {
|
|||||||
}
|
}
|
||||||
m3u8_content
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
src-tauri/src/recorder/danmu.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
use crate::db::{AccountRow, Database, RecordRow};
|
use crate::db::{AccountRow, Database, RecordRow};
|
||||||
use crate::recorder::bilibili::UserInfo;
|
use crate::recorder::bilibili::UserInfo;
|
||||||
|
use crate::recorder::danmu::DanmuEntry;
|
||||||
use crate::recorder::RecorderError;
|
use crate::recorder::RecorderError;
|
||||||
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
|
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
use custom_error::custom_error;
|
use custom_error::custom_error;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
use hyper::Method;
|
||||||
use hyper::{
|
use hyper::{
|
||||||
service::{make_service_fn, service_fn},
|
service::{make_service_fn, service_fn},
|
||||||
Body, Request, Response, Server,
|
Body, Request, Response, Server,
|
||||||
@@ -70,7 +72,6 @@ impl From<RecorderManagerError> for String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RecorderManager {
|
impl RecorderManager {
|
||||||
|
|
||||||
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
||||||
RecorderManager {
|
RecorderManager {
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -122,6 +123,9 @@ impl RecorderManager {
|
|||||||
if recorder.is_none() {
|
if recorder.is_none() {
|
||||||
return Err(RecorderManagerError::NotFound { room_id });
|
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(())
|
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(
|
async fn start_hls_server(
|
||||||
&self,
|
&self,
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
@@ -238,6 +254,18 @@ impl RecorderManager {
|
|||||||
let recorders = recorders.clone();
|
let recorders = recorders.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
async move {
|
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 cache_path = config.read().await.cache.clone();
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
let path_segs: Vec<&str> = path.split('/').collect();
|
let path_segs: Vec<&str> = path.split('/').collect();
|
||||||
@@ -280,7 +308,7 @@ impl RecorderManager {
|
|||||||
} else {
|
} else {
|
||||||
// try to find requested ts file in recorder's cache
|
// try to find requested ts file in recorder's cache
|
||||||
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
|
// 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);
|
let recorder = recorders.get(&room_id);
|
||||||
if recorder.is_none() {
|
if recorder.is_none() {
|
||||||
return Ok::<_, Infallible>(
|
return Ok::<_, Infallible>(
|
||||||
|
|||||||
@@ -8,12 +8,9 @@
|
|||||||
"title": "BiliBili ShadowReplay",
|
"title": "BiliBili ShadowReplay",
|
||||||
"width": 1300,
|
"width": 1300,
|
||||||
"height": 600,
|
"height": 600,
|
||||||
"transparent": true,
|
"transparent": false,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"theme": "Light",
|
"theme": "Light"
|
||||||
"windowEffects": {
|
|
||||||
"effects": ["tabbed", "mica"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,5 +80,6 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
background-color: #e5e7eb;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -122,10 +122,21 @@
|
|||||||
(a: RecordItem) => {
|
(a: RecordItem) => {
|
||||||
console.log(a);
|
console.log(a);
|
||||||
archive = 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() {
|
async function get_video_list() {
|
||||||
videos = (
|
videos = (
|
||||||
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
|
||||||
@@ -159,7 +170,8 @@
|
|||||||
}
|
}
|
||||||
loading = true;
|
loading = true;
|
||||||
let new_cover = generateCover();
|
let new_cover = generateCover();
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成中`);
|
update_title(`切片生成中`);
|
||||||
|
try {
|
||||||
let new_video = (await invoke("clip_range", {
|
let new_video = (await invoke("clip_range", {
|
||||||
roomId: room_id,
|
roomId: room_id,
|
||||||
cover: new_cover,
|
cover: new_cover,
|
||||||
@@ -167,7 +179,7 @@
|
|||||||
x: start,
|
x: start,
|
||||||
y: end,
|
y: end,
|
||||||
})) as VideoItem;
|
})) as VideoItem;
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成成功`);
|
update_title(`切片生成成功`);
|
||||||
console.log("video file generatd:", video);
|
console.log("video file generatd:", video);
|
||||||
await get_video_list();
|
await get_video_list();
|
||||||
video_selected = new_video.id;
|
video_selected = new_video.id;
|
||||||
@@ -176,13 +188,16 @@
|
|||||||
});
|
});
|
||||||
cover = new_video.cover;
|
cover = new_video.cover;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
alert("Err generating clip: " + e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function do_post() {
|
async function do_post() {
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿上传中`);
|
update_title(`投稿上传中`);
|
||||||
loading = true;
|
loading = true;
|
||||||
// render cover with text
|
// render cover with text
|
||||||
const ecapture = document.getElementById("capture");
|
const ecapture = document.getElementById("capture");
|
||||||
@@ -201,13 +216,13 @@
|
|||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
loading = false;
|
loading = false;
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿成功`);
|
update_title(`投稿成功`);
|
||||||
video_selected = 0;
|
video_selected = 0;
|
||||||
await get_video_list();
|
await get_video_list();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
loading = false;
|
loading = false;
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿失败`);
|
update_title(`投稿失败`);
|
||||||
alert(e);
|
alert(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -217,9 +232,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading = true;
|
loading = true;
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除中`);
|
update_title(`删除中`);
|
||||||
await invoke("delete_video", { id: video_selected });
|
await invoke("delete_video", { id: video_selected });
|
||||||
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除成功`);
|
update_title(`删除成功`);
|
||||||
loading = false;
|
loading = false;
|
||||||
video_selected = 0;
|
video_selected = 0;
|
||||||
video = null;
|
video = null;
|
||||||
@@ -246,7 +261,7 @@
|
|||||||
<TitleBar dark />
|
<TitleBar dark />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-row">
|
<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} />
|
<Player bind:start bind:end {port} {room_id} {ts} />
|
||||||
<Modal title="预览" bind:open={preview} autoclose>
|
<Modal title="预览" bind:open={preview} autoclose>
|
||||||
<!-- svelte-ignore a11y-media-has-caption -->
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
@@ -254,7 +269,7 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<div
|
||||||
id="post-panel"
|
id="post-panel"
|
||||||
@@ -271,7 +286,7 @@
|
|||||||
>
|
>
|
||||||
<div id="capture" class="cover-wrap relative cursor-pointer">
|
<div id="capture" class="cover-wrap relative cursor-pointer">
|
||||||
<div
|
<div
|
||||||
class="cover-text absolute py-2 px-8"
|
class="cover-text absolute py-1 px-8"
|
||||||
class:play-icon={false}
|
class:play-icon={false}
|
||||||
>
|
>
|
||||||
{cover_text}
|
{cover_text}
|
||||||
@@ -385,6 +400,7 @@
|
|||||||
.cover-text {
|
.cover-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
line-height: 1.3;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: rgb(255, 127, 0);
|
color: rgb(255, 127, 0);
|
||||||
text-shadow:
|
text-shadow:
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
站直播流,并生成视频投稿的工具。
|
站直播流,并生成视频投稿的工具。
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-4">
|
<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
|
>https://github.com/Xinrea/bili-shadowreplay</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
TableBodyCell,
|
TableBodyCell,
|
||||||
Modal,
|
Modal,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
SpeedDial,
|
||||||
|
Listgroup,
|
||||||
|
ListgroupItem,
|
||||||
|
Textarea,
|
||||||
|
Hr,
|
||||||
} from "flowbite-svelte";
|
} from "flowbite-svelte";
|
||||||
import Image from "./Image.svelte";
|
import Image from "./Image.svelte";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
@@ -32,6 +37,9 @@
|
|||||||
let oauth_key = "";
|
let oauth_key = "";
|
||||||
let check_interval = null;
|
let check_interval = null;
|
||||||
|
|
||||||
|
let manualModal = false;
|
||||||
|
let cookie_str = "";
|
||||||
|
|
||||||
async function handle_qr() {
|
async function handle_qr() {
|
||||||
if (check_interval) {
|
if (check_interval) {
|
||||||
clearInterval(check_interval);
|
clearInterval(check_interval);
|
||||||
@@ -52,7 +60,7 @@
|
|||||||
async function check_qr() {
|
async function check_qr() {
|
||||||
let qr_status: { code: number; cookies: string } = await invoke(
|
let qr_status: { code: number; cookies: string } = await invoke(
|
||||||
"get_qr_status",
|
"get_qr_status",
|
||||||
{ qrcodeKey: oauth_key }
|
{ qrcodeKey: oauth_key },
|
||||||
);
|
);
|
||||||
if (qr_status.code == 0) {
|
if (qr_status.code == 0) {
|
||||||
clearInterval(check_interval);
|
clearInterval(check_interval);
|
||||||
@@ -61,6 +69,20 @@
|
|||||||
addModal = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="p-8 pt-12 h-full overflow-auto">
|
<div class="p-8 pt-12 h-full overflow-auto">
|
||||||
@@ -116,16 +138,23 @@
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fixed end-4 bottom-4">
|
<SpeedDial defaultClass="absolute end-6 bottom-6" placement="top-end">
|
||||||
<Button
|
<Listgroup active>
|
||||||
pill={true}
|
<ListgroupItem
|
||||||
class="!p-2"
|
class="flex gap-2 md:px-5"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
addModal = true;
|
addModal = true;
|
||||||
requestAnimationFrame(handle_qr);
|
requestAnimationFrame(handle_qr);
|
||||||
}}><UserAddSolid class="w-8 h-8" /></Button
|
}}>扫码添加</ListgroupItem
|
||||||
>
|
>
|
||||||
</div>
|
<ListgroupItem
|
||||||
|
class="flex gap-2 md:px-5"
|
||||||
|
on:click={() => {
|
||||||
|
manualModal = true;
|
||||||
|
}}>手动添加</ListgroupItem
|
||||||
|
>
|
||||||
|
</Listgroup>
|
||||||
|
</SpeedDial>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="请使用 BiliBili App 扫码登录"
|
title="请使用 BiliBili App 扫码登录"
|
||||||
@@ -137,3 +166,20 @@
|
|||||||
<canvas id="qr" />
|
<canvas id="qr" />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
||||||
|
|||||||
@@ -3,12 +3,38 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { AccountInfo, AccountItem } from "./db";
|
import type { AccountInfo, AccountItem } from "./db";
|
||||||
|
|
||||||
|
interface DanmuEntry {
|
||||||
|
ts: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
export let port;
|
export let port;
|
||||||
export let room_id;
|
export let room_id;
|
||||||
export let ts;
|
export let ts;
|
||||||
export let start = 0;
|
export let start = 0;
|
||||||
export let end = 0;
|
export let end = 0;
|
||||||
let show_detail = false;
|
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() {
|
async function init() {
|
||||||
const video = document.getElementById("video") as HTMLVideoElement;
|
const video = document.getElementById("video") as HTMLVideoElement;
|
||||||
const ui = video["ui"];
|
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.
|
// 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).player = player;
|
||||||
(window as any).ui = ui;
|
(window as any).ui = ui;
|
||||||
|
|
||||||
|
player.addEventListener("ended", async () => {
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
player.addEventListener("manifestloaded", (event) => {
|
||||||
|
console.log("Manifest loaded:", event);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await player.load(
|
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`,
|
||||||
@@ -39,15 +73,9 @@
|
|||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addEventListener("ended", async () => {
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
|
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
|
||||||
document.querySelector(
|
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
|
||||||
".shaka-back-to-overflow-button .material-icons-round",
|
|
||||||
).innerHTML = "arrow_back_ios_new";
|
|
||||||
|
|
||||||
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
|
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
|
||||||
const shakaBottomControls = document.querySelector(
|
const shakaBottomControls = document.querySelector(
|
||||||
".shaka-bottom-controls.shaka-no-propagation",
|
".shaka-bottom-controls.shaka-no-propagation",
|
||||||
@@ -66,6 +94,41 @@
|
|||||||
`;
|
`;
|
||||||
shakaBottomControls.appendChild(selfSeekbar);
|
shakaBottomControls.appendChild(selfSeekbar);
|
||||||
|
|
||||||
|
// 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
|
// add a account select
|
||||||
const accountSelect = document.createElement("select");
|
const accountSelect = document.createElement("select");
|
||||||
accountSelect.style.height = "30px";
|
accountSelect.style.height = "30px";
|
||||||
@@ -114,7 +177,21 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let danmu_enabled = true;
|
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
|
// create a danmaku toggle button
|
||||||
const danmakuToggle = document.createElement("button");
|
const danmakuToggle = document.createElement("button");
|
||||||
danmakuToggle.innerText = "弹幕已开启";
|
danmakuToggle.innerText = "弹幕已开启";
|
||||||
@@ -134,15 +211,6 @@
|
|||||||
: "rgba(255, 0, 0, 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);
|
|
||||||
|
|
||||||
// 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
|
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.style.width = "100%";
|
overlay.style.width = "100%";
|
||||||
@@ -151,7 +219,7 @@
|
|||||||
overlay.style.top = "0";
|
overlay.style.top = "0";
|
||||||
overlay.style.left = "0";
|
overlay.style.left = "0";
|
||||||
overlay.style.pointerEvents = "none";
|
overlay.style.pointerEvents = "none";
|
||||||
overlay.style.zIndex = "40";
|
overlay.style.zIndex = "30";
|
||||||
overlay.style.display = "flex";
|
overlay.style.display = "flex";
|
||||||
overlay.style.alignItems = "center";
|
overlay.style.alignItems = "center";
|
||||||
overlay.style.flexDirection = "column";
|
overlay.style.flexDirection = "column";
|
||||||
@@ -162,12 +230,7 @@
|
|||||||
// Store the positions of the last few danmakus to avoid overlap
|
// Store the positions of the last few danmakus to avoid overlap
|
||||||
const danmakuPositions = [];
|
const danmakuPositions = [];
|
||||||
|
|
||||||
// listen to danmaku event
|
function danmu_handler(content: string) {
|
||||||
listen("danmu:" + room_id, (event: { payload: string }) => {
|
|
||||||
console.log("danmu", event.payload);
|
|
||||||
if (!danmu_enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const danmaku = document.createElement("p");
|
const danmaku = document.createElement("p");
|
||||||
danmaku.style.position = "absolute";
|
danmaku.style.position = "absolute";
|
||||||
|
|
||||||
@@ -199,7 +262,8 @@
|
|||||||
danmaku.style.margin = "0";
|
danmaku.style.margin = "0";
|
||||||
danmaku.style.padding = "0";
|
danmaku.style.padding = "0";
|
||||||
danmaku.style.zIndex = "500";
|
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);
|
overlay.appendChild(danmaku);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||||
@@ -207,8 +271,41 @@
|
|||||||
danmaku.addEventListener("transitionend", () => {
|
danmaku.addEventListener("transitionend", () => {
|
||||||
overlay.removeChild(danmaku);
|
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() {
|
function isLive() {
|
||||||
return player.isLive();
|
return player.isLive();
|
||||||
}
|
}
|
||||||
@@ -303,6 +400,9 @@
|
|||||||
}
|
}
|
||||||
requestAnimationFrame(updateSeekbar);
|
requestAnimationFrame(updateSeekbar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
meta_parse();
|
||||||
|
|
||||||
// receive tauri emit
|
// receive tauri emit
|
||||||
document.addEventListener("shaka-ui-loaded", init);
|
document.addEventListener("shaka-ui-loaded", init);
|
||||||
|
|
||||||
@@ -333,8 +433,8 @@
|
|||||||
<p><kbd>]</kbd>设定选区结束</p>
|
<p><kbd>]</kbd>设定选区结束</p>
|
||||||
<p><kbd>q</kbd>跳转到选区开始</p>
|
<p><kbd>q</kbd>跳转到选区开始</p>
|
||||||
<p><kbd>e</kbd>跳转到选区结束</p>
|
<p><kbd>e</kbd>跳转到选区结束</p>
|
||||||
<p><kbd>Alt</kbd><kbd>←</kbd>前进</p>
|
<p><kbd>←</kbd>前进</p>
|
||||||
<p><kbd>Alt</kbd><kbd>→</kbd>后退</p>
|
<p><kbd>→</kbd>后退</p>
|
||||||
<p><kbd>c</kbd>清除选区</p>
|
<p><kbd>c</kbd>清除选区</p>
|
||||||
<p><kbd>m</kbd>静音</p>
|
<p><kbd>m</kbd>静音</p>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -145,9 +145,7 @@
|
|||||||
<Badge color="dark">未直播</Badge>
|
<Badge color="dark">未直播</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</TableBodyCell>
|
</TableBodyCell>
|
||||||
<TableBodyCell
|
<TableBodyCell>{format_time(room.total_length)}</TableBodyCell>
|
||||||
>{format_time(room.total_length)}</TableBodyCell
|
|
||||||
>
|
|
||||||
<TableBodyCell>
|
<TableBodyCell>
|
||||||
<Button size="sm" color="dark"
|
<Button size="sm" color="dark"
|
||||||
>操作<ChevronDownOutline
|
>操作<ChevronDownOutline
|
||||||
@@ -164,13 +162,13 @@
|
|||||||
});
|
});
|
||||||
}}>打开直播流</DropdownItem
|
}}>打开直播流</DropdownItem
|
||||||
>
|
>
|
||||||
<DropdownItem
|
<!-- <DropdownItem
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
quickClipRoom = room.room_id;
|
quickClipRoom = room.room_id;
|
||||||
quickClipSelected = 30;
|
quickClipSelected = 30;
|
||||||
quickClipModal = true;
|
quickClipModal = true;
|
||||||
}}>快速切片</DropdownItem
|
}}>快速切片</DropdownItem
|
||||||
>
|
> -->
|
||||||
{/if}
|
{/if}
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
@@ -207,9 +205,7 @@
|
|||||||
<ExclamationCircleOutline
|
<ExclamationCircleOutline
|
||||||
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200"
|
||||||
/>
|
/>
|
||||||
<h3
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
|
||||||
class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
确定要移除这个直播间吗?
|
确定要移除这个直播间吗?
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
@@ -277,11 +273,8 @@
|
|||||||
on:click={() => {
|
on:click={() => {
|
||||||
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
|
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
|
||||||
async (e) => {
|
async (e) => {
|
||||||
await message(
|
await message("请检查房间号是否有效:" + e, "添加失败");
|
||||||
"请检查房间号是否有效:" + e,
|
}
|
||||||
"添加失败",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}}>确定</Button
|
}}>确定</Button
|
||||||
>
|
>
|
||||||
@@ -301,13 +294,9 @@
|
|||||||
<TableBody tableBodyClass="divide-y">
|
<TableBody tableBodyClass="divide-y">
|
||||||
{#each archives as archive}
|
{#each archives as archive}
|
||||||
<TableBodyRow>
|
<TableBodyRow>
|
||||||
<TableBodyCell
|
<TableBodyCell>{format_ts(archive.created_at)}</TableBodyCell>
|
||||||
>{format_ts(archive.created_at)}</TableBodyCell
|
|
||||||
>
|
|
||||||
<TableBodyCell>{archive.title}</TableBodyCell>
|
<TableBodyCell>{archive.title}</TableBodyCell>
|
||||||
<TableBodyCell
|
<TableBodyCell>{format_duration(archive.length)}</TableBodyCell>
|
||||||
>{format_duration(archive.length)}</TableBodyCell
|
|
||||||
>
|
|
||||||
<TableBodyCell>
|
<TableBodyCell>
|
||||||
<span>{format_size(archive.size)}</span>
|
<span>{format_size(archive.size)}</span>
|
||||||
</TableBodyCell>
|
</TableBodyCell>
|
||||||
@@ -328,12 +317,9 @@
|
|||||||
roomId: archiveRoom.room_id,
|
roomId: archiveRoom.room_id,
|
||||||
ts: archive.live_id,
|
ts: archive.live_id,
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
archives = await invoke(
|
archives = await invoke("get_archives", {
|
||||||
"get_archives",
|
|
||||||
{
|
|
||||||
roomId: archiveRoom.room_id,
|
roomId: archiveRoom.room_id,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}}>移除</Button
|
}}>移除</Button
|
||||||
>
|
>
|
||||||
|
|||||||