Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4592d5ca6 | ||
|
|
5e9dba5d58 | ||
|
|
2cfd140d4a | ||
|
|
13e421bfba | ||
|
|
1933727f89 | ||
|
|
4e16bfcc18 | ||
|
|
398ee831de | ||
|
|
6d01280039 | ||
|
|
8aaa701348 | ||
|
|
733a36571b | ||
|
|
f40ac28781 | ||
|
|
d73e95d2e5 | ||
|
|
770338a68a | ||
|
|
f58dafbde8 | ||
|
|
004712e851 | ||
|
|
4e53ed2cf8 | ||
|
|
92ccad6253 | ||
|
|
74c5e9bb09 | ||
|
|
2724d6b4d3 | ||
|
|
212e144422 | ||
|
|
205a1b82e7 | ||
|
|
44b4604581 | ||
|
|
3d3454b5a4 | ||
|
|
67f1b04b67 | ||
|
|
fd7d299e55 | ||
|
|
ada492f3f0 | ||
|
|
8a4e4fd32b | ||
|
|
86ced2a217 | ||
|
|
c62251dfe9 | ||
|
|
8bf0f5d36e | ||
|
|
a4b6567947 | ||
|
|
6c5c628bbf | ||
|
|
1d6593340d | ||
|
|
0d992d205f | ||
|
|
f82e79efd4 | ||
|
|
5cdb6b6f75 | ||
|
|
7316a022be | ||
|
|
dbd8a29b73 | ||
|
|
d6a5a02d68 | ||
|
|
9eef00b913 | ||
|
|
3b9dd4824b | ||
|
|
902c1ad39e | ||
|
|
b4f6dea97f | ||
|
|
c1c252f54a | ||
|
|
303b5f8847 | ||
|
|
26f55a463b | ||
|
|
0fa2c366dc | ||
|
|
bf1588e414 | ||
|
|
3be0f25dfc | ||
|
|
0e53028922 | ||
|
|
bc458647a3 | ||
|
|
6c5080394a | ||
|
|
30d45ca2c3 | ||
|
|
614aa3184f | ||
|
|
cacd28bd87 | ||
|
|
a3dfe86a04 | ||
|
|
79c65cab63 | ||
|
|
52237b9385 | ||
|
|
b8d22e92ff | ||
|
|
faac0e29b5 | ||
|
|
899afac910 | ||
|
|
7b2cbfefcc | ||
|
|
793e532240 | ||
|
|
68c7bd251e |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: 提交一个 BUG
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: Xinrea
|
||||
|
||||
---
|
||||
|
||||
**描述:**
|
||||
简要描述一下这个 BUG 的现象
|
||||
|
||||
**如何遇到:**
|
||||
遇到这个 BUG 之前进行了哪些操作
|
||||
|
||||
**期望:**
|
||||
如果执行相同的操作,期望发生什么
|
||||
|
||||
**日志和截图:**
|
||||
如果可以的话,请尽量附上相关截图和日志文件(日志是位于安装目录下,名为 bsr.log 的文件)。
|
||||
|
||||
**相关信息:**
|
||||
- 程序版本:
|
||||
- 系统类型:
|
||||
|
||||
**其他**
|
||||
任何其他想说的
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: 提交一个新功能的建议
|
||||
title: "[feature]"
|
||||
labels: enhancement
|
||||
assignees: Xinrea
|
||||
|
||||
---
|
||||
|
||||
**遇到的问题:**
|
||||
在使用过程中遇到了什么问题让你想要提出建议
|
||||
|
||||
**想要的功能:**
|
||||
想要怎样的新功能来解决这个问题
|
||||
|
||||
**通过什么方式实现(有思路的话):**
|
||||
如果有相关的实现思路或者是参考,可以在此提供
|
||||
|
||||
**其他:**
|
||||
其他任何想说的话
|
||||
32
README.md
@@ -6,10 +6,12 @@
|
||||

|
||||

|
||||
|
||||
BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
> [!WARNING]
|
||||
> v2.0.0 版本为重大更新,将不兼容 v1.x 版本的数据。
|
||||
|
||||
> [!NOTE]
|
||||
> 由于软件在快速开发中,截图说明可能有变动,仅供参考
|
||||
BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
|
||||
目前仅支持 B 站和抖音平台的直播。
|
||||
|
||||

|
||||
|
||||
@@ -17,8 +19,6 @@ BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的
|
||||
|
||||

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

|
||||
@@ -30,30 +30,34 @@ BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的
|
||||
|
||||
无论是正在进行的直播还是历史录播,都可在预览窗口进行回放,同时也可以进行切片编辑以及投稿。关于预览窗口的相关说明请见 [预览窗口](#预览窗口)。
|
||||
|
||||
## 消息管理
|
||||
|
||||

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

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

|
||||
|
||||
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
|
||||
预览窗口是一个多功能的窗口,可以用于观看直播流、回放历史录播、编辑切片、记录时间点以及投稿等操作。如果当前播放的是直播流,那么会有实时弹幕观看以及发送弹幕相关的选项。
|
||||
|
||||
通过预览窗口的快捷键操作,可以快速选择时间区间,进行切片生成以及投稿。
|
||||
|
||||
无论是弹幕发送还是投稿,均可自由选择账号,只要在账号管理中添加了该账号。
|
||||
|
||||
进度条上方会显示弹幕频率图,可以直观地看到弹幕的分布情况;右侧的弹幕统计过滤器可以用于过滤弹幕,只显示含有指定文字的弹幕的统计情况。
|
||||
|
||||
## 封面编辑
|
||||
|
||||

|
||||
|
||||
在预览窗口中,生成切片后可以进行封面编辑,包括关键帧的选择、文字的添加和拖动等。
|
||||
|
||||
## 设置
|
||||
|
||||

|
||||
@@ -61,4 +65,4 @@ BiliBili ShadowReplay 是一个缓存 B 站直播并进行实时编辑投稿的
|
||||
在设置页面可以进行一些基本的设置,包括缓存和切片的保存路径,以及相关事件是否显示通知等。
|
||||
|
||||
> [!WARNING]
|
||||
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。缓存切换开始和结束均会在消息管理中有记录。
|
||||
> 缓存目录进行切换时,会有文件复制等操作,如果缓存量较大,可能会耗费较长时间;且在此期间预览功能会暂时失效,需要等待操作完成。
|
||||
|
||||
BIN
doc/accounts.png
|
Before Width: | Height: | Size: 487 KiB After Width: | Height: | Size: 555 KiB |
BIN
doc/archives.png
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 1.2 MiB |
BIN
doc/coveredit.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.8 MiB |
BIN
doc/messages.png
|
Before Width: | Height: | Size: 638 KiB |
BIN
doc/rooms.png
|
Before Width: | Height: | Size: 678 KiB After Width: | Height: | Size: 1.9 MiB |
BIN
doc/settings.png
|
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 622 KiB |
BIN
doc/summary.png
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 721 KiB |
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-cn">
|
||||
<html lang="zh-cn" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
@@ -18,6 +18,38 @@
|
||||
height: 12px; /* 设置滑块按钮高度 */
|
||||
border-radius: 50%; /* 设置为圆形 */
|
||||
}
|
||||
html {
|
||||
scrollbar-face-color: #646464;
|
||||
scrollbar-base-color: #646464;
|
||||
scrollbar-3dlight-color: #646464;
|
||||
scrollbar-highlight-color: #646464;
|
||||
scrollbar-track-color: #000;
|
||||
scrollbar-arrow-color: #000;
|
||||
scrollbar-shadow-color: #646464;
|
||||
scrollbar-dark-shadow-color: #646464;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
background-color: #666;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #646464;
|
||||
}
|
||||
::-webkit-scrollbar-track-piece {
|
||||
background-color: #000;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
height: 50px;
|
||||
background-color: #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: #646464;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
19
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,20 +11,21 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.0.0-rc.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0-rc.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
||||
"@tauri-apps/plugin-http": "^2.0.0-rc.2",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tauri-apps/plugin-os": "^2.0.0-rc",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0-rc.1",
|
||||
"@tauri-apps/plugin-sql": "^2.0.0-rc.1",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@tauri-apps/plugin-sql": "~2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lucide-svelte": "^0.479.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.0.0",
|
||||
"@tauri-apps/cli": "^2.0.2",
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
BIN
public/imgs/donate.png
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
public/imgs/douyin.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
299
src-tauri/Cargo.lock
generated
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
@@ -316,9 +316,9 @@ checksum = "7a40729d2133846d9ed0ea60a8b9541bccddab49cd30f0715a1da672fe9a2524"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.68"
|
||||
version = "0.1.87"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
|
||||
checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -407,6 +407,8 @@ name = "bili-shadowreplay"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"async-trait",
|
||||
"base64 0.21.0",
|
||||
"chrono",
|
||||
"custom_error",
|
||||
"dashmap",
|
||||
@@ -417,6 +419,7 @@ dependencies = [
|
||||
"log",
|
||||
"m3u8-rs",
|
||||
"md5",
|
||||
"mime_guess",
|
||||
"notify-rust",
|
||||
"pct-str",
|
||||
"platform-dirs",
|
||||
@@ -1196,17 +1199,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.2"
|
||||
@@ -2376,124 +2368,6 @@ dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_locid_transform_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locid_transform",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_provider_macros",
|
||||
"stable_deref_trait",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_macros"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -2520,27 +2394,6 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.2"
|
||||
@@ -2846,12 +2699,6 @@ version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
@@ -2975,6 +2822,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -5203,17 +5060,6 @@ dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.1"
|
||||
@@ -5810,16 +5656,6 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.6.0"
|
||||
@@ -6163,6 +5999,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.12"
|
||||
@@ -6216,12 +6058,12 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.3"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada"
|
||||
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna 1.0.3",
|
||||
"idna 0.5.0",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
@@ -6250,24 +6092,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-decode"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca61eb27fa339aa08826a29f03e87b99b4d8f0fc2255306fd266bb1b6a9de498"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.3.0"
|
||||
@@ -7122,18 +6952,6 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.44.1"
|
||||
@@ -7205,30 +7023,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.0.1"
|
||||
@@ -7314,55 +7108,12 @@ dependencies = [
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.76",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.0.0"
|
||||
|
||||
@@ -10,10 +10,10 @@ edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.1", features = [] }
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0.1", features = ["protocol-asset", "tray-icon"] }
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
|
||||
serde_json = "1.0"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
serde_derive = "1.0.158"
|
||||
@@ -39,15 +39,18 @@ urlencoding = "2.1.3"
|
||||
log = "0.4.22"
|
||||
simplelog = "0.12.2"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
tauri-plugin-dialog = "2.0.1"
|
||||
tauri-plugin-shell = "2.0.1"
|
||||
tauri-plugin-fs = "2.0.1"
|
||||
tauri-plugin-http = "2.0.1"
|
||||
tauri-utils = "2.0.1"
|
||||
tauri-plugin-sql = { version = "2.0.1", features = ["sqlite"] }
|
||||
tauri-plugin-os = "2.0.1"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-utils = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
rand = "0.8.5"
|
||||
base64 = "0.21"
|
||||
mime_guess = "2.0"
|
||||
async-trait = "0.1.87"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
@@ -55,4 +58,4 @@ rand = "0.8.5"
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
},
|
||||
{
|
||||
"url": "https://*.afdiancdn.com/"
|
||||
},
|
||||
{
|
||||
"url": "https://*.douyin.com/"
|
||||
},
|
||||
{
|
||||
"url": "https://*.douyinpic.com/"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -57,6 +63,12 @@
|
||||
"http:default",
|
||||
"sql:default",
|
||||
"os:default",
|
||||
"notification:default"
|
||||
"notification:default",
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
"http:default",
|
||||
"shell:default",
|
||||
"sql:default",
|
||||
"os:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default"]}}
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default"]}}
|
||||
75
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use platform_dirs::AppDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct Config {
|
||||
pub cache: String,
|
||||
pub output: String,
|
||||
pub primary_uid: u64,
|
||||
pub webid: String,
|
||||
pub webid_ts: i64,
|
||||
pub live_start_notify: bool,
|
||||
pub live_end_notify: bool,
|
||||
pub clip_notify: bool,
|
||||
pub post_notify: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Self {
|
||||
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
|
||||
let config_path = app_dirs.config_dir.join("Conf.toml");
|
||||
if let Ok(content) = std::fs::read_to_string(config_path) {
|
||||
if let Ok(config) = toml::from_str(&content) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
let config = Config {
|
||||
webid: "".to_string(),
|
||||
webid_ts: 0,
|
||||
cache: app_dirs
|
||||
.cache_dir
|
||||
.join("cache")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
output: app_dirs
|
||||
.data_dir
|
||||
.join("output")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
primary_uid: 0,
|
||||
live_start_notify: true,
|
||||
live_end_notify: true,
|
||||
clip_notify: true,
|
||||
post_notify: true,
|
||||
};
|
||||
config.save();
|
||||
config
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let content = toml::to_string(&self).unwrap();
|
||||
let app_dirs = AppDirs::new(Some("cn.vjoi.bili-shadowreplay"), false).unwrap();
|
||||
// Create app dirs if not exists
|
||||
std::fs::create_dir_all(&app_dirs.config_dir).unwrap();
|
||||
let config_path = app_dirs.config_dir.join("Conf.toml");
|
||||
std::fs::write(config_path, content).unwrap();
|
||||
}
|
||||
|
||||
pub fn set_cache_path(&mut self, path: &str) {
|
||||
self.cache = path.to_string();
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn set_output_path(&mut self, path: &str) {
|
||||
self.output = path.into();
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn webid_expired(&self) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
// expire in 20 hours
|
||||
now - self.webid_ts > 72000
|
||||
}
|
||||
}
|
||||
47
src-tauri/src/database.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use custom_error::custom_error;
|
||||
use sqlx::Pool;
|
||||
use sqlx::Sqlite;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub mod account;
|
||||
pub mod message;
|
||||
pub mod record;
|
||||
pub mod recorder;
|
||||
pub mod video;
|
||||
|
||||
pub struct Database {
|
||||
db: RwLock<Option<Pool<Sqlite>>>,
|
||||
}
|
||||
|
||||
custom_error! { pub DatabaseError
|
||||
InsertError = "Entry insert failed",
|
||||
NotFoundError = "Entry not found",
|
||||
InvalidCookiesError = "Cookies are invalid",
|
||||
DBError {err: sqlx::Error } = "DB error: {err}",
|
||||
SQLError { sql: String } = "SQL is incorret: {sql}"
|
||||
}
|
||||
|
||||
impl From<DatabaseError> for String {
|
||||
fn from(value: DatabaseError) -> Self {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for DatabaseError {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
DatabaseError::DBError { err: value }
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new() -> Database {
|
||||
Database {
|
||||
db: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// db *must* be set in tauri setup
|
||||
pub async fn set(&self, p: Pool<Sqlite>) {
|
||||
*self.db.write().await = Some(p);
|
||||
}
|
||||
}
|
||||
141
src-tauri/src/database/account.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use crate::recorder::PlatformType;
|
||||
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct AccountRow {
|
||||
pub platform: String,
|
||||
pub uid: u64,
|
||||
pub name: String,
|
||||
pub avatar: String,
|
||||
pub csrf: String,
|
||||
pub cookies: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// accounts
|
||||
impl Database {
|
||||
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
|
||||
pub async fn add_account(&self, platform: &str, cookies: &str) -> Result<AccountRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let platform = PlatformType::from_str(platform).unwrap();
|
||||
|
||||
let csrf = if platform == PlatformType::Douyin {
|
||||
Some("".to_string())
|
||||
} else {
|
||||
// parse cookies
|
||||
cookies
|
||||
.split(';')
|
||||
.map(|cookie| cookie.trim())
|
||||
.find_map(|cookie| -> Option<String> {
|
||||
match cookie.starts_with("bili_jct=") {
|
||||
true => {
|
||||
let var_name = &"bili_jct=";
|
||||
Some(cookie[var_name.len()..].to_string())
|
||||
}
|
||||
false => None,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if csrf.is_none() {
|
||||
return Err(DatabaseError::InvalidCookiesError);
|
||||
}
|
||||
|
||||
// parse uid
|
||||
let uid = if platform == PlatformType::BiliBili {
|
||||
cookies
|
||||
.split("DedeUserID=")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.split(";")
|
||||
.collect::<Vec<&str>>()
|
||||
.first()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| DatabaseError::InvalidCookiesError)?
|
||||
} else {
|
||||
// generate a random uid
|
||||
rand::thread_rng().gen_range(10000..=i32::MAX) as u64
|
||||
};
|
||||
|
||||
let account = AccountRow {
|
||||
platform: platform.as_str().to_string(),
|
||||
uid,
|
||||
name: "".into(),
|
||||
avatar: "".into(),
|
||||
csrf: csrf.unwrap(),
|
||||
cookies: cookies.into(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
sqlx::query("INSERT INTO accounts (uid, platform, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)").bind(account.uid as i64).bind(&account.platform).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn remove_account(&self, platform: &str, uid: u64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("DELETE FROM accounts WHERE uid = $1 and platform = $2")
|
||||
.bind(uid as i64)
|
||||
.bind(platform)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
if sql.rows_affected() != 1 {
|
||||
return Err(DatabaseError::NotFoundError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_account(
|
||||
&self,
|
||||
platform: &str,
|
||||
uid: u64,
|
||||
name: &str,
|
||||
avatar: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4")
|
||||
.bind(name)
|
||||
.bind(avatar)
|
||||
.bind(uid as i64)
|
||||
.bind(platform)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
if sql.rows_affected() != 1 {
|
||||
return Err(DatabaseError::NotFoundError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_accounts(&self) -> Result<Vec<AccountRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts")
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_account(&self, platform: &str, uid: u64) -> Result<AccountRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(
|
||||
sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE uid = $1 and platform = $2")
|
||||
.bind(uid as i64)
|
||||
.bind(platform)
|
||||
.fetch_one(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_account_by_platform(&self, platform: &str) -> Result<AccountRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE platform = $1")
|
||||
.bind(platform)
|
||||
.fetch_one(&lock)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
55
src-tauri/src/database/message.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct MessageRow {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub read: u8,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// messages
|
||||
// CREATE TABLE messages (id INTEGER PRIMARY KEY, title TEXT, content TEXT, read INTEGER, created_at TEXT);
|
||||
impl Database {
|
||||
pub async fn new_message(&self, title: &str, content: &str) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (title, content, read, created_at) VALUES ($1, $2, 0, $3)",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_message(&self, id: i64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE messages SET read = $1 WHERE id = $2")
|
||||
.bind(1)
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_message(&self, id: i64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("DELETE FROM messages WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_messages(&self) -> Result<Vec<MessageRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, MessageRow>("SELECT * FROM messages;")
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
121
src-tauri/src/database/record.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crate::recorder::PlatformType;
|
||||
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct RecordRow {
|
||||
pub platform: String,
|
||||
pub live_id: String,
|
||||
pub room_id: u64,
|
||||
pub title: String,
|
||||
pub length: i64,
|
||||
pub size: i64,
|
||||
pub created_at: String,
|
||||
pub cover: Option<String>,
|
||||
}
|
||||
|
||||
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, title TEXT, length INTEGER, size INTEGER, created_at TEXT);
|
||||
impl Database {
|
||||
pub async fn get_records(&self, room_id: u64) -> Result<Vec<RecordRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(
|
||||
sqlx::query_as::<_, RecordRow>("SELECT * FROM records WHERE room_id = $1")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_record(&self, room_id: u64, live_id: &str) -> Result<RecordRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records WHERE live_id = $1 and room_id = $2",
|
||||
)
|
||||
.bind(live_id)
|
||||
.bind(room_id as i64)
|
||||
.fetch_one(&lock)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn add_record(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
live_id: &str,
|
||||
room_id: u64,
|
||||
title: &str,
|
||||
cover: Option<String>,
|
||||
) -> Result<RecordRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let record = RecordRow {
|
||||
platform: platform.as_str().to_string(),
|
||||
live_id: live_id.to_string(),
|
||||
room_id,
|
||||
title: title.into(),
|
||||
length: 0,
|
||||
size: 0,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
cover,
|
||||
};
|
||||
if let Err(e) = sqlx::query("INSERT INTO records (live_id, room_id, title, length, size, cover, created_at, platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)").bind(record.live_id.clone())
|
||||
.bind(record.room_id as i64).bind(&record.title).bind(0).bind(0).bind(&record.cover).bind(&record.created_at).bind(platform.as_str().to_string()).execute(&lock).await {
|
||||
// if the record already exists, return the existing record
|
||||
if e.to_string().contains("UNIQUE constraint failed") {
|
||||
return self.get_record(room_id, live_id).await;
|
||||
}
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn remove_record(&self, live_id: &str) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("DELETE FROM records WHERE live_id = $1")
|
||||
.bind(live_id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_record(
|
||||
&self,
|
||||
live_id: &str,
|
||||
length: i64,
|
||||
size: u64,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE records SET length = $1, size = $2 WHERE live_id = $3")
|
||||
.bind(length)
|
||||
.bind(size as i64)
|
||||
.bind(live_id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_total_length(&self) -> Result<i64, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let result: (i64,) = sqlx::query_as("SELECT SUM(length) FROM records;")
|
||||
.fetch_one(&lock)
|
||||
.await?;
|
||||
Ok(result.0)
|
||||
}
|
||||
|
||||
pub async fn get_today_record_count(&self) -> Result<i64, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM records WHERE created_at >= $1;")
|
||||
.bind(Utc::now().date_naive().and_hms_opt(0, 0, 0).unwrap().to_string())
|
||||
.fetch_one(&lock)
|
||||
.await?;
|
||||
Ok(result.0)
|
||||
}
|
||||
|
||||
pub async fn get_recent_record(&self, offset: u64, limit: u64) -> Result<Vec<RecordRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecordRow>("SELECT * FROM records ORDER BY created_at DESC LIMIT $1 OFFSET $2")
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
62
src-tauri/src/database/recorder.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
use chrono::Utc;
|
||||
use crate::recorder::PlatformType;
|
||||
/// Recorder in database is pretty simple
|
||||
/// because many room infos are collected in realtime
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct RecorderRow {
|
||||
pub room_id: u64,
|
||||
pub created_at: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
// recorders
|
||||
impl Database {
|
||||
pub async fn add_recorder(&self, platform: PlatformType, room_id: u64) -> Result<RecorderRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let recorder = RecorderRow {
|
||||
room_id,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
platform: platform.as_str().to_string(),
|
||||
};
|
||||
let _ = sqlx::query("INSERT INTO recorders (room_id, created_at, platform) VALUES ($1, $2, $3)")
|
||||
.bind(room_id as i64)
|
||||
.bind(&recorder.created_at)
|
||||
.bind(platform.as_str())
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(recorder)
|
||||
}
|
||||
|
||||
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("DELETE FROM recorders WHERE room_id = $1")
|
||||
.bind(room_id as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
if sql.rows_affected() != 1 {
|
||||
return Err(DatabaseError::NotFoundError);
|
||||
}
|
||||
|
||||
// remove related archive
|
||||
let _ = self.remove_archive(room_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_recorders(&self) -> Result<Vec<RecorderRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecorderRow>("SELECT room_id, created_at, platform FROM recorders")
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn remove_archive(&self, room_id: u64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let _ = sqlx::query("DELETE FROM records WHERE room_id = $1")
|
||||
.bind(room_id as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
100
src-tauri/src/database/video.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use super::Database;
|
||||
use super::DatabaseError;
|
||||
|
||||
// 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 {
|
||||
pub id: i64,
|
||||
pub room_id: u64,
|
||||
pub cover: String,
|
||||
pub file: String,
|
||||
pub length: i64,
|
||||
pub size: i64,
|
||||
pub status: i64,
|
||||
pub bvid: String,
|
||||
pub title: String,
|
||||
pub desc: String,
|
||||
pub tags: String,
|
||||
pub area: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
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?,
|
||||
)
|
||||
}
|
||||
|
||||
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?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn update_video(&self, video_row: &VideoRow) -> 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(video_row.status)
|
||||
.bind(&video_row.bvid)
|
||||
.bind(&video_row.title)
|
||||
.bind(&video_row.desc)
|
||||
.bind(&video_row.tags)
|
||||
.bind(video_row.area)
|
||||
.bind(video_row.id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_video(&self, id: i64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("DELETE FROM videos WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_video(&self, video: &VideoRow) -> Result<VideoRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
|
||||
.bind(video.room_id as i64)
|
||||
.bind(&video.cover)
|
||||
.bind(&video.file)
|
||||
.bind(video.length)
|
||||
.bind(video.size)
|
||||
.bind(video.status)
|
||||
.bind(&video.bvid)
|
||||
.bind(&video.title)
|
||||
.bind(&video.desc)
|
||||
.bind(&video.tags)
|
||||
.bind(video.area)
|
||||
.bind(&video.created_at)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
let video = VideoRow {
|
||||
id: sql.last_insert_rowid(),
|
||||
..video.clone()
|
||||
};
|
||||
Ok(video)
|
||||
}
|
||||
|
||||
pub async fn update_video_cover(&self, id: i64, cover: String) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE videos SET cover = $1 WHERE id = $2")
|
||||
.bind(cover)
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
use chrono::Utc;
|
||||
use custom_error::custom_error;
|
||||
use sqlx::Pool;
|
||||
use sqlx::Sqlite;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct Database {
|
||||
db: RwLock<Option<Pool<Sqlite>>>,
|
||||
}
|
||||
|
||||
/// Recorder in database is pretty simple
|
||||
/// because many room infos are collected in realtime
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct RecorderRow {
|
||||
pub room_id: u64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct AccountRow {
|
||||
pub uid: u64,
|
||||
pub name: String,
|
||||
pub avatar: String,
|
||||
pub csrf: String,
|
||||
pub cookies: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
custom_error! { pub DatabaseError
|
||||
InsertError = "Entry insert failed",
|
||||
NotFoundError = "Entry not found",
|
||||
InvalidCookiesError = "Cookies are invalid",
|
||||
DBError {err: sqlx::Error } = "DB error: {err}",
|
||||
SQLError { sql: String } = "SQL is incorret: {sql}"
|
||||
}
|
||||
|
||||
impl From<DatabaseError> for String {
|
||||
fn from(value: DatabaseError) -> Self {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for DatabaseError {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
DatabaseError::DBError { err: value }
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new() -> Database {
|
||||
Database {
|
||||
db: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// db *must* be set in tauri setup
|
||||
pub async fn set(&self, p: Pool<Sqlite>) {
|
||||
*self.db.write().await = Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// recorders
|
||||
impl Database {
|
||||
pub async fn add_recorder(&self, room_id: u64) -> Result<RecorderRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let recorder = RecorderRow {
|
||||
room_id,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
let _ = sqlx::query("INSERT INTO recorders (room_id, created_at) VALUES ($1, $2)")
|
||||
.bind(room_id as i64)
|
||||
.bind(&recorder.created_at)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(recorder)
|
||||
}
|
||||
|
||||
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("DELETE FROM recorders WHERE room_id = $1")
|
||||
.bind(room_id as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
if sql.rows_affected() != 1 {
|
||||
return Err(DatabaseError::NotFoundError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_recorders(&self) -> Result<Vec<RecorderRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecorderRow>("SELECT * FROM recorders")
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
// accounts
|
||||
impl Database {
|
||||
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
|
||||
pub async fn add_account(&self, cookies: &str) -> Result<AccountRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
// parse cookies
|
||||
let csrf =
|
||||
cookies
|
||||
.split(';')
|
||||
.map(|cookie| cookie.trim())
|
||||
.find_map(|cookie| -> Option<String> {
|
||||
match cookie.starts_with("bili_jct=") {
|
||||
true => {
|
||||
let var_name = &"bili_jct=";
|
||||
Some(cookie[var_name.len()..].to_string())
|
||||
}
|
||||
false => None,
|
||||
}
|
||||
});
|
||||
if csrf.is_none() {
|
||||
return Err(DatabaseError::InvalidCookiesError);
|
||||
}
|
||||
// parse uid
|
||||
let uid = cookies
|
||||
.split("DedeUserID=")
|
||||
.collect::<Vec<&str>>()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.split(";")
|
||||
.collect::<Vec<&str>>()
|
||||
.first()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| DatabaseError::InvalidCookiesError)?;
|
||||
let account = AccountRow {
|
||||
uid,
|
||||
name: "".into(),
|
||||
avatar: "".into(),
|
||||
csrf: csrf.unwrap(),
|
||||
cookies: cookies.into(),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
sqlx::query("INSERT INTO accounts (uid, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6)").bind(account.uid as i64).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn remove_account(&self, uid: u64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("DELETE FROM accounts WHERE uid = $1")
|
||||
.bind(uid as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
if sql.rows_affected() != 1 {
|
||||
return Err(DatabaseError::NotFoundError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_account(
|
||||
&self,
|
||||
uid: u64,
|
||||
name: &str,
|
||||
avatar: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let sql = sqlx::query("UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3")
|
||||
.bind(name)
|
||||
.bind(avatar)
|
||||
.bind(uid as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
if sql.rows_affected() != 1 {
|
||||
return Err(DatabaseError::NotFoundError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_accounts(&self) -> Result<Vec<AccountRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts")
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_account(&self, uid: u64) -> Result<AccountRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(
|
||||
sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE uid = $1")
|
||||
.bind(uid as i64)
|
||||
.fetch_one(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct MessageRow {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub read: u8,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// messages
|
||||
// CREATE TABLE messages (id INTEGER PRIMARY KEY, title TEXT, content TEXT, read INTEGER, created_at TEXT);
|
||||
impl Database {
|
||||
pub async fn new_message(&self, title: &str, content: &str) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (title, content, read, created_at) VALUES ($1, $2, 0, $3)",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_message(&self, id: i64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE messages SET read = $1 WHERE id = $2")
|
||||
.bind(1)
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_message(&self, id: i64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("DELETE FROM messages WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_messages(&self) -> Result<Vec<MessageRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, MessageRow>("SELECT * FROM messages;")
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct RecordRow {
|
||||
pub live_id: u64,
|
||||
pub room_id: u64,
|
||||
pub title: String,
|
||||
pub length: i64,
|
||||
pub size: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, title TEXT, length INTEGER, size INTEGER, created_at TEXT);
|
||||
impl Database {
|
||||
pub async fn get_records(&self, room_id: u64) -> Result<Vec<RecordRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(
|
||||
sqlx::query_as::<_, RecordRow>("SELECT * FROM records WHERE room_id = $1")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_record(&self, room_id: u64, live_id: u64) -> Result<RecordRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, RecordRow>(
|
||||
"SELECT * FROM records WHERE live_id = $1 and room_id = $2",
|
||||
)
|
||||
.bind(live_id as i64)
|
||||
.bind(room_id as i64)
|
||||
.fetch_one(&lock)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn add_record(
|
||||
&self,
|
||||
live_id: u64,
|
||||
room_id: u64,
|
||||
title: &str,
|
||||
) -> Result<RecordRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let record = RecordRow {
|
||||
live_id,
|
||||
room_id,
|
||||
title: title.into(),
|
||||
length: 0,
|
||||
size: 0,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
if let Err(e) = sqlx::query("INSERT INTO records (live_id, room_id, title, length, size, created_at) VALUES ($1, $2, $3, $4, $5, $6)").bind(record.live_id as i64)
|
||||
.bind(record.room_id as i64).bind(&record.title).bind(0).bind(0).bind(&record.created_at).execute(&lock).await {
|
||||
// if the record already exists, return the existing record
|
||||
if e.to_string().contains("UNIQUE constraint failed") {
|
||||
return self.get_record(room_id, live_id).await;
|
||||
}
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn remove_record(&self, live_id: u64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("DELETE FROM records WHERE live_id = $1")
|
||||
.bind(live_id as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_record(
|
||||
&self,
|
||||
live_id: u64,
|
||||
length: i64,
|
||||
size: u64,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE records SET length = $1, size = $2 WHERE live_id = $3")
|
||||
.bind(length)
|
||||
.bind(size as i64)
|
||||
.bind(live_id as i64)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
pub id: i64,
|
||||
pub room_id: u64,
|
||||
pub cover: String,
|
||||
pub file: String,
|
||||
pub length: i64,
|
||||
pub size: i64,
|
||||
pub status: i64,
|
||||
pub bvid: String,
|
||||
pub title: String,
|
||||
pub desc: String,
|
||||
pub tags: String,
|
||||
pub area: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
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?,
|
||||
)
|
||||
}
|
||||
|
||||
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?,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
.bind(bvid)
|
||||
.bind(title)
|
||||
.bind(desc)
|
||||
.bind(tags)
|
||||
.bind(area as i64)
|
||||
.bind(video_id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_video(&self, id: i64) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("DELETE FROM videos WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_video(
|
||||
&self,
|
||||
room_id: u64,
|
||||
cover: &str,
|
||||
file: &str,
|
||||
length: i64,
|
||||
size: i64,
|
||||
status: i64,
|
||||
bvid: &str,
|
||||
title: &str,
|
||||
desc: &str,
|
||||
tags: &str,
|
||||
area: i64,
|
||||
) -> Result<VideoRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
let mut video = VideoRow {
|
||||
id: 0,
|
||||
room_id,
|
||||
cover: cover.into(),
|
||||
file: file.into(),
|
||||
length,
|
||||
size,
|
||||
status,
|
||||
bvid: bvid.into(),
|
||||
title: title.into(),
|
||||
desc: desc.into(),
|
||||
tags: tags.into(),
|
||||
area,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
|
||||
.bind(video.room_id as i64)
|
||||
.bind(&video.cover)
|
||||
.bind(&video.file)
|
||||
.bind(video.length)
|
||||
.bind(video.size)
|
||||
.bind(video.status)
|
||||
.bind(&video.bvid)
|
||||
.bind(&video.title)
|
||||
.bind(&video.desc)
|
||||
.bind(&video.tags)
|
||||
.bind(video.area)
|
||||
.bind(&video.created_at)
|
||||
.execute(&lock)
|
||||
.await?;
|
||||
video.id = sql.last_insert_rowid();
|
||||
Ok(video)
|
||||
}
|
||||
}
|
||||
48
src-tauri/src/ffmpeg.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
use ffmpeg_sidecar::{
|
||||
command::FfmpegCommand,
|
||||
event::{FfmpegEvent, LogLevel},
|
||||
};
|
||||
|
||||
pub struct TranscodeConfig {
|
||||
pub input_path: String,
|
||||
pub input_format: String,
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
|
||||
pub struct TranscodeResult {
|
||||
pub output_path: String,
|
||||
}
|
||||
|
||||
|
||||
pub fn transcode(work_dir: &str, config: TranscodeConfig) -> Result<TranscodeResult, String> {
|
||||
let input_path = config.input_path;
|
||||
let input_format = config.input_format;
|
||||
let output_path = config.output_path;
|
||||
|
||||
println!("transcode task start: input_path: {}, output_path: {}", input_path, output_path);
|
||||
|
||||
FfmpegCommand::new()
|
||||
.args([
|
||||
"-f", input_format.as_str(),
|
||||
])
|
||||
.input(format!("{}/{}", work_dir, input_path))
|
||||
.args(["-c", "copy"])
|
||||
.args(["-y", format!("{}/{}", work_dir, output_path).as_str()])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.unwrap()
|
||||
.for_each(|e| match e {
|
||||
FfmpegEvent::Log(LogLevel::Error, e) => println!("Error: {}", e),
|
||||
FfmpegEvent::Progress(p) => println!("Progress: {}", p.time),
|
||||
_ => {}
|
||||
});
|
||||
|
||||
println!("transcode task end: output_path: {}", output_path);
|
||||
|
||||
Ok(TranscodeResult {
|
||||
output_path: format!("{}/{}", work_dir, output_path),
|
||||
})
|
||||
}
|
||||
92
src-tauri/src/handlers/account.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use crate::database::account::AccountRow;
|
||||
use crate::recorder::bilibili::client::{QrInfo, QrStatus};
|
||||
use crate::state::State;
|
||||
use tauri::State as TauriState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_accounts(state: TauriState<'_, State>) -> Result<super::AccountInfo, String> {
|
||||
let config = state.config.read().await.clone();
|
||||
let account_info = super::AccountInfo {
|
||||
primary_uid: config.primary_uid,
|
||||
accounts: state.db.get_accounts().await?,
|
||||
};
|
||||
Ok(account_info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_account(state: TauriState<'_, State>, platform: String, cookies: &str) -> Result<AccountRow, String> {
|
||||
let mut is_primary = false;
|
||||
if platform == "bilibili" && (state.config.read().await.primary_uid == 0 || state.db.get_accounts().await?.is_empty()) {
|
||||
is_primary = true;
|
||||
}
|
||||
let account = state.db.add_account(&platform, cookies).await?;
|
||||
if platform == "bilibili" {
|
||||
if is_primary {
|
||||
state.config.write().await.webid = state.client.fetch_webid(&account).await?;
|
||||
state.config.write().await.webid_ts = chrono::Utc::now().timestamp();
|
||||
state.config.write().await.primary_uid = account.uid;
|
||||
}
|
||||
let account_info = state
|
||||
.client
|
||||
.get_user_info(&state.config.read().await.webid, &account, account.uid)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.update_account(
|
||||
&platform,
|
||||
account_info.user_id,
|
||||
&account_info.user_name,
|
||||
&account_info.user_avatar_url,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_account(state: TauriState<'_, State>, platform: String, uid: u64) -> Result<(), String> {
|
||||
if state.db.get_accounts().await?.len() == 1 {
|
||||
return Err("At least one account is required".into());
|
||||
}
|
||||
// logout
|
||||
if platform == "bilibili" {
|
||||
let account = state.db.get_account(&platform, uid).await?;
|
||||
state.client.logout(&account).await?;
|
||||
}
|
||||
Ok(state.db.remove_account(&platform, uid).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account_count(state: TauriState<'_, State>) -> Result<u64, String> {
|
||||
Ok(state.db.get_accounts().await?.len() as u64)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_primary(state: TauriState<'_, State>, platform: String, uid: u64) -> Result<(), String> {
|
||||
if platform == "bilibili" {
|
||||
if (state.db.get_account(&platform, uid).await).is_ok() {
|
||||
state.config.write().await.primary_uid = uid;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Account not exist".into())
|
||||
}
|
||||
} else {
|
||||
Err("Unsupported platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_qr_status(state: tauri::State<'_, State>, qrcode_key: &str) -> Result<QrStatus, ()> {
|
||||
match state.client.get_qr_status(qrcode_key).await {
|
||||
Ok(qr_status) => Ok(qr_status),
|
||||
Err(_e) => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_qr(state: tauri::State<'_, State>) -> Result<QrInfo, ()> {
|
||||
match state.client.get_qr().await {
|
||||
Ok(qr_info) => Ok(qr_info),
|
||||
Err(_e) => Err(()),
|
||||
}
|
||||
}
|
||||
149
src-tauri/src/handlers/config.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use crate::config::Config;
|
||||
use crate::state::State;
|
||||
use tauri::State as TauriState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(state: TauriState<'_, State>) -> Result<Config, ()> {
|
||||
Ok(state.config.read().await.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_cache_path(
|
||||
state: TauriState<'_, State>,
|
||||
cache_path: String,
|
||||
) -> Result<(), String> {
|
||||
let old_cache_path = state.config.read().await.cache.clone();
|
||||
if old_cache_path == cache_path {
|
||||
return Ok(());
|
||||
}
|
||||
// TODO only pause recorders
|
||||
// stop and clear all recorders
|
||||
state.recorder_manager.stop_all().await;
|
||||
// first switch to new cache
|
||||
state.config.write().await.set_cache_path(&cache_path);
|
||||
log::info!("Cache path changed: {}", cache_path);
|
||||
// Copy old cache to new cache
|
||||
log::info!("Start copy old cache to new cache");
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"缓存目录切换",
|
||||
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut old_cache_entries = vec![];
|
||||
if let Ok(entries) = std::fs::read_dir(&old_cache_path) {
|
||||
for entry in entries.flatten() {
|
||||
// check if entry is the same as new cache path
|
||||
if entry.path() == std::path::Path::new(&cache_path) {
|
||||
continue;
|
||||
}
|
||||
old_cache_entries.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
// copy all entries to new cache
|
||||
for entry in &old_cache_entries {
|
||||
let new_entry = std::path::Path::new(&cache_path).join(entry.file_name().unwrap());
|
||||
// if entry is a folder
|
||||
if entry.is_dir() {
|
||||
if let Err(e) = crate::handlers::utils::copy_dir_all(entry, &new_entry) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
} else if let Err(e) = std::fs::copy(entry, &new_entry) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Copy old cache to new cache done");
|
||||
state.db.new_message("缓存目录切换", "缓存切换完成").await?;
|
||||
// start all recorders
|
||||
let primary_account = state
|
||||
.db
|
||||
.get_account("bilibili", state.config.read().await.primary_uid)
|
||||
.await?;
|
||||
crate::init_rooms(
|
||||
state.db.clone(),
|
||||
state.recorder_manager.clone(),
|
||||
&primary_account,
|
||||
&state.config.read().await.webid,
|
||||
)
|
||||
.await;
|
||||
|
||||
// remove all old cache entries
|
||||
for entry in old_cache_entries {
|
||||
if entry.is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&entry) {
|
||||
log::error!("Remove old cache error: {}", e);
|
||||
}
|
||||
} else if let Err(e) = std::fs::remove_file(&entry) {
|
||||
log::error!("Remove old cache error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_output_path(state: TauriState<'_, State>, output_path: String) -> Result<(), ()> {
|
||||
let mut config = state.config.write().await;
|
||||
let old_output_path = config.output.clone();
|
||||
if old_output_path == output_path {
|
||||
return Ok(());
|
||||
}
|
||||
// list all file and folder in old output
|
||||
let mut old_output_entries = vec![];
|
||||
if let Ok(entries) = std::fs::read_dir(&old_output_path) {
|
||||
for entry in entries.flatten() {
|
||||
// check if entry is the same as new output path
|
||||
if entry.path() == std::path::Path::new(&output_path) {
|
||||
continue;
|
||||
}
|
||||
old_output_entries.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
// rename all entries to new output
|
||||
for entry in &old_output_entries {
|
||||
let new_entry = std::path::Path::new(&output_path).join(entry.file_name().unwrap());
|
||||
// if entry is a folder
|
||||
if entry.is_dir() {
|
||||
if let Err(e) = crate::handlers::utils::copy_dir_all(entry, &new_entry) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
} else if let Err(e) = std::fs::copy(entry, &new_entry) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all old output entries
|
||||
for entry in old_output_entries {
|
||||
if entry.is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&entry) {
|
||||
log::error!("Remove old cache error: {}", e);
|
||||
}
|
||||
} else if let Err(e) = std::fs::remove_file(&entry) {
|
||||
log::error!("Remove old cache error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
config.set_output_path(&output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_notify(
|
||||
state: TauriState<'_, 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;
|
||||
state.config.write().await.post_notify = post_notify;
|
||||
state.config.write().await.save();
|
||||
Ok(())
|
||||
}
|
||||
18
src-tauri/src/handlers/message.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use crate::database::message::MessageRow;
|
||||
use crate::state::State;
|
||||
use tauri::State as TauriState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_messages(state: TauriState<'_, State>) -> Result<Vec<MessageRow>, String> {
|
||||
Ok(state.db.get_messages().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_message(state: TauriState<'_, State>, id: i64) -> Result<(), String> {
|
||||
Ok(state.db.read_message(id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(state: TauriState<'_, State>, id: i64) -> Result<(), String> {
|
||||
Ok(state.db.delete_message(id).await?)
|
||||
}
|
||||
21
src-tauri/src/handlers/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
pub mod account;
|
||||
pub mod config;
|
||||
pub mod message;
|
||||
pub mod recorder;
|
||||
pub mod utils;
|
||||
pub mod video;
|
||||
|
||||
use crate::database::account::AccountRow;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct AccountInfo {
|
||||
pub primary_uid: u64,
|
||||
pub accounts: Vec<AccountRow>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DiskInfo {
|
||||
pub disk: String,
|
||||
pub total: u64,
|
||||
pub free: u64,
|
||||
}
|
||||
214
src-tauri/src/handlers/recorder.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use crate::database::record::RecordRow;
|
||||
use crate::database::recorder::RecorderRow;
|
||||
use crate::recorder::danmu::DanmuEntry;
|
||||
use crate::recorder::PlatformType;
|
||||
use crate::recorder::RecorderInfo;
|
||||
use crate::recorder_manager::RecorderList;
|
||||
use crate::state::State;
|
||||
use tauri::State as TauriState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_recorder_list(state: TauriState<'_, State>) -> Result<RecorderList, ()> {
|
||||
Ok(state.recorder_manager.get_recorder_list().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_recorder(
|
||||
state: TauriState<'_, State>,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
) -> Result<RecorderRow, String> {
|
||||
log::info!("Add recorder: {} {}", platform, room_id);
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
let account = match platform {
|
||||
PlatformType::BiliBili => {
|
||||
if let Ok(account) = state
|
||||
.db
|
||||
.get_account("bilibili", state.config.read().await.primary_uid)
|
||||
.await
|
||||
{
|
||||
if state.config.read().await.webid_expired() {
|
||||
log::info!("Webid expired, refetching");
|
||||
state.config.write().await.webid = state.client.fetch_webid(&account).await?;
|
||||
state.config.write().await.webid_ts = chrono::Utc::now().timestamp();
|
||||
}
|
||||
Ok(account)
|
||||
} else {
|
||||
Err("没有可用账号,请先添加账号".to_string())
|
||||
}
|
||||
}
|
||||
PlatformType::Douyin => {
|
||||
if let Ok(account) = state.db.get_account_by_platform("douyin").await {
|
||||
Ok(account)
|
||||
} else {
|
||||
Err("没有可用账号,请先添加账号".to_string())
|
||||
}
|
||||
}
|
||||
_ => Err("不支持的平台".to_string()),
|
||||
};
|
||||
|
||||
match account {
|
||||
Ok(account) => match state
|
||||
.recorder_manager
|
||||
.add_recorder(
|
||||
state.config.read().await.webid.as_str(),
|
||||
&account,
|
||||
platform,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let room = state.db.add_recorder(platform, room_id).await?;
|
||||
state
|
||||
.db
|
||||
.new_message("添加直播间", &format!("添加了新直播间 {}", room_id))
|
||||
.await?;
|
||||
Ok(room)
|
||||
}
|
||||
Err(e) => Err(format!("添加失败: {}", e)),
|
||||
},
|
||||
Err(e) => Err(format!("添加失败: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_recorder(
|
||||
state: TauriState<'_, State>,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
) -> Result<(), String> {
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
match state
|
||||
.recorder_manager
|
||||
.remove_recorder(platform, room_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
state
|
||||
.db
|
||||
.new_message("移除直播间", &format!("移除了直播间 {}", room_id))
|
||||
.await?;
|
||||
Ok(state.db.remove_recorder(room_id).await?)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_room_info(
|
||||
state: TauriState<'_, State>,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
) -> Result<RecorderInfo, String> {
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
if let Some(info) = state
|
||||
.recorder_manager
|
||||
.get_recorder_info(platform, room_id)
|
||||
.await
|
||||
{
|
||||
Ok(info)
|
||||
} else {
|
||||
Err("Not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_archives(
|
||||
state: TauriState<'_, State>,
|
||||
room_id: u64,
|
||||
) -> Result<Vec<RecordRow>, String> {
|
||||
log::debug!("Get archives for {}", room_id);
|
||||
Ok(state.recorder_manager.get_archives(room_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_archive(
|
||||
state: TauriState<'_, State>,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<RecordRow, String> {
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.get_archive(room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_archive(
|
||||
state: TauriState<'_, State>,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<(), String> {
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
state
|
||||
.recorder_manager
|
||||
.delete_archive(platform, room_id, &live_id)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"删除历史缓存",
|
||||
&format!("删除了房间 {} 的历史缓存 {}", room_id, live_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_danmu_record(
|
||||
state: TauriState<'_, State>,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<Vec<DanmuEntry>, String> {
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
Ok(state
|
||||
.recorder_manager
|
||||
.get_danmu(platform, room_id, &live_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_danmaku(
|
||||
state: TauriState<'_, State>,
|
||||
uid: u64,
|
||||
room_id: u64,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
let account = state.db.get_account("bilibili", uid).await?;
|
||||
state
|
||||
.client
|
||||
.send_danmaku(&account, room_id, &message)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_total_length(state: TauriState<'_, State>) -> Result<i64, String> {
|
||||
match state.db.get_total_length().await {
|
||||
Ok(total_length) => Ok(total_length),
|
||||
Err(e) => Err(format!("Failed to get total length: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_today_record_count(state: TauriState<'_, State>) -> Result<i64, String> {
|
||||
match state.db.get_today_record_count().await {
|
||||
Ok(count) => Ok(count),
|
||||
Err(e) => Err(format!("Failed to get today record count: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_recent_record(
|
||||
state: TauriState<'_, State>,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
) -> Result<Vec<RecordRow>, String> {
|
||||
match state.db.get_recent_record(offset, limit).await {
|
||||
Ok(records) => Ok(records),
|
||||
Err(e) => Err(format!("Failed to get recent record: {}", e)),
|
||||
}
|
||||
}
|
||||
195
src-tauri/src/handlers/utils.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use std::process::Command;
|
||||
|
||||
use tauri::Theme;
|
||||
use tauri_utils::config::WindowEffectsConfig;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::recorder::PlatformType;
|
||||
use crate::state::State;
|
||||
|
||||
pub fn copy_dir_all(
|
||||
src: impl AsRef<std::path::Path>,
|
||||
dst: impl AsRef<std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_in_folder(path: String) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("explorer")
|
||||
.args(["/select,", &path]) // The comma after select is not a typo
|
||||
.spawn()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::fs::metadata;
|
||||
use std::path::PathBuf;
|
||||
if path.contains(",") {
|
||||
// see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76
|
||||
let new_path = match metadata(&path).unwrap().is_dir() {
|
||||
true => path,
|
||||
false => {
|
||||
let mut path2 = PathBuf::from(path);
|
||||
path2.pop();
|
||||
path2.into_os_string().into_string().unwrap()
|
||||
}
|
||||
};
|
||||
Command::new("xdg-open").arg(&new_path).spawn().unwrap();
|
||||
} else {
|
||||
Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
format!("array:string:\"file://{path}\"").as_str(),
|
||||
"string:\"\"",
|
||||
])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &path])
|
||||
.spawn()
|
||||
.unwrap()
|
||||
.wait()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DiskInfo {
|
||||
disk: String,
|
||||
total: u64,
|
||||
free: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_disk_info(state: tauri::State<'_, State>) -> Result<DiskInfo, ()> {
|
||||
let cache = state.config.read().await.cache.clone();
|
||||
// check system disk info
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
// get cache disk info
|
||||
let mut disk_info = DiskInfo {
|
||||
disk: "".into(),
|
||||
total: 0,
|
||||
free: 0,
|
||||
};
|
||||
for disk in disks.list() {
|
||||
// if output is under disk mount point
|
||||
if cache.starts_with(disk.mount_point().to_str().unwrap()) {
|
||||
// if MacOS, using disk name
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
disk_info.disk = disk.name().to_str().unwrap().into();
|
||||
}
|
||||
// if Windows, using disk mount point
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
disk_info.disk = disk.mount_point().to_str().unwrap().into();
|
||||
}
|
||||
disk_info.total = disk.total_space();
|
||||
disk_info.free = disk.available_space();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(disk_info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_to_file(
|
||||
_state: tauri::State<'_, State>,
|
||||
file_name: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(file_name)
|
||||
.await;
|
||||
if file.is_err() {
|
||||
return Err(format!("Open file failed: {}", file.err().unwrap()));
|
||||
}
|
||||
let mut file = file.unwrap();
|
||||
if let Err(e) = file.write_all(content.as_bytes()).await {
|
||||
return Err(format!("Write file failed: {}", e));
|
||||
}
|
||||
if let Err(e) = file.flush().await {
|
||||
return Err(format!("Flush file failed: {}", e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_live(
|
||||
state: tauri::State<'_, State>,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Open player window: {} {}", room_id, live_id);
|
||||
let addr = state.recorder_manager.get_hls_server_addr().await.unwrap();
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
let recorder_info = state
|
||||
.recorder_manager
|
||||
.get_recorder_info(platform, room_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let handle = state.app_handle.clone();
|
||||
let builder = tauri::WebviewWindowBuilder::new(
|
||||
&handle,
|
||||
format!("Live:{}:{}", room_id, live_id),
|
||||
tauri::WebviewUrl::App(
|
||||
format!(
|
||||
"live_index.html?port={}&platform={}&room_id={}&live_id={}",
|
||||
addr.port(),
|
||||
platform.as_str(),
|
||||
room_id,
|
||||
live_id
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.title(format!(
|
||||
"Live[{}] {}",
|
||||
room_id, recorder_info.room_info.room_title
|
||||
))
|
||||
.theme(Some(Theme::Light))
|
||||
.inner_size(1200.0, 800.0)
|
||||
.effects(WindowEffectsConfig {
|
||||
effects: vec![
|
||||
tauri_utils::WindowEffect::Tabbed,
|
||||
tauri_utils::WindowEffect::Mica,
|
||||
],
|
||||
state: None,
|
||||
radius: None,
|
||||
color: None,
|
||||
});
|
||||
|
||||
if let Err(e) = builder.decorations(true).build() {
|
||||
log::error!("live window build failed: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
176
src-tauri/src/handlers/video.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::database::video::VideoRow;
|
||||
use crate::recorder::bilibili::profile::Profile;
|
||||
use crate::recorder::PlatformType;
|
||||
use crate::state::State;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use tauri::State as TauriState;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clip_range(
|
||||
state: TauriState<'_, State>,
|
||||
cover: String,
|
||||
platform: String,
|
||||
room_id: u64,
|
||||
live_id: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Result<VideoRow, String> {
|
||||
log::info!(
|
||||
"Clip room_id: {}, ts: {}, start: {}, end: {}",
|
||||
room_id,
|
||||
live_id,
|
||||
x,
|
||||
y
|
||||
);
|
||||
let platform = PlatformType::from_str(&platform).unwrap();
|
||||
let file = state
|
||||
.recorder_manager
|
||||
.clip_range(&state.config.read().await.output, platform, room_id, &live_id, x, y)
|
||||
.await?;
|
||||
// get file metadata from fs
|
||||
let metadata = std::fs::metadata(&file).map_err(|e| e.to_string())?;
|
||||
// get filename from path
|
||||
let filename = Path::new(&file)
|
||||
.file_name()
|
||||
.ok_or("Invalid file path")?
|
||||
.to_str()
|
||||
.ok_or("Invalid file path")?;
|
||||
// add video to db
|
||||
let video = state
|
||||
.db
|
||||
.add_video(&VideoRow {
|
||||
id: 0,
|
||||
status: 0,
|
||||
room_id,
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
cover: cover.clone(),
|
||||
file: filename.into(),
|
||||
length: (y - x) as i64,
|
||||
size: metadata.len() as i64,
|
||||
bvid: "".into(),
|
||||
title: "".into(),
|
||||
desc: "".into(),
|
||||
tags: "".into(),
|
||||
area: 0,
|
||||
})
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"生成新切片",
|
||||
&format!(
|
||||
"生成了房间 {} 的切片,长度 {:.1}s:{}",
|
||||
room_id,
|
||||
y - x,
|
||||
filename
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.clip_notify {
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 切片完成")
|
||||
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(video)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_procedure(
|
||||
state: TauriState<'_, State>,
|
||||
uid: u64,
|
||||
room_id: u64,
|
||||
video_id: i64,
|
||||
cover: String,
|
||||
mut profile: Profile,
|
||||
) -> Result<String, String> {
|
||||
let account = state.db.get_account("bilibili", uid).await?;
|
||||
// get video info from dbs
|
||||
let mut video_row = state.db.get_video(video_id).await?;
|
||||
// construct file path
|
||||
let output = state.config.read().await.output.clone();
|
||||
let file = format!("{}/{}", output, video_row.file);
|
||||
let path = Path::new(&file);
|
||||
let cover_url = state.client.upload_cover(&account, &cover);
|
||||
if let Ok(video) = state.client.prepare_video(&account, path).await {
|
||||
profile.cover = cover_url.await.unwrap_or("".to_string());
|
||||
if let Ok(ret) = state.client.submit_video(&account, &profile, &video).await {
|
||||
// update video status and details
|
||||
// 1 means uploaded
|
||||
video_row.status = 1;
|
||||
video_row.bvid = ret.bvid.clone();
|
||||
video_row.title = profile.title;
|
||||
video_row.desc = profile.desc;
|
||||
video_row.tags = profile.tag;
|
||||
video_row.area = profile.tid as i64;
|
||||
state.db.update_video(&video_row).await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"投稿成功",
|
||||
&format!("投稿了房间 {} 的切片:{}", room_id, ret.bvid),
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.post_notify {
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 投稿成功")
|
||||
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(ret.bvid)
|
||||
} else {
|
||||
Err("Submit video failed".to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Preload video failed".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_video(state: TauriState<'_, State>, id: i64) -> Result<VideoRow, String> {
|
||||
Ok(state.db.get_video(id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_videos(state: TauriState<'_, State>, room_id: u64) -> Result<Vec<VideoRow>, String> {
|
||||
Ok(state.db.get_videos(room_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_video(state: TauriState<'_, State>, id: i64) -> Result<(), String> {
|
||||
// get video info from dbus
|
||||
let video = state.db.get_video(id).await?;
|
||||
// delete video files
|
||||
let filepath = format!("{}/{}", state.config.read().await.output, video.file);
|
||||
let file = Path::new(&filepath);
|
||||
if let Err(e) = std::fs::remove_file(file) {
|
||||
log::error!("Delete video file error: {}", e);
|
||||
}
|
||||
Ok(state.db.delete_video(id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_video_typelist(
|
||||
state: TauriState<'_, State>,
|
||||
) -> Result<Vec<crate::recorder::bilibili::response::Typelist>, String> {
|
||||
let account = state
|
||||
.db
|
||||
.get_account("bilibili", state.config.read().await.primary_uid)
|
||||
.await?;
|
||||
Ok(state.client.get_video_typelist(&account).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_video_cover(state: TauriState<'_, State>, id: i64, cover: String) -> Result<(), String> {
|
||||
Ok(state.db.update_video_cover(id, cover).await?)
|
||||
}
|
||||
798
src-tauri/src/recorder/bilibili/client.rs
Normal file
@@ -0,0 +1,798 @@
|
||||
use super::errors::BiliClientError;
|
||||
use super::profile;
|
||||
use super::profile::Profile;
|
||||
use super::response;
|
||||
use super::response::Format;
|
||||
use super::response::GeneralResponse;
|
||||
use super::response::PostVideoMetaResponse;
|
||||
use super::response::PreuploadResponse;
|
||||
use super::response::VideoSubmitData;
|
||||
use crate::database::account::AccountRow;
|
||||
use base64::Engine;
|
||||
use pct_str::PctString;
|
||||
use pct_str::URIReserved;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct RoomInfo {
|
||||
pub live_status: u8,
|
||||
pub room_cover_url: String,
|
||||
pub room_id: u64,
|
||||
pub room_keyframe_url: String,
|
||||
pub room_title: String,
|
||||
pub user_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct UserInfo {
|
||||
pub user_id: u64,
|
||||
pub user_name: String,
|
||||
pub user_sign: String,
|
||||
pub user_avatar_url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QrInfo {
|
||||
pub oauth_key: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QrStatus {
|
||||
pub code: u8,
|
||||
pub cookies: String,
|
||||
}
|
||||
|
||||
/// BiliClient is thread safe
|
||||
pub struct BiliClient {
|
||||
client: Client,
|
||||
headers: reqwest::header::HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum StreamType {
|
||||
TS,
|
||||
FMP4,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiliStream {
|
||||
pub format: StreamType,
|
||||
pub host: String,
|
||||
pub path: String,
|
||||
pub extra: String,
|
||||
pub expire: i64,
|
||||
}
|
||||
|
||||
impl fmt::Display for BiliStream {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"type: {:?}, host: {}, path: {}, extra: {}, expire: {}",
|
||||
self.format, self.host, self.path, self.extra, self.expire
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl BiliStream {
|
||||
pub fn new(format: StreamType, base_url: &str, host: &str, extra: &str) -> BiliStream {
|
||||
BiliStream {
|
||||
format,
|
||||
host: host.into(),
|
||||
path: BiliStream::get_path(base_url),
|
||||
extra: extra.into(),
|
||||
expire: BiliStream::get_expire(extra).unwrap_or(600000),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(&self) -> String {
|
||||
format!("{}{}{}?{}", self.host, self.path, "index.m3u8", self.extra)
|
||||
}
|
||||
|
||||
pub fn ts_url(&self, seg_name: &str) -> String {
|
||||
format!("{}{}{}?{}", self.host, self.path, seg_name, self.extra)
|
||||
}
|
||||
|
||||
pub fn get_path(base_url: &str) -> String {
|
||||
match base_url.rfind('/') {
|
||||
Some(pos) => base_url[..pos + 1].to_string(),
|
||||
None => base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_expire(extra: &str) -> Option<i64> {
|
||||
extra.split('&').find_map(|param| {
|
||||
if param.starts_with("expires=") {
|
||||
param.split('=').nth(1)?.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BiliClient {
|
||||
pub fn new() -> Result<BiliClient, BiliClientError> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36".parse().unwrap());
|
||||
|
||||
if let Ok(client) = Client::builder().timeout(Duration::from_secs(10)).build() {
|
||||
Ok(BiliClient { client, headers })
|
||||
} else {
|
||||
Err(BiliClientError::InitClientError)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_webid(&self, account: &AccountRow) -> Result<String, BiliClientError> {
|
||||
// get webid from html content
|
||||
// webid is in script tag <script id="__RENDER_DATA__" type="application/json">
|
||||
// https://space.bilibili.com/{user_id}
|
||||
let url = format!("https://space.bilibili.com/{}", account.uid);
|
||||
let res = self.client.get(&url).send().await?;
|
||||
let content = res.text().await?;
|
||||
let re =
|
||||
Regex::new(r#"<script id="__RENDER_DATA__" type="application/json">(.+?)</script>"#)
|
||||
.unwrap();
|
||||
let cap = re.captures(&content).ok_or(BiliClientError::InvalidValue)?;
|
||||
let str = cap.get(1).ok_or(BiliClientError::InvalidValue)?.as_str();
|
||||
// str need url decode
|
||||
let json_str = urlencoding::decode(str).map_err(|_| BiliClientError::InvalidValue)?; // url decode
|
||||
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
||||
let webid = json["access_id"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?;
|
||||
log::info!("webid: {}", webid);
|
||||
Ok(webid.into())
|
||||
}
|
||||
|
||||
pub async fn get_qr(&self) -> Result<QrInfo, BiliClientError> {
|
||||
let res: serde_json::Value = self
|
||||
.client
|
||||
.get("https://passport.bilibili.com/x/passport-login/web/qrcode/generate")
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(QrInfo {
|
||||
oauth_key: res["data"]["qrcode_key"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.to_string(),
|
||||
url: res["data"]["url"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_qr_status(&self, qrcode_key: &str) -> Result<QrStatus, BiliClientError> {
|
||||
let res: serde_json::Value = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key={}",
|
||||
qrcode_key
|
||||
))
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let code: u8 = res["data"]["code"].as_u64().unwrap_or(400) as u8;
|
||||
let mut cookies: String = "".to_string();
|
||||
if code == 0 {
|
||||
let url = res["data"]["url"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.to_string();
|
||||
let query_str = url.split('?').last().unwrap();
|
||||
cookies = query_str.replace('&', ";");
|
||||
}
|
||||
Ok(QrStatus { code, cookies })
|
||||
}
|
||||
|
||||
pub async fn logout(&self, account: &AccountRow) -> Result<(), BiliClientError> {
|
||||
let url = "https://passport.bilibili.com/login/exit/v2";
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let params = [("csrf", account.csrf.clone())];
|
||||
let _ = self
|
||||
.client
|
||||
.post(url)
|
||||
.headers(headers)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_info(
|
||||
&self,
|
||||
webid: &str,
|
||||
account: &AccountRow,
|
||||
user_id: u64,
|
||||
) -> Result<UserInfo, BiliClientError> {
|
||||
let params: Value = json!({
|
||||
"mid": user_id.to_string(),
|
||||
"platform": "web",
|
||||
"web_location": "1550101",
|
||||
"token": "",
|
||||
"w_webid": webid,
|
||||
});
|
||||
let params = self.get_sign(params).await?;
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let res: serde_json::Value = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api.bilibili.com/x/space/wbi/acc/info?{}",
|
||||
params
|
||||
))
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
if res["code"].as_i64().unwrap_or(-1) != 0 {
|
||||
log::error!(
|
||||
"Get user info failed {}",
|
||||
res["code"].as_i64().unwrap_or(-1)
|
||||
);
|
||||
return Err(BiliClientError::InvalidCode);
|
||||
}
|
||||
Ok(UserInfo {
|
||||
user_id,
|
||||
user_name: res["data"]["name"].as_str().unwrap_or("").to_string(),
|
||||
user_sign: res["data"]["sign"].as_str().unwrap_or("").to_string(),
|
||||
user_avatar_url: res["data"]["face"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_room_info(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
room_id: u64,
|
||||
) -> Result<RoomInfo, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let res: serde_json::Value = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api.live.bilibili.com/room/v1/Room/get_info?room_id={}",
|
||||
room_id
|
||||
))
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let code = res["code"].as_u64().ok_or(BiliClientError::InvalidValue)?;
|
||||
if code != 0 {
|
||||
return Err(BiliClientError::InvalidCode);
|
||||
}
|
||||
|
||||
let room_id = res["data"]["room_id"]
|
||||
.as_u64()
|
||||
.ok_or(BiliClientError::InvalidValue)?;
|
||||
let room_title = res["data"]["title"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.to_string();
|
||||
let room_cover_url = res["data"]["user_cover"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.to_string();
|
||||
let room_keyframe_url = res["data"]["keyframe"]
|
||||
.as_str()
|
||||
.ok_or(BiliClientError::InvalidValue)?
|
||||
.to_string();
|
||||
let user_id = res["data"]["uid"]
|
||||
.as_u64()
|
||||
.ok_or(BiliClientError::InvalidValue)?;
|
||||
let live_status = res["data"]["live_status"]
|
||||
.as_u64()
|
||||
.ok_or(BiliClientError::InvalidValue)? as u8;
|
||||
Ok(RoomInfo {
|
||||
room_id,
|
||||
room_title,
|
||||
room_cover_url,
|
||||
room_keyframe_url,
|
||||
user_id,
|
||||
live_status,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_cover_base64(&self, url: &str) -> Result<String, BiliClientError> {
|
||||
log::info!("get_cover_base64: {}", url);
|
||||
let response = self.client.get(url).send().await?;
|
||||
let bytes = response.bytes().await?;
|
||||
let base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mime_type = mime_guess::from_path(url)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
Ok(format!("data:{};base64,{}", mime_type, base64))
|
||||
}
|
||||
|
||||
pub async fn get_play_url(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
room_id: u64,
|
||||
) -> Result<BiliStream, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let res: GeneralResponse = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id={}&protocol=1&format=0,1,2&codec=0&qn=10000&platform=h5",
|
||||
room_id
|
||||
))
|
||||
.headers(headers)
|
||||
.send().await?
|
||||
.json().await?;
|
||||
if res.code == 0 {
|
||||
if let response::Data::RoomPlayInfo(data) = res.data {
|
||||
if let Some(stream) = data.playurl_info.playurl.stream.first() {
|
||||
// Get fmp4 format
|
||||
if let Some(f) = stream.format.iter().find(|f| f.format_name == "fmp4") {
|
||||
self.get_stream(f).await
|
||||
} else {
|
||||
log::error!("No fmp4 stream found: {:#?}", data);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
} else {
|
||||
log::error!("No stream provided: {:#?}", data);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
} else {
|
||||
log::error!("Invalid response: {:#?}", res);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
} else {
|
||||
log::error!("Invalid response: {:#?}", res);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stream(&self, format: &Format) -> Result<BiliStream, BiliClientError> {
|
||||
if let Some(codec) = format.codec.first() {
|
||||
if let Some(url_info) = codec.url_info.first() {
|
||||
Ok(BiliStream::new(
|
||||
StreamType::FMP4,
|
||||
&codec.base_url,
|
||||
&url_info.host,
|
||||
&url_info.extra,
|
||||
))
|
||||
} else {
|
||||
Err(BiliClientError::InvalidFormat)
|
||||
}
|
||||
} else {
|
||||
Err(BiliClientError::InvalidFormat)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_index_content(&self, url: &String) -> Result<String, BiliClientError> {
|
||||
Ok(self
|
||||
.client
|
||||
.get(url.to_owned())
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn download_ts(&self, url: &str, file_path: &str) -> Result<u64, BiliClientError> {
|
||||
let res = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await?;
|
||||
let mut file = std::fs::File::create(file_path)?;
|
||||
let bytes = res.bytes().await?;
|
||||
let size = bytes.len() as u64;
|
||||
let mut content = std::io::Cursor::new(bytes);
|
||||
std::io::copy(&mut content, &mut file)?;
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
// Method from js code
|
||||
pub async fn get_sign(&self, mut parameters: Value) -> Result<String, BiliClientError> {
|
||||
let table = vec![
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42,
|
||||
19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60,
|
||||
51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
||||
];
|
||||
let nav_info: Value = self
|
||||
.client
|
||||
.get("https://api.bilibili.com/x/web-interface/nav")
|
||||
.headers(self.headers.clone())
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let re = Regex::new(r"wbi/(.*).png").unwrap();
|
||||
let img = re
|
||||
.captures(nav_info["data"]["wbi_img"]["img_url"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let sub = re
|
||||
.captures(nav_info["data"]["wbi_img"]["sub_url"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let raw_string = format!("{}{}", img, sub);
|
||||
let mut encoded = Vec::new();
|
||||
table.into_iter().for_each(|x| {
|
||||
if x < raw_string.len() {
|
||||
encoded.push(raw_string.as_bytes()[x]);
|
||||
}
|
||||
});
|
||||
// only keep 32 bytes of encoded
|
||||
encoded = encoded[0..32].to_vec();
|
||||
let encoded = String::from_utf8(encoded).unwrap();
|
||||
// Timestamp in seconds
|
||||
let wts = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
parameters
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("wts".to_owned(), serde_json::Value::String(wts.to_string()));
|
||||
// Get all keys from parameters into vec
|
||||
let mut keys = parameters
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(|x| x.to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
// sort keys
|
||||
keys.sort();
|
||||
let mut params = String::new();
|
||||
keys.iter().for_each(|x| {
|
||||
params.push_str(x);
|
||||
params.push('=');
|
||||
// Value filters !'()* characters
|
||||
let value = parameters
|
||||
.get(x)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace(['!', '\'', '(', ')', '*'], "");
|
||||
let value = PctString::encode(value.chars(), URIReserved);
|
||||
params.push_str(value.as_str());
|
||||
// add & if not last
|
||||
if x != keys.last().unwrap() {
|
||||
params.push('&');
|
||||
}
|
||||
});
|
||||
// md5 params+encoded
|
||||
let w_rid = md5::compute(params.to_string() + encoded.as_str());
|
||||
let params = params + format!("&w_rid={:x}", w_rid).as_str();
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
async fn preupload_video(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
video_file: &Path,
|
||||
) -> Result<PreuploadResponse, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let url = format!(
|
||||
"https://member.bilibili.com/preupload?name={}&r=upos&profile=ugcfx/bup",
|
||||
video_file.file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await?
|
||||
.json::<PreuploadResponse>()
|
||||
.await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn post_video_meta(
|
||||
&self,
|
||||
preupload_response: &PreuploadResponse,
|
||||
video_file: &Path,
|
||||
) -> Result<PostVideoMetaResponse, BiliClientError> {
|
||||
let url = format!(
|
||||
"https:{}{}?uploads=&output=json&profile=ugcfx/bup&filesize={}&partsize={}&biz_id={}",
|
||||
preupload_response.endpoint,
|
||||
preupload_response.upos_uri.replace("upos:/", ""),
|
||||
video_file.metadata().unwrap().len(),
|
||||
preupload_response.chunk_size,
|
||||
preupload_response.biz_id
|
||||
);
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Upos-Auth", &preupload_response.auth)
|
||||
.send()
|
||||
.await?
|
||||
.json::<PostVideoMetaResponse>()
|
||||
.await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn upload_video(
|
||||
&self,
|
||||
preupload_response: &PreuploadResponse,
|
||||
post_video_meta_response: &PostVideoMetaResponse,
|
||||
video_file: &Path,
|
||||
) -> Result<usize, BiliClientError> {
|
||||
let mut file = File::open(video_file).await?;
|
||||
let mut buffer = vec![0; preupload_response.chunk_size];
|
||||
let file_size = video_file.metadata()?.len();
|
||||
let chunk_size = preupload_response.chunk_size as u64; // 确保使用 u64 类型
|
||||
let total_chunks = (file_size as f64 / chunk_size as f64).ceil() as usize; // 计算总分块数
|
||||
|
||||
let start = Instant::now();
|
||||
let mut chunk = 0;
|
||||
let mut read_total = 0;
|
||||
while let Ok(size) = file.read(&mut buffer[read_total..]).await {
|
||||
read_total += size;
|
||||
log::debug!("size: {}, total: {}", size, read_total);
|
||||
if size > 0 && (read_total as u64) < chunk_size {
|
||||
continue;
|
||||
}
|
||||
if size == 0 && read_total == 0 {
|
||||
break;
|
||||
}
|
||||
let url = format!(
|
||||
"https:{}{}?partNumber={}&uploadId={}&chunk={}&chunks={}&size={}&start={}&end={}&total={}",
|
||||
preupload_response.endpoint,
|
||||
preupload_response.upos_uri.replace("upos:/", ""),
|
||||
chunk + 1,
|
||||
post_video_meta_response.upload_id,
|
||||
chunk,
|
||||
total_chunks,
|
||||
read_total,
|
||||
chunk * preupload_response.chunk_size,
|
||||
chunk * preupload_response.chunk_size + read_total,
|
||||
video_file.metadata().unwrap().len()
|
||||
);
|
||||
self.client
|
||||
.put(&url)
|
||||
.header("X-Upos-Auth", &preupload_response.auth)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Content-Length", read_total.to_string())
|
||||
.body(buffer[..read_total].to_vec())
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
chunk += 1;
|
||||
read_total = 0;
|
||||
log::debug!(
|
||||
"[bili]speed: {:.1} KiB/s",
|
||||
(chunk * preupload_response.chunk_size) as f64
|
||||
/ start.elapsed().as_secs_f64()
|
||||
/ 1024.0
|
||||
);
|
||||
}
|
||||
Ok(total_chunks)
|
||||
}
|
||||
|
||||
async fn end_upload(
|
||||
&self,
|
||||
preupload_response: &PreuploadResponse,
|
||||
post_video_meta_response: &PostVideoMetaResponse,
|
||||
chunks: usize,
|
||||
) -> Result<(), BiliClientError> {
|
||||
let url = format!(
|
||||
"https:{}{}?output=json&name={}&profile=ugcfx/bup&uploadId={}&biz_id={}",
|
||||
preupload_response.endpoint,
|
||||
preupload_response.upos_uri.replace("upos:/", ""),
|
||||
preupload_response.upos_uri,
|
||||
post_video_meta_response.upload_id,
|
||||
preupload_response.biz_id
|
||||
);
|
||||
let parts: Vec<Value> = (1..=chunks)
|
||||
.map(|i| json!({ "partNumber": i, "eTag": "etag" }))
|
||||
.collect();
|
||||
let body = json!({ "parts": parts });
|
||||
self.client
|
||||
.post(&url)
|
||||
.header("X-Upos-Auth", &preupload_response.auth)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.body(body.to_string())
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prepare_video(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
video_file: &Path,
|
||||
) -> Result<profile::Video, BiliClientError> {
|
||||
log::info!("Start Preparing Video: {}", video_file.to_str().unwrap());
|
||||
let preupload = self.preupload_video(account, video_file).await?;
|
||||
log::info!("Preupload Response: {:?}", preupload);
|
||||
let metaposted = self.post_video_meta(&preupload, video_file).await?;
|
||||
log::info!("Post Video Meta Response: {:?}", metaposted);
|
||||
let uploaded = self
|
||||
.upload_video(&preupload, &metaposted, video_file)
|
||||
.await?;
|
||||
log::info!("Uploaded: {}", uploaded);
|
||||
self.end_upload(&preupload, &metaposted, uploaded).await?;
|
||||
let filename = Path::new(&metaposted.key)
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
Ok(profile::Video {
|
||||
title: "".to_string(),
|
||||
filename: filename.to_string(),
|
||||
desc: "".to_string(),
|
||||
cid: preupload.biz_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn submit_video(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
profile_template: &Profile,
|
||||
video: &profile::Video,
|
||||
) -> Result<VideoSubmitData, BiliClientError> {
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let url = format!(
|
||||
"https://member.bilibili.com/x/vu/web/add/v3?ts={}&csrf={}",
|
||||
chrono::Local::now().timestamp(),
|
||||
account.csrf
|
||||
);
|
||||
let mut preprofile = profile_template.clone();
|
||||
preprofile.videos.push(video.clone());
|
||||
match self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.body(serde_json::ser::to_string(&preprofile).unwrap_or("".to_string()))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(raw_resp) => {
|
||||
let json = raw_resp.json().await?;
|
||||
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json) {
|
||||
match resp.data {
|
||||
response::Data::VideoSubmit(data) => Ok(data),
|
||||
_ => Err(BiliClientError::InvalidResponse),
|
||||
}
|
||||
} else {
|
||||
println!("Parse response failed");
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Send failed {}", e);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_cover(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
cover: &str,
|
||||
) -> Result<String, BiliClientError> {
|
||||
let url = format!(
|
||||
"https://member.bilibili.com/x/vu/web/cover/up?ts={}",
|
||||
chrono::Local::now().timestamp(),
|
||||
);
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let params = [("csrf", account.csrf.clone()), ("cover", cover.to_string())];
|
||||
match self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(raw_resp) => {
|
||||
let json = raw_resp.json().await?;
|
||||
if let Ok(resp) = serde_json::from_value::<GeneralResponse>(json) {
|
||||
match resp.data {
|
||||
response::Data::Cover(data) => Ok(data.url),
|
||||
_ => Err(BiliClientError::InvalidResponse),
|
||||
}
|
||||
} else {
|
||||
println!("Parse response failed");
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Send failed {}", e);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_danmaku(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
room_id: u64,
|
||||
message: &str,
|
||||
) -> Result<(), BiliClientError> {
|
||||
let url = "https://api.live.bilibili.com/msg/send".to_string();
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let params = [
|
||||
("bubble", "0"),
|
||||
("msg", message),
|
||||
("color", "16777215"),
|
||||
("mode", "1"),
|
||||
("fontsize", "25"),
|
||||
("room_type", "0"),
|
||||
("rnd", &format!("{}", chrono::Local::now().timestamp())),
|
||||
("roomid", &format!("{}", room_id)),
|
||||
("csrf", &account.csrf),
|
||||
("csrf_token", &account.csrf),
|
||||
];
|
||||
let _ = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_video_typelist(
|
||||
&self,
|
||||
account: &AccountRow,
|
||||
) -> Result<Vec<response::Typelist>, BiliClientError> {
|
||||
let url = "https://member.bilibili.com/x/vupre/web/archive/pre?lang=cn";
|
||||
let mut headers = self.headers.clone();
|
||||
headers.insert("cookie", account.cookies.parse().unwrap());
|
||||
let resp: GeneralResponse = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
if resp.code == 0 {
|
||||
if let response::Data::VideoTypeList(data) = resp.data {
|
||||
Ok(data.typelist)
|
||||
} else {
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
} else {
|
||||
log::error!("Get video typelist failed with code {}", resp.code);
|
||||
Err(BiliClientError::InvalidResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GeneralResponse {
|
||||
@@ -13,6 +15,8 @@ pub struct GeneralResponse {
|
||||
pub enum Data {
|
||||
VideoSubmit(VideoSubmitData),
|
||||
Cover(CoverData),
|
||||
RoomPlayInfo(RoomPlayInfoData),
|
||||
VideoTypeList(VideoTypeListData),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -41,3 +45,183 @@ pub struct PostVideoMetaResponse {
|
||||
pub key: String,
|
||||
pub upload_id: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoomPlayInfoData {
|
||||
#[serde(rename = "room_id")]
|
||||
pub room_id: i64,
|
||||
#[serde(rename = "short_id")]
|
||||
pub short_id: i64,
|
||||
pub uid: i64,
|
||||
#[serde(rename = "is_hidden")]
|
||||
pub is_hidden: bool,
|
||||
#[serde(rename = "is_locked")]
|
||||
pub is_locked: bool,
|
||||
#[serde(rename = "is_portrait")]
|
||||
pub is_portrait: bool,
|
||||
#[serde(rename = "live_status")]
|
||||
pub live_status: i64,
|
||||
#[serde(rename = "hidden_till")]
|
||||
pub hidden_till: i64,
|
||||
#[serde(rename = "lock_till")]
|
||||
pub lock_till: i64,
|
||||
pub encrypted: bool,
|
||||
#[serde(rename = "pwd_verified")]
|
||||
pub pwd_verified: bool,
|
||||
#[serde(rename = "live_time")]
|
||||
pub live_time: i64,
|
||||
#[serde(rename = "room_shield")]
|
||||
pub room_shield: i64,
|
||||
#[serde(rename = "all_special_types")]
|
||||
pub all_special_types: Vec<i64>,
|
||||
#[serde(rename = "playurl_info")]
|
||||
pub playurl_info: PlayurlInfo,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayurlInfo {
|
||||
#[serde(rename = "conf_json")]
|
||||
pub conf_json: String,
|
||||
pub playurl: Playurl,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Playurl {
|
||||
pub cid: i64,
|
||||
#[serde(rename = "g_qn_desc")]
|
||||
pub g_qn_desc: Vec<GQnDesc>,
|
||||
pub stream: Vec<Stream>,
|
||||
#[serde(rename = "p2p_data")]
|
||||
pub p2p_data: P2pData,
|
||||
#[serde(rename = "dolby_qn")]
|
||||
pub dolby_qn: Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GQnDesc {
|
||||
pub qn: i64,
|
||||
pub desc: String,
|
||||
#[serde(rename = "hdr_desc")]
|
||||
pub hdr_desc: String,
|
||||
#[serde(rename = "attr_desc")]
|
||||
pub attr_desc: Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Stream {
|
||||
#[serde(rename = "protocol_name")]
|
||||
pub protocol_name: String,
|
||||
pub format: Vec<Format>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Format {
|
||||
#[serde(rename = "format_name")]
|
||||
pub format_name: String,
|
||||
pub codec: Vec<Codec>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Codec {
|
||||
#[serde(rename = "codec_name")]
|
||||
pub codec_name: String,
|
||||
#[serde(rename = "current_qn")]
|
||||
pub current_qn: i64,
|
||||
#[serde(rename = "accept_qn")]
|
||||
pub accept_qn: Vec<i64>,
|
||||
#[serde(rename = "base_url")]
|
||||
pub base_url: String,
|
||||
#[serde(rename = "url_info")]
|
||||
pub url_info: Vec<UrlInfo>,
|
||||
#[serde(rename = "hdr_qn")]
|
||||
pub hdr_qn: Value,
|
||||
#[serde(rename = "dolby_type")]
|
||||
pub dolby_type: i64,
|
||||
#[serde(rename = "attr_name")]
|
||||
pub attr_name: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UrlInfo {
|
||||
pub host: String,
|
||||
pub extra: String,
|
||||
#[serde(rename = "stream_ttl")]
|
||||
pub stream_ttl: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct P2pData {
|
||||
pub p2p: bool,
|
||||
#[serde(rename = "p2p_type")]
|
||||
pub p2p_type: i64,
|
||||
#[serde(rename = "m_p2p")]
|
||||
pub m_p2p: bool,
|
||||
#[serde(rename = "m_servers")]
|
||||
pub m_servers: Value,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoTypeListData {
|
||||
pub typelist: Vec<Typelist>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Typelist {
|
||||
pub id: i64,
|
||||
pub parent: i64,
|
||||
#[serde(rename = "parent_name")]
|
||||
pub parent_name: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub desc: String,
|
||||
#[serde(rename = "intro_original")]
|
||||
pub intro_original: String,
|
||||
#[serde(rename = "intro_copy")]
|
||||
pub intro_copy: String,
|
||||
pub notice: String,
|
||||
#[serde(rename = "copy_right")]
|
||||
pub copy_right: i64,
|
||||
pub show: bool,
|
||||
pub rank: i64,
|
||||
pub children: Vec<Children>,
|
||||
#[serde(rename = "max_video_count")]
|
||||
pub max_video_count: i64,
|
||||
#[serde(rename = "request_id")]
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Children {
|
||||
pub id: i64,
|
||||
pub parent: i64,
|
||||
#[serde(rename = "parent_name")]
|
||||
pub parent_name: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub desc: String,
|
||||
#[serde(rename = "intro_original")]
|
||||
pub intro_original: String,
|
||||
#[serde(rename = "intro_copy")]
|
||||
pub intro_copy: String,
|
||||
pub notice: String,
|
||||
#[serde(rename = "copy_right")]
|
||||
pub copy_right: i64,
|
||||
pub show: bool,
|
||||
pub rank: i64,
|
||||
#[serde(rename = "max_video_count")]
|
||||
pub max_video_count: i64,
|
||||
#[serde(rename = "request_id")]
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ impl DanmuStorage {
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(file_path)
|
||||
.await;
|
||||
if file.is_err() {
|
||||
@@ -45,10 +46,10 @@ impl DanmuStorage {
|
||||
.open(file_path)
|
||||
.await
|
||||
.expect("create danmu.txt failed");
|
||||
return Some(DanmuStorage {
|
||||
Some(DanmuStorage {
|
||||
cache: RwLock::new(preload_cache),
|
||||
file: RwLock::new(file),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn add_line(&self, ts: u64, content: &str) {
|
||||
|
||||
549
src-tauri/src/recorder/douyin.rs
Normal file
@@ -0,0 +1,549 @@
|
||||
pub mod client;
|
||||
mod response;
|
||||
use super::entry::{EntryStore, TsEntry};
|
||||
use super::{
|
||||
danmu::DanmuEntry, errors::RecorderError, PlatformType, Recorder, RecorderInfo, RoomInfo,
|
||||
UserInfo,
|
||||
};
|
||||
use crate::database::Database;
|
||||
use crate::ffmpeg::{transcode, TranscodeConfig};
|
||||
use crate::{config::Config, database::account::AccountRow};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use client::DouyinClientError;
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum LiveStatus {
|
||||
Live,
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for RecorderError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
RecorderError::IoError { err }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DouyinClientError> for RecorderError {
|
||||
fn from(err: DouyinClientError) -> Self {
|
||||
RecorderError::DouyinClientError { err }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DouyinRecorder {
|
||||
client: client::DouyinClient,
|
||||
db: Arc<Database>,
|
||||
pub room_id: u64,
|
||||
pub room_info: Arc<RwLock<Option<response::DouyinRoomInfoResponse>>>,
|
||||
pub stream_url: Arc<RwLock<Option<String>>>,
|
||||
pub entry_store: Arc<RwLock<Option<EntryStore>>>,
|
||||
pub live_id: Arc<RwLock<String>>,
|
||||
pub live_status: Arc<RwLock<LiveStatus>>,
|
||||
running: Arc<RwLock<bool>>,
|
||||
last_update: Arc<RwLock<i64>>,
|
||||
m3u8_cache: DashMap<String, String>,
|
||||
config: Arc<RwLock<Config>>,
|
||||
}
|
||||
|
||||
impl DouyinRecorder {
|
||||
pub fn new(
|
||||
room_id: u64,
|
||||
config: Arc<RwLock<Config>>,
|
||||
douyin_account: &AccountRow,
|
||||
db: &Arc<Database>,
|
||||
) -> Self {
|
||||
let client = client::DouyinClient::new(douyin_account);
|
||||
Self {
|
||||
db: db.clone(),
|
||||
room_id,
|
||||
live_id: Arc::new(RwLock::new(String::new())),
|
||||
entry_store: Arc::new(RwLock::new(None)),
|
||||
client,
|
||||
room_info: Arc::new(RwLock::new(None)),
|
||||
stream_url: Arc::new(RwLock::new(None)),
|
||||
live_status: Arc::new(RwLock::new(LiveStatus::Offline)),
|
||||
running: Arc::new(RwLock::new(false)),
|
||||
last_update: Arc::new(RwLock::new(Utc::now().timestamp())),
|
||||
m3u8_cache: DashMap::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_status(&self) -> bool {
|
||||
match self.client.get_room_info(self.room_id).await {
|
||||
Ok(info) => {
|
||||
let live_status = info.data.room_status == 0; // room_status == 0 表示正在直播
|
||||
|
||||
if (*self.live_status.read().await == LiveStatus::Live) != live_status {
|
||||
log::info!("[{}]Live status changed: {}", self.room_id, live_status);
|
||||
if live_status {
|
||||
// Get stream URL when live starts
|
||||
if !info.data.data[0]
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hls_pull_url
|
||||
.is_empty()
|
||||
{
|
||||
*self.live_id.write().await = info.data.data[0].id_str.clone();
|
||||
*self.live_status.write().await = LiveStatus::Live;
|
||||
// create a new record
|
||||
let cover_url = info.data.data[0]
|
||||
.cover
|
||||
.as_ref()
|
||||
.map(|cover| cover.url_list[0].clone());
|
||||
let cover = if let Some(url) = cover_url {
|
||||
Some(self.client.get_cover_base64(&url).await.unwrap())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.db
|
||||
.add_record(
|
||||
PlatformType::Douyin,
|
||||
self.live_id.read().await.as_str(),
|
||||
self.room_id,
|
||||
&info.data.data[0].title,
|
||||
cover,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to add record: {}", e);
|
||||
}
|
||||
|
||||
// setup entry store
|
||||
let work_dir =
|
||||
self.get_work_dir(self.live_id.read().await.as_str()).await;
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
*self.entry_store.write().await = Some(entry_store);
|
||||
}
|
||||
} else {
|
||||
*self.live_status.write().await = LiveStatus::Offline;
|
||||
self.reset().await;
|
||||
}
|
||||
}
|
||||
|
||||
*self.room_info.write().await = Some(info);
|
||||
live_status
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[{}]Update room status failed: {}", self.room_id, e);
|
||||
*self.live_status.read().await == LiveStatus::Live
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset(&self) {
|
||||
*self.entry_store.write().await = None;
|
||||
*self.live_id.write().await = String::new();
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
}
|
||||
|
||||
async fn get_work_dir(&self, live_id: &str) -> String {
|
||||
format!(
|
||||
"{}/douyin/{}/{}/",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
live_id
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_best_stream_url(
|
||||
&self,
|
||||
room_info: &response::DouyinRoomInfoResponse,
|
||||
) -> Option<String> {
|
||||
let stream_url = room_info.data.data[0]
|
||||
.stream_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hls_pull_url_map
|
||||
.clone();
|
||||
if let Some(url) = stream_url.full_hd1 {
|
||||
Some(url)
|
||||
} else if let Some(url) = stream_url.hd1 {
|
||||
Some(url)
|
||||
} else if let Some(url) = stream_url.sd1 {
|
||||
Some(url)
|
||||
} else {
|
||||
stream_url.sd2
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_entries(&self) -> Result<u128, RecorderError> {
|
||||
let task_begin_time = std::time::Instant::now();
|
||||
|
||||
// Get current room info and stream URL
|
||||
let room_info = self.room_info.read().await;
|
||||
|
||||
if room_info.is_none() {
|
||||
return Err(RecorderError::NoRoomInfo);
|
||||
}
|
||||
|
||||
if self.stream_url.read().await.is_none() {
|
||||
let new_stream_url = self.get_best_stream_url(room_info.as_ref().unwrap()).await;
|
||||
if new_stream_url.is_none() {
|
||||
return Err(RecorderError::NoStreamAvailable);
|
||||
}
|
||||
*self.stream_url.write().await = Some(new_stream_url.unwrap());
|
||||
}
|
||||
let stream_url = self.stream_url.read().await.as_ref().unwrap().clone();
|
||||
|
||||
// Get m3u8 playlist
|
||||
let (playlist, updated_stream_url) = self.client.get_m3u8_content(&stream_url).await?;
|
||||
|
||||
*self.stream_url.write().await = Some(updated_stream_url);
|
||||
|
||||
let mut new_segment_fetched = false;
|
||||
let work_dir = self.get_work_dir(self.live_id.read().await.as_str()).await;
|
||||
|
||||
// Create work directory if not exists
|
||||
tokio::fs::create_dir_all(&work_dir).await?;
|
||||
|
||||
let last_sequence = self
|
||||
.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.last_sequence();
|
||||
let continue_sequence = self
|
||||
.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.continue_sequence;
|
||||
let mut sequence = playlist.media_sequence + continue_sequence;
|
||||
|
||||
for segment in playlist.segments {
|
||||
if sequence <= last_sequence {
|
||||
sequence += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
new_segment_fetched = true;
|
||||
let mut uri = segment.uri.clone();
|
||||
// if uri contains ?params, remove it
|
||||
if let Some(pos) = uri.find('?') {
|
||||
uri = uri[..pos].to_string();
|
||||
}
|
||||
|
||||
let ts_url = if uri.starts_with("http") {
|
||||
uri.clone()
|
||||
} else {
|
||||
// Get the base URL without the filename and query parameters
|
||||
let base_url = stream_url
|
||||
.rfind('/')
|
||||
.map(|i| &stream_url[..=i])
|
||||
.unwrap_or(&stream_url);
|
||||
// Get the query parameters
|
||||
let query = stream_url.find('?').map(|i| &stream_url[i..]).unwrap_or("");
|
||||
// Combine: base_url + new_filename + query_params
|
||||
format!("{}{}{}", base_url, uri, query)
|
||||
};
|
||||
|
||||
let file_name = format!("{}.ts", sequence);
|
||||
|
||||
// Download segment
|
||||
match self
|
||||
.client
|
||||
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
|
||||
.await
|
||||
{
|
||||
Ok(size) => {
|
||||
let ts_entry = TsEntry {
|
||||
url: file_name,
|
||||
sequence,
|
||||
length: segment.duration as f64,
|
||||
size,
|
||||
ts: Utc::now().timestamp(),
|
||||
is_header: false,
|
||||
};
|
||||
|
||||
self.entry_store
|
||||
.write()
|
||||
.await
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.add_entry(ts_entry)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to download segment: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
sequence += 1;
|
||||
}
|
||||
|
||||
if new_segment_fetched {
|
||||
*self.last_update.write().await = Utc::now().timestamp();
|
||||
self.update_record().await;
|
||||
}
|
||||
|
||||
Ok(task_begin_time.elapsed().as_millis())
|
||||
}
|
||||
|
||||
async fn update_record(&self) {
|
||||
if let Err(e) = self
|
||||
.db
|
||||
.update_record(
|
||||
self.live_id.read().await.as_str(),
|
||||
self.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.total_duration() as i64,
|
||||
self.entry_store.read().await.as_ref().unwrap().total_size(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to update record: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_m3u8(&self, live_id: &str) -> String {
|
||||
let mut m3u8_content = "#EXTM3U\n".to_string();
|
||||
m3u8_content += "#EXT-X-VERSION:3\n";
|
||||
|
||||
let entries = if live_id == *self.live_id.read().await {
|
||||
m3u8_content += "#EXT-X-PLAYLIST-TYPE:EVENT\n";
|
||||
self.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_entries()
|
||||
.clone()
|
||||
} else {
|
||||
m3u8_content += "#EXT-X-PLAYLIST-TYPE:VOD\n";
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
entry_store.get_entries().clone()
|
||||
};
|
||||
|
||||
m3u8_content += "#EXT-X-OFFSET:0\n";
|
||||
|
||||
if entries.is_empty() {
|
||||
return m3u8_content;
|
||||
}
|
||||
|
||||
m3u8_content += &format!(
|
||||
"#EXT-X-TARGETDURATION:{}\n",
|
||||
entries.last().unwrap().length as u64
|
||||
);
|
||||
|
||||
let mut previous_seq = entries.first().unwrap().sequence;
|
||||
for entry in entries {
|
||||
if entry.sequence - previous_seq > 1 {
|
||||
m3u8_content += "#EXT-X-DISCONTINUITY\n";
|
||||
}
|
||||
previous_seq = entry.sequence;
|
||||
let date_str = Utc.timestamp_opt(entry.ts, 0).unwrap().to_rfc3339();
|
||||
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
|
||||
m3u8_content += &format!("#EXTINF:{:.2},\n", entry.length);
|
||||
m3u8_content += &format!("/douyin/{}/{}/{}\n", self.room_id, live_id, entry.url);
|
||||
}
|
||||
|
||||
if *self.live_status.read().await != LiveStatus::Live {
|
||||
m3u8_content += "#EXT-X-ENDLIST\n";
|
||||
}
|
||||
|
||||
m3u8_content
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Recorder for DouyinRecorder {
|
||||
async fn run(&self) {
|
||||
*self.running.write().await = true;
|
||||
|
||||
let self_clone = self.clone();
|
||||
tokio::spawn(async move {
|
||||
while *self_clone.running.read().await {
|
||||
if self_clone.check_status().await {
|
||||
// Live status is ok, start recording
|
||||
while *self_clone.running.read().await {
|
||||
match self_clone.update_entries().await {
|
||||
Ok(ms) => {
|
||||
if ms < 1000 {
|
||||
tokio::time::sleep(Duration::from_millis(1000 - ms as u64))
|
||||
.await;
|
||||
} else {
|
||||
log::warn!(
|
||||
"[{}]Update entries cost too long: {}ms",
|
||||
self_clone.room_id,
|
||||
ms
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[{}]Update entries error: {}", self_clone.room_id, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check status again after 2-5 seconds
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
continue;
|
||||
}
|
||||
// Check live status every 10s
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
log::info!("recording thread {} quit.", self_clone.room_id);
|
||||
});
|
||||
}
|
||||
|
||||
async fn stop(&self) {
|
||||
*self.running.write().await = false;
|
||||
}
|
||||
|
||||
async fn clip_range(
|
||||
&self,
|
||||
live_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
output_path: &str,
|
||||
) -> Result<String, RecorderError> {
|
||||
let work_dir = self.get_work_dir(live_id).await;
|
||||
let entries = if live_id == *self.live_id.read().await {
|
||||
self.entry_store
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_entries()
|
||||
.clone()
|
||||
} else {
|
||||
let entry_store = EntryStore::new(&work_dir).await;
|
||||
entry_store.get_entries().clone()
|
||||
};
|
||||
|
||||
if entries.is_empty() {
|
||||
return Err(RecorderError::EmptyCache);
|
||||
}
|
||||
|
||||
let mut file_list = Vec::new();
|
||||
|
||||
let mut offset = 0.0;
|
||||
for entry in entries {
|
||||
if offset >= x && offset <= y {
|
||||
file_list.push(format!("{}/{}", work_dir, entry.url));
|
||||
}
|
||||
offset += entry.length;
|
||||
|
||||
if offset > y {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let file_name = format!(
|
||||
"[{}]{}_{}_{:.1}.ts",
|
||||
self.room_id,
|
||||
live_id,
|
||||
Utc::now().format("%m%d%H%M%S"),
|
||||
y - x
|
||||
);
|
||||
|
||||
let output_file = format!("{}/{}", output_path, file_name);
|
||||
tokio::fs::create_dir_all(output_path)
|
||||
.await
|
||||
.map_err(|e| RecorderError::IoError { err: e })?;
|
||||
|
||||
// Merge ts files
|
||||
let mut output = tokio::fs::File::create(&output_file)
|
||||
.await
|
||||
.map_err(|e| RecorderError::IoError { err: e })?;
|
||||
for file_path in file_list {
|
||||
if let Ok(mut file) = tokio::fs::File::open(file_path).await {
|
||||
let mut buffer = Vec::new();
|
||||
if file.read_to_end(&mut buffer).await.is_ok() {
|
||||
let _ = output.write_all(&buffer).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
output
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| RecorderError::IoError { err: e })?;
|
||||
|
||||
let transcode_config = TranscodeConfig {
|
||||
input_path: file_name.clone(),
|
||||
input_format: "mpegts".to_string(),
|
||||
// replace .ts with .mp4
|
||||
output_path: file_name.replace(".ts", ".mp4"),
|
||||
};
|
||||
|
||||
let transcode_result = transcode(output_path, transcode_config);
|
||||
|
||||
// delete the original ts file
|
||||
tokio::fs::remove_file(output_file).await?;
|
||||
|
||||
Ok(transcode_result.unwrap().output_path)
|
||||
}
|
||||
|
||||
async fn m3u8_content(&self, live_id: &str) -> String {
|
||||
if let Some(cached) = self.m3u8_cache.get(live_id) {
|
||||
return cached.clone();
|
||||
}
|
||||
self.generate_m3u8(live_id).await
|
||||
}
|
||||
|
||||
async fn info(&self) -> RecorderInfo {
|
||||
let room_info = self.room_info.read().await;
|
||||
let room_cover_url = room_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.data.data[0].cover.as_ref())
|
||||
.map(|cover| cover.url_list[0].clone())
|
||||
.unwrap_or_default();
|
||||
RecorderInfo {
|
||||
room_id: self.room_id,
|
||||
room_info: RoomInfo {
|
||||
room_id: self.room_id,
|
||||
room_title: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.data[0].title.clone())
|
||||
.unwrap_or_default(),
|
||||
room_cover: room_cover_url,
|
||||
},
|
||||
user_info: UserInfo {
|
||||
user_id: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.user.sec_uid.clone())
|
||||
.unwrap_or_default(),
|
||||
user_name: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.user.nickname.clone())
|
||||
.unwrap_or_default(),
|
||||
user_avatar: room_info
|
||||
.as_ref()
|
||||
.map(|info| info.data.user.avatar_thumb.url_list[0].clone())
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
total_length: if let Some(store) = self.entry_store.read().await.as_ref() {
|
||||
store.total_duration()
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
current_live_id: self.live_id.read().await.clone(),
|
||||
live_status: *self.live_status.read().await == LiveStatus::Live,
|
||||
platform: PlatformType::Douyin.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn comments(&self, _live_id: &str) -> Result<Vec<DanmuEntry>, RecorderError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn is_recording(&self, live_id: &str) -> bool {
|
||||
*self.live_id.read().await == live_id && *self.live_status.read().await == LiveStatus::Live
|
||||
}
|
||||
}
|
||||
123
src-tauri/src/recorder/douyin/client.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use base64::Engine;
|
||||
use reqwest::{Client, Error as ReqwestError};
|
||||
use m3u8_rs::{Playlist, MediaPlaylist};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use crate::database::account::AccountRow;
|
||||
|
||||
use super::response::DouyinRoomInfoResponse;
|
||||
use std::fmt;
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DouyinClientError {
|
||||
Network(ReqwestError),
|
||||
Io(std::io::Error),
|
||||
Playlist(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for DouyinClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Network(e) => write!(f, "Network error: {}", e),
|
||||
Self::Io(e) => write!(f, "IO error: {}", e),
|
||||
Self::Playlist(e) => write!(f, "Playlist error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ReqwestError> for DouyinClientError {
|
||||
fn from(err: ReqwestError) -> Self {
|
||||
DouyinClientError::Network(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for DouyinClientError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
DouyinClientError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DouyinClient {
|
||||
client: Client,
|
||||
cookies: String,
|
||||
}
|
||||
|
||||
impl DouyinClient {
|
||||
pub fn new(account: &AccountRow) -> Self {
|
||||
let client = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap();
|
||||
Self { client, cookies: account.cookies.clone() }
|
||||
}
|
||||
|
||||
pub async fn get_room_info(&self, room_id: u64) -> Result<DouyinRoomInfoResponse, DouyinClientError> {
|
||||
let url = format!(
|
||||
"https://live.douyin.com/webcast/room/web/enter/?aid=6383&app_name=douyin_web&live_id=1&device_platform=web&language=zh-CN&enter_from=web_live&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Chrome&browser_version=122.0.0.0&web_rid={}",
|
||||
room_id
|
||||
);
|
||||
|
||||
let resp = self.client.get(&url)
|
||||
.header("Referer", "https://live.douyin.com/")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", self.cookies.clone())
|
||||
.send()
|
||||
.await?
|
||||
.json::<DouyinRoomInfoResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn get_cover_base64(&self, url: &str) -> Result<String, DouyinClientError> {
|
||||
log::info!("get_cover_base64: {}", url);
|
||||
let response = self.client.get(url).send().await?;
|
||||
let bytes = response.bytes().await?;
|
||||
let base64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mime_type = mime_guess::from_path(url).first_or_octet_stream().to_string();
|
||||
Ok(format!("data:{};base64,{}", mime_type, base64))
|
||||
}
|
||||
|
||||
pub async fn get_m3u8_content(&self, url: &str) -> Result<(MediaPlaylist, String), DouyinClientError> {
|
||||
let content = self.client.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
// m3u8 content: #EXTM3U
|
||||
// #EXT-X-VERSION:3
|
||||
// #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000
|
||||
// http://7167739a741646b4651b6949b2f3eb8e.livehwc3.cn/pull-hls-l26.douyincdn.com/third/stream-693342996808860134_or4.m3u8?sub_m3u8=true&user_session_id=16090eb45ab8a2f042f7c46563936187&major_anchor_level=common&edge_slice=true&expire=67d944ec&sign=47b95cc6e8de20d82f3d404412fa8406
|
||||
if content.contains("BANDWIDTH") {
|
||||
let new_url = content.lines().last().unwrap();
|
||||
return Box::pin(self.get_m3u8_content(new_url)).await;
|
||||
}
|
||||
|
||||
match m3u8_rs::parse_playlist_res(content.as_bytes()) {
|
||||
Ok(Playlist::MasterPlaylist(_)) => {
|
||||
Err(DouyinClientError::Playlist("Unexpected master playlist".to_string()))
|
||||
}
|
||||
Ok(Playlist::MediaPlaylist(pl)) => Ok((pl, url.to_string())),
|
||||
Err(e) => Err(DouyinClientError::Playlist(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_ts(&self, url: &str, path: &str) -> Result<u64, DouyinClientError> {
|
||||
let response = self.client.get(url)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() != reqwest::StatusCode::OK {
|
||||
return Err(DouyinClientError::Network(response.error_for_status().unwrap_err()));
|
||||
}
|
||||
|
||||
let content = response.bytes().await?;
|
||||
let mut file = File::create(path).await?;
|
||||
file.write_all(&content).await?;
|
||||
|
||||
Ok(content.len() as u64)
|
||||
}
|
||||
}
|
||||
592
src-tauri/src/recorder/douyin/response.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
use serde_derive::Deserialize;
|
||||
use serde_derive::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DouyinRoomInfoResponse {
|
||||
pub data: Data,
|
||||
#[serde(rename = "status_code")]
|
||||
pub status_code: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Data {
|
||||
pub data: Vec<Daum>,
|
||||
pub user: User,
|
||||
#[serde(rename = "room_status")]
|
||||
pub room_status: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Daum {
|
||||
#[serde(rename = "id_str")]
|
||||
pub id_str: String,
|
||||
pub status: i64,
|
||||
#[serde(rename = "status_str")]
|
||||
pub status_str: String,
|
||||
pub title: String,
|
||||
pub cover: Option<Cover>,
|
||||
#[serde(rename = "stream_url")]
|
||||
pub stream_url: Option<StreamUrl>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cover {
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamUrl {
|
||||
#[serde(rename = "flv_pull_url")]
|
||||
pub flv_pull_url: FlvPullUrl,
|
||||
#[serde(rename = "default_resolution")]
|
||||
pub default_resolution: String,
|
||||
#[serde(rename = "hls_pull_url_map")]
|
||||
pub hls_pull_url_map: HlsPullUrlMap,
|
||||
#[serde(rename = "hls_pull_url")]
|
||||
pub hls_pull_url: String,
|
||||
#[serde(rename = "stream_orientation")]
|
||||
pub stream_orientation: i64,
|
||||
#[serde(rename = "live_core_sdk_data")]
|
||||
pub live_core_sdk_data: LiveCoreSdkData,
|
||||
pub extra: Extra,
|
||||
#[serde(rename = "pull_datas")]
|
||||
pub pull_datas: PullDatas,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlvPullUrl {
|
||||
#[serde(rename = "FULL_HD1")]
|
||||
pub full_hd1: Option<String>,
|
||||
#[serde(rename = "HD1")]
|
||||
pub hd1: Option<String>,
|
||||
#[serde(rename = "SD1")]
|
||||
pub sd1: Option<String>,
|
||||
#[serde(rename = "SD2")]
|
||||
pub sd2: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HlsPullUrlMap {
|
||||
#[serde(rename = "FULL_HD1")]
|
||||
pub full_hd1: Option<String>,
|
||||
#[serde(rename = "HD1")]
|
||||
pub hd1: Option<String>,
|
||||
#[serde(rename = "SD1")]
|
||||
pub sd1: Option<String>,
|
||||
#[serde(rename = "SD2")]
|
||||
pub sd2: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveCoreSdkData {
|
||||
#[serde(rename = "pull_data")]
|
||||
pub pull_data: PullData,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullData {
|
||||
pub options: Options,
|
||||
#[serde(rename = "stream_data")]
|
||||
pub stream_data: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Options {
|
||||
#[serde(rename = "default_quality")]
|
||||
pub default_quality: DefaultQuality,
|
||||
pub qualities: Vec<Quality>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DefaultQuality {
|
||||
pub name: String,
|
||||
#[serde(rename = "sdk_key")]
|
||||
pub sdk_key: String,
|
||||
#[serde(rename = "v_codec")]
|
||||
pub v_codec: String,
|
||||
pub resolution: String,
|
||||
pub level: i64,
|
||||
#[serde(rename = "v_bit_rate")]
|
||||
pub v_bit_rate: i64,
|
||||
#[serde(rename = "additional_content")]
|
||||
pub additional_content: String,
|
||||
pub fps: i64,
|
||||
pub disable: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Quality {
|
||||
pub name: String,
|
||||
#[serde(rename = "sdk_key")]
|
||||
pub sdk_key: String,
|
||||
#[serde(rename = "v_codec")]
|
||||
pub v_codec: String,
|
||||
pub resolution: String,
|
||||
pub level: i64,
|
||||
#[serde(rename = "v_bit_rate")]
|
||||
pub v_bit_rate: i64,
|
||||
#[serde(rename = "additional_content")]
|
||||
pub additional_content: String,
|
||||
pub fps: i64,
|
||||
pub disable: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Extra {
|
||||
pub height: i64,
|
||||
pub width: i64,
|
||||
pub fps: i64,
|
||||
#[serde(rename = "max_bitrate")]
|
||||
pub max_bitrate: i64,
|
||||
#[serde(rename = "min_bitrate")]
|
||||
pub min_bitrate: i64,
|
||||
#[serde(rename = "default_bitrate")]
|
||||
pub default_bitrate: i64,
|
||||
#[serde(rename = "bitrate_adapt_strategy")]
|
||||
pub bitrate_adapt_strategy: i64,
|
||||
#[serde(rename = "anchor_interact_profile")]
|
||||
pub anchor_interact_profile: i64,
|
||||
#[serde(rename = "audience_interact_profile")]
|
||||
pub audience_interact_profile: i64,
|
||||
#[serde(rename = "hardware_encode")]
|
||||
pub hardware_encode: bool,
|
||||
#[serde(rename = "video_profile")]
|
||||
pub video_profile: i64,
|
||||
#[serde(rename = "h265_enable")]
|
||||
pub h265_enable: bool,
|
||||
#[serde(rename = "gop_sec")]
|
||||
pub gop_sec: i64,
|
||||
#[serde(rename = "bframe_enable")]
|
||||
pub bframe_enable: bool,
|
||||
pub roi: bool,
|
||||
#[serde(rename = "sw_roi")]
|
||||
pub sw_roi: bool,
|
||||
#[serde(rename = "bytevc1_enable")]
|
||||
pub bytevc1_enable: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullDatas {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Owner {
|
||||
#[serde(rename = "id_str")]
|
||||
pub id_str: String,
|
||||
#[serde(rename = "sec_uid")]
|
||||
pub sec_uid: String,
|
||||
pub nickname: String,
|
||||
#[serde(rename = "avatar_thumb")]
|
||||
pub avatar_thumb: AvatarThumb,
|
||||
#[serde(rename = "follow_info")]
|
||||
pub follow_info: FollowInfo,
|
||||
pub subscribe: Subscribe,
|
||||
#[serde(rename = "foreign_user")]
|
||||
pub foreign_user: i64,
|
||||
#[serde(rename = "open_id_str")]
|
||||
pub open_id_str: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AvatarThumb {
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FollowInfo {
|
||||
#[serde(rename = "follow_status")]
|
||||
pub follow_status: i64,
|
||||
#[serde(rename = "follow_status_str")]
|
||||
pub follow_status_str: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Subscribe {
|
||||
#[serde(rename = "is_member")]
|
||||
pub is_member: bool,
|
||||
pub level: i64,
|
||||
#[serde(rename = "identity_type")]
|
||||
pub identity_type: i64,
|
||||
#[serde(rename = "buy_type")]
|
||||
pub buy_type: i64,
|
||||
pub open: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoomAuth {
|
||||
#[serde(rename = "Chat")]
|
||||
pub chat: bool,
|
||||
#[serde(rename = "Danmaku")]
|
||||
pub danmaku: bool,
|
||||
#[serde(rename = "Gift")]
|
||||
pub gift: bool,
|
||||
#[serde(rename = "LuckMoney")]
|
||||
pub luck_money: bool,
|
||||
#[serde(rename = "Digg")]
|
||||
pub digg: bool,
|
||||
#[serde(rename = "RoomContributor")]
|
||||
pub room_contributor: bool,
|
||||
#[serde(rename = "Props")]
|
||||
pub props: bool,
|
||||
#[serde(rename = "UserCard")]
|
||||
pub user_card: bool,
|
||||
#[serde(rename = "POI")]
|
||||
pub poi: bool,
|
||||
#[serde(rename = "MoreAnchor")]
|
||||
pub more_anchor: i64,
|
||||
#[serde(rename = "Banner")]
|
||||
pub banner: i64,
|
||||
#[serde(rename = "Share")]
|
||||
pub share: i64,
|
||||
#[serde(rename = "UserCorner")]
|
||||
pub user_corner: i64,
|
||||
#[serde(rename = "Landscape")]
|
||||
pub landscape: i64,
|
||||
#[serde(rename = "LandscapeChat")]
|
||||
pub landscape_chat: i64,
|
||||
#[serde(rename = "PublicScreen")]
|
||||
pub public_screen: i64,
|
||||
#[serde(rename = "GiftAnchorMt")]
|
||||
pub gift_anchor_mt: i64,
|
||||
#[serde(rename = "RecordScreen")]
|
||||
pub record_screen: i64,
|
||||
#[serde(rename = "DonationSticker")]
|
||||
pub donation_sticker: i64,
|
||||
#[serde(rename = "HourRank")]
|
||||
pub hour_rank: i64,
|
||||
#[serde(rename = "CommerceCard")]
|
||||
pub commerce_card: i64,
|
||||
#[serde(rename = "AudioChat")]
|
||||
pub audio_chat: i64,
|
||||
#[serde(rename = "DanmakuDefault")]
|
||||
pub danmaku_default: i64,
|
||||
#[serde(rename = "KtvOrderSong")]
|
||||
pub ktv_order_song: i64,
|
||||
#[serde(rename = "SelectionAlbum")]
|
||||
pub selection_album: i64,
|
||||
#[serde(rename = "Like")]
|
||||
pub like: i64,
|
||||
#[serde(rename = "MultiplierPlayback")]
|
||||
pub multiplier_playback: i64,
|
||||
#[serde(rename = "DownloadVideo")]
|
||||
pub download_video: i64,
|
||||
#[serde(rename = "Collect")]
|
||||
pub collect: i64,
|
||||
#[serde(rename = "TimedShutdown")]
|
||||
pub timed_shutdown: i64,
|
||||
#[serde(rename = "Seek")]
|
||||
pub seek: i64,
|
||||
#[serde(rename = "Denounce")]
|
||||
pub denounce: i64,
|
||||
#[serde(rename = "Dislike")]
|
||||
pub dislike: i64,
|
||||
#[serde(rename = "OnlyTa")]
|
||||
pub only_ta: i64,
|
||||
#[serde(rename = "CastScreen")]
|
||||
pub cast_screen: i64,
|
||||
#[serde(rename = "CommentWall")]
|
||||
pub comment_wall: i64,
|
||||
#[serde(rename = "BulletStyle")]
|
||||
pub bullet_style: i64,
|
||||
#[serde(rename = "ShowGamePlugin")]
|
||||
pub show_game_plugin: i64,
|
||||
#[serde(rename = "VSGift")]
|
||||
pub vsgift: i64,
|
||||
#[serde(rename = "VSTopic")]
|
||||
pub vstopic: i64,
|
||||
#[serde(rename = "VSRank")]
|
||||
pub vsrank: i64,
|
||||
#[serde(rename = "AdminCommentWall")]
|
||||
pub admin_comment_wall: i64,
|
||||
#[serde(rename = "CommerceComponent")]
|
||||
pub commerce_component: i64,
|
||||
#[serde(rename = "DouPlus")]
|
||||
pub dou_plus: i64,
|
||||
#[serde(rename = "GamePointsPlaying")]
|
||||
pub game_points_playing: i64,
|
||||
#[serde(rename = "Poster")]
|
||||
pub poster: i64,
|
||||
#[serde(rename = "Highlights")]
|
||||
pub highlights: i64,
|
||||
#[serde(rename = "TypingCommentState")]
|
||||
pub typing_comment_state: i64,
|
||||
#[serde(rename = "StrokeUpDownGuide")]
|
||||
pub stroke_up_down_guide: i64,
|
||||
#[serde(rename = "UpRightStatsFloatingLayer")]
|
||||
pub up_right_stats_floating_layer: i64,
|
||||
#[serde(rename = "CastScreenExplicit")]
|
||||
pub cast_screen_explicit: i64,
|
||||
#[serde(rename = "Selection")]
|
||||
pub selection: i64,
|
||||
#[serde(rename = "IndustryService")]
|
||||
pub industry_service: i64,
|
||||
#[serde(rename = "VerticalRank")]
|
||||
pub vertical_rank: i64,
|
||||
#[serde(rename = "EnterEffects")]
|
||||
pub enter_effects: i64,
|
||||
#[serde(rename = "FansClub")]
|
||||
pub fans_club: i64,
|
||||
#[serde(rename = "EmojiOutside")]
|
||||
pub emoji_outside: i64,
|
||||
#[serde(rename = "CanSellTicket")]
|
||||
pub can_sell_ticket: i64,
|
||||
#[serde(rename = "DouPlusPopularityGem")]
|
||||
pub dou_plus_popularity_gem: i64,
|
||||
#[serde(rename = "MissionCenter")]
|
||||
pub mission_center: i64,
|
||||
#[serde(rename = "ExpandScreen")]
|
||||
pub expand_screen: i64,
|
||||
#[serde(rename = "FansGroup")]
|
||||
pub fans_group: i64,
|
||||
#[serde(rename = "Topic")]
|
||||
pub topic: i64,
|
||||
#[serde(rename = "AnchorMission")]
|
||||
pub anchor_mission: i64,
|
||||
#[serde(rename = "Teleprompter")]
|
||||
pub teleprompter: i64,
|
||||
#[serde(rename = "LongTouch")]
|
||||
pub long_touch: i64,
|
||||
#[serde(rename = "FirstFeedHistChat")]
|
||||
pub first_feed_hist_chat: i64,
|
||||
#[serde(rename = "MoreHistChat")]
|
||||
pub more_hist_chat: i64,
|
||||
#[serde(rename = "TaskBanner")]
|
||||
pub task_banner: i64,
|
||||
#[serde(rename = "SpecialStyle")]
|
||||
pub special_style: SpecialStyle,
|
||||
#[serde(rename = "FixedChat")]
|
||||
pub fixed_chat: i64,
|
||||
#[serde(rename = "QuizGamePointsPlaying")]
|
||||
pub quiz_game_points_playing: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpecialStyle {
|
||||
#[serde(rename = "Chat")]
|
||||
pub chat: Chat,
|
||||
#[serde(rename = "Like")]
|
||||
pub like: Like,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Chat {
|
||||
#[serde(rename = "UnableStyle")]
|
||||
pub unable_style: i64,
|
||||
#[serde(rename = "Content")]
|
||||
pub content: String,
|
||||
#[serde(rename = "OffType")]
|
||||
pub off_type: i64,
|
||||
#[serde(rename = "AnchorSwitchForPaidLive")]
|
||||
pub anchor_switch_for_paid_live: i64,
|
||||
#[serde(rename = "ContentForPaidLive")]
|
||||
pub content_for_paid_live: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Like {
|
||||
#[serde(rename = "UnableStyle")]
|
||||
pub unable_style: i64,
|
||||
#[serde(rename = "Content")]
|
||||
pub content: String,
|
||||
#[serde(rename = "OffType")]
|
||||
pub off_type: i64,
|
||||
#[serde(rename = "AnchorSwitchForPaidLive")]
|
||||
pub anchor_switch_for_paid_live: i64,
|
||||
#[serde(rename = "ContentForPaidLive")]
|
||||
pub content_for_paid_live: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Stats {
|
||||
#[serde(rename = "total_user_desp")]
|
||||
pub total_user_desp: String,
|
||||
#[serde(rename = "like_count")]
|
||||
pub like_count: i64,
|
||||
#[serde(rename = "total_user_str")]
|
||||
pub total_user_str: String,
|
||||
#[serde(rename = "user_count_str")]
|
||||
pub user_count_str: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerMap {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerDetail {
|
||||
#[serde(rename = "linker_play_modes")]
|
||||
pub linker_play_modes: Vec<Value>,
|
||||
#[serde(rename = "big_party_layout_config_version")]
|
||||
pub big_party_layout_config_version: i64,
|
||||
#[serde(rename = "accept_audience_pre_apply")]
|
||||
pub accept_audience_pre_apply: bool,
|
||||
#[serde(rename = "linker_ui_layout")]
|
||||
pub linker_ui_layout: i64,
|
||||
#[serde(rename = "enable_audience_linkmic")]
|
||||
pub enable_audience_linkmic: i64,
|
||||
#[serde(rename = "function_type")]
|
||||
pub function_type: String,
|
||||
#[serde(rename = "linker_map_str")]
|
||||
pub linker_map_str: LinkerMapStr,
|
||||
#[serde(rename = "ktv_lyric_mode")]
|
||||
pub ktv_lyric_mode: String,
|
||||
#[serde(rename = "init_source")]
|
||||
pub init_source: String,
|
||||
#[serde(rename = "forbid_apply_from_other")]
|
||||
pub forbid_apply_from_other: bool,
|
||||
#[serde(rename = "ktv_exhibit_mode")]
|
||||
pub ktv_exhibit_mode: i64,
|
||||
#[serde(rename = "enlarge_guest_turn_on_source")]
|
||||
pub enlarge_guest_turn_on_source: i64,
|
||||
#[serde(rename = "playmode_detail")]
|
||||
pub playmode_detail: PlaymodeDetail,
|
||||
#[serde(rename = "client_ui_info")]
|
||||
pub client_ui_info: String,
|
||||
#[serde(rename = "manual_open_ui")]
|
||||
pub manual_open_ui: i64,
|
||||
#[serde(rename = "feature_list")]
|
||||
pub feature_list: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkerMapStr {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaymodeDetail {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoomViewStats {
|
||||
#[serde(rename = "is_hidden")]
|
||||
pub is_hidden: bool,
|
||||
#[serde(rename = "display_short")]
|
||||
pub display_short: String,
|
||||
#[serde(rename = "display_middle")]
|
||||
pub display_middle: String,
|
||||
#[serde(rename = "display_long")]
|
||||
pub display_long: String,
|
||||
#[serde(rename = "display_value")]
|
||||
pub display_value: i64,
|
||||
#[serde(rename = "display_version")]
|
||||
pub display_version: i64,
|
||||
pub incremental: bool,
|
||||
#[serde(rename = "display_type")]
|
||||
pub display_type: i64,
|
||||
#[serde(rename = "display_short_anchor")]
|
||||
pub display_short_anchor: String,
|
||||
#[serde(rename = "display_middle_anchor")]
|
||||
pub display_middle_anchor: String,
|
||||
#[serde(rename = "display_long_anchor")]
|
||||
pub display_long_anchor: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SceneTypeInfo {
|
||||
#[serde(rename = "is_union_live_room")]
|
||||
pub is_union_live_room: bool,
|
||||
#[serde(rename = "is_life")]
|
||||
pub is_life: bool,
|
||||
#[serde(rename = "is_protected_room")]
|
||||
pub is_protected_room: i64,
|
||||
#[serde(rename = "is_lasted_goods_room")]
|
||||
pub is_lasted_goods_room: i64,
|
||||
#[serde(rename = "is_desire_room")]
|
||||
pub is_desire_room: i64,
|
||||
#[serde(rename = "commentary_type")]
|
||||
pub commentary_type: bool,
|
||||
#[serde(rename = "is_sub_orientation_vertical_room")]
|
||||
pub is_sub_orientation_vertical_room: i64,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EntranceList {
|
||||
#[serde(rename = "group_id")]
|
||||
pub group_id: i64,
|
||||
#[serde(rename = "component_type")]
|
||||
pub component_type: i64,
|
||||
#[serde(rename = "op_type")]
|
||||
pub op_type: i64,
|
||||
pub text: String,
|
||||
#[serde(rename = "schema_url")]
|
||||
pub schema_url: String,
|
||||
#[serde(rename = "show_type")]
|
||||
pub show_type: i64,
|
||||
#[serde(rename = "data_status")]
|
||||
pub data_status: i64,
|
||||
pub extra: String,
|
||||
pub icon: Option<Icon>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Icon {
|
||||
#[serde(rename = "url_list")]
|
||||
pub url_list: Vec<String>,
|
||||
pub uri: String,
|
||||
pub height: i64,
|
||||
pub width: i64,
|
||||
#[serde(rename = "avg_color")]
|
||||
pub avg_color: String,
|
||||
#[serde(rename = "image_type")]
|
||||
pub image_type: i64,
|
||||
#[serde(rename = "open_web_url")]
|
||||
pub open_web_url: String,
|
||||
#[serde(rename = "is_animated")]
|
||||
pub is_animated: bool,
|
||||
#[serde(rename = "flex_setting_list")]
|
||||
pub flex_setting_list: Vec<Value>,
|
||||
#[serde(rename = "text_setting_list")]
|
||||
pub text_setting_list: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
#[serde(rename = "id_str")]
|
||||
pub id_str: String,
|
||||
#[serde(rename = "sec_uid")]
|
||||
pub sec_uid: String,
|
||||
pub nickname: String,
|
||||
#[serde(rename = "avatar_thumb")]
|
||||
pub avatar_thumb: AvatarThumb,
|
||||
#[serde(rename = "follow_info")]
|
||||
pub follow_info: FollowInfo,
|
||||
#[serde(rename = "foreign_user")]
|
||||
pub foreign_user: i64,
|
||||
#[serde(rename = "open_id_str")]
|
||||
pub open_id_str: String,
|
||||
}
|
||||
149
src-tauri/src/recorder/entry.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use async_std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{prelude::BufReadExt, BufReader, WriteExt},
|
||||
path::Path,
|
||||
stream::StreamExt,
|
||||
};
|
||||
|
||||
const ENTRY_FILE_NAME: &str = "entries.log";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TsEntry {
|
||||
pub url: String,
|
||||
pub sequence: u64,
|
||||
pub length: f64,
|
||||
pub size: u64,
|
||||
pub ts: i64,
|
||||
pub is_header: bool,
|
||||
}
|
||||
|
||||
pub struct EntryStore {
|
||||
// append only log file
|
||||
log_file: File,
|
||||
header: Option<TsEntry>,
|
||||
entries: Vec<TsEntry>,
|
||||
total_duration: f64,
|
||||
total_size: u64,
|
||||
last_sequence: u64,
|
||||
|
||||
pub continue_sequence: u64,
|
||||
}
|
||||
|
||||
impl EntryStore {
|
||||
pub async fn new(work_dir: &str) -> Self {
|
||||
// if work_dir is not exists, create it
|
||||
if !Path::new(work_dir).exists().await {
|
||||
std::fs::create_dir_all(work_dir).unwrap();
|
||||
}
|
||||
// open append only log file
|
||||
let log_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(format!("{}/{}", work_dir, ENTRY_FILE_NAME))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut entry_store = Self {
|
||||
log_file,
|
||||
header: None,
|
||||
entries: vec![],
|
||||
total_duration: 0.0,
|
||||
total_size: 0,
|
||||
last_sequence: 0,
|
||||
continue_sequence: 0,
|
||||
};
|
||||
|
||||
entry_store.load(work_dir).await;
|
||||
|
||||
entry_store
|
||||
}
|
||||
|
||||
async fn load(&mut self, work_dir: &str) {
|
||||
let file = OpenOptions::new()
|
||||
.create(false)
|
||||
.read(true)
|
||||
.open(format!("{}/{}", work_dir, ENTRY_FILE_NAME))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut lines = BufReader::new(file).lines();
|
||||
while let Some(Ok(line)) = lines.next().await {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
let entry = TsEntry {
|
||||
url: parts[0].to_string(),
|
||||
sequence: parts[1].parse().unwrap(),
|
||||
length: parts[2].parse().unwrap(),
|
||||
size: parts[3].parse().unwrap(),
|
||||
ts: parts[4].parse().unwrap(),
|
||||
is_header: parts[5].parse().unwrap(),
|
||||
};
|
||||
|
||||
if entry.sequence > self.last_sequence {
|
||||
self.last_sequence = entry.sequence;
|
||||
}
|
||||
|
||||
if entry.is_header {
|
||||
self.header = Some(entry.clone());
|
||||
} else {
|
||||
self.entries.push(entry.clone());
|
||||
}
|
||||
|
||||
self.total_duration += entry.length;
|
||||
self.total_size += entry.size;
|
||||
}
|
||||
|
||||
self.continue_sequence = self.last_sequence + 100;
|
||||
}
|
||||
|
||||
pub async fn add_entry(&mut self, entry: TsEntry) {
|
||||
if entry.is_header {
|
||||
self.header = Some(entry.clone());
|
||||
} else {
|
||||
self.entries.push(entry.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = self
|
||||
.log_file
|
||||
.write_all(
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}|{}\n",
|
||||
entry.url, entry.sequence, entry.length, entry.size, entry.ts, entry.is_header
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to write entry to log file: {}", e);
|
||||
}
|
||||
self.log_file.flush().await.unwrap();
|
||||
|
||||
if self.last_sequence < entry.sequence {
|
||||
self.last_sequence = entry.sequence;
|
||||
}
|
||||
|
||||
self.total_duration += entry.length;
|
||||
self.total_size += entry.size;
|
||||
}
|
||||
|
||||
pub fn get_header(&self) -> Option<&TsEntry> {
|
||||
self.header.as_ref()
|
||||
}
|
||||
|
||||
pub fn get_entries(&self) -> &Vec<TsEntry> {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn total_duration(&self) -> f64 {
|
||||
self.total_duration
|
||||
}
|
||||
|
||||
pub fn total_size(&self) -> u64 {
|
||||
self.total_size
|
||||
}
|
||||
|
||||
pub fn last_sequence(&self) -> u64 {
|
||||
self.last_sequence
|
||||
}
|
||||
|
||||
pub fn last_ts(&self) -> Option<i64> {
|
||||
self.entries.last().map(|entry| entry.ts)
|
||||
}
|
||||
}
|
||||
23
src-tauri/src/recorder/errors.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use super::bilibili::client::BiliStream;
|
||||
use super::douyin::client::DouyinClientError;
|
||||
use custom_error::custom_error;
|
||||
|
||||
custom_error! {pub RecorderError
|
||||
IndexNotFound {url: String} = "Index not found: {url}",
|
||||
ArchiveInUse {live_id: String} = "Can not delete current stream: {live_id}",
|
||||
EmptyCache = "Cache is empty",
|
||||
M3u8ParseFailed {content: String } = "Parse m3u8 content failed: {content}",
|
||||
NoStreamAvailable = "No available stream provided",
|
||||
FreezedStream {stream: BiliStream} = "Stream is freezed: {stream}",
|
||||
StreamExpired {stream: BiliStream} = "Stream is nearly expired: {stream}",
|
||||
NoRoomInfo = "No room info provided",
|
||||
InvalidStream {stream: BiliStream} = "Invalid stream: {stream}",
|
||||
SlowStream {stream: BiliStream} = "Stream is too slow: {stream}",
|
||||
EmptyHeader = "Header url is empty",
|
||||
InvalidTimestamp = "Header timestamp is invalid",
|
||||
InvalidDBOP {err: crate::database::DatabaseError } = "Database error: {err}",
|
||||
BiliClientError {err: super::bilibili::errors::BiliClientError} = "BiliClient error: {err}",
|
||||
DouyinClientError {err: DouyinClientError} = "DouyinClient error: {err}",
|
||||
ClipError {err: String} = "FFMPEG error: {err}",
|
||||
IoError {err: std::io::Error} = "IO error: {err}",
|
||||
}
|
||||
0
src-tauri/src/recorder/ts.rs
Normal file
@@ -1,16 +1,20 @@
|
||||
use crate::db::{AccountRow, Database, RecordRow};
|
||||
use crate::recorder::bilibili::UserInfo;
|
||||
use crate::config::Config;
|
||||
use crate::database::DatabaseError;
|
||||
use crate::database::{account::AccountRow, record::RecordRow, Database};
|
||||
use crate::recorder::bilibili::BiliRecorder;
|
||||
use crate::recorder::danmu::DanmuEntry;
|
||||
use crate::recorder::RecorderError;
|
||||
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
|
||||
use crate::Config;
|
||||
use crate::recorder::douyin::DouyinRecorder;
|
||||
use crate::recorder::errors::RecorderError;
|
||||
use crate::recorder::PlatformType;
|
||||
use crate::recorder::Recorder;
|
||||
use crate::recorder::RecorderInfo;
|
||||
use custom_error::custom_error;
|
||||
use dashmap::DashMap;
|
||||
use hyper::Method;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
use tauri::AppHandle;
|
||||
@@ -22,29 +26,23 @@ pub struct RecorderList {
|
||||
pub recorders: Vec<RecorderInfo>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
|
||||
pub struct RecorderInfo {
|
||||
pub room_id: u64,
|
||||
pub room_info: RoomInfo,
|
||||
pub user_info: UserInfo,
|
||||
pub total_length: f64,
|
||||
pub current_ts: u64,
|
||||
pub live_status: bool,
|
||||
}
|
||||
|
||||
pub struct RecorderManager {
|
||||
app_handle: AppHandle,
|
||||
db: Arc<Database>,
|
||||
config: Arc<RwLock<Config>>,
|
||||
recorders: Arc<DashMap<u64, BiliRecorder>>,
|
||||
recorders: Arc<RwLock<HashMap<String, Box<dyn Recorder>>>>,
|
||||
hls_server_addr: Arc<RwLock<Option<SocketAddr>>>,
|
||||
}
|
||||
|
||||
custom_error! {pub RecorderManagerError
|
||||
AlreadyExisted { room_id: u64 } = "Recorder {room_id} already existed",
|
||||
NotFound {room_id: u64 } = "Recorder {room_id} not found",
|
||||
RecorderError { err: RecorderError } = "Recorder error",
|
||||
IOError {err: std::io::Error } = "IO error",
|
||||
HLSError { err: hyper::Error } = "HLS server error",
|
||||
AlreadyExisted { room_id: u64 } = "房间 {room_id} 已存在",
|
||||
NotFound {room_id: u64 } = "房间 {room_id} 不存在",
|
||||
InvalidPlatformType { platform: String } = "不支持的平台: {platform}",
|
||||
RecorderError { err: RecorderError } = "录播器错误: {err}",
|
||||
IOError {err: std::io::Error } = "IO 错误: {err}",
|
||||
HLSError { err: hyper::Error } = "HLS 服务器错误: {err}",
|
||||
DatabaseError { err: DatabaseError } = "数据库错误: {err}",
|
||||
Recording { live_id: String } = "无法删除正在录制的直播 {live_id}",
|
||||
}
|
||||
|
||||
impl From<hyper::Error> for RecorderManagerError {
|
||||
@@ -65,6 +63,12 @@ impl From<RecorderError> for RecorderManagerError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DatabaseError> for RecorderManagerError {
|
||||
fn from(value: DatabaseError) -> Self {
|
||||
RecorderManagerError::DatabaseError { err: value }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecorderManagerError> for String {
|
||||
fn from(value: RecorderManagerError) -> Self {
|
||||
value.to_string()
|
||||
@@ -72,11 +76,16 @@ impl From<RecorderManagerError> for String {
|
||||
}
|
||||
|
||||
impl RecorderManager {
|
||||
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
||||
pub fn new(
|
||||
app_handle: AppHandle,
|
||||
db: Arc<Database>,
|
||||
config: Arc<RwLock<Config>>,
|
||||
) -> RecorderManager {
|
||||
RecorderManager {
|
||||
app_handle,
|
||||
db,
|
||||
config,
|
||||
recorders: Arc::new(DashMap::new()),
|
||||
recorders: Arc::new(RwLock::new(HashMap::new())),
|
||||
hls_server_addr: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -84,118 +93,143 @@ impl RecorderManager {
|
||||
/// starting HLS server
|
||||
pub async fn run_hls(&self) -> Result<(), RecorderManagerError> {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
let server_addr = self.start_hls_server(listener).await?;
|
||||
log::info!("HLS server started on {}", server_addr);
|
||||
self.hls_server_addr.write().await.replace(server_addr);
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let addr = self.start_hls_server(listener).await?;
|
||||
*self.hls_server_addr.write().await = Some(addr);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_recorder(
|
||||
&self,
|
||||
webid: &str,
|
||||
db: &Arc<Database>,
|
||||
account: &AccountRow,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
) -> Result<(), RecorderManagerError> {
|
||||
// check existing recorder
|
||||
if self.recorders.contains_key(&room_id) {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if self.recorders.read().await.contains_key(&recorder_id) {
|
||||
return Err(RecorderManagerError::AlreadyExisted { room_id });
|
||||
}
|
||||
let recorder = BiliRecorder::new(
|
||||
self.app_handle.clone(),
|
||||
webid,
|
||||
db,
|
||||
room_id,
|
||||
account,
|
||||
self.config.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.recorders.insert(room_id, recorder);
|
||||
// run recorder
|
||||
let recorder = self.recorders.get(&room_id).unwrap();
|
||||
recorder.value().run().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_recorder(&self, room_id: u64) -> Result<(), RecorderManagerError> {
|
||||
let recorder = self.recorders.remove(&room_id);
|
||||
if recorder.is_none() {
|
||||
return Err(RecorderManagerError::NotFound { room_id });
|
||||
let recorder: Box<dyn Recorder + 'static> = match platform {
|
||||
PlatformType::BiliBili => Box::new(
|
||||
BiliRecorder::new(
|
||||
self.app_handle.clone(),
|
||||
webid,
|
||||
&self.db,
|
||||
room_id,
|
||||
account,
|
||||
self.config.clone(),
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
PlatformType::Douyin => Box::new(DouyinRecorder::new(
|
||||
room_id,
|
||||
self.config.clone(),
|
||||
account,
|
||||
&self.db,
|
||||
)),
|
||||
_ => {
|
||||
return Err(RecorderManagerError::InvalidPlatformType {
|
||||
platform: platform.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
self.recorders
|
||||
.write()
|
||||
.await
|
||||
.insert(recorder_id.clone(), recorder);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
recorder_ref.run().await;
|
||||
}
|
||||
// remove related cache folder
|
||||
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
|
||||
tokio::fs::remove_dir_all(cache_folder).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clip(
|
||||
pub async fn stop_all(&self) {
|
||||
for recorder_ref in self.recorders.read().await.values() {
|
||||
recorder_ref.stop().await;
|
||||
}
|
||||
|
||||
// remove all recorders
|
||||
self.recorders.write().await.clear();
|
||||
}
|
||||
|
||||
pub async fn remove_recorder(
|
||||
&self,
|
||||
output_path: &str,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
d: f64,
|
||||
) -> Result<String, RecorderManagerError> {
|
||||
let recorder = self.recorders.get(&room_id);
|
||||
if recorder.is_none() {
|
||||
) -> Result<(), RecorderManagerError> {
|
||||
// check recorder exists
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if !self.recorders.read().await.contains_key(&recorder_id) {
|
||||
return Err(RecorderManagerError::NotFound { room_id });
|
||||
}
|
||||
let recorder = recorder.unwrap();
|
||||
Ok(recorder.value().clip(room_id, d, output_path).await?)
|
||||
|
||||
// stop recorder
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
recorder_ref.stop().await;
|
||||
}
|
||||
|
||||
// remove recorder
|
||||
self.recorders.write().await.remove(&recorder_id);
|
||||
|
||||
// remove related cache folder
|
||||
let cache_folder = format!(
|
||||
"{}/{}/{}",
|
||||
self.config.read().await.cache,
|
||||
platform.as_str(),
|
||||
room_id
|
||||
);
|
||||
let _ = tokio::fs::remove_dir_all(cache_folder).await;
|
||||
log::info!("Recorder {} cache folder removed", room_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clip_range(
|
||||
&self,
|
||||
output_path: &str,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
ts: u64,
|
||||
live_id: &str,
|
||||
start: f64,
|
||||
end: f64,
|
||||
) -> Result<String, RecorderManagerError> {
|
||||
let recorder = self.recorders.get(&room_id);
|
||||
if recorder.is_none() {
|
||||
let recorders = self.recorders.read().await;
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if !recorders.contains_key(&recorder_id) {
|
||||
log::error!("Recorder {} not found", recorder_id);
|
||||
return Err(RecorderManagerError::NotFound { room_id });
|
||||
}
|
||||
let recorder = recorder.unwrap();
|
||||
let recorder = recorders.get(&recorder_id).unwrap();
|
||||
Ok(recorder
|
||||
.value()
|
||||
.clip_range(ts, start, end, output_path)
|
||||
.clip_range(live_id, start, end, output_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_recorder_list(&self) -> RecorderList {
|
||||
let mut summary = RecorderList {
|
||||
count: self.recorders.len(),
|
||||
count: self.recorders.read().await.len(),
|
||||
recorders: Vec::new(),
|
||||
};
|
||||
|
||||
for recorder in self.recorders.iter() {
|
||||
let recorder = recorder.value();
|
||||
let room_info = RecorderInfo {
|
||||
room_id: recorder.room_id,
|
||||
room_info: recorder.room_info.read().await.clone(),
|
||||
user_info: recorder.user_info.read().await.clone(),
|
||||
total_length: *recorder.ts_length.read().await,
|
||||
current_ts: *recorder.timestamp.read().await,
|
||||
live_status: *recorder.live_status.read().await,
|
||||
};
|
||||
for recorder_ref in self.recorders.read().await.iter() {
|
||||
let room_info = recorder_ref.1.info().await;
|
||||
summary.recorders.push(room_info);
|
||||
}
|
||||
|
||||
summary.recorders.sort_by(|a, b| a.room_id.cmp(&b.room_id));
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
pub async fn get_recorder_info(&self, room_id: u64) -> Option<RecorderInfo> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
let room_info = RecorderInfo {
|
||||
room_id: recorder.room_id,
|
||||
room_info: recorder.room_info.read().await.clone(),
|
||||
user_info: recorder.user_info.read().await.clone(),
|
||||
total_length: *recorder.ts_length.read().await,
|
||||
current_ts: *recorder.timestamp.read().await,
|
||||
live_status: *recorder.live_status.read().await,
|
||||
};
|
||||
pub async fn get_recorder_info(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
) -> Option<RecorderInfo> {
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
let room_info = recorder_ref.info().await;
|
||||
Some(room_info)
|
||||
} else {
|
||||
None
|
||||
@@ -203,38 +237,46 @@ impl RecorderManager {
|
||||
}
|
||||
|
||||
pub async fn get_archives(&self, room_id: u64) -> Result<Vec<RecordRow>, RecorderManagerError> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
Ok(recorder.get_archives().await?)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
Ok(self.db.get_records(room_id).await?)
|
||||
}
|
||||
|
||||
pub async fn get_archive(
|
||||
&self,
|
||||
room_id: u64,
|
||||
live_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<RecordRow, RecorderManagerError> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
Ok(recorder.get_archive(live_id).await?)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
Ok(self.db.get_record(room_id, live_id).await?)
|
||||
}
|
||||
|
||||
pub async fn delete_archive(&self, room_id: u64, ts: u64) {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
recorder.delete_archive(ts).await;
|
||||
pub async fn delete_archive(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<(), RecorderManagerError> {
|
||||
log::info!("Deleting {}:{}", room_id, live_id);
|
||||
// check if this is still recording
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
let recorder = recorder_ref.as_ref();
|
||||
if recorder.is_recording(live_id).await {
|
||||
return Err(RecorderManagerError::Recording {
|
||||
live_id: live_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(self.db.remove_record(live_id).await?)
|
||||
}
|
||||
|
||||
pub async fn get_danmu(
|
||||
&self,
|
||||
platform: PlatformType,
|
||||
room_id: u64,
|
||||
live_id: u64,
|
||||
live_id: &str,
|
||||
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
Ok(recorder.get_danmu_record(live_id).await)
|
||||
let recorder_id = format!("{}:{}", platform.as_str(), room_id);
|
||||
if let Some(recorder_ref) = self.recorders.read().await.get(&recorder_id) {
|
||||
Ok(recorder_ref.comments(live_id).await?)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
@@ -266,11 +308,14 @@ impl RecorderManager {
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let cache_path = config.read().await.cache.clone();
|
||||
let path = req.uri().path();
|
||||
let path_segs: Vec<&str> = path.split('/').collect();
|
||||
// path_segs should be size 4: /21484828/{timestamp}/playlist.m3u8
|
||||
if path_segs.len() != 4 {
|
||||
|
||||
// path_segs should be size 5: /{platform}/{room_id}/{live_id}/playlist.m3u8
|
||||
if path_segs.len() != 5 {
|
||||
log::warn!("Invalid request path: {}", path);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(400)
|
||||
@@ -278,13 +323,18 @@ impl RecorderManager {
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
// parse recorder type
|
||||
let platform = path_segs[1];
|
||||
// parse room id
|
||||
let room_id = path_segs[1].parse::<u64>().unwrap();
|
||||
let timestamp = path_segs[2].parse::<u64>().unwrap();
|
||||
// if path is /room_id/{timestamp}/playlist.m3u8
|
||||
if path_segs[3] == "playlist.m3u8" {
|
||||
let room_id = path_segs[2].parse::<u64>().unwrap();
|
||||
// parse live id
|
||||
let live_id = path_segs[3];
|
||||
|
||||
if path_segs[4] == "playlist.m3u8" {
|
||||
// get recorder
|
||||
let recorder = recorders.get(&room_id);
|
||||
let recorder_key = format!("{}:{}", platform, room_id);
|
||||
let recorders = recorders.read().await;
|
||||
let recorder = recorders.get(&recorder_key);
|
||||
if recorder.is_none() {
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
@@ -295,7 +345,7 @@ impl RecorderManager {
|
||||
}
|
||||
let recorder = recorder.unwrap();
|
||||
// response with recorder generated m3u8, which contains ts entries that cached in local
|
||||
let m3u8_content = recorder.value().generate_m3u8(timestamp).await;
|
||||
let m3u8_content = recorder.m3u8_content(live_id).await;
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(200)
|
||||
@@ -309,7 +359,9 @@ impl RecorderManager {
|
||||
// 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.replace("%7C", "|"));
|
||||
let recorder = recorders.get(&room_id);
|
||||
let recorders = recorders.read().await;
|
||||
let recorder_id = format!("{}:{}", platform, room_id);
|
||||
let recorder = recorders.get(&recorder_id);
|
||||
if recorder.is_none() {
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
|
||||
23
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use custom_error::custom_error;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::recorder::bilibili::client::BiliClient;
|
||||
use crate::recorder_manager::RecorderManager;
|
||||
|
||||
custom_error! {
|
||||
StateError
|
||||
RecorderAlreadyExists = "Recorder already exists",
|
||||
RecorderCreateError = "Recorder create error",
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct State {
|
||||
pub db: Arc<Database>,
|
||||
pub client: Arc<BiliClient>,
|
||||
pub config: Arc<RwLock<Config>>,
|
||||
pub recorder_manager: Arc<RecorderManager>,
|
||||
pub app_handle: tauri::AppHandle,
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"identifier": "cn.vjoi.bilishadowreplay",
|
||||
"plugins": {
|
||||
"sql": {
|
||||
"preload": ["sqlite:data.db"]
|
||||
"preload": ["sqlite:data_v2.db"]
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"width": 1300,
|
||||
"height": 600,
|
||||
"transparent": false,
|
||||
"decorations": false,
|
||||
"decorations": true,
|
||||
"theme": "Light"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,44 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Room from "./lib/Room.svelte";
|
||||
import Room from "./page/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";
|
||||
import Summary from "./page/Summary.svelte";
|
||||
import Setting from "./page/Setting.svelte";
|
||||
import Account from "./page/Account.svelte";
|
||||
import About from "./page/About.svelte";
|
||||
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} />
|
||||
<BSidebar bind:activeUrl={active} />
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="content bg-white dark:bg-[#2c2c2e]">
|
||||
<!-- 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 class="page" class:visible={active == "#直播间"}>
|
||||
<Room />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#消息"}>
|
||||
<Messages bind:message_cnt />
|
||||
</div>
|
||||
<div class="h-full page" class:visible={active == "#账号"}>
|
||||
<div class="page" class:visible={active == "#账号"}>
|
||||
<Account />
|
||||
</div>
|
||||
<!-- <div class="page" class:visible={active == "#自动化"}>
|
||||
<div>自动化[开发中]</div>
|
||||
</div> -->
|
||||
<div class="page" class:visible={active == "#设置"}>
|
||||
<Setting />
|
||||
</div>
|
||||
@@ -64,22 +49,25 @@
|
||||
|
||||
.visible {
|
||||
opacity: 1 !important;
|
||||
max-height: fit-content !important;
|
||||
height: 100% !important;
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
.page {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
height: 0;
|
||||
transform: translateX(100%);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Textarea,
|
||||
Modal,
|
||||
Select,
|
||||
Hr,
|
||||
} from "flowbite-svelte";
|
||||
import Player from "./lib/Player.svelte";
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import html2canvas from "html2canvas";
|
||||
import type { AccountInfo, RecordItem } from "./lib/db";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { ClapperboardPlaySolid, PlayOutline } from "flowbite-svelte-icons";
|
||||
import type { Profile, VideoItem, Config } from "./lib/interface";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let use_titlebar = platform() == "windows";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
Pen,
|
||||
} from "lucide-svelte";
|
||||
import type { Profile, VideoItem, Config, Marker } from "./lib/interface";
|
||||
import TypeSelect from "./lib/TypeSelect.svelte";
|
||||
import MarkerPanel from "./lib/MarkerPanel.svelte";
|
||||
import CoverEditor from "./lib/CoverEditor.svelte";
|
||||
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const port = urlParams.get("port");
|
||||
const port = parseInt(urlParams.get("port"));
|
||||
const room_id = parseInt(urlParams.get("room_id"));
|
||||
const ts = parseInt(urlParams.get("ts"));
|
||||
const platform = urlParams.get("platform");
|
||||
const live_id = urlParams.get("live_id");
|
||||
|
||||
// get profile in local storage with a default value
|
||||
let profile: Profile = get_profile();
|
||||
@@ -46,6 +38,10 @@
|
||||
return default_profile();
|
||||
}
|
||||
|
||||
$: {
|
||||
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
function default_profile(): Profile {
|
||||
return {
|
||||
videos: [],
|
||||
@@ -94,49 +90,47 @@
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
|
||||
let cover_text = "";
|
||||
let preview = false;
|
||||
let show_cover_editor = false;
|
||||
let text_style = {
|
||||
position: { x: 8, y: 8 },
|
||||
fontSize: 24,
|
||||
color: "#FF7F00"
|
||||
};
|
||||
let uid_selected = 0;
|
||||
let video_selected = 0;
|
||||
$: video_src = video ? convertFileSrc(config.output + "/" + video.name) : "";
|
||||
|
||||
let accounts = [];
|
||||
let videos = [];
|
||||
|
||||
let video = null;
|
||||
let cover = "";
|
||||
let selected_video = null;
|
||||
|
||||
invoke("get_accounts").then((account_info: AccountInfo) => {
|
||||
accounts = account_info.accounts.map((a) => {
|
||||
return {
|
||||
value: a.uid,
|
||||
name: a.name,
|
||||
platform: a.platform,
|
||||
};
|
||||
});
|
||||
console.log(accounts);
|
||||
accounts = accounts.filter((a) => a.platform === 'bilibili');
|
||||
});
|
||||
|
||||
get_video_list();
|
||||
|
||||
invoke("get_archive", { roomId: room_id, liveId: ts }).then(
|
||||
invoke("get_archive", { roomId: room_id, liveId: live_id }).then(
|
||||
(a: RecordItem) => {
|
||||
console.log(a);
|
||||
archive = a;
|
||||
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
|
||||
},
|
||||
appWindow.setTitle(`[${room_id}]${archive.title}`);
|
||||
}
|
||||
);
|
||||
|
||||
function update_title(str: string) {
|
||||
appWindow.setTitle(
|
||||
`[${room_id}][${format_ts(ts)}]${archive.title} - ${str}`,
|
||||
`[${room_id}]${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[]
|
||||
@@ -144,19 +138,22 @@
|
||||
return {
|
||||
value: v.id,
|
||||
name: v.file,
|
||||
file: convertFileSrc(config.output + "/" + v.file),
|
||||
cover: v.cover,
|
||||
};
|
||||
});
|
||||
console.log(videos, video_selected);
|
||||
}
|
||||
|
||||
function find_video(e) {
|
||||
if (!e.target) {
|
||||
selected_video = null;
|
||||
return;
|
||||
}
|
||||
const id = parseInt(e.target.value);
|
||||
video = videos.find((v) => {
|
||||
selected_video = videos.find((v) => {
|
||||
return v.value == id;
|
||||
});
|
||||
cover = video.cover;
|
||||
console.log("video selected", videos, video, e, id);
|
||||
console.log("video selected", videos, selected_video, e, id);
|
||||
}
|
||||
|
||||
async function generate_clip() {
|
||||
@@ -174,19 +171,20 @@
|
||||
try {
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
platform: platform,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
liveId: live_id,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
update_title(`切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
console.log("video file generatd:", selected_video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
selected_video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
selected_video.cover = new_video.cover;
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
alert("Err generating clip: " + e);
|
||||
@@ -194,24 +192,19 @@
|
||||
}
|
||||
|
||||
async function do_post() {
|
||||
if (!video) {
|
||||
if (!selected_video) {
|
||||
return;
|
||||
}
|
||||
update_title(`投稿上传中`);
|
||||
loading = true;
|
||||
// render cover with text
|
||||
const ecapture = document.getElementById("capture");
|
||||
const render_canvas = await html2canvas(ecapture, {
|
||||
scale: 720 / ecapture.clientHeight,
|
||||
});
|
||||
const rendered_cover = render_canvas.toDataURL();
|
||||
|
||||
// update profile in local storage
|
||||
window.localStorage.setItem("profile-" + room_id, JSON.stringify(profile));
|
||||
invoke("upload_procedure", {
|
||||
uid: uid_selected,
|
||||
roomId: room_id,
|
||||
videoId: video_selected,
|
||||
cover: rendered_cover,
|
||||
cover: selected_video.cover,
|
||||
profile: profile,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -228,7 +221,7 @@
|
||||
}
|
||||
|
||||
async function delete_video() {
|
||||
if (!video) {
|
||||
if (!selected_video) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
@@ -237,148 +230,375 @@
|
||||
update_title(`删除成功`);
|
||||
loading = false;
|
||||
video_selected = 0;
|
||||
video = null;
|
||||
cover = "";
|
||||
selected_video = null;
|
||||
await get_video_list();
|
||||
}
|
||||
|
||||
// when window resize, update post panel height
|
||||
onMount(() => {
|
||||
let post_panel = document.getElementById("post-panel");
|
||||
if (post_panel) {
|
||||
post_panel.style.height = `calc(100vh - 35px)`;
|
||||
}
|
||||
window.addEventListener("resize", () => {
|
||||
if (post_panel) {
|
||||
post_panel.style.height = `calc(100vh - 35px)`;
|
||||
}
|
||||
});
|
||||
});
|
||||
let player;
|
||||
let lpanel_collapsed = true;
|
||||
let rpanel_collapsed = false;
|
||||
let markers: Marker[] = [];
|
||||
// load markers from local storage
|
||||
markers = JSON.parse(
|
||||
window.localStorage.getItem(`markers:${room_id}:${live_id}`) || "[]"
|
||||
);
|
||||
$: {
|
||||
// makers changed, save to local storage
|
||||
window.localStorage.setItem(
|
||||
`markers:${room_id}:${live_id}`,
|
||||
JSON.stringify(markers)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if use_titlebar}
|
||||
<TitleBar dark />
|
||||
{/if}
|
||||
<div class="flex flex-row">
|
||||
<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 -->
|
||||
<video src={video_src} controls />
|
||||
</Modal>
|
||||
</div>
|
||||
<div class="flex flex-row overflow-hidden">
|
||||
<div
|
||||
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[39]"
|
||||
class="flex relative h-screen border-solid bg-gray-950 border-r-2 border-gray-800 z-[501] transition-all duration-300 ease-in-out"
|
||||
class:w-[200px]={!lpanel_collapsed}
|
||||
class:w-0={lpanel_collapsed}
|
||||
>
|
||||
<div
|
||||
id="post-panel"
|
||||
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
|
||||
class:titlebar={use_titlebar}
|
||||
<div class="relative flex w-full overflow-hidden">
|
||||
<div
|
||||
class="w-[200px] transition-all duration-300 overflow-hidden flex-shrink-0"
|
||||
style="margin-left: {lpanel_collapsed ? '-200px' : '0'};"
|
||||
>
|
||||
<div class="w-full whitespace-nowrap">
|
||||
<MarkerPanel
|
||||
{archive}
|
||||
bind:markers
|
||||
on:markerClick={(e) => {
|
||||
player.seek(e.detail.offset);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="collapse-btn lp transition-transform duration-300 absolute"
|
||||
on:click={() => {
|
||||
lpanel_collapsed = !lpanel_collapsed;
|
||||
}}
|
||||
>
|
||||
{#if lpanel_collapsed}
|
||||
<ChevronRight class="text-white" size={20} />
|
||||
{:else}
|
||||
<ChevronLeft class="text-white" size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-hidden h-screen w-full relative">
|
||||
<Player
|
||||
bind:start
|
||||
bind:end
|
||||
bind:this={player}
|
||||
{port}
|
||||
{platform}
|
||||
{room_id}
|
||||
{live_id}
|
||||
{markers}
|
||||
on:markerAdd={(e) => {
|
||||
markers.push({
|
||||
offset: e.detail.offset,
|
||||
realtime: e.detail.realtime,
|
||||
content: "[空标记点]",
|
||||
});
|
||||
markers = markers.sort((a, b) => a.offset - b.offset);
|
||||
}}
|
||||
/>
|
||||
{#if preview}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
{#if video}
|
||||
<div
|
||||
class="w-full mb-2"
|
||||
on:click={() => {
|
||||
preview = true;
|
||||
}}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 backdrop-blur-sm z-[1000] transition-opacity duration-200"
|
||||
class:opacity-0={!preview}
|
||||
class:opacity-100={preview}
|
||||
on:click={() => preview = false}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] bg-[#1c1c1e] rounded-xl shadow-2xl overflow-hidden transition-all duration-200 scale-100"
|
||||
class:opacity-0={!preview}
|
||||
class:opacity-100={preview}
|
||||
class:scale-95={!preview}
|
||||
class:scale-100={preview}
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div id="capture" class="cover-wrap relative cursor-pointer">
|
||||
<div
|
||||
class="cover-text absolute py-1 px-8"
|
||||
class:play-icon={false}
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-800/50 bg-[#2c2c2e]">
|
||||
<h3 class="text-lg font-medium text-white">预览视频</h3>
|
||||
<button
|
||||
class="w-6 h-6 rounded-full bg-[#ff5f57] hover:bg-[#ff5f57]/90 transition-colors duration-200 flex items-center justify-center group"
|
||||
on:click={() => preview = false}
|
||||
>
|
||||
{cover_text}
|
||||
</div>
|
||||
<div class="play-icon opacity-0">
|
||||
<PlayOutline class="w-full h-full absolute" color="white" />
|
||||
</div>
|
||||
<img src={cover} alt="cover" />
|
||||
<svg class="w-3 h-3 text-[#1c1c1e] opacity-0 group-hover:opacity-100 transition-opacity duration-200" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 视频容器 -->
|
||||
<div class="relative aspect-video bg-black">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
src={selected_video?.file}
|
||||
controls
|
||||
class="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full flex flex-col justify-center">
|
||||
<Label>切片列表</Label>
|
||||
<Select
|
||||
items={videos}
|
||||
bind:value={video_selected}
|
||||
on:change={find_video}
|
||||
class="mb-2"
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<Button on:click={generate_clip} disabled={loading} color="primary">
|
||||
{#if loading}
|
||||
<Spinner class="me-3" size="4" />
|
||||
{:else}
|
||||
<ClapperboardPlaySolid />
|
||||
{/if}
|
||||
从选区生成新切片</Button
|
||||
>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!loading && !video}
|
||||
on:click={delete_video}>删除</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<Hr />
|
||||
<Label class="mt-4">标题</Label>
|
||||
<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}
|
||||
on:change={() => {
|
||||
window.localStorage.setItem(
|
||||
"profile-" + room_id,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label class="mt-2">标签</Label>
|
||||
<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}
|
||||
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}
|
||||
</div>
|
||||
<div
|
||||
class="flex relative h-screen border-solid bg-gray-950 border-l-2 border-gray-800 text-white transition-all duration-300 ease-in-out"
|
||||
class:w-[400px]={!rpanel_collapsed}
|
||||
class:w-0={rpanel_collapsed}
|
||||
>
|
||||
<button
|
||||
class="collapse-btn rp transition-transform duration-300"
|
||||
class:translate-x-[-20px]={!rpanel_collapsed}
|
||||
class:translate-x-0={rpanel_collapsed}
|
||||
on:click={() => {
|
||||
rpanel_collapsed = !rpanel_collapsed;
|
||||
}}
|
||||
>
|
||||
{#if rpanel_collapsed}
|
||||
<ChevronLeft class="text-white" size={20} />
|
||||
{:else}
|
||||
<ChevronRight class="text-white" size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
id="post-panel"
|
||||
class="h-screen bg-[#1c1c1e] text-white w-[400px] flex flex-col transition-opacity duration-300"
|
||||
class:opacity-0={rpanel_collapsed}
|
||||
class:opacity-100={!rpanel_collapsed}
|
||||
class:invisible={rpanel_collapsed}
|
||||
>
|
||||
<!-- 顶部标题栏 -->
|
||||
<div
|
||||
class="flex-none sticky top-0 z-10 backdrop-blur-xl bg-[#1c1c1e]/80 px-6 py-4 border-b border-gray-800/50"
|
||||
>
|
||||
<h2 class="text-lg font-medium">视频投稿</h2>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="px-6 py-4 space-y-8">
|
||||
<!-- 切片操作区 -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-300">切片列表</h3>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
on:click={generate_clip}
|
||||
disabled={loading}
|
||||
class="px-4 py-1.5 bg-[#0A84FF] text-white text-sm rounded-lg
|
||||
transition-all duration-200 hover:bg-[#0A84FF]/90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center space-x-2"
|
||||
>
|
||||
{#if loading}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
|
||||
/>
|
||||
{/if}
|
||||
<span>生成切片</span>
|
||||
</button>
|
||||
{#if selected_video}
|
||||
<button
|
||||
on:click={delete_video}
|
||||
disabled={loading}
|
||||
class="px-4 py-1.5 text-red-500 text-sm rounded-lg
|
||||
transition-all duration-200 hover:bg-red-500/10
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
bind:value={video_selected}
|
||||
on:change={find_video}
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
transition duration-200 outline-none appearance-none
|
||||
hover:border-gray-700/50"
|
||||
>
|
||||
<option value={0}>选择切片</option>
|
||||
{#each videos as video}
|
||||
<option value={video.value}>{video.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<!-- 封面预览 -->
|
||||
{#if selected_video && selected_video.id != -1}
|
||||
<section>
|
||||
<div class="group">
|
||||
<div class="text-sm text-gray-400 mb-2 flex items-center justify-between">
|
||||
<span>视频封面</span>
|
||||
<button
|
||||
class="text-[#0A84FF] hover:text-[#0A84FF]/80 transition-colors duration-200 flex items-center space-x-1"
|
||||
on:click={() => show_cover_editor = true}
|
||||
>
|
||||
<Pen class="w-4 h-4" />
|
||||
<span class="text-xs">创建新封面</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="capture"
|
||||
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50 cursor-pointer group"
|
||||
on:click={() => preview = true}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100
|
||||
transition duration-200 flex items-center justify-center backdrop-blur-[2px]"
|
||||
>
|
||||
<div
|
||||
class="bg-white/10 backdrop-blur p-3 rounded-full opacity-0 group-hover:opacity-50"
|
||||
>
|
||||
<Play class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<img src={selected_video.cover} alt="视频封面" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 表单区域 -->
|
||||
<section class="space-y-8">
|
||||
<!-- 基本信息 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-400">基本信息</h3>
|
||||
<!-- 标题 -->
|
||||
<div class="space-y-2">
|
||||
<label for="title" class="block text-sm font-medium text-gray-300"
|
||||
>标题</label
|
||||
>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={profile.title}
|
||||
placeholder="输入视频标题"
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
transition duration-200 outline-none
|
||||
hover:border-gray-700/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 视频分区 -->
|
||||
<div class="space-y-2">
|
||||
<label for="tid" class="block text-sm font-medium text-gray-300"
|
||||
>视频分区</label
|
||||
>
|
||||
<div class="w-full" id="tid">
|
||||
<TypeSelect bind:value={profile.tid} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投稿账号 -->
|
||||
<div id="uid" class="space-y-2">
|
||||
<label for="uid" class="block text-sm font-medium text-gray-300"
|
||||
>投稿账号</label
|
||||
>
|
||||
<select
|
||||
bind:value={uid_selected}
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
transition duration-200 outline-none appearance-none
|
||||
hover:border-gray-700/50"
|
||||
>
|
||||
{#each accounts as account}
|
||||
<option value={account.value}>{account.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-400">详细信息</h3>
|
||||
<!-- 描述 -->
|
||||
<div class="space-y-2">
|
||||
<label for="desc" class="block text-sm font-medium text-gray-300"
|
||||
>描述</label
|
||||
>
|
||||
<textarea
|
||||
id="desc"
|
||||
bind:value={profile.desc}
|
||||
placeholder="输入视频描述"
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
transition duration-200 outline-none resize-none h-32
|
||||
hover:border-gray-700/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="space-y-2">
|
||||
<label for="tag" class="block text-sm font-medium text-gray-300"
|
||||
>标签</label
|
||||
>
|
||||
<input
|
||||
id="tag"
|
||||
type="text"
|
||||
bind:value={profile.tag}
|
||||
placeholder="输入视频标签,用逗号分隔"
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
transition duration-200 outline-none
|
||||
hover:border-gray-700/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 动态 -->
|
||||
<div class="space-y-2">
|
||||
<label for="dynamic" class="block text-sm font-medium text-gray-300"
|
||||
>动态</label
|
||||
>
|
||||
<textarea
|
||||
id="dynamic"
|
||||
bind:value={profile.dynamic}
|
||||
placeholder="输入动态内容"
|
||||
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
|
||||
border border-gray-800/50 focus:border-[#0A84FF]
|
||||
transition duration-200 outline-none resize-none h-32
|
||||
hover:border-gray-700/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投稿按钮 -->
|
||||
{#if selected_video}
|
||||
<div class="h-10" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
{#if selected_video}
|
||||
<div
|
||||
class="flex-none sticky bottom-0 px-6 py-4 bg-gradient-to-t from-[#1c1c1e] via-[#1c1c1e] to-transparent"
|
||||
>
|
||||
<button
|
||||
on:click={do_post}
|
||||
disabled={loading}
|
||||
class="w-full px-4 py-2.5 bg-[#0A84FF] text-white rounded-lg
|
||||
transition-all duration-200 hover:bg-[#0A84FF]/90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center space-x-2"
|
||||
>
|
||||
{#if loading}
|
||||
<Spinner class="me-3" size="4" />
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
|
||||
/>
|
||||
{/if}
|
||||
投稿
|
||||
</Button>
|
||||
<span>投稿</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -386,31 +606,44 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CoverEditor
|
||||
bind:show={show_cover_editor}
|
||||
video={selected_video}
|
||||
on:coverUpdate={(event) => {
|
||||
selected_video = {
|
||||
...selected_video,
|
||||
cover: event.detail.cover
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
.cover-wrap:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: 50%;
|
||||
width: 20px;
|
||||
height: 40px;
|
||||
}
|
||||
.cover-wrap:hover .play-icon {
|
||||
opacity: 0.5;
|
||||
.collapse-btn.rp {
|
||||
left: -20px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
|
||||
border-right: none;
|
||||
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.cover-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
color: rgb(255, 127, 0);
|
||||
text-shadow:
|
||||
-1px -1px 0 rgba(255, 255, 255, 1),
|
||||
1px -1px 0 rgba(255, 255, 255, 1),
|
||||
-1px 1px 0 rgba(255, 255, 255, 1),
|
||||
1px 1px 0 rgba(255, 255, 255, 1),
|
||||
-2px -2px 0 rgba(255, 255, 255, 0.5),
|
||||
2px -2px 0 rgba(255, 255, 255, 0.5),
|
||||
-2px 2px 0 rgba(255, 255, 255, 0.5),
|
||||
2px 2px 0 rgba(255, 255, 255, 0.5); /* 创建细腻的白色描边效果 */
|
||||
.collapse-btn.lp {
|
||||
right: -20px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border: 2px solid rgb(31 41 55 / var(--tw-border-opacity));
|
||||
border-left: none;
|
||||
background-color: rgb(3 7 18 / var(--tw-bg-opacity));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script type="ts">
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Card } from "flowbite-svelte";
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
let version = "";
|
||||
getVersion().then((v) => {
|
||||
version = v;
|
||||
appWindow.setTitle(`BiliBili ShadowReplay - v${version}`);
|
||||
console.log(version);
|
||||
});
|
||||
let latest_version = "";
|
||||
// get lastest version from github release api
|
||||
fetch("https://api.github.com/repos/Xinrea/bili-shadowreplay/releases/latest")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
latest_version = data.tag_name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12 h-full overflow-auto">
|
||||
<Card size="lg">
|
||||
<h1 class="text-2xl font-bold">关于</h1>
|
||||
<p>
|
||||
BiliBili ShadowReplay 是一个用于实时查看和剪辑 B
|
||||
站直播流,并生成视频投稿的工具。
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
项目地址: <a
|
||||
target="_blank"
|
||||
href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
>https://github.com/Xinrea/bili-shadowreplay</a
|
||||
>
|
||||
</p>
|
||||
<p>作者: Xinrea</p>
|
||||
<p>
|
||||
当前版本: v{version}
|
||||
</p>
|
||||
<p>
|
||||
最新版本: {latest_version}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,185 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Table,
|
||||
TableHead,
|
||||
TableHeadCell,
|
||||
TableBody,
|
||||
TableBodyRow,
|
||||
TableBodyCell,
|
||||
Modal,
|
||||
ButtonGroup,
|
||||
SpeedDial,
|
||||
Listgroup,
|
||||
ListgroupItem,
|
||||
Textarea,
|
||||
Hr,
|
||||
} from "flowbite-svelte";
|
||||
import Image from "./Image.svelte";
|
||||
import QRCode from "qrcode";
|
||||
import type { AccountItem, AccountInfo } from "./db";
|
||||
import { PlusOutline, UserAddSolid } from "flowbite-svelte-icons";
|
||||
|
||||
let account_info: AccountInfo = {
|
||||
primary_uid: 0,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
async function update_accounts() {
|
||||
account_info = await invoke("get_accounts");
|
||||
}
|
||||
|
||||
update_accounts();
|
||||
|
||||
let addModal = false;
|
||||
let oauth_key = "";
|
||||
let check_interval = null;
|
||||
|
||||
let manualModal = false;
|
||||
let cookie_str = "";
|
||||
|
||||
async function handle_qr() {
|
||||
if (check_interval) {
|
||||
clearInterval(check_interval);
|
||||
}
|
||||
let qr_info: { url: string; oauthKey: string } = await invoke("get_qr");
|
||||
oauth_key = qr_info.oauthKey;
|
||||
const canvas = document.getElementById("qr");
|
||||
QRCode.toCanvas(canvas, qr_info.url, function (error) {
|
||||
if (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
}
|
||||
canvas.style.display = "block";
|
||||
check_interval = setInterval(check_qr, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function check_qr() {
|
||||
let qr_status: { code: number; cookies: string } = await invoke(
|
||||
"get_qr_status",
|
||||
{ qrcodeKey: oauth_key },
|
||||
);
|
||||
if (qr_status.code == 0) {
|
||||
clearInterval(check_interval);
|
||||
await invoke("add_account", { cookies: qr_status.cookies });
|
||||
await update_accounts();
|
||||
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">
|
||||
<Table hoverable={true} divClass="relative max-h-full" shadow>
|
||||
<TableHead>
|
||||
<TableHeadCell>UID</TableHeadCell>
|
||||
<TableHeadCell>头像</TableHeadCell>
|
||||
<TableHeadCell>用户名</TableHeadCell>
|
||||
<TableHeadCell>状态</TableHeadCell>
|
||||
<TableHeadCell>添加时间</TableHeadCell>
|
||||
<TableHeadCell>操作</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each account_info.accounts as account}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell>{account.uid}</TableBodyCell>
|
||||
<TableBodyCell
|
||||
><Image
|
||||
iclass="rounded-full w-12"
|
||||
src={account.avatar}
|
||||
/></TableBodyCell
|
||||
>
|
||||
<TableBodyCell>{account.name}</TableBodyCell>
|
||||
<TableBodyCell
|
||||
>{account.uid == account_info.primary_uid
|
||||
? "主账号"
|
||||
: "普通账号"}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell
|
||||
>{new Date(account.created_at).toLocaleString()}</TableBodyCell
|
||||
>
|
||||
<TableBodyCell>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
on:click={async () => {
|
||||
await invoke("remove_account", { uid: account.uid });
|
||||
await update_accounts();
|
||||
}}>注销</Button
|
||||
>
|
||||
{#if account.uid != account_info.primary_uid}
|
||||
<Button
|
||||
on:click={async () => {
|
||||
await invoke("set_primary", { uid: account.uid });
|
||||
await update_accounts();
|
||||
}}>设置为主账号</Button
|
||||
>
|
||||
{/if}
|
||||
</ButtonGroup></TableBodyCell
|
||||
>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 扫码登录"
|
||||
bind:open={addModal}
|
||||
size="sm"
|
||||
autoclose
|
||||
>
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<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>
|
||||
@@ -1,152 +1,68 @@
|
||||
<script>
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarGroup,
|
||||
SidebarItem,
|
||||
SidebarWrapper,
|
||||
} from "flowbite-svelte";
|
||||
import {
|
||||
ChartPieSolid,
|
||||
GridSolid,
|
||||
MailBoxSolid,
|
||||
UserSolid,
|
||||
EditOutline,
|
||||
BookSolid,
|
||||
InfoCircleSolid,
|
||||
CogSolid,
|
||||
} from "flowbite-svelte-icons";
|
||||
let spanClass = "flex-1 ms-3 whitespace-nowrap";
|
||||
// acitveUrl is shared between project
|
||||
export let activeUrl = "#总览";
|
||||
export let room_count = 0;
|
||||
export let message_cnt = 0;
|
||||
import { Info, LayoutDashboard, Settings, Users, Video, FileVideo } from "lucide-svelte";
|
||||
|
||||
// acitveUrl is shared between project
|
||||
export let activeUrl = "#总览";
|
||||
|
||||
function navigate(route) {
|
||||
activeUrl = route;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar {activeUrl} asideClass="w-72 h-full z-[40]">
|
||||
<SidebarWrapper divClass="overflow-y-auto py-4 px-3 bg-gray-50 h-full">
|
||||
<SidebarGroup>
|
||||
<SidebarItem
|
||||
label="总览"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#总览";
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<ChartPieSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
label="直播间"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#直播间";
|
||||
}}
|
||||
{spanClass}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<GridSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="subtext">
|
||||
<span
|
||||
class="inline-flex justify-center items-center px-2 ms-3 text-sm font-medium text-gray-800 bg-gray-200 rounded-full dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{room_count}
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
label="消息"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#消息";
|
||||
}}
|
||||
{spanClass}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<MailBoxSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="subtext">
|
||||
<span
|
||||
class="inline-flex justify-center items-center p-3 ms-3 w-3 h-3 text-sm font-medium text-primary-600 bg-primary-200 rounded-full dark:bg-primary-900 dark:text-primary-200"
|
||||
>
|
||||
{message_cnt}
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
label="账号"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#账号";
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<UserSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
<!-- <SidebarItem
|
||||
label="自动化"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#自动化";
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<EditOutline
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SidebarItem> -->
|
||||
<SidebarItem
|
||||
label="设置"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#设置";
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<CogSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup border>
|
||||
<SidebarItem
|
||||
label="文档"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#文档";
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<BookSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
label="关于"
|
||||
href="#"
|
||||
on:click={() => {
|
||||
activeUrl = "#关于";
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<InfoCircleSolid
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SidebarItem>
|
||||
</SidebarGroup>
|
||||
</SidebarWrapper>
|
||||
</Sidebar>
|
||||
<style>
|
||||
:global(img.text-\[\#0A84FF\]) {
|
||||
filter: invert(48%) sepia(85%) saturate(2229%) hue-rotate(198deg) brightness(100%) contrast(101%);
|
||||
}
|
||||
|
||||
:global(img.text-gray-700) {
|
||||
filter: invert(23%) sepia(10%) saturate(532%) hue-rotate(182deg) brightness(94%) contrast(90%);
|
||||
}
|
||||
|
||||
:global(.dark img.text-\[\#0A84FF\]) {
|
||||
filter: invert(48%) sepia(85%) saturate(2229%) hue-rotate(198deg) brightness(100%) contrast(101%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
class="w-48 bg-[#f0f0f3]/50 dark:bg-[#2c2c2e]/50 backdrop-blur-xl border-r border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<nav class="p-3 space-y-1">
|
||||
<button
|
||||
on:click={() => navigate("#总览")}
|
||||
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#总览' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
|
||||
>
|
||||
<LayoutDashboard
|
||||
class="w-5 h-5 {activeUrl === '#总览' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}"
|
||||
/>
|
||||
<span>总览</span>
|
||||
</button>
|
||||
<button
|
||||
on:click={() => navigate("#直播间")}
|
||||
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#直播间' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
|
||||
>
|
||||
<Video class="w-5 h-5 {activeUrl === '#直播间' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||
<span>直播间</span>
|
||||
</button>
|
||||
<button
|
||||
on:click={() => navigate("#账号")}
|
||||
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#账号' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
|
||||
>
|
||||
<Users class="w-5 h-5 {activeUrl === '#账号' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||
<span>账号</span>
|
||||
</button>
|
||||
<button
|
||||
on:click={() => navigate("#设置")}
|
||||
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#设置' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
|
||||
>
|
||||
<Settings class="w-5 h-5 {activeUrl === '#设置' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
<button
|
||||
on:click={() => navigate("#关于")}
|
||||
class="flex w-full items-center space-x-2 px-3 py-2 rounded-lg {activeUrl === '#关于' ? 'bg-blue-500/10 text-[#0A84FF]' : 'text-gray-700'} dark:text-[#0A84FF] hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
|
||||
>
|
||||
<Info class="w-5 h-5 {activeUrl === '#关于' ? 'text-[#0A84FF]' : 'text-gray-700 dark:text-[#0A84FF]'}" />
|
||||
<span>关于</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
8
src/lib/BilibiliIcon.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
let className = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="{className}">
|
||||
<path fill="currentColor" d="M7.172 2.757L10.414 6h3.171l3.243-3.242a1 1 0 0 1 1.415 1.415L16.414 6H18.5A3.5 3.5 0 0 1 22 9.5v8a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 17.5v-8A3.5 3.5 0 0 1 5.5 6h2.085L5.757 4.171a1 1 0 0 1 1.415-1.414zM18.5 8h-13a1.5 1.5 0 0 0-1.493 1.356L4 9.5v8a1.5 1.5 0 0 0 1.356 1.493L5.5 19h13a1.5 1.5 0 0 0 1.493-1.356L20 17.5v-8a1.5 1.5 0 0 0-1.356-1.493L18.5 8zM8 11a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1zm8 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
641
src/lib/CoverEditor.svelte
Normal file
@@ -0,0 +1,641 @@
|
||||
<script lang="ts">
|
||||
import { Play, X, Type, Palette, Move, Plus, Trash2 } from "lucide-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let video = null;
|
||||
export let show: boolean = false;
|
||||
|
||||
// 文本列表
|
||||
let texts = [
|
||||
{
|
||||
id: 1,
|
||||
content: "",
|
||||
position: { x: 50, y: 50 },
|
||||
fontSize: 48,
|
||||
color: "#FF7F00",
|
||||
strokeColor: "#FFFFFF",
|
||||
},
|
||||
];
|
||||
|
||||
let selectedTextId = 1;
|
||||
|
||||
let isDragging = false;
|
||||
let startPos = { x: 0, y: 0 };
|
||||
let startTextPos = { x: 0, y: 0 };
|
||||
|
||||
let videoElement: HTMLVideoElement;
|
||||
let videoFrame;
|
||||
let isVideoLoaded = false;
|
||||
let currentTime = 0;
|
||||
let duration = 0;
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
let canvasWidth = 1280;
|
||||
let canvasHeight = 720;
|
||||
let scale = 1;
|
||||
let backgroundImage: HTMLImageElement;
|
||||
let redrawRequestId: number | null = null;
|
||||
let isRedrawScheduled = false;
|
||||
|
||||
onMount(() => {
|
||||
ctx = canvas.getContext("2d");
|
||||
loadBackgroundImage();
|
||||
resizeCanvas();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (redrawRequestId !== null) {
|
||||
cancelAnimationFrame(redrawRequestId);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function handleResize() {
|
||||
if (!isRedrawScheduled) {
|
||||
isRedrawScheduled = true;
|
||||
redrawRequestId = requestAnimationFrame(() => {
|
||||
resizeCanvas();
|
||||
isRedrawScheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRedraw() {
|
||||
if (!isRedrawScheduled) {
|
||||
isRedrawScheduled = true;
|
||||
redrawRequestId = requestAnimationFrame(() => {
|
||||
redraw();
|
||||
isRedrawScheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadBackgroundImage() {
|
||||
if (!videoFrame) {
|
||||
return;
|
||||
}
|
||||
backgroundImage = new Image();
|
||||
backgroundImage.crossOrigin = "anonymous";
|
||||
backgroundImage.onload = () => {
|
||||
scheduleRedraw();
|
||||
};
|
||||
backgroundImage.onerror = (e) => {
|
||||
console.error("Failed to load image:", e);
|
||||
};
|
||||
backgroundImage.src = videoFrame;
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const container = document.getElementById("cover-container");
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
scale = rect.width / canvasWidth;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
if (!ctx) return;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制背景图片
|
||||
if (backgroundImage && backgroundImage.complete) {
|
||||
ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 绘制所有文本
|
||||
texts.forEach((text) => {
|
||||
drawText(text);
|
||||
});
|
||||
}
|
||||
|
||||
function drawText(text) {
|
||||
if (!ctx) return;
|
||||
|
||||
const x = (text.position.x / 100) * canvas.width;
|
||||
const y = (text.position.y / 100) * canvas.height;
|
||||
|
||||
ctx.font = `bold ${text.fontSize}px sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// 绘制描边
|
||||
ctx.strokeStyle = text.strokeColor;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.miterLimit = 2;
|
||||
ctx.strokeText(text.content, x, y);
|
||||
|
||||
// 绘制半透明描边
|
||||
ctx.strokeStyle = `${text.strokeColor}80`;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeText(text.content, x, y);
|
||||
|
||||
// 绘制文本
|
||||
ctx.fillStyle = text.color;
|
||||
ctx.fillText(text.content, x, y);
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// 检查是否点击到文本
|
||||
texts.forEach((text) => {
|
||||
const textX = (text.position.x / 100) * rect.width;
|
||||
const textY = (text.position.y / 100) * rect.height;
|
||||
|
||||
ctx.font = `bold ${text.fontSize}px sans-serif`;
|
||||
const metrics = ctx.measureText(text.content);
|
||||
const textWidth = metrics.width;
|
||||
const textHeight = text.fontSize;
|
||||
|
||||
if (
|
||||
x >= textX - textWidth / 2 - 10 &&
|
||||
x <= textX + textWidth / 2 + 10 &&
|
||||
y >= textY - textHeight / 2 - 10 &&
|
||||
y <= textY + textHeight / 2 + 10
|
||||
) {
|
||||
isDragging = true;
|
||||
selectedTextId = text.id;
|
||||
startPos = { x: event.clientX, y: event.clientY };
|
||||
startTextPos = { ...text.position };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
if (!isDragging) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const deltaX = ((event.clientX - startPos.x) / rect.width) * 100;
|
||||
const deltaY = ((event.clientY - startPos.y) / rect.height) * 100;
|
||||
|
||||
// 限制文本位置在画布范围内
|
||||
const newX = Math.max(0, Math.min(100, startTextPos.x + deltaX));
|
||||
const newY = Math.max(0, Math.min(100, startTextPos.y + deltaY));
|
||||
|
||||
texts = texts.map((text) => {
|
||||
if (text.id === selectedTextId) {
|
||||
return {
|
||||
...text,
|
||||
position: {
|
||||
x: newX,
|
||||
y: newY,
|
||||
},
|
||||
};
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addNewText() {
|
||||
const newId = Math.max(0, ...texts.map((t) => t.id)) + 1;
|
||||
texts = [
|
||||
...texts,
|
||||
{
|
||||
id: newId,
|
||||
content: "",
|
||||
position: { x: 50, y: 50 },
|
||||
fontSize: 48,
|
||||
color: "#FF7F00",
|
||||
strokeColor: "#FFFFFF",
|
||||
},
|
||||
];
|
||||
selectedTextId = newId;
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
function deleteText(id: number) {
|
||||
texts = texts.filter((t) => t.id !== id);
|
||||
if (texts.length > 0) {
|
||||
selectedTextId = texts[0].id;
|
||||
}
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
function handleVideoLoaded() {
|
||||
isVideoLoaded = true;
|
||||
duration = videoElement.duration;
|
||||
updateCoverFromVideo();
|
||||
}
|
||||
|
||||
function handleTimeUpdate() {
|
||||
currentTime = videoElement.currentTime;
|
||||
}
|
||||
|
||||
function handleSeek(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const time = parseFloat(target.value);
|
||||
if (videoElement) {
|
||||
videoElement.currentTime = time;
|
||||
updateCoverFromVideo();
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateCoverFromVideo() {
|
||||
if (!videoElement) return;
|
||||
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = videoElement.videoWidth;
|
||||
tempCanvas.height = videoElement.videoHeight;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
tempCtx.drawImage(videoElement, 0, 0, tempCanvas.width, tempCanvas.height);
|
||||
videoFrame = tempCanvas.toDataURL("image/jpeg");
|
||||
loadBackgroundImage();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
show = false;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 确保 Canvas 已完全渲染
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(async () => {
|
||||
// 强制重绘一次
|
||||
redraw();
|
||||
// 等待一帧以确保渲染完成
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
// 直接使用 canvas 的内容作为新封面
|
||||
const newCover = canvas.toDataURL("image/jpeg");
|
||||
|
||||
await invoke("update_video_cover", {
|
||||
id: video.value,
|
||||
cover: newCover,
|
||||
});
|
||||
|
||||
// 触发自定义事件通知父组件更新封面
|
||||
dispatch("coverUpdate", { cover: newCover });
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
alert("更新封面失败: " + e);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleTextInput(text, event: Event) {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
text.content = target.value;
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
$: {
|
||||
// 当文本内容或样式改变时重绘
|
||||
if (ctx) {
|
||||
texts = texts.map((text) => {
|
||||
if (text.id === selectedTextId) {
|
||||
return {
|
||||
...text,
|
||||
content: text.content,
|
||||
fontSize: text.fontSize,
|
||||
color: text.color,
|
||||
position: text.position,
|
||||
};
|
||||
}
|
||||
return text;
|
||||
});
|
||||
scheduleRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
$: selectedText = texts.find((t) => t.id === selectedTextId);
|
||||
|
||||
// 监听 show 变化,当模态框显示时重新绘制
|
||||
$: if (show && ctx) {
|
||||
setTimeout(() => {
|
||||
loadBackgroundImage();
|
||||
resizeCanvas();
|
||||
}, 50);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:mousemove={handleMouseMove}
|
||||
on:mouseup={handleMouseUp}
|
||||
on:blur={() => (isDragging = false)}
|
||||
on:visibilitychange={() => {
|
||||
if (document.hidden) {
|
||||
isDragging = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Modal Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/30 backdrop-blur-sm z-[1000] transition-opacity duration-200"
|
||||
class:opacity-0={!show}
|
||||
class:opacity-100={show}
|
||||
class:pointer-events-none={!show}
|
||||
>
|
||||
<!-- Modal Content -->
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] bg-[#1c1c1e] rounded-2xl shadow-2xl overflow-hidden transition-all duration-200"
|
||||
class:opacity-0={!show}
|
||||
class:opacity-100={show}
|
||||
class:scale-95={!show}
|
||||
class:scale-100={show}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-gray-800/50 bg-[#2c2c2e]"
|
||||
>
|
||||
<h3 class="text-base font-medium text-white">编辑封面</h3>
|
||||
<button
|
||||
class="w-[22px] h-[22px] rounded-full bg-[#ff5f57] hover:bg-[#ff5f57]/90 transition-colors duration-200 flex items-center justify-center group"
|
||||
on:click={handleClose}
|
||||
>
|
||||
<X
|
||||
class="w-3 h-3 text-[#1c1c1e] opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Video Frame Selection -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-400 flex items-center justify-between">
|
||||
<span class="font-medium">选择视频帧</span>
|
||||
<div class="flex items-center space-x-2 text-xs">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span class="opacity-50">/</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Video Element -->
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
src={video?.file}
|
||||
class="hidden"
|
||||
crossorigin="anonymous"
|
||||
on:loadedmetadata={handleVideoLoaded}
|
||||
on:timeupdate={handleTimeUpdate}
|
||||
/>
|
||||
|
||||
<!-- Video Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration}
|
||||
step="0.1"
|
||||
bind:value={currentTime}
|
||||
on:input={handleSeek}
|
||||
class="flex-1"
|
||||
disabled={!isVideoLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cover Preview -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-400 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-medium">视频封面</span>
|
||||
<span class="text-xs opacity-60">(拖拽文字调整位置)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="cover-container"
|
||||
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50 aspect-video"
|
||||
>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
on:mousedown={handleMouseDown}
|
||||
class="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Controls -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- Text List and Input -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Text List -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label
|
||||
class="flex items-center space-x-2 text-sm font-medium text-gray-300"
|
||||
>
|
||||
<Type class="w-4 h-4" />
|
||||
<span>文字列表</span>
|
||||
</label>
|
||||
<button
|
||||
on:click={addNewText}
|
||||
class="p-1.5 rounded-lg bg-[#2c2c2e] hover:bg-[#3c3c3e] transition-colors duration-200 text-white"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
{#each texts as text (text.id)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="flex items-center space-x-2 p-2 rounded-lg transition-colors duration-200 cursor-pointer"
|
||||
class:bg-[#2c2c2e]={selectedTextId === text.id}
|
||||
on:click={() => (selectedTextId = text.id)}
|
||||
>
|
||||
<textarea
|
||||
value={text.content}
|
||||
on:input={(e) => handleTextInput(text, e)}
|
||||
placeholder="输入文字内容"
|
||||
class="flex-1 bg-transparent text-white text-sm outline-none resize-none placeholder:text-gray-500"
|
||||
/>
|
||||
{#if texts.length > 1}
|
||||
<button
|
||||
on:click={() => deleteText(text.id)}
|
||||
class="p-1 rounded hover:bg-[#3c3c3e] transition-colors duration-200 text-red-500"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Style Controls -->
|
||||
{#if selectedText}
|
||||
<div class="w-48 space-y-2">
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label
|
||||
class="flex items-center space-x-2 text-sm font-medium text-gray-300"
|
||||
>
|
||||
<Palette class="w-4 h-4" />
|
||||
<span>文字样式</span>
|
||||
</label>
|
||||
<div
|
||||
class="space-y-3 p-2.5 rounded-lg bg-[#2c2c2e] border border-gray-800/50"
|
||||
>
|
||||
<!-- Font Size -->
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="fontSize"
|
||||
class="text-xs text-gray-400 font-medium">字体大小</label
|
||||
>
|
||||
<input
|
||||
id="fontSize"
|
||||
type="range"
|
||||
bind:value={selectedText.fontSize}
|
||||
min="48"
|
||||
max="160"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- Colors -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Text Color -->
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="textColor"
|
||||
class="text-xs text-gray-400 font-medium">文字颜色</label
|
||||
>
|
||||
<input
|
||||
id="textColor"
|
||||
type="color"
|
||||
bind:value={selectedText.color}
|
||||
class="w-full h-7 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<!-- Stroke Color -->
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="strokeColor"
|
||||
class="text-xs text-gray-400 font-medium">描边颜色</label
|
||||
>
|
||||
<input
|
||||
id="strokeColor"
|
||||
type="color"
|
||||
bind:value={selectedText.strokeColor}
|
||||
class="w-full h-7 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="px-5 py-3 border-t border-gray-800/50 flex justify-end space-x-3"
|
||||
>
|
||||
<button
|
||||
class="px-4 py-1.5 text-gray-400 hover:text-white transition-colors duration-200 text-sm font-medium"
|
||||
on:click={handleClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 bg-[#0A84FF] text-white rounded-lg hover:bg-[#0A84FF]/90 transition-colors duration-200 text-sm font-medium"
|
||||
on:click={handleSave}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
height: 24px;
|
||||
margin: -8px 0;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #4a4a4a;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #0a84ff;
|
||||
margin-top: -6px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="range"]:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="range"]:active::-webkit-slider-thumb {
|
||||
transform: scale(0.95);
|
||||
background: #0A84FF/90;
|
||||
}
|
||||
|
||||
input[type="range"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
8
src/lib/DouyinIcon.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
let className = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class={className}>
|
||||
<path fill="currentColor" d="M22.5 9.84202C20.4357 9.84696 18.4221 9.20321 16.7435 8.00171V16.3813C16.7429 17.9333 16.2685 19.4482 15.3838 20.7233C14.499 21.9984 13.246 22.973 11.7923 23.5168C10.3387 24.0606 8.75362 24.1477 7.24914 23.7664C5.74466 23.3851 4.39245 22.5536 3.37333 21.3787C2.3542 20.2037 1.71674 18.7397 1.54617 17.1913C1.3756 15.6429 1.68007 14.0803 2.41884 12.7023C3.15762 11.3244 4.2942 10.1891 5.68563 9.43829C7.07704 8.68749 8.65658 8.35815 10.2285 8.48908V12.8205C9.45028 12.5892 8.61144 12.633 7.86093 12.9452C7.11043 13.2574 6.49034 13.8208 6.09024 14.5447C5.69013 15.2686 5.53293 16.1105 5.64381 16.9409C5.75469 17.7714 6.12761 18.5399 6.70443 19.1393C7.28125 19.7387 8.02641 20.1352 8.84547 20.2664C9.66452 20.3976 10.5066 20.2568 11.2403 19.8658C11.974 19.4749 12.5556 18.8558 12.8932 18.0975C13.2308 17.3392 13.3049 16.4943 13.1033 15.6896V0H16.7429C16.7429 0.277113 16.7449 0.554225 16.7489 0.826725C16.7865 2.47069 17.3159 4.05778 18.2582 5.37345C19.2005 6.68912 20.5055 7.66808 22.0009 8.16951C22.1665 8.22944 22.3337 8.28805 22.5 8.34202V9.84202Z"/>
|
||||
</svg>
|
||||
@@ -5,13 +5,11 @@
|
||||
let b = "";
|
||||
async function getImage(url: string) {
|
||||
if (!url) {
|
||||
return "";
|
||||
return "/imgs/douyin.png";
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
});
|
||||
console.log(response.status); // e.g. 200
|
||||
console.log(response.statusText); // e.g. "OK"
|
||||
return URL.createObjectURL(await response.blob());
|
||||
}
|
||||
async function init() {
|
||||
|
||||
144
src/lib/MarkerPanel.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
BanOutline,
|
||||
CloseOutline,
|
||||
ForwardOutline,
|
||||
ClockOutline,
|
||||
} from "flowbite-svelte-icons";
|
||||
import type { Marker } from "./interface";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { Tooltip } from "flowbite-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import type { RecordItem } from "./db";
|
||||
const dispatch = createEventDispatcher();
|
||||
export let archive: RecordItem;
|
||||
export let markers: Marker[] = [];
|
||||
|
||||
let realtime = false;
|
||||
|
||||
function format_duration(duration: number) {
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function format_realtime(ts: number) {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function dispatch_markerclick(marker: Marker) {
|
||||
dispatch("markerClick", marker);
|
||||
}
|
||||
|
||||
async function export_to_file() {
|
||||
let r = "# 由 BiliShadowReplay 自动生成\n";
|
||||
r += `# ${archive.title} - 直播开始时间:${format_realtime(archive.live_id * 1000)}\n\n`;
|
||||
for (let i in markers) {
|
||||
r += `[${format_realtime(markers[i].realtime)}][${format_duration(markers[i].offset)}] ${
|
||||
markers[i].content
|
||||
}\n`;
|
||||
}
|
||||
let file_name = `[${archive.room_id}][${format_realtime(archive.live_id)
|
||||
.split(" ")[0]
|
||||
.replaceAll("/", "-")}]${archive.title}.txt`;
|
||||
console.log("export to file", file_name);
|
||||
const path = await save({
|
||||
title: "导出标记列表",
|
||||
defaultPath: file_name,
|
||||
});
|
||||
if (!path) return;
|
||||
await invoke("export_to_file", { fileName: path, content: r });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full h-screen text-white p-4 pr-0">
|
||||
<div class="mb-4 flex flex-row justify-between">
|
||||
<div class="flex">
|
||||
<span class="mr-1">标记列表</span>
|
||||
<button
|
||||
class="mr-1"
|
||||
on:click={() => {
|
||||
realtime = !realtime;
|
||||
}}><ClockOutline /></button
|
||||
>
|
||||
<Tooltip>切换时间形式</Tooltip>
|
||||
<button on:click={export_to_file}><ForwardOutline /></button>
|
||||
<Tooltip>导出为文件</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
class="mr-2"
|
||||
on:click={() => {
|
||||
markers = [];
|
||||
}}><BanOutline /></button
|
||||
>
|
||||
<Tooltip>清空</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
{#each markers as marker, i}
|
||||
<div class="marker-entry">
|
||||
<div class="marker-control">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span
|
||||
class="offset"
|
||||
on:click={() => {
|
||||
dispatch_markerclick(marker);
|
||||
}}
|
||||
>{realtime
|
||||
? format_realtime(marker.realtime)
|
||||
: format_duration(marker.offset)}</span
|
||||
>
|
||||
<button
|
||||
class="hover:bg-red-900"
|
||||
on:click={() => {
|
||||
// remove this entry
|
||||
markers = markers.filter((_, idx) => idx !== i);
|
||||
}}><CloseOutline /></button
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
class="content w-full"
|
||||
bind:value={marker.content}
|
||||
on:change={(v) => {
|
||||
if (marker.content == "") {
|
||||
marker.content = "[空标记点]";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.marker-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.marker-entry:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.marker-entry:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.marker-entry .offset {
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.marker-entry .content {
|
||||
background: transparent;
|
||||
}
|
||||
.marker-control {
|
||||
display: flex;
|
||||
padding-right: 4px;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableBodyRow,
|
||||
TableBodyCell,
|
||||
Button,
|
||||
} from "flowbite-svelte";
|
||||
import type { MessageItem } from "./db";
|
||||
import { CloseCircleSolid, InfoCircleSolid } from "flowbite-svelte-icons";
|
||||
|
||||
export let message_cnt = 0;
|
||||
let messages: MessageItem[] = [];
|
||||
async function update() {
|
||||
messages = ((await invoke("get_messages")) as MessageItem[]).sort(
|
||||
(a, b) => b.id - a.id
|
||||
);
|
||||
message_cnt = messages.length;
|
||||
}
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
|
||||
async function delete_message(id: number) {
|
||||
await invoke("delete_message", { id: id });
|
||||
await update();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12 h-full overflow-hidden">
|
||||
<Table hoverable={true} divClass="relative max-h-full overflow-auto" shadow>
|
||||
<TableBody tableBodyClass="divide-y">
|
||||
{#each messages as message}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell tdClass="pl-6 py-4 text-center">
|
||||
<InfoCircleSolid class="w-8 h-8" />
|
||||
</TableBodyCell>
|
||||
<TableBodyCell tdClass="text-wrap px-6 py-4">
|
||||
<p class="text-lg font-bold">{message.title}</p>
|
||||
<p class="text-slate-500">{message.content}</p>
|
||||
</TableBodyCell>
|
||||
<TableBodyCell tdClass="px-6 py-4 text-end"
|
||||
><p class="text-slate-400">
|
||||
{new Date(message.created_at).toLocaleString()}
|
||||
</p></TableBodyCell
|
||||
>
|
||||
<TableBodyCell tdClass="px-6 py-4 text-end">
|
||||
<Button
|
||||
class="!p-2"
|
||||
size="sm"
|
||||
color="red"
|
||||
on:click={async () => {
|
||||
await delete_message(message.id);
|
||||
}}
|
||||
>
|
||||
<CloseCircleSolid />
|
||||
</Button>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
{#if messages.length == 0}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell tdClass="pl-6 py-4 text-center" colspan="4">
|
||||
<p class="text-slate-400 text-lg">暂无消息</p>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -1,24 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AccountInfo, AccountItem } from "./db";
|
||||
import type { AccountInfo } from "./db";
|
||||
import type { Marker, RecorderList, RecorderInfo } from "./interface";
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { GridOutline, SortHorizontalOutline } from "flowbite-svelte-icons";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
interface DanmuEntry {
|
||||
ts: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export let port;
|
||||
export let room_id;
|
||||
export let ts;
|
||||
export let port: number;
|
||||
export let platform: string;
|
||||
export let room_id: number;
|
||||
export let live_id: string;
|
||||
export let start = 0;
|
||||
export let end = 0;
|
||||
export let markers: Marker[] = [];
|
||||
export function seek(offset: number) {
|
||||
video.currentTime = offset;
|
||||
}
|
||||
let video: HTMLVideoElement;
|
||||
let show_detail = false;
|
||||
let show_list = false;
|
||||
let global_offset = 0;
|
||||
let recorders: RecorderInfo[] = [];
|
||||
|
||||
// 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`)
|
||||
fetch(
|
||||
`http://127.0.0.1:${port}/${platform}/${room_id}/${live_id}/playlist.m3u8`
|
||||
)
|
||||
.then((response) => response.text())
|
||||
.then((m3u8Content) => {
|
||||
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
|
||||
@@ -35,8 +50,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function update_stream_list() {
|
||||
recorders = (
|
||||
(await invoke("get_recorder_list")) as RecorderList
|
||||
).recorders.filter((r) => r.live_status && r.room_id != room_id);
|
||||
}
|
||||
|
||||
function go_to(platform: string, room_id: number, live_id: string) {
|
||||
const url = `${window.location.origin}${window.location.pathname}?port=${port}&platform=${platform}&room_id=${room_id}&live_id=${live_id}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const video = document.getElementById("video") as HTMLVideoElement;
|
||||
await meta_parse();
|
||||
|
||||
update_stream_list();
|
||||
|
||||
setInterval(async () => {
|
||||
await update_stream_list();
|
||||
}, 5 * 1000);
|
||||
|
||||
video = document.getElementById("video") as HTMLVideoElement;
|
||||
const ui = video["ui"];
|
||||
const controls = ui.getControls();
|
||||
const player = controls.getPlayer();
|
||||
@@ -53,8 +87,15 @@
|
||||
(window as any).player = player;
|
||||
(window as any).ui = ui;
|
||||
|
||||
player.configure({
|
||||
streaming: {
|
||||
lowLatencyMode: true,
|
||||
},
|
||||
});
|
||||
|
||||
player.addEventListener("ended", async () => {
|
||||
location.reload();
|
||||
// prevent endless reload
|
||||
setTimeout(location.reload, 3 * 1000);
|
||||
});
|
||||
player.addEventListener("manifestloaded", (event) => {
|
||||
console.log("Manifest loaded:", event);
|
||||
@@ -62,7 +103,7 @@
|
||||
|
||||
try {
|
||||
await player.load(
|
||||
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
|
||||
`http://127.0.0.1:${port}/${platform}/${room_id}/${live_id}/playlist.m3u8`
|
||||
);
|
||||
// This runs if the asynchronous load is successful.
|
||||
console.log("The video has now been loaded!");
|
||||
@@ -90,7 +131,7 @@
|
||||
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
|
||||
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
|
||||
const shakaBottomControls = document.querySelector(
|
||||
".shaka-bottom-controls.shaka-no-propagation",
|
||||
".shaka-bottom-controls.shaka-no-propagation"
|
||||
);
|
||||
const selfSeekbar = document.createElement("div");
|
||||
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
|
||||
@@ -113,179 +154,191 @@
|
||||
// get danmaku record
|
||||
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
|
||||
roomId: room_id,
|
||||
ts: ts,
|
||||
liveId: live_id,
|
||||
platform: platform,
|
||||
})) 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);
|
||||
let ts = parseInt(live_id);
|
||||
|
||||
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) {
|
||||
if (platform == "bilibili") {
|
||||
// history danmaku sender
|
||||
setInterval(() => {
|
||||
if (video.paused) {
|
||||
return;
|
||||
}
|
||||
danmu_handler(event.payload.content);
|
||||
});
|
||||
}
|
||||
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 + ts) * 1000);
|
||||
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
danmakuToggle.style.height = "30px";
|
||||
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
danmakuToggle.style.color = "white";
|
||||
danmakuToggle.style.border = "1px solid gray";
|
||||
danmakuToggle.style.padding = "0 10px";
|
||||
danmakuToggle.style.boxSizing = "border-box";
|
||||
danmakuToggle.style.fontSize = "1em";
|
||||
danmakuToggle.addEventListener("click", async () => {
|
||||
danmu_enabled = !danmu_enabled;
|
||||
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
|
||||
// clear background color
|
||||
danmakuToggle.style.backgroundColor = danmu_enabled
|
||||
? "rgba(0, 128, 255, 0.5)"
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
let danmus = danmu_records.filter((v) => {
|
||||
return v.ts >= cur - 1000 && v.ts < cur;
|
||||
});
|
||||
danmus.forEach((v) => danmu_handler(v.content));
|
||||
}, 1000);
|
||||
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "30";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
overlay.style.paddingTop = "10%";
|
||||
// place overlay to the top of the video
|
||||
video.parentElement.appendChild(overlay);
|
||||
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";
|
||||
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
// get accounts from tauri
|
||||
const account_info = (await invoke("get_accounts")) as AccountInfo;
|
||||
account_info.accounts.forEach((account) => {
|
||||
if (account.platform !== "bilibili") {
|
||||
return;
|
||||
}
|
||||
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 = "30%";
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function danmu_handler(content: string) {
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
|
||||
// Calculate a random position for the danmaku
|
||||
let topPosition;
|
||||
let attempts = 0;
|
||||
do {
|
||||
topPosition = Math.random() * 30;
|
||||
attempts++;
|
||||
} while (
|
||||
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
|
||||
attempts < 10
|
||||
);
|
||||
|
||||
// Record the position
|
||||
danmakuPositions.push(topPosition);
|
||||
if (danmakuPositions.length > 10) {
|
||||
danmakuPositions.shift(); // Keep the last 10 positions
|
||||
// listen to danmaku event
|
||||
const unlisten = await 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);
|
||||
}
|
||||
);
|
||||
window.onbeforeunload = () => {
|
||||
unlisten();
|
||||
};
|
||||
}
|
||||
|
||||
danmaku.style.top = `${topPosition}%`;
|
||||
danmaku.style.right = "0";
|
||||
danmaku.style.color = "white";
|
||||
danmaku.style.fontSize = "1.2em";
|
||||
danmaku.style.whiteSpace = "nowrap";
|
||||
danmaku.style.transform = "translateX(100%)";
|
||||
danmaku.style.transition = "transform 10s linear";
|
||||
danmaku.style.pointerEvents = "none";
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
danmaku.style.zIndex = "500";
|
||||
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
|
||||
danmaku.innerText = content;
|
||||
overlay.appendChild(danmaku);
|
||||
requestAnimationFrame(() => {
|
||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
danmakuToggle.style.height = "30px";
|
||||
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
danmakuToggle.style.color = "white";
|
||||
danmakuToggle.style.border = "1px solid gray";
|
||||
danmakuToggle.style.padding = "0 10px";
|
||||
danmakuToggle.style.boxSizing = "border-box";
|
||||
danmakuToggle.style.fontSize = "1em";
|
||||
danmakuToggle.addEventListener("click", async () => {
|
||||
danmu_enabled = !danmu_enabled;
|
||||
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
|
||||
// clear background color
|
||||
danmakuToggle.style.backgroundColor = danmu_enabled
|
||||
? "rgba(0, 128, 255, 0.5)"
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
danmaku.addEventListener("transitionend", () => {
|
||||
overlay.removeChild(danmaku);
|
||||
});
|
||||
}
|
||||
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "30";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
overlay.style.paddingTop = "10%";
|
||||
// place overlay to the top of the video
|
||||
video.parentElement.appendChild(overlay);
|
||||
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
|
||||
function danmu_handler(content: string) {
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
|
||||
// Calculate a random position for the danmaku
|
||||
let topPosition = 0;
|
||||
let attempts = 0;
|
||||
do {
|
||||
topPosition = Math.random() * 30;
|
||||
attempts++;
|
||||
} while (
|
||||
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
|
||||
attempts < 10
|
||||
);
|
||||
|
||||
// Record the position
|
||||
danmakuPositions.push(topPosition);
|
||||
if (danmakuPositions.length > 10) {
|
||||
danmakuPositions.shift(); // Keep the last 10 positions
|
||||
}
|
||||
|
||||
danmaku.style.top = `${topPosition}%`;
|
||||
danmaku.style.right = "0";
|
||||
danmaku.style.color = "white";
|
||||
danmaku.style.fontSize = "1.2em";
|
||||
danmaku.style.whiteSpace = "nowrap";
|
||||
danmaku.style.transform = "translateX(100%)";
|
||||
danmaku.style.transition = "transform 10s linear";
|
||||
danmaku.style.pointerEvents = "none";
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
danmaku.style.zIndex = "500";
|
||||
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
|
||||
danmaku.innerText = content;
|
||||
overlay.appendChild(danmaku);
|
||||
requestAnimationFrame(() => {
|
||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||
});
|
||||
danmaku.addEventListener("transitionend", () => {
|
||||
overlay.removeChild(danmaku);
|
||||
});
|
||||
}
|
||||
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
}
|
||||
|
||||
// create a playback rate select to of shaka-spacer
|
||||
const playbackRateSelect = document.createElement("select");
|
||||
@@ -315,6 +368,55 @@
|
||||
|
||||
shakaSpacer.appendChild(playbackRateSelect);
|
||||
|
||||
let danmu_statistics: { ts: number; count: number }[] = [];
|
||||
|
||||
if (platform == "bilibili") {
|
||||
// create a danmu statistics select into shaka-spacer
|
||||
let statisticKey = "";
|
||||
const statisticKeyInput = document.createElement("input");
|
||||
statisticKeyInput.style.height = "30px";
|
||||
statisticKeyInput.style.width = "100px";
|
||||
statisticKeyInput.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
|
||||
statisticKeyInput.style.color = "white";
|
||||
statisticKeyInput.style.border = "1px solid gray";
|
||||
statisticKeyInput.style.padding = "0 10px";
|
||||
statisticKeyInput.style.boxSizing = "border-box";
|
||||
statisticKeyInput.style.fontSize = "1em";
|
||||
statisticKeyInput.style.right = "75px";
|
||||
statisticKeyInput.placeholder = "弹幕统计过滤";
|
||||
statisticKeyInput.style.position = "absolute";
|
||||
|
||||
function update_statistics() {
|
||||
let counts = {};
|
||||
danmu_records.forEach((e) => {
|
||||
if (statisticKey != "" && !e.content.includes(statisticKey)) {
|
||||
return;
|
||||
}
|
||||
const timeSlot = Math.floor(e.ts / 10000) * 10000; // 将时间戳向下取整到10秒
|
||||
counts[timeSlot] = (counts[timeSlot] || 0) + 1;
|
||||
});
|
||||
danmu_statistics = [];
|
||||
for (let ts in counts) {
|
||||
danmu_statistics.push({ ts: parseInt(ts), count: counts[ts] });
|
||||
}
|
||||
}
|
||||
|
||||
update_statistics();
|
||||
|
||||
if (isLive()) {
|
||||
setInterval(async () => {
|
||||
update_statistics();
|
||||
}, 10 * 1000);
|
||||
}
|
||||
|
||||
statisticKeyInput.addEventListener("change", () => {
|
||||
statisticKey = statisticKeyInput.value;
|
||||
update_statistics();
|
||||
});
|
||||
|
||||
shakaSpacer.appendChild(statisticKeyInput);
|
||||
}
|
||||
|
||||
// shaka-spacer should be flex-direction: column
|
||||
shakaSpacer.style.flexDirection = "column";
|
||||
|
||||
@@ -336,6 +438,15 @@
|
||||
}
|
||||
switch (e.key) {
|
||||
case "[":
|
||||
e.preventDefault();
|
||||
start = parseFloat(video.currentTime.toFixed(2));
|
||||
if (end < start) {
|
||||
end = get_total();
|
||||
}
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "【":
|
||||
e.preventDefault();
|
||||
start = parseFloat(video.currentTime.toFixed(2));
|
||||
if (end < start) {
|
||||
end = get_total();
|
||||
@@ -343,6 +454,15 @@
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "]":
|
||||
e.preventDefault();
|
||||
end = parseFloat(video.currentTime.toFixed(2));
|
||||
if (start > end) {
|
||||
start = 0;
|
||||
}
|
||||
console.log(start, end);
|
||||
break;
|
||||
case "】":
|
||||
e.preventDefault();
|
||||
end = parseFloat(video.currentTime.toFixed(2));
|
||||
if (start > end) {
|
||||
start = 0;
|
||||
@@ -350,6 +470,7 @@
|
||||
console.log(start, end);
|
||||
break;
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (e.repeat) {
|
||||
break;
|
||||
}
|
||||
@@ -359,22 +480,31 @@
|
||||
video.pause();
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
case "p":
|
||||
e.preventDefault();
|
||||
if (e.repeat) {
|
||||
break;
|
||||
}
|
||||
video.muted = !video.muted;
|
||||
// dispatch event
|
||||
dispatch("markerAdd", {
|
||||
offset: video.currentTime,
|
||||
realtime: ts + video.currentTime,
|
||||
});
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
video.currentTime -= 3;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
video.currentTime += 3;
|
||||
break;
|
||||
case "q":
|
||||
e.preventDefault();
|
||||
video.currentTime = start;
|
||||
break;
|
||||
case "e":
|
||||
e.preventDefault();
|
||||
if (end == 0) {
|
||||
video.currentTime = get_total();
|
||||
} else {
|
||||
@@ -382,23 +512,101 @@
|
||||
}
|
||||
break;
|
||||
case "c":
|
||||
e.preventDefault();
|
||||
start = 0;
|
||||
end = 0;
|
||||
break;
|
||||
case "h":
|
||||
e.preventDefault();
|
||||
show_detail = !show_detail;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const seekbarContainer = selfSeekbar.querySelector(
|
||||
".shaka-seek-bar-container.self-defined"
|
||||
) as HTMLElement;
|
||||
|
||||
const statisticGraph = document.createElement(
|
||||
"canvas"
|
||||
) as HTMLCanvasElement;
|
||||
statisticGraph.style.pointerEvents = "none";
|
||||
statisticGraph.style.position = "absolute";
|
||||
statisticGraph.style.bottom = "11px";
|
||||
statisticGraph.style.zIndex = "20";
|
||||
const canvas = statisticGraph.getContext("2d");
|
||||
seekbarContainer.appendChild(statisticGraph);
|
||||
|
||||
// draw statistics
|
||||
function drawStatistics(points: { ts: number; count: number }[]) {
|
||||
if (points == undefined) {
|
||||
points = [];
|
||||
}
|
||||
// preprocess points
|
||||
let preprocessed = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
preprocessed.push(points[i - 1]);
|
||||
let gap = (points[i].ts - points[i - 1].ts) / 1000;
|
||||
if (gap > 10) {
|
||||
// add zero point to fill gap
|
||||
let cnt = 1;
|
||||
while (gap > 10) {
|
||||
preprocessed.push({
|
||||
ts: points[i - 1].ts + cnt * 10 * 1000,
|
||||
count: 0,
|
||||
});
|
||||
cnt += 1;
|
||||
gap -= 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (points.length > 0) {
|
||||
preprocessed.push(points[points.length - 1]);
|
||||
}
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
statisticGraph.width = seekbarContainer.clientWidth * scale;
|
||||
statisticGraph.height = 30 * scale;
|
||||
statisticGraph.style.width = `${seekbarContainer.clientWidth}px`;
|
||||
statisticGraph.style.height = "30px";
|
||||
const canvasHeight = statisticGraph.height;
|
||||
const canvasWidth = statisticGraph.width;
|
||||
// find value range
|
||||
const minValue = 0;
|
||||
const maxValue = Math.max(...preprocessed.map((v) => v.count));
|
||||
const beginTime = player.getPresentationStartTimeAsDate().getTime();
|
||||
const duration = get_total() * 1000;
|
||||
canvas.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
if (preprocessed.length > 0) {
|
||||
canvas.beginPath();
|
||||
const x = ((preprocessed[0].ts - beginTime) / duration) * canvasWidth;
|
||||
const y =
|
||||
(1 - (preprocessed[0].count - minValue) / (maxValue - minValue)) *
|
||||
canvasHeight;
|
||||
canvas.moveTo(x, y);
|
||||
for (let i = 0; i < preprocessed.length; i++) {
|
||||
const x = ((preprocessed[i].ts - beginTime) / duration) * canvasWidth;
|
||||
const y =
|
||||
(1 - (preprocessed[i].count - minValue) / (maxValue - minValue)) *
|
||||
canvasHeight;
|
||||
canvas.lineTo(x, y);
|
||||
if (i == preprocessed.length - 1) {
|
||||
canvas.lineTo(x, canvasHeight);
|
||||
}
|
||||
}
|
||||
canvas.strokeStyle = "rgba(245, 166, 39, 0.5)";
|
||||
canvas.stroke();
|
||||
canvas.lineTo(x, canvasHeight);
|
||||
canvas.closePath();
|
||||
canvas.fillStyle = "rgba(245, 166, 39, 0.5)";
|
||||
canvas.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function updateSeekbar() {
|
||||
const total = get_total();
|
||||
const first_point = start / total;
|
||||
const second_point = end / total;
|
||||
// set background color for self-defined seekbar between first_point and second_point using linear-gradient
|
||||
const seekbarContainer = selfSeekbar.querySelector(
|
||||
".shaka-seek-bar-container.self-defined",
|
||||
) as HTMLElement;
|
||||
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
|
||||
first_point * 100
|
||||
}%, rgb(0, 255, 0) ${first_point * 100}%, rgb(0, 255, 0) ${
|
||||
@@ -408,13 +616,42 @@
|
||||
}%, rgba(255, 255, 255, 0.4) ${
|
||||
first_point * 100
|
||||
}%, rgba(255, 255, 255, 0.2) ${first_point * 100}%)`;
|
||||
// render markers in shaka-ad-markers
|
||||
const adMarkers = document.querySelector(
|
||||
".shaka-ad-markers"
|
||||
) as HTMLElement;
|
||||
if (adMarkers) {
|
||||
// clean previous markers
|
||||
adMarkers.innerHTML = "";
|
||||
for (const marker of markers) {
|
||||
const markerElement = document.createElement("div");
|
||||
markerElement.style.position = "absolute";
|
||||
markerElement.style.width = "6px";
|
||||
markerElement.style.height = "7px";
|
||||
markerElement.style.backgroundColor = "#93A8AC";
|
||||
markerElement.style.left = `calc(${(marker.offset / total) * 100}% - 3px)`;
|
||||
markerElement.style.top = "-12px";
|
||||
markerElement.style.zIndex = "30";
|
||||
// little triangle on the bottom
|
||||
const triangle = document.createElement("div");
|
||||
triangle.style.width = "0";
|
||||
triangle.style.height = "0";
|
||||
triangle.style.borderLeft = "3px solid transparent";
|
||||
triangle.style.borderRight = "3px solid transparent";
|
||||
triangle.style.borderTop = "4px solid #93A8AC";
|
||||
triangle.style.position = "absolute";
|
||||
triangle.style.top = "7px";
|
||||
triangle.style.left = "0";
|
||||
markerElement.appendChild(triangle);
|
||||
adMarkers.appendChild(markerElement);
|
||||
}
|
||||
drawStatistics(danmu_statistics);
|
||||
}
|
||||
requestAnimationFrame(updateSeekbar);
|
||||
}
|
||||
requestAnimationFrame(updateSeekbar);
|
||||
}
|
||||
|
||||
meta_parse();
|
||||
|
||||
// receive tauri emit
|
||||
document.addEventListener("shaka-ui-loaded", init);
|
||||
|
||||
@@ -448,10 +685,43 @@
|
||||
<p><kbd>←</kbd>前进</p>
|
||||
<p><kbd>→</kbd>后退</p>
|
||||
<p><kbd>c</kbd>清除选区</p>
|
||||
<p><kbd>m</kbd>静音</p>
|
||||
<p><kbd>p</kbd>创建标记</p>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div id="shortcuts">
|
||||
<button
|
||||
id="shortcut-btn"
|
||||
on:click={() => {
|
||||
show_list = !show_list;
|
||||
}}
|
||||
>
|
||||
<GridOutline />
|
||||
</button>
|
||||
{#if show_list}
|
||||
<ul class="shortcut-list">
|
||||
{#each recorders as recorder}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<li
|
||||
class="shortcut"
|
||||
on:click={() => {
|
||||
go_to(
|
||||
recorder.platform,
|
||||
recorder.room_id,
|
||||
recorder.current_live_id
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SortHorizontalOutline />[{recorder.user_info.user_name}]{recorder
|
||||
.room_info.room_title}
|
||||
</li>
|
||||
{/each}
|
||||
{#if recorders.length == 0}
|
||||
<p>没有其它正在直播的房间</p>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
video {
|
||||
@@ -471,7 +741,7 @@
|
||||
}
|
||||
|
||||
#overlay {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
border-radius: 6px;
|
||||
@@ -483,4 +753,45 @@
|
||||
font-size: 0.8em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#shortcuts {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
z-index: 501;
|
||||
}
|
||||
|
||||
#shortcut-btn {
|
||||
width: 36px;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#shortcut-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.shortcut-list {
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shortcut:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
<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";
|
||||
|
||||
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);
|
||||
}
|
||||
</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
|
||||
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}
|
||||
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>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Toggle,
|
||||
Input,
|
||||
Label,
|
||||
Card,
|
||||
} from "flowbite-svelte";
|
||||
|
||||
import type { Config } from "./interface";
|
||||
|
||||
let setting_model: Config = {
|
||||
cache: "",
|
||||
output: "",
|
||||
primary_uid: 0,
|
||||
live_start_notify: true,
|
||||
live_end_notify: true,
|
||||
clip_notify: true,
|
||||
post_notify: true,
|
||||
};
|
||||
|
||||
async function get_config() {
|
||||
let config: Config = await invoke("get_config");
|
||||
setting_model = config;
|
||||
console.log(config);
|
||||
}
|
||||
|
||||
async function browse_folder() {
|
||||
const selected = await open({ directory: true });
|
||||
return Array.isArray(selected) ? selected[0] : selected;
|
||||
}
|
||||
|
||||
async function update_notify() {
|
||||
await invoke("update_notify", {
|
||||
liveStartNotify: setting_model.live_start_notify,
|
||||
liveEndNotify: setting_model.live_end_notify,
|
||||
clipNotify: setting_model.clip_notify,
|
||||
postNotify: setting_model.post_notify,
|
||||
});
|
||||
}
|
||||
|
||||
get_config();
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12">
|
||||
<Card>
|
||||
<h5
|
||||
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
|
||||
>
|
||||
通知设置
|
||||
</h5>
|
||||
<Toggle
|
||||
class="mb-2"
|
||||
bind:checked={setting_model.live_start_notify}
|
||||
on:change={update_notify}>开播通知</Toggle
|
||||
>
|
||||
<Toggle
|
||||
class="mb-2"
|
||||
bind:checked={setting_model.live_end_notify}
|
||||
on:change={update_notify}>下播通知</Toggle
|
||||
>
|
||||
<Toggle
|
||||
class="mb-2"
|
||||
bind:checked={setting_model.clip_notify}
|
||||
on:change={update_notify}>切片完成通知</Toggle
|
||||
>
|
||||
<Toggle
|
||||
class="mb-2"
|
||||
bind:checked={setting_model.post_notify}
|
||||
on:change={update_notify}>投稿完成通知</Toggle
|
||||
>
|
||||
</Card>
|
||||
<Card size="xl" class="mt-4">
|
||||
<h5
|
||||
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
|
||||
>
|
||||
目录设置
|
||||
</h5>
|
||||
<Label>缓存目录</Label>
|
||||
<ButtonGroup>
|
||||
<Input value={setting_model.cache} readonly />
|
||||
<Button
|
||||
color="primary"
|
||||
on:click={async () => {
|
||||
const new_folder = await browse_folder();
|
||||
if (new_folder) {
|
||||
setting_model.cache = new_folder;
|
||||
await invoke("set_cache_path", {
|
||||
cachePath: setting_model.cache,
|
||||
});
|
||||
}
|
||||
}}>Browse</Button
|
||||
>
|
||||
<Button
|
||||
color="alternative"
|
||||
on:click={async () => {
|
||||
await invoke("show_in_folder", {
|
||||
path: setting_model.cache,
|
||||
});
|
||||
}}>Open</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
|
||||
<Label class="mt-4">输出目录</Label>
|
||||
<ButtonGroup>
|
||||
<Input value={setting_model.output} readonly />
|
||||
<Button
|
||||
color="primary"
|
||||
on:click={async () => {
|
||||
const new_folder = await browse_folder();
|
||||
if (new_folder) {
|
||||
setting_model.output = new_folder;
|
||||
await invoke("set_output_path", {
|
||||
outputPath: setting_model.output,
|
||||
});
|
||||
}
|
||||
}}>Browse</Button
|
||||
>
|
||||
<Button
|
||||
color="alternative"
|
||||
on:click={async () => {
|
||||
await invoke("show_in_folder", {
|
||||
path: setting_model.output,
|
||||
});
|
||||
}}>Open</Button
|
||||
>
|
||||
</ButtonGroup>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { Card, List, Li, Tooltip } from "flowbite-svelte";
|
||||
import { GithubSolid, GlobeSolid } from "flowbite-svelte-icons";
|
||||
import Image from "./Image.svelte";
|
||||
import type { RecorderList, DiskInfo } from "./interface";
|
||||
import type { RecordItem } from "./db";
|
||||
const INTERVAL = 5000;
|
||||
|
||||
let summary: RecorderList = {
|
||||
count: 0,
|
||||
recorders: [],
|
||||
};
|
||||
|
||||
let disk_info: DiskInfo = {
|
||||
disk: "",
|
||||
total: 0,
|
||||
free: 0,
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
let online = 0;
|
||||
let disk_usage = 0;
|
||||
|
||||
async function update_summary() {
|
||||
summary = (await invoke("get_recorder_list")) as RecorderList;
|
||||
total = summary.count;
|
||||
online = summary.recorders.filter((r) => r.live_status).length;
|
||||
// each recorder get archive size
|
||||
console.log(summary.recorders);
|
||||
let new_disk_usage = 0;
|
||||
for (const recorder of summary.recorders) {
|
||||
new_disk_usage += await get_disk_usage(recorder.room_id);
|
||||
}
|
||||
disk_usage = new_disk_usage;
|
||||
|
||||
// get disk info
|
||||
disk_info = await invoke("get_disk_info");
|
||||
}
|
||||
update_summary();
|
||||
setInterval(update_summary, INTERVAL);
|
||||
|
||||
async function get_disk_usage(room_id: number) {
|
||||
let ds = 0;
|
||||
const archives = (await invoke("get_archives", {
|
||||
roomId: room_id,
|
||||
})) as RecordItem[];
|
||||
for (const archive of archives) {
|
||||
ds += archive.size;
|
||||
}
|
||||
return ds;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
interface Sponser {
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
let sponsers: Sponser[] = [];
|
||||
async function get_sponsers() {
|
||||
const response = await fetch(
|
||||
"https://afdian.com/api/creator/get-sponsors?user_id=bbb3f596df9c11ea922752540025c377&type=new&page=1",
|
||||
);
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
if (data.ec == 200) {
|
||||
sponsers = data.data.list.slice(0, 10);
|
||||
}
|
||||
}
|
||||
get_sponsers();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 p-8 pt-12">
|
||||
<Card class="!max-w-none">
|
||||
<h5
|
||||
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
|
||||
>
|
||||
支持该项目的开发
|
||||
</h5>
|
||||
<List tag="ul" class="space-y-1 text-gray-500">
|
||||
<Li
|
||||
>反馈 BUG 或提供建议:<a
|
||||
href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
target="_blank"><GithubSolid class="inline" />GitHub</a
|
||||
></Li
|
||||
>
|
||||
<Li
|
||||
>赞助:<a href="https://afdian.com/a/Xinrea" target="_blank"
|
||||
><GlobeSolid class="inline" />爱发电</a
|
||||
></Li
|
||||
>
|
||||
</List>
|
||||
<div class="mt-4 flex flex-row items-center">
|
||||
<span>感谢</span>
|
||||
{#each sponsers as sp}
|
||||
<Image iclass="rounded-full w-8" src={sp.avatar} />
|
||||
<Tooltip>{sp.name}</Tooltip>
|
||||
{/each}
|
||||
<span>等的赞助</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="!max-w-none">
|
||||
<h5
|
||||
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
|
||||
>
|
||||
直播间总览
|
||||
</h5>
|
||||
<p class="font-normal text-gray-700 dark:text-gray-400 leading-tight">
|
||||
目前共有 {total} 个直播间,其中 {online} 个正在直播,{total -
|
||||
online} 个未直播;共占用磁盘空间 {format_size(disk_usage)}。
|
||||
</p>
|
||||
<p class="font-normal text-gray-700 dark:text-gray-400 leading-tight">
|
||||
直播缓存所在磁盘:{disk_info.disk},总容量 {format_size(
|
||||
disk_info.total,
|
||||
)},剩余容量 {format_size(disk_info.free)}。
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
const appWindow = getCurrentWindow();
|
||||
export let dark = false;
|
||||
</script>
|
||||
|
||||
<div data-tauri-drag-region class="titlebar z-[1000]" class:dark>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="titlebar-button"
|
||||
id="titlebar-minimize"
|
||||
on:click={() => appWindow.minimize()}
|
||||
>
|
||||
<img
|
||||
src="https://api.iconify.design/mdi:window-minimize.svg"
|
||||
alt="minimize"
|
||||
/>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="titlebar-button"
|
||||
id="titlebar-maximize"
|
||||
on:click={async () => {
|
||||
let m = await appWindow.isMaximized();
|
||||
if (m) {
|
||||
appWindow.unmaximize();
|
||||
} else {
|
||||
appWindow.maximize();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="https://api.iconify.design/mdi:window-maximize.svg"
|
||||
alt="maximize"
|
||||
/>
|
||||
</div>
|
||||
<div class="titlebar-button" id="titlebar-close">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<img
|
||||
src="https://api.iconify.design/mdi:close.svg"
|
||||
alt="close"
|
||||
on:click={() => appWindow.close()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.titlebar {
|
||||
height: 35px;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
}
|
||||
|
||||
.titlebar-button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.titlebar-button:hover {
|
||||
@apply bg-gray-50 bg-opacity-50;
|
||||
}
|
||||
|
||||
.dark .titlebar-button:hover {
|
||||
@apply bg-gray-300 bg-opacity-50;
|
||||
}
|
||||
</style>
|
||||
86
src/lib/TypeSelect.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Dropdown, DropdownItem, Select } from "flowbite-svelte";
|
||||
import { ChevronDownOutline } from "flowbite-svelte-icons";
|
||||
import type { Children, VideoType } from "./interface";
|
||||
export let value = 0;
|
||||
let parentSelected: VideoType;
|
||||
let areaSelected: Children;
|
||||
let parentOpen = false;
|
||||
let areaOpen = false;
|
||||
let items: VideoType[] = [];
|
||||
async function get_video_typelist() {
|
||||
items = (await invoke("get_video_typelist")) as VideoType[];
|
||||
// find parentSelected by value
|
||||
let valid = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
for (let j = 0; j < items[i].children.length; j++) {
|
||||
if (items[i].children[j].id === value) {
|
||||
parentSelected = items[i];
|
||||
areaSelected = items[i].children[j];
|
||||
valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!valid) {
|
||||
parentSelected = items[0];
|
||||
areaSelected = items[0].children[0];
|
||||
value = areaSelected.id;
|
||||
}
|
||||
}
|
||||
get_video_typelist();
|
||||
</script>
|
||||
|
||||
<div class="flex">
|
||||
<button
|
||||
class="z-10 w-2/5 inline-flex justify-between items-center py-2.5 px-4 text-sm font-medium text-center rounded-s-lg focus:border-primary-500 focus:ring-primary-500 bg-gray-700 text-white placeholder-gray-400 border border-gray-600"
|
||||
type="button"
|
||||
>
|
||||
{parentSelected ? parentSelected.name : ""}
|
||||
<ChevronDownOutline class="w-6 h-6 ms-2" />
|
||||
</button>
|
||||
<Dropdown
|
||||
bind:open={parentOpen}
|
||||
containerClass="divide-y z-50 h-48 overflow-y-auto w-24"
|
||||
>
|
||||
{#each items as item}
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
parentOpen = false;
|
||||
areaOpen = false;
|
||||
parentSelected = item;
|
||||
areaSelected = parentSelected.children[0];
|
||||
value = areaSelected.id;
|
||||
}}
|
||||
class="flex items-center">{item.name}</DropdownItem
|
||||
>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
<button
|
||||
class="z-10 w-3/5 inline-flex justify-between items-center py-2.5 px-4 text-sm font-medium text-center rounded-e-lg focus:border-primary-500 focus:ring-primary-500 bg-gray-700 text-white placeholder-gray-400 border border-gray-600"
|
||||
type="button"
|
||||
>
|
||||
{areaSelected ? areaSelected.name : ""}
|
||||
<ChevronDownOutline class="w-6 h-6 ms-2" />
|
||||
</button>
|
||||
<Dropdown
|
||||
bind:open={areaOpen}
|
||||
containerClass="divide-y z-50 h-48 overflow-y-auto min-w-32 bg-gray-700 text-gray-200 rounded-lg border-gray-100 border-gray-600 divide-gray-100 divide-gray-600 shadow-md"
|
||||
>
|
||||
{#each parentSelected.children as child}
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
areaOpen = false;
|
||||
parentOpen = false;
|
||||
areaSelected = child;
|
||||
value = child.id;
|
||||
}}
|
||||
class="flex items-center">{child.name}</DropdownItem
|
||||
>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
import Database from "@tauri-apps/plugin-sql";
|
||||
|
||||
export const db = await Database.load("sqlite:data.db");
|
||||
export const db = await Database.load("sqlite:data_v2.db");
|
||||
|
||||
// sql: r#"
|
||||
// CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, length INTEGER, size INTEGER, created_at TEXT);
|
||||
@@ -10,11 +10,13 @@ export const db = await Database.load("sqlite:data.db");
|
||||
// "#,
|
||||
|
||||
export interface RecorderItem {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AccountItem {
|
||||
platform: string;
|
||||
uid: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
@@ -33,79 +35,17 @@ export interface MessageItem {
|
||||
|
||||
// from RecordRow
|
||||
export interface RecordItem {
|
||||
platform: string;
|
||||
title: string;
|
||||
live_id: number;
|
||||
live_id: string;
|
||||
room_id: number;
|
||||
length: number;
|
||||
size: number;
|
||||
created_at: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
primary_uid: number;
|
||||
accounts: AccountItem[];
|
||||
}
|
||||
|
||||
// CREATE TABLE recorders (room_id INTEGER PRIMARY KEY, created_at TEXT);
|
||||
export class Recorders {
|
||||
static async add(room_id: number): Promise<boolean> {
|
||||
const result = await db.execute(
|
||||
"INSERT into recorders (room_id, created_at) VALUES ($1, $2)",
|
||||
[room_id, new Date().toISOString()],
|
||||
);
|
||||
return result.rowsAffected == 1;
|
||||
}
|
||||
|
||||
static async remove(room_id: number): Promise<boolean> {
|
||||
const result = await db.execute("DELETE FROM recirders WHERE room_id=$1", [
|
||||
room_id,
|
||||
]);
|
||||
return result.rowsAffected == 1;
|
||||
}
|
||||
|
||||
static async query(): Promise<RecorderItem[]> {
|
||||
return await db.select("SELECT * FROM recorders");
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(cookies_str: string) {
|
||||
const cookies = cookies_str.split("; ");
|
||||
const cookieObject = {};
|
||||
|
||||
cookies.forEach((cookie) => {
|
||||
const [name, value] = cookie.split("=");
|
||||
cookieObject[decodeURIComponent(name)] = decodeURIComponent(value);
|
||||
});
|
||||
|
||||
return cookieObject;
|
||||
}
|
||||
|
||||
// CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT);
|
||||
export class Accounts {
|
||||
static async login(): Promise<boolean> {
|
||||
const result = (await db.select("SELECT * FROM accounts")) as AccountItem[];
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
static async add(cookies: string): Promise<boolean> {
|
||||
const obj = parseCookies(cookies);
|
||||
const uid = parseInt(obj["DedeUserID"]);
|
||||
const csrf = obj["bili_jct"];
|
||||
const result = await db.execute(
|
||||
"INSERT OR REPLACE INTO accounts (uid, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
[uid, name, avatar, csrf, cookies, new Date().toISOString],
|
||||
);
|
||||
return result.rowsAffected == 1;
|
||||
}
|
||||
|
||||
static async remove(uid: number): Promise<boolean> {
|
||||
const result = await db.execute("DELETE FROM accounts WHERE uid = $1", [
|
||||
uid,
|
||||
]);
|
||||
return result.rowsAffected == 1;
|
||||
}
|
||||
|
||||
static async query(): Promise<AccountItem[]> {
|
||||
return await db.select("SELECT * FROM accounts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface RoomInfo {
|
||||
live_status: number;
|
||||
room_cover_url: string;
|
||||
room_cover: string;
|
||||
room_id: number;
|
||||
room_keyframe_url: string;
|
||||
room_title: string;
|
||||
@@ -11,15 +11,16 @@ export interface UserInfo {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_sign: string;
|
||||
user_avatar_url: string;
|
||||
user_avatar: string;
|
||||
}
|
||||
|
||||
export interface RecorderInfo {
|
||||
platform: string;
|
||||
room_id: number;
|
||||
room_info: RoomInfo;
|
||||
user_info: UserInfo;
|
||||
total_length: number;
|
||||
current_ts: number;
|
||||
current_live_id: string;
|
||||
live_status: boolean;
|
||||
}
|
||||
|
||||
@@ -89,6 +90,7 @@ export interface Config {
|
||||
live_end_notify: boolean;
|
||||
clip_notify: boolean;
|
||||
post_notify: boolean;
|
||||
auto_cleanup: boolean;
|
||||
}
|
||||
|
||||
export interface DiskInfo {
|
||||
@@ -96,3 +98,44 @@ export interface DiskInfo {
|
||||
total: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
export interface VideoType {
|
||||
id: number;
|
||||
parent: number;
|
||||
parent_name: string;
|
||||
name: string;
|
||||
description: string;
|
||||
desc: string;
|
||||
intro_original: string;
|
||||
intro_copy: string;
|
||||
notice: string;
|
||||
copy_right: number;
|
||||
show: boolean;
|
||||
rank: number;
|
||||
children: Children[];
|
||||
max_video_count: number;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
export interface Children {
|
||||
id: number;
|
||||
parent: number;
|
||||
parent_name: string;
|
||||
name: string;
|
||||
description: string;
|
||||
desc: string;
|
||||
intro_original: string;
|
||||
intro_copy: string;
|
||||
notice: string;
|
||||
copy_right: number;
|
||||
show: boolean;
|
||||
rank: number;
|
||||
max_video_count: number;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
offset: number;
|
||||
realtime: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
201
src/page/About.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<script type="ts">
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { BookOpen, MessageCircle, Video, Heart } from "lucide-svelte";
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
let version = "";
|
||||
let showDonateModal = false;
|
||||
getVersion().then((v) => {
|
||||
version = v;
|
||||
appWindow.setTitle(`BiliBili ShadowReplay - v${version}`);
|
||||
console.log(version);
|
||||
});
|
||||
let latest_version = "";
|
||||
let releases = [];
|
||||
|
||||
// get releases from github api
|
||||
fetch("https://api.github.com/repos/Xinrea/bili-shadowreplay/releases")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
latest_version = data[0].tag_name;
|
||||
releases = data.slice(0, 3).map((release) => ({
|
||||
version: release.tag_name,
|
||||
date: new Date(release.published_at).toLocaleDateString(),
|
||||
description: release.body,
|
||||
}));
|
||||
});
|
||||
|
||||
function formatReleaseNotes(notes) {
|
||||
if (!notes) return [];
|
||||
return notes
|
||||
.split("\n")
|
||||
.filter(
|
||||
(line) => line.trim().startsWith("*") || line.trim().startsWith("-")
|
||||
)
|
||||
.map((line) => {
|
||||
line = line.trim().replace(/^[*-]\s*/, "");
|
||||
// Remove commit hash at the end (- hash or hash)
|
||||
line = line
|
||||
.replace(/\s*-\s*[a-f0-9]{40}$/, "")
|
||||
.replace(/\s+[a-f0-9]{40}$/, "");
|
||||
return line;
|
||||
})
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function toggleDonateModal() {
|
||||
showDonateModal = !showDonateModal;
|
||||
}
|
||||
|
||||
function handleModalClickOutside(event) {
|
||||
const modal = document.querySelector(".mac-modal");
|
||||
if (modal && !modal.contains(event.target)) {
|
||||
showDonateModal = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="max-w-2xl mx-auto space-y-8">
|
||||
<!-- App Info -->
|
||||
<div class="text-center space-y-4">
|
||||
<div
|
||||
class="w-24 h-24 mx-auto bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg flex items-center justify-center"
|
||||
>
|
||||
<Video class="w-12 h-12 icon-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
BiliBili ShadowReplay
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Version {version}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
on:click={() => {
|
||||
// tauri open url
|
||||
open("https://github.com/Xinrea/bili-shadowreplay/wiki");
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center"
|
||||
>
|
||||
<BookOpen class="w-5 h-5 icon-primary" />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
>说明</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
on:click={() => {
|
||||
// tauri open url
|
||||
open("https://github.com/Xinrea/bili-shadowreplay/issues");
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center"
|
||||
>
|
||||
<MessageCircle class="w-5 h-5 icon-primary" />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
>意见反馈</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
on:click={toggleDonateModal}
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-pink-500/10 flex items-center justify-center"
|
||||
>
|
||||
<Heart class="w-5 h-5 text-pink-500" />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
>打赏支持</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- What's New -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
What's New
|
||||
</h2>
|
||||
<div
|
||||
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{#each releases as release}
|
||||
<div
|
||||
class="p-4 {release !== releases[releases.length - 1]
|
||||
? 'border-b border-gray-200 dark:border-gray-700'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Version {release.version}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>Released on {release.date}</span
|
||||
>
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{#each formatReleaseNotes(release.description) as note}
|
||||
<li class="flex items-start space-x-2">
|
||||
<span class="text-blue-500">•</span>
|
||||
<span>{note}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showDonateModal}
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center"
|
||||
style="position: absolute; min-height: 100%; width: 100%; top: 0; left: 0;"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-[#3c3c3e] rounded-lg p-6 max-w-md w-full mx-4 mac-modal"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
打赏支持
|
||||
</h3>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
on:click={toggleDonateModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="/imgs/donate.png"
|
||||
class="max-w-full h-auto rounded-lg"
|
||||
alt="打赏二维码"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-4 text-center text-sm text-gray-600 dark:text-gray-300">
|
||||
感谢您的支持!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window on:mousedown={handleModalClickOutside} />
|
||||
388
src/page/Account.svelte
Normal file
@@ -0,0 +1,388 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { scale, fade } from "svelte/transition";
|
||||
import { Textarea } from "flowbite-svelte";
|
||||
import Image from "../lib/Image.svelte";
|
||||
import QRCode from "qrcode";
|
||||
import type { AccountItem, AccountInfo } from "../lib/db";
|
||||
import { Ellipsis, Plus } from "lucide-svelte";
|
||||
|
||||
let account_info: AccountInfo = {
|
||||
primary_uid: 0,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
async function update_accounts() {
|
||||
account_info = await invoke("get_accounts");
|
||||
}
|
||||
|
||||
update_accounts();
|
||||
|
||||
let addModal = false;
|
||||
let activeTab = "qr"; // 'qr' or 'manual'
|
||||
let selectedPlatform = "bilibili"; // 'bilibili' or 'douyin'
|
||||
let oauth_key = "";
|
||||
let check_interval = null;
|
||||
let cookie_str = "";
|
||||
|
||||
let manualModal = false;
|
||||
|
||||
let activeDropdown = null;
|
||||
|
||||
function toggleDropdown(uid) {
|
||||
if (activeDropdown === uid) {
|
||||
activeDropdown = null;
|
||||
} else {
|
||||
activeDropdown = uid;
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event) {
|
||||
if (
|
||||
activeDropdown !== null &&
|
||||
!event.target.closest(".dropdown-container")
|
||||
) {
|
||||
activeDropdown = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalClickOutside(event) {
|
||||
const modal = document.querySelector(".mac-modal");
|
||||
if (
|
||||
modal &&
|
||||
!modal.contains(event.target) &&
|
||||
!event.target.closest("button")
|
||||
) {
|
||||
addModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handle_qr() {
|
||||
if (check_interval) {
|
||||
clearInterval(check_interval);
|
||||
}
|
||||
let qr_info: { url: string; oauthKey: string } = await invoke("get_qr");
|
||||
oauth_key = qr_info.oauthKey;
|
||||
const canvas = document.getElementById("qr");
|
||||
QRCode.toCanvas(canvas, qr_info.url, function (error) {
|
||||
if (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
}
|
||||
canvas.style.display = "block";
|
||||
check_interval = setInterval(check_qr, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function check_qr() {
|
||||
let qr_status: { code: number; cookies: string } = await invoke(
|
||||
"get_qr_status",
|
||||
{ qrcodeKey: oauth_key }
|
||||
);
|
||||
if (qr_status.code == 0) {
|
||||
clearInterval(check_interval);
|
||||
await invoke("add_account", {
|
||||
cookies: qr_status.cookies,
|
||||
platform: selectedPlatform,
|
||||
});
|
||||
await update_accounts();
|
||||
addModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add_cookie() {
|
||||
if (cookie_str == "") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log("add_cookie", cookie_str, selectedPlatform);
|
||||
await invoke("add_account", {
|
||||
cookies: cookie_str,
|
||||
platform: selectedPlatform,
|
||||
});
|
||||
await update_accounts();
|
||||
cookie_str = "";
|
||||
addModal = false;
|
||||
} catch (e) {
|
||||
alert("添加账号失败:" + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:click={handleClickOutside}
|
||||
on:mousedown={handleModalClickOutside}
|
||||
/>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
账号
|
||||
</h1>
|
||||
<div
|
||||
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span> 共 {account_info.accounts.length} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
if (activeTab === "qr") {
|
||||
requestAnimationFrame(handle_qr);
|
||||
}
|
||||
}}
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Plus class="w-5 h-5 icon-white" />
|
||||
<span>添加账号</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Account List -->
|
||||
<div class="space-y-4">
|
||||
<!-- Online Account -->
|
||||
{#each account_info.accounts.sort( (a, b) => (b.uid === account_info.primary_uid ? 1 : a.uid === account_info.primary_uid ? -1 : 0) ) as account (account.uid)}
|
||||
<div
|
||||
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<Image
|
||||
iclass="w-12 h-12 rounded-full object-cover"
|
||||
src={account.avatar}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
{account.platform === "bilibili"
|
||||
? account.name
|
||||
: "抖音账号" + account.uid}
|
||||
</h3>
|
||||
{#if account.uid == account_info.primary_uid}
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 text-xs"
|
||||
>主账号</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if account.platform === "bilibili"}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
UID: {account.uid}
|
||||
</p>
|
||||
{/if}
|
||||
{#if account.platform === "douyin"}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
仅用于获取直播流
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative dropdown-container">
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c]"
|
||||
on:click|stopPropagation={() => toggleDropdown(account.uid)}
|
||||
>
|
||||
<Ellipsis class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
{#if activeDropdown === account.uid}
|
||||
<div
|
||||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90"
|
||||
style="transform-origin: top right;"
|
||||
in:scale={{ duration: 100, start: 0.95 }}
|
||||
out:scale={{ duration: 100, start: 0.95 }}
|
||||
>
|
||||
{#if account.uid !== account_info.primary_uid && account.platform === "bilibili"}
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-white hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c] rounded-t-lg"
|
||||
on:click={async () => {
|
||||
await invoke("set_primary", { uid: account.uid });
|
||||
await update_accounts();
|
||||
activeDropdown = null;
|
||||
}}
|
||||
>
|
||||
设为主账号
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-[#e5e5e5] dark:hover:bg-[#3a3a3c] {account.uid !==
|
||||
account_info.primary_uid
|
||||
? ''
|
||||
: 'rounded-t-lg'} rounded-b-lg"
|
||||
on:click={async () => {
|
||||
await invoke("remove_account", {
|
||||
platform: account.platform,
|
||||
uid: account.uid,
|
||||
});
|
||||
await update_accounts();
|
||||
activeDropdown = null;
|
||||
}}
|
||||
>
|
||||
注销账号
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add Account Card -->
|
||||
<button
|
||||
class="w-full p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
if (activeTab === "qr") {
|
||||
requestAnimationFrame(handle_qr);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center space-y-2">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center"
|
||||
>
|
||||
<Plus class="w-6 h-6 icon-primary" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
添加新账号
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
添加一个新账号,用于获取直播流和投稿
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if addModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<div
|
||||
class="mac-modal w-[400px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<h2 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
添加账号
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Platform Selection -->
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="platform"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
平台
|
||||
</label>
|
||||
<div class="flex p-0.5 bg-[#f5f5f7] dark:bg-[#1c1c1e] rounded-lg">
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
|
||||
'bilibili'
|
||||
? 'bg-white dark:bg-[#3c3c3e] shadow-sm text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
|
||||
on:click={() => {
|
||||
selectedPlatform = "bilibili";
|
||||
activeTab = "qr";
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}
|
||||
>
|
||||
哔哩哔哩
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
|
||||
'douyin'
|
||||
? 'bg-white dark:bg-[#3c3c3e] shadow-sm text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
|
||||
on:click={() => {
|
||||
selectedPlatform = "douyin";
|
||||
activeTab = "manual";
|
||||
}}
|
||||
>
|
||||
抖音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Methods (Only show for Bilibili) -->
|
||||
{#if selectedPlatform === "bilibili"}
|
||||
<div class="flex rounded-lg bg-[#f5f5f7] dark:bg-[#1c1c1e] p-1">
|
||||
<button
|
||||
class="flex-1 px-4 py-1.5 text-sm rounded-md transition-colors {activeTab ===
|
||||
'qr'
|
||||
? 'bg-white dark:bg-[#3c3c3e] shadow-sm font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400'}"
|
||||
on:click={() => {
|
||||
activeTab = "qr";
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}
|
||||
>
|
||||
扫码登录
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-1.5 text-sm rounded-md transition-colors {activeTab ===
|
||||
'manual'
|
||||
? 'bg-white dark:bg-[#3c3c3e] shadow-sm font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400'}"
|
||||
on:click={() => {
|
||||
activeTab = "manual";
|
||||
}}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="space-y-4">
|
||||
{#if selectedPlatform === "bilibili" && activeTab === "qr"}
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="bg-white p-4 rounded-lg">
|
||||
<canvas id="qr" />
|
||||
</div>
|
||||
<p class="text-sm text-center text-gray-600 dark:text-gray-400">
|
||||
请使用 BiliBili App 扫描二维码登录
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<Textarea
|
||||
bind:value={cookie_str}
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 bg-[#f5f5f7] dark:bg-[#1c1c1e] border-0 rounded-lg resize-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={selectedPlatform === "bilibili"
|
||||
? "请粘贴 BiliBili 账号的 Cookie"
|
||||
: "请粘贴抖音账号的 Cookie"}
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class="px-4 py-2 bg-[#0A84FF] hover:bg-[#0A84FF]/90 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
on:click={() => {
|
||||
add_cookie();
|
||||
}}
|
||||
>
|
||||
添加账号
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
716
src/page/Room.svelte
Normal file
@@ -0,0 +1,716 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import { Dropdown, DropdownItem } from "flowbite-svelte";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import type { RecorderList } from "../lib/interface";
|
||||
import Image from "../lib/Image.svelte";
|
||||
import type { RecordItem } from "../lib/db";
|
||||
import {
|
||||
Ellipsis,
|
||||
Play,
|
||||
Plus,
|
||||
Scissors,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
History,
|
||||
} from "lucide-svelte";
|
||||
import BilibiliIcon from "../lib/BilibiliIcon.svelte";
|
||||
import DouyinIcon from "../lib/DouyinIcon.svelte";
|
||||
|
||||
export let room_count = 0;
|
||||
let room_active = 0;
|
||||
let room_inactive = 0;
|
||||
|
||||
let summary: RecorderList = {
|
||||
count: 0,
|
||||
recorders: [],
|
||||
};
|
||||
|
||||
let searchQuery = "";
|
||||
$: filteredRecorders = summary.recorders.filter((room) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
room.room_info.room_title.toLowerCase().includes(query) ||
|
||||
room.user_info.user_name.toLowerCase().includes(query) ||
|
||||
room.room_id.toString().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
async function update_summary() {
|
||||
summary = (await invoke("get_recorder_list")) as RecorderList;
|
||||
room_count = summary.count;
|
||||
room_active = summary.recorders.filter((room) => room.live_status).length;
|
||||
room_inactive = summary.recorders.filter(
|
||||
(room) => !room.live_status
|
||||
).length;
|
||||
}
|
||||
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 = null;
|
||||
|
||||
let addModal = false;
|
||||
let addRoom = "";
|
||||
let addValid = false;
|
||||
let addErrorMsg = "";
|
||||
let selectedPlatform = "bilibili";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function handleModalClickOutside(event) {
|
||||
const modal = document.querySelector(".mac-modal");
|
||||
if (
|
||||
modal &&
|
||||
!modal.contains(event.target) &&
|
||||
!event.target.closest("button")
|
||||
) {
|
||||
addModal = false;
|
||||
archiveModal = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
直播间
|
||||
</h1>
|
||||
<div
|
||||
class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="flex items-center space-x-1">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span>{room_active} 直播中</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{room_inactive} 未直播</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<Search
|
||||
class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="搜索直播间..."
|
||||
class="pl-10 pr-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center space-x-2"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
}}
|
||||
>
|
||||
<Plus class="w-5 h-5 icon-white" />
|
||||
<span>添加新直播间</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room Grid -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<!-- Active Room Card -->
|
||||
{#each filteredRecorders as room (room.room_id)}
|
||||
{#if room.live_status}
|
||||
<div
|
||||
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="relative">
|
||||
<Image
|
||||
src={room.room_info.room_cover}
|
||||
iclass="w-full h-40 object-cover rounded-lg"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-2 left-2 px-2 py-1 rounded-full bg-green-500 text-white text-xs flex items-center space-x-1"
|
||||
>
|
||||
<span>直播中</span>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-2 right-2 p-1.5 rounded-lg bg-gray-900/50 hover:bg-gray-900/70 transition-colors"
|
||||
>
|
||||
<Ellipsis class="w-5 h-5 icon-white" />
|
||||
</button>
|
||||
<Dropdown class="whitespace-nowrap">
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
open("https://live.bilibili.com/" + room.room_id);
|
||||
}}>打开网页直播间</DropdownItem
|
||||
>
|
||||
<DropdownItem
|
||||
class="text-red-500"
|
||||
on:click={() => {
|
||||
deleteRoom = room;
|
||||
deleteModal = true;
|
||||
}}>移除直播间</DropdownItem
|
||||
>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if room.platform === "bilibili"}
|
||||
<BilibiliIcon class="w-4 h-4" />
|
||||
{:else if room.platform === "douyin"}
|
||||
<DouyinIcon class="w-4 h-4" />
|
||||
{/if}
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
{room.room_info.room_title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<button
|
||||
class="flex items-center space-x-2 p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
on:click={() => {
|
||||
if (room.platform === "bilibili") {
|
||||
open(
|
||||
"https://space.bilibili.com/" + room.user_info.user_id
|
||||
);
|
||||
} else if (room.platform === "douyin") {
|
||||
console.log(room.user_info);
|
||||
open(
|
||||
"https://www.douyin.com/user/" +
|
||||
room.user_info.user_id
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={room.user_info.user_avatar}
|
||||
iclass="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span>{room.user_info.user_name}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
on:click={() => {
|
||||
invoke("open_live", {
|
||||
platform: room.platform,
|
||||
roomId: room.room_id,
|
||||
liveId: room.current_live_id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
on:click={() => {
|
||||
archiveRoom = room;
|
||||
showArchives(room.room_id);
|
||||
}}
|
||||
>
|
||||
<History class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Inactive Room Card -->
|
||||
{#each filteredRecorders as room (room.room_id)}
|
||||
{#if !room.live_status}
|
||||
<div
|
||||
class="p-4 rounded-xl bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="relative">
|
||||
<Image
|
||||
src={room.room_info.room_cover}
|
||||
iclass="w-full h-40 object-cover rounded-lg brightness-75"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-2 left-2 px-2 py-1 rounded-full bg-gray-700 text-white text-xs flex items-center space-x-1"
|
||||
>
|
||||
<span>未直播</span>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-2 right-2 p-1.5 rounded-lg bg-gray-900/50 hover:bg-gray-900/70 transition-colors"
|
||||
>
|
||||
<Ellipsis class="w-5 h-5" color="white" />
|
||||
</button>
|
||||
<Dropdown class="whitespace-nowrap">
|
||||
{#if room.live_status}
|
||||
<DropdownItem
|
||||
on:click={async () => {
|
||||
await invoke("open_live", {
|
||||
platform: room.platform,
|
||||
roomId: room.room_id,
|
||||
liveId: room.current_live_id,
|
||||
});
|
||||
}}>打开直播流</DropdownItem
|
||||
>
|
||||
{/if}
|
||||
<DropdownItem
|
||||
on:click={() => {
|
||||
open("https://live.bilibili.com/" + room.room_id);
|
||||
}}>打开网页直播间</DropdownItem
|
||||
>
|
||||
<DropdownItem
|
||||
class="text-red-500"
|
||||
on:click={() => {
|
||||
deleteRoom = room;
|
||||
deleteModal = true;
|
||||
}}>移除直播间</DropdownItem
|
||||
>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if room.platform === "bilibili"}
|
||||
<BilibiliIcon class="w-4 h-4" />
|
||||
{:else if room.platform === "douyin"}
|
||||
<DouyinIcon class="w-4 h-4" />
|
||||
{/if}
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
{room.room_info.room_title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<button
|
||||
class="flex items-center space-x-2 p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
on:click={() => {
|
||||
if (room.platform === "bilibili") {
|
||||
open(
|
||||
"https://space.bilibili.com/" + room.user_info.user_id
|
||||
);
|
||||
} else if (room.platform === "douyin") {
|
||||
console.log(room.user_info);
|
||||
open(
|
||||
"https://www.douyin.com/user/" +
|
||||
room.user_info.user_id
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={room.user_info.user_avatar}
|
||||
iclass="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span>{room.user_info.user_name}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
on:click={() => {
|
||||
archiveRoom = room;
|
||||
showArchives(room.room_id);
|
||||
}}
|
||||
>
|
||||
<History class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add Room Card -->
|
||||
<button
|
||||
class="p-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400 transition-colors flex flex-col items-center justify-center space-y-2"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center"
|
||||
>
|
||||
<Plus class="w-6 h-6 icon-primary" />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-blue-600 dark:text-blue-400"
|
||||
>添加新直播间</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>配置一个新直播间以及其相关设置</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if deleteModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<div
|
||||
class="mac-modal w-[320px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
移除直播间
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
此操作将移除所有相关的录制记录
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button
|
||||
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
|
||||
on:click={() => {
|
||||
deleteModal = false;
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="w-24 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
on:click={async () => {
|
||||
await invoke("remove_recorder", {
|
||||
roomId: deleteRoom.room_id,
|
||||
platform: deleteRoom.platform,
|
||||
});
|
||||
deleteModal = false;
|
||||
}}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if addModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<div
|
||||
class="mac-modal w-[400px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<h2 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
添加直播间
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="platform"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
平台
|
||||
</label>
|
||||
<div class="flex p-0.5 bg-[#f5f5f7] dark:bg-[#1c1c1e] rounded-lg">
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
|
||||
'bilibili'
|
||||
? 'bg-white dark:bg-[#323234] shadow-sm text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
|
||||
on:click={() => (selectedPlatform = "bilibili")}
|
||||
>
|
||||
哔哩哔哩
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors {selectedPlatform ===
|
||||
'douyin'
|
||||
? 'bg-white dark:bg-[#323234] shadow-sm text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}"
|
||||
on:click={() => (selectedPlatform = "douyin")}
|
||||
>
|
||||
抖音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="room_id"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{selectedPlatform === "bilibili" ? "房间号" : "直播间ID"}
|
||||
</label>
|
||||
<input
|
||||
id="room_id"
|
||||
type="text"
|
||||
bind:value={addRoom}
|
||||
class="w-full px-3 py-2 bg-[#f5f5f7] dark:bg-[#1c1c1e] border-0 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
|
||||
placeholder={selectedPlatform === "bilibili"
|
||||
? "请输入直播间房间号"
|
||||
: "请输入抖音直播间ID"}
|
||||
on:change={() => {
|
||||
if (!addRoom) {
|
||||
addErrorMsg = "";
|
||||
addValid = false;
|
||||
return;
|
||||
}
|
||||
const room_id = Number(addRoom);
|
||||
if (Number.isInteger(room_id) && room_id > 0) {
|
||||
addErrorMsg = "";
|
||||
addValid = true;
|
||||
} else {
|
||||
addErrorMsg = "ID格式错误,请检查输入";
|
||||
addValid = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if addErrorMsg}
|
||||
<p class="text-sm text-red-600 dark:text-red-500">
|
||||
{addErrorMsg}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
|
||||
on:click={() => {
|
||||
addModal = false;
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-[#0A84FF] hover:bg-[#0A84FF]/90 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!addValid}
|
||||
on:click={() => {
|
||||
invoke("add_recorder", {
|
||||
roomId: Number(addRoom),
|
||||
platform: selectedPlatform,
|
||||
})
|
||||
.then(() => {
|
||||
addModal = false;
|
||||
addRoom = "";
|
||||
})
|
||||
.catch(async (e) => {
|
||||
await message(e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if archiveModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<div
|
||||
class="mac-modal w-[900px] bg-white dark:bg-[#323234] rounded-xl shadow-xl overflow-hidden flex flex-col max-h-[80vh]"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex justify-between items-center px-6 py-4 border-b border-gray-200 dark:border-gray-700/50"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<h2 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
直播间记录
|
||||
</h2>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{archiveRoom?.user_info.user_name} · {archiveRoom?.room_id}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
on:click={() => (archiveModal = false)}
|
||||
>
|
||||
<X class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700/50">
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>直播时间</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>标题</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>时长</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>大小</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>码率</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>操作</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700/50">
|
||||
{#each archives as archive}
|
||||
<tr
|
||||
class="group hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-gray-900 dark:text-white"
|
||||
>{format_ts(archive.created_at).split(" ")[0]}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>{format_ts(archive.created_at).split(" ")[1]}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
{#if archive.cover}
|
||||
<Image
|
||||
src={archive.cover}
|
||||
iclass="w-12 h-8 rounded object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-sm text-gray-900 dark:text-white"
|
||||
>{archive.title}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white"
|
||||
>{format_duration(archive.length)}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white"
|
||||
>{format_size(archive.size)}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
|
||||
>{calc_bitrate(archive.size, archive.length)} Kbps</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div
|
||||
class="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-blue-500/10 transition-colors"
|
||||
title="编辑切片"
|
||||
on:click={() => {
|
||||
invoke("open_live", {
|
||||
platform: archiveRoom.platform,
|
||||
roomId: archiveRoom.room_id,
|
||||
liveId: archive.live_id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Scissors class="w-4 h-4 icon-primary" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-red-500/10 transition-colors"
|
||||
title="删除记录"
|
||||
on:click={() => {
|
||||
invoke("delete_archive", {
|
||||
platform: archiveRoom.platform,
|
||||
roomId: archiveRoom.room_id,
|
||||
liveId: archive.live_id,
|
||||
})
|
||||
.then(async () => {
|
||||
archives = await invoke("get_archives", {
|
||||
roomId: archiveRoom.room_id,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 class="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:window on:mousedown={handleModalClickOutside} />
|
||||
284
src/page/Setting.svelte
Normal file
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import type { Config } from "../lib/interface";
|
||||
import { Bell, HardDrive, AlertTriangle } from "lucide-svelte";
|
||||
|
||||
let setting_model: Config = {
|
||||
cache: "",
|
||||
output: "",
|
||||
primary_uid: 0,
|
||||
live_start_notify: true,
|
||||
live_end_notify: true,
|
||||
clip_notify: true,
|
||||
post_notify: true,
|
||||
auto_cleanup: true,
|
||||
};
|
||||
|
||||
let showModal = false;
|
||||
|
||||
async function get_config() {
|
||||
let config: Config = await invoke("get_config");
|
||||
setting_model = config;
|
||||
console.log(config);
|
||||
}
|
||||
|
||||
async function browse_folder() {
|
||||
const selected = await open({ directory: true });
|
||||
return Array.isArray(selected) ? selected[0] : selected;
|
||||
}
|
||||
|
||||
async function update_notify() {
|
||||
await invoke("update_notify", {
|
||||
liveStartNotify: setting_model.live_start_notify,
|
||||
liveEndNotify: setting_model.live_end_notify,
|
||||
clipNotify: setting_model.clip_notify,
|
||||
postNotify: setting_model.post_notify,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCacheChange() {
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleOutputChange() {
|
||||
const new_folder = await browse_folder();
|
||||
if (new_folder) {
|
||||
setting_model.output = new_folder;
|
||||
await invoke("set_output_path", {
|
||||
outputPath: setting_model.output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmChange() {
|
||||
showModal = false;
|
||||
const new_folder = await browse_folder();
|
||||
if (new_folder) {
|
||||
setting_model.cache = new_folder;
|
||||
await invoke("set_cache_path", {
|
||||
cachePath: setting_model.cache,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get_config();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="h-screen">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between dark:bg-[#1c1c1e] py-2 -mt-2 z-10"
|
||||
>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Settings Sections -->
|
||||
<div class="space-y-6 pb-6">
|
||||
<!-- Storage Settings -->
|
||||
<div class="space-y-4">
|
||||
<h2
|
||||
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
|
||||
>
|
||||
<HardDrive class="w-5 h-5 dark:icon-white" />
|
||||
<span>存储设置</span>
|
||||
</h2>
|
||||
<div
|
||||
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
|
||||
>
|
||||
<!-- Cache Location -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
缓存路径
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{setting_model.cache}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
on:click={handleCacheChange}
|
||||
>
|
||||
变更
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
切片保存路径
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{setting_model.output}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
on:click={handleOutputChange}
|
||||
>
|
||||
变更
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<div class="space-y-4">
|
||||
<h2
|
||||
class="text-lg font-medium text-gray-900 dark:text-white flex items-center space-x-2"
|
||||
>
|
||||
<Bell class="w-5 h-5 dark:icon-white" />
|
||||
<span>通知设置</span>
|
||||
</h2>
|
||||
<div
|
||||
class="bg-white dark:bg-[#3c3c3e] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"
|
||||
>
|
||||
<!-- Stream Start -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
直播开始通知
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
当直播间开始直播时,会收到通知
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-block w-11 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer opacity-0 w-0 h-0"
|
||||
bind:checked={setting_model.live_start_notify}
|
||||
on:change={update_notify}
|
||||
/>
|
||||
<span
|
||||
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
|
||||
></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
下播通知
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
当直播间结束直播时,会收到通知
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-block w-11 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer opacity-0 w-0 h-0"
|
||||
bind:checked={setting_model.live_end_notify}
|
||||
on:change={update_notify}
|
||||
/>
|
||||
<span
|
||||
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
|
||||
></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
切片完成通知
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
当切片完成时,会收到通知
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-block w-11 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer opacity-0 w-0 h-0"
|
||||
bind:checked={setting_model.clip_notify}
|
||||
on:change={update_notify}
|
||||
/>
|
||||
<span
|
||||
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
|
||||
></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
投稿完成通知
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
当投稿完成时,会收到通知
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-block w-11 h-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer opacity-0 w-0 h-0"
|
||||
bind:checked={setting_model.post_notify}
|
||||
on:change={update_notify}
|
||||
/>
|
||||
<span
|
||||
class="switch-slider absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-300 dark:bg-gray-600 rounded-full transition-all duration-300 before:absolute before:h-4 before:w-4 before:left-1 before:bottom-1 before:bg-white before:rounded-full before:transition-all before:duration-300 peer-checked:bg-blue-500 peer-checked:before:translate-x-5"
|
||||
></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if showModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white dark:bg-[#2c2c2e] rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<div class="flex items-start space-x-3 mb-4">
|
||||
<AlertTriangle class="w-6 h-6 text-yellow-500 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
确认变更
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
根据文件大小,可能需要耗时较长时间,迁移期间直播间会暂时移除,迁移完成后直播间会自动恢复。
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2 font-bold">
|
||||
迁移期间请不要关闭程序,且不要在迁移期间再次更改目录!
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
确认要进行变更吗?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button
|
||||
class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
on:click={() => (showModal = false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
on:click={confirmChange}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
458
src/page/Summary.svelte
Normal file
@@ -0,0 +1,458 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { RecorderList, DiskInfo } from "../lib/interface";
|
||||
import type { RecordItem } from "../lib/db";
|
||||
const INTERVAL = 1000;
|
||||
import { scale } from "svelte/transition";
|
||||
import { CalendarCheck, Clock, Database, HardDrive, Play, RefreshCw, Trash2, Users, Video } from "lucide-svelte";
|
||||
|
||||
let summary: RecorderList = {
|
||||
count: 0,
|
||||
recorders: [],
|
||||
};
|
||||
|
||||
let disk_info: DiskInfo = {
|
||||
disk: "",
|
||||
total: 0,
|
||||
free: 0,
|
||||
};
|
||||
|
||||
let total = 0;
|
||||
let online = 0;
|
||||
let disk_usage = 0;
|
||||
let account_count = 0;
|
||||
let total_length = 0;
|
||||
let today_record_count = 0;
|
||||
let recent_records: RecordItem[] = [];
|
||||
let activeDropdown = null;
|
||||
let loading = false;
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
let hasNewRecords = false;
|
||||
const RECORDS_PER_PAGE = 5;
|
||||
|
||||
async function update_summary() {
|
||||
summary = (await invoke("get_recorder_list")) as RecorderList;
|
||||
total = summary.count;
|
||||
online = summary.recorders.filter((r) => r.live_status).length;
|
||||
let new_disk_usage = 0;
|
||||
for (const recorder of summary.recorders) {
|
||||
new_disk_usage += await get_disk_usage(recorder.room_id);
|
||||
}
|
||||
disk_usage = new_disk_usage;
|
||||
|
||||
// get disk info
|
||||
disk_info = await invoke("get_disk_info");
|
||||
account_count = await get_account_count();
|
||||
|
||||
// get total length
|
||||
total_length = await get_total_length();
|
||||
|
||||
// get today record count
|
||||
today_record_count = await get_today_record_count();
|
||||
|
||||
// check for new records
|
||||
if (recent_records.length > 0) {
|
||||
const latestRecords = (await invoke("get_recent_record", {
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
})) as RecordItem[];
|
||||
|
||||
if (latestRecords.length > 0 && (!recent_records[0] || latestRecords[0].live_id !== recent_records[0].live_id)) {
|
||||
hasNewRecords = true;
|
||||
}
|
||||
} else {
|
||||
// Initial load
|
||||
await loadMoreRecords();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreRecords() {
|
||||
if (loading || (!hasMore && !hasNewRecords)) return;
|
||||
|
||||
loading = true;
|
||||
const newRecords = (await invoke("get_recent_record", {
|
||||
offset: hasNewRecords ? 0 : offset,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
})) as RecordItem[];
|
||||
|
||||
if (hasNewRecords) {
|
||||
recent_records = newRecords;
|
||||
offset = newRecords.length;
|
||||
hasNewRecords = false;
|
||||
hasMore = true;
|
||||
} else {
|
||||
if (newRecords.length < RECORDS_PER_PAGE) {
|
||||
hasMore = false;
|
||||
}
|
||||
recent_records = [...recent_records, ...newRecords];
|
||||
offset += newRecords.length;
|
||||
}
|
||||
|
||||
console.log(recent_records);
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleScroll(event) {
|
||||
const target = event.target;
|
||||
// If we're at the top and there are new records, load them
|
||||
if (target.scrollTop === 0 && hasNewRecords) {
|
||||
loadMoreRecords();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise check if we need to load more old records
|
||||
const bottom = target.scrollHeight - target.scrollTop - target.clientHeight < 50;
|
||||
if (bottom && !hasNewRecords) {
|
||||
loadMoreRecords();
|
||||
}
|
||||
}
|
||||
|
||||
update_summary();
|
||||
setInterval(update_summary, INTERVAL);
|
||||
|
||||
async function get_disk_usage(room_id: number) {
|
||||
let ds = 0;
|
||||
const archives = (await invoke("get_archives", {
|
||||
roomId: room_id,
|
||||
})) as RecordItem[];
|
||||
for (const archive of archives) {
|
||||
ds += archive.size;
|
||||
}
|
||||
return ds;
|
||||
}
|
||||
|
||||
async function get_total_length(): Promise<number> {
|
||||
return await invoke("get_total_length");
|
||||
}
|
||||
|
||||
async function get_today_record_count(): Promise<number> {
|
||||
return await invoke("get_today_record_count");
|
||||
}
|
||||
|
||||
async function get_account_count(): Promise<number> {
|
||||
return await invoke("get_account_count");
|
||||
}
|
||||
|
||||
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 format_time(time: number) {
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor((time % 3600) / 60);
|
||||
const seconds = time % 60;
|
||||
// two digits
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// format date to YYYY-MM-DD HH:MM:SS
|
||||
function format_date(date: string) {
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
function toggleDropdown(id) {
|
||||
if (activeDropdown === id) {
|
||||
activeDropdown = null;
|
||||
} else {
|
||||
activeDropdown = id;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (activeDropdown !== null && !event.target.closest(".dropdown-container")) {
|
||||
activeDropdown = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecord(record: RecordItem) {
|
||||
try {
|
||||
await invoke("delete_archive", {
|
||||
platform: record.platform,
|
||||
roomId: record.room_id,
|
||||
liveId: record.live_id,
|
||||
});
|
||||
|
||||
// Remove the record from the list
|
||||
recent_records = recent_records.filter(r => r.live_id !== record.live_id);
|
||||
|
||||
// Update stats
|
||||
disk_usage -= record.size;
|
||||
total_length -= record.length;
|
||||
if (new Date(record.created_at).toDateString() === new Date().toDateString()) {
|
||||
today_record_count--;
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecords() {
|
||||
// Reset pagination
|
||||
offset = 0;
|
||||
hasMore = true;
|
||||
recent_records = [];
|
||||
// Load records from beginning
|
||||
await loadMoreRecords();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<div class="flex-1 p-6 overflow-y-auto" on:scroll={handleScroll}>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">总览</h1>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<!-- Cache Size -->
|
||||
<div
|
||||
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-3 rounded-lg bg-blue-500">
|
||||
<HardDrive class="w-6 h-6 icon-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">缓存占用</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{format_size(disk_usage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-3 rounded-lg bg-orange-500">
|
||||
<Database class="w-6 h-6 icon-white" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">磁盘使用</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{format_size(disk_info.free)}剩余
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{format_size(disk_info.total - disk_info.free)}
|
||||
</p>
|
||||
<div
|
||||
class="w-full h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden mt-1.5"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-orange-500 rounded-full"
|
||||
style="width: {((disk_info.total - disk_info.free) /
|
||||
disk_info.total) *
|
||||
100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Rooms -->
|
||||
<div
|
||||
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-3 rounded-lg bg-green-500">
|
||||
<Video class="w-6 h-6 icon-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">直播间</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{online} / {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Accounts -->
|
||||
<div
|
||||
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-3 rounded-lg bg-purple-500">
|
||||
<Users class="w-6 h-6 icon-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">账号</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{account_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Recording Time -->
|
||||
<div
|
||||
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-3 rounded-lg bg-indigo-500">
|
||||
<Clock class="w-6 h-6 icon-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">总缓存时长</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{format_time(total_length)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Recordings -->
|
||||
<div
|
||||
class="p-6 rounded-xl bg-white dark:bg-[#3c3c3e] shadow-sm border border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-3 rounded-lg bg-pink-500">
|
||||
<CalendarCheck class="w-6 h-6 icon-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
今日缓存直播数
|
||||
</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{today_record_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Recordings -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
最近的直播记录
|
||||
</h2>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors text-gray-500 dark:text-gray-400"
|
||||
on:click={refreshRecords}
|
||||
>
|
||||
<RefreshCw class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
</div>
|
||||
{#if hasNewRecords}
|
||||
<button
|
||||
class="px-3 py-1 text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 rounded-full hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors"
|
||||
on:click={loadMoreRecords}
|
||||
>
|
||||
记录有更新 • 点击刷新
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<!-- Recording Items -->
|
||||
{#each recent_records as record}
|
||||
<div
|
||||
class="p-4 rounded-lg bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 flex items-center justify-between hover:border-blue-500 dark:hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
{#if record.cover !== ""}
|
||||
<img
|
||||
src={record.cover}
|
||||
class="w-32 h-18 rounded-lg object-cover"
|
||||
alt="Gaming stream thumbnail"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
{record.title}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{format_date(record.created_at)} • {format_size(record.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
on:click={() => {
|
||||
invoke("open_live", {
|
||||
platform: record.platform,
|
||||
roomId: record.room_id,
|
||||
liveId: record.live_id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play class="w-5 h-5 dark:icon-white" />
|
||||
</button>
|
||||
<div class="relative dropdown-container">
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-red-600 dark:text-red-400"
|
||||
on:click|stopPropagation={() => toggleDropdown(record.live_id)}
|
||||
>
|
||||
<Trash2 class="w-5 h-5 icon-danger" />
|
||||
</button>
|
||||
{#if activeDropdown === record.live_id}
|
||||
<div
|
||||
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg bg-white dark:bg-[#3c3c3e] border border-gray-200 dark:border-gray-700 backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90 z-50"
|
||||
style="transform-origin: top right;"
|
||||
in:scale={{ duration: 100, start: 0.95 }}
|
||||
out:scale={{ duration: 100, start: 0.95 }}
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">确认删除</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">此操作无法撤销</p>
|
||||
</div>
|
||||
<div class="p-2 flex space-x-2">
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-md transition-colors"
|
||||
on:click={() => {
|
||||
activeDropdown = null;
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-3 py-1.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors"
|
||||
on:click={() => {
|
||||
deleteRecord(record);
|
||||
activeDropdown = null;
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasMore && recent_records.length > 0}
|
||||
<div class="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
没有更多记录了
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,5 +7,17 @@ body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-white {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.icon-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ const mobile =
|
||||
// https://vitejs.dev/config/
|
||||
// @ts-ignore
|
||||
export default defineConfig(async () => ({
|
||||
optimizeDeps: {
|
||||
exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
preprocess: [
|
||||
|
||||
286
yarn.lock
@@ -31,7 +31,7 @@
|
||||
|
||||
"@esbuild/darwin-arm64@0.18.20":
|
||||
version "0.18.20"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1"
|
||||
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz"
|
||||
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
|
||||
|
||||
"@esbuild/darwin-x64@0.18.20":
|
||||
@@ -121,19 +121,19 @@
|
||||
|
||||
"@esbuild/win32-x64@0.18.20":
|
||||
version "0.18.20"
|
||||
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
|
||||
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
|
||||
|
||||
"@floating-ui/core@^1.6.0":
|
||||
version "1.6.7"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.7.tgz#7602367795a390ff0662efd1c7ae8ca74e75fb12"
|
||||
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.7.tgz"
|
||||
integrity sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.7"
|
||||
|
||||
"@floating-ui/dom@^1.6.10":
|
||||
version "1.6.10"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.10.tgz#b74c32f34a50336c86dcf1f1c845cf3a39e26d6f"
|
||||
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.10.tgz"
|
||||
integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.6.0"
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
"@floating-ui/utils@^0.2.7":
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
|
||||
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz"
|
||||
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
|
||||
|
||||
"@isaacs/cliui@^8.0.2":
|
||||
@@ -224,12 +224,12 @@
|
||||
|
||||
"@popperjs/core@^2.9.3":
|
||||
version "2.11.8"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
|
||||
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
|
||||
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
||||
|
||||
"@rollup/plugin-node-resolve@^15.2.3":
|
||||
version "15.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz#e5e0b059bd85ca57489492f295ce88c2d4b0daf9"
|
||||
resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz"
|
||||
integrity sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==
|
||||
dependencies:
|
||||
"@rollup/pluginutils" "^5.0.1"
|
||||
@@ -241,7 +241,7 @@
|
||||
|
||||
"@rollup/pluginutils@^5.0.1":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz#7e53eddc8c7f483a4ad0b94afb1f7f5fd3c771e0"
|
||||
resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz"
|
||||
integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.0"
|
||||
@@ -268,135 +268,130 @@
|
||||
svelte-hmr "^0.15.3"
|
||||
vitefu "^0.2.4"
|
||||
|
||||
"@tauri-apps/api@2.0.0-rc.0":
|
||||
version "2.0.0-rc.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-rc.0.tgz#902b4e9803ecdcc0a3913d7c09df26d651f3dfd1"
|
||||
integrity sha512-v454Qs3REHc3Za59U+/eSmBsdmF+3NE5+76+lFDaitVqN4ZglDHENDaMARYKGJVZuxiSkzyqG0SeG7lLQjVkPA==
|
||||
|
||||
"@tauri-apps/api@^2.0.0":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.2.tgz#266767f4a4641014e86a000e7e02e1a344ced45a"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.0.2.tgz"
|
||||
integrity sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==
|
||||
|
||||
"@tauri-apps/api@^2.0.0-rc.4":
|
||||
version "2.0.0-rc.4"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-rc.4.tgz#2b4c3493d86382981787c52006c6c9e5bf16bc08"
|
||||
integrity sha512-UNiIhhKG08j4ooss2oEEVexffmWkgkYlC2M3GcX3VPtNsqFgVNL8Mcw/4Y7rO9M9S+ffAMnLOF5ypzyuyb8tyg==
|
||||
"@tauri-apps/api@^2.1.1":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.1.1.tgz"
|
||||
integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.2.tgz#5a078381ce0e9e83a710fa5ae812c8709ca993cc"
|
||||
integrity sha512-B+/a8Q6wAqmB4A4HVeK0oQP5TdQGKW60ZLOI9O2ktH2HPr9ETr3XkwXPuJ2uAOuGEgtRZHBgFOIgG000vMnKlg==
|
||||
"@tauri-apps/cli-darwin-arm64@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.1.0.tgz"
|
||||
integrity sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw==
|
||||
|
||||
"@tauri-apps/cli-darwin-x64@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.2.tgz#87db71cc8dfe2196b33cf5ab26b99a4fa3fa01ac"
|
||||
integrity sha512-kaurhn6XT4gAVCPAQSSHl/CHFxTS0ljc47N7iGTSlYJ03sCWPRZeNuVa/bn6rolz9MA2JfnRnFqB1pUL6jzp9Q==
|
||||
"@tauri-apps/cli-darwin-x64@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.1.0.tgz#08c5f446b65bc351a8e74c0c8019324ae6864351"
|
||||
integrity sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA==
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.2.tgz#0ca2dc0b563028181bb0f005d3049c01a7dd904f"
|
||||
integrity sha512-bVrofjlacMxmGMcqK18iBW05tsZXOd19/MnqruFFcHSVjvkGGIXHMtUbMXnZNXBPkHDsnfytNtkY9SZGfCFaBA==
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.1.0.tgz#681d0967d0335b93ed8ab4b8bb5d820c72cc8abf"
|
||||
integrity sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w==
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.2.tgz#10c0baa2255dc476707bb06619e6a9bd8874c639"
|
||||
integrity sha512-7XCBn0TTBVQGnV42dXcbHPLg/9W8kJoVzuliIozvNGyRWxfXqDbQYzpI48HUQG3LgHMabcw8+pVZAfGhevLrCA==
|
||||
"@tauri-apps/cli-linux-arm64-gnu@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.1.0.tgz#922ddc50849ae9f2976f30fdb4b79708badb767b"
|
||||
integrity sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ==
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.2.tgz#6636e383dae70eabf9f6dd22470ad2edb9776d87"
|
||||
integrity sha512-1xi2SreGVlpAL68MCsDUY63rdItUdPZreXIAcOVqvUehcJRYOa1XGSBhrV0YXRgZeh0AtKC19z6PRzcv4rosZA==
|
||||
"@tauri-apps/cli-linux-arm64-musl@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.1.0.tgz#063020d217faf90b226d93b5054546ae0db14c16"
|
||||
integrity sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA==
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.2.tgz#925e7714ad72eff67b42ed25a781b9f13262c296"
|
||||
integrity sha512-WVjwYzPWFqZVg1fx6KSU5w47Q0VbMyaCp34qs5EcS8EIU0/RnofdzqUoOYqvgGVgNgoz7Pj5dXK2SkS8BHXMmA==
|
||||
"@tauri-apps/cli-linux-x64-gnu@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.1.0.tgz#7d7991a2b956232f96ec614f15189d408421dc03"
|
||||
integrity sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA==
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.2.tgz#7e706660196c5db7ea821ceec7e315706f73cb5e"
|
||||
integrity sha512-h5miE2mctgaQNn/BbG9o1pnJcrx+VGBi2A6JFqGu934lFgSV5+s28M8Gc8AF2JgFH4hQV4IuMkeSw8Chu5Dodg==
|
||||
"@tauri-apps/cli-linux-x64-musl@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.1.0.tgz#af6293ce56296619d656342f11d83c35c8465979"
|
||||
integrity sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw==
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.0.2.tgz#aaee06d53f5d42afe8bf994e42cf5679c9e1ebd3"
|
||||
integrity sha512-2b8oO0+dYonahG5PfA/zoq0zlafLclfmXgqoWDZ++UiPtQHJNpNeEQ8GWbSFKGHQ494Jo6jHvazOojGRE1kqAg==
|
||||
"@tauri-apps/cli-win32-arm64-msvc@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.1.0.tgz#adb2b17d9939cdbcb136c5e24bf90d15485265dc"
|
||||
integrity sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg==
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.2.tgz#ec6d8eef16d024eeae2ce4e3422b8a51f419e661"
|
||||
integrity sha512-axgICLunFi0To3EibdCBgbST5RocsSmtM4c04+CbcX8WQQosJ9ziWlCSrrOTRr+gJERAMSvEyVUS98f6bWMw9A==
|
||||
"@tauri-apps/cli-win32-ia32-msvc@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.1.0.tgz#5356a56a30bfc50a0edde3e91c16ccd3fa837d71"
|
||||
integrity sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg==
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc@2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.2.tgz#bb5d011a0d44297f148c654c05b93a71cebacaa1"
|
||||
integrity sha512-JR17cM6+DyExZRgpXr2/DdqvcFYi/EKvQt8dI5R1/uQoesWd8jeNnrU7c1FG1Zmw9+pTzDztsNqEKsrNq2sNIg==
|
||||
"@tauri-apps/cli-win32-x64-msvc@2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.1.0.tgz#065f76de7a3aabe6d490b7f15cb09e7f8e911614"
|
||||
integrity sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw==
|
||||
|
||||
"@tauri-apps/cli@^2.0.2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.0.2.tgz#908629846f6edb1622a480f69a9469b3b9b7110e"
|
||||
integrity sha512-R4ontHZvXORArERAHIidp5zRfZEshZczTiK+poslBv7AGKpQZoMw+E49zns7mOmP64i2Cq9Ci0pJvi4Rm8Okzw==
|
||||
"@tauri-apps/cli@^2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.1.0.tgz"
|
||||
integrity sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==
|
||||
optionalDependencies:
|
||||
"@tauri-apps/cli-darwin-arm64" "2.0.2"
|
||||
"@tauri-apps/cli-darwin-x64" "2.0.2"
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf" "2.0.2"
|
||||
"@tauri-apps/cli-linux-arm64-gnu" "2.0.2"
|
||||
"@tauri-apps/cli-linux-arm64-musl" "2.0.2"
|
||||
"@tauri-apps/cli-linux-x64-gnu" "2.0.2"
|
||||
"@tauri-apps/cli-linux-x64-musl" "2.0.2"
|
||||
"@tauri-apps/cli-win32-arm64-msvc" "2.0.2"
|
||||
"@tauri-apps/cli-win32-ia32-msvc" "2.0.2"
|
||||
"@tauri-apps/cli-win32-x64-msvc" "2.0.2"
|
||||
"@tauri-apps/cli-darwin-arm64" "2.1.0"
|
||||
"@tauri-apps/cli-darwin-x64" "2.1.0"
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf" "2.1.0"
|
||||
"@tauri-apps/cli-linux-arm64-gnu" "2.1.0"
|
||||
"@tauri-apps/cli-linux-arm64-musl" "2.1.0"
|
||||
"@tauri-apps/cli-linux-x64-gnu" "2.1.0"
|
||||
"@tauri-apps/cli-linux-x64-musl" "2.1.0"
|
||||
"@tauri-apps/cli-win32-arm64-msvc" "2.1.0"
|
||||
"@tauri-apps/cli-win32-ia32-msvc" "2.1.0"
|
||||
"@tauri-apps/cli-win32-x64-msvc" "2.1.0"
|
||||
|
||||
"@tauri-apps/plugin-dialog@^2.0.0-rc.1":
|
||||
version "2.0.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0-rc.1.tgz#a3b0fd51a547e796ff93c58172fad48ad61a9970"
|
||||
integrity sha512-H28gh6BfZtjflHQ+HrmWwunDriBI3AQLAKnMs50GA6zeNUULqbQr7VXbAAKeJL/0CmWcecID4PKXVoSlaWRhEg==
|
||||
"@tauri-apps/plugin-dialog@~2":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.1.tgz"
|
||||
integrity sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tauri-apps/plugin-fs@^2.0.0-rc.2":
|
||||
version "2.0.0-rc.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-fs/-/plugin-fs-2.0.0-rc.2.tgz#85362170983b12ad6a4808e51daff5ba4a8916fa"
|
||||
integrity sha512-TFjCfso3tN4b5s2EBjqP8N2gYrPh93Ds3VNKj8pCXv4wbvnItyfG0aHO0haUsedBOHQryDwv9vDAdPX6/T0a+g==
|
||||
"@tauri-apps/plugin-fs@~2":
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.0.2.tgz"
|
||||
integrity sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tauri-apps/plugin-http@^2.0.0-rc.2":
|
||||
version "2.0.0-rc.2"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-http/-/plugin-http-2.0.0-rc.2.tgz#385a3c0808db86b59ab0b183c85e4fd2de989cbd"
|
||||
integrity sha512-s/AbbMaQPgqnIOoObvWNAjJOV17gyf9G+U6gmvjLoFbt7D6jsujOUW6fn+Oe/+rzNSEeo1ZSVrUoMen5DgM+OA==
|
||||
"@tauri-apps/plugin-http@~2":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.0.1.tgz"
|
||||
integrity sha512-j6IA3pVBybSCwPpsihpX4z8bs6PluuGtr06ahL/xy4D8HunNBTmRmadJrFOQi0gOAbaig4MkQ15nzNLBLy8R1A==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tauri-apps/plugin-notification@~2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0.tgz#7f5e75a87e4c11d28aede278063c670befd900e3"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.0.0.tgz"
|
||||
integrity sha512-6qEDYJS7mgXZWLXA0EFL+DVCJh8sJlzSoyw6B50pxhLPVFjc5Vr5DVzl5W3mUHaYhod5wsC984eQnlCCGqxYDA==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tauri-apps/plugin-os@^2.0.0-rc":
|
||||
version "2.0.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.0.0-rc.1.tgz#325e23e007df6d3247654eca2741e700519ab1cd"
|
||||
integrity sha512-PV8zlSTmYfiN2xzILUmlDSEycS7UYbH2yXk/ZqF+qQU6/s+OVQvmSth4EhllFjcpvPbtqELvpzfjw+2qEouchA==
|
||||
"@tauri-apps/plugin-os@~2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.0.0.tgz"
|
||||
integrity sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tauri-apps/plugin-shell@^2.0.0-rc.1":
|
||||
version "2.0.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0-rc.1.tgz#9facf3bbcedfa2de676cb4cfc703687377aa12a3"
|
||||
integrity sha512-JtNROc0rqEwN/g93ig5pK4cl1vUo2yn+osCpY9de64cy/d9hRzof7AuYOgvt/Xcd5VPQmlgo2AGvUh5sQRSR1A==
|
||||
"@tauri-apps/plugin-shell@~2":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.0.1.tgz"
|
||||
integrity sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tauri-apps/plugin-sql@^2.0.0-rc.1":
|
||||
version "2.0.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-sql/-/plugin-sql-2.0.0-rc.1.tgz#aa21b4744cab72d082297844500c4b53837df964"
|
||||
integrity sha512-F8Wq+L8SdVt76WCU161WCeM5CLkh3gzKblXhXyFFJ8m+BY1P83zVyCxriems3mYULvPPtFwlg8NEgLJycS1HYQ==
|
||||
"@tauri-apps/plugin-sql@~2":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.0.1.tgz"
|
||||
integrity sha512-SxvRO/qwq/dHHGJ+79Bx4tB/wlfUE44sP1+wpuGOp11fgmfmOaf3nlZAl0P0KX+U3h0rwR/f7PMRQ6Eg408DYQ==
|
||||
dependencies:
|
||||
"@tauri-apps/api" "^2.0.0-rc.4"
|
||||
"@tauri-apps/api" "^2.0.0"
|
||||
|
||||
"@tsconfig/node10@^1.0.7":
|
||||
version "1.0.11"
|
||||
@@ -425,17 +420,10 @@
|
||||
|
||||
"@types/estree@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz"
|
||||
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
|
||||
|
||||
"@types/node@*":
|
||||
version "22.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.4.tgz#83f7d1f65bc2ed223bdbf57c7884f1d5a4fa84e8"
|
||||
integrity sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==
|
||||
dependencies:
|
||||
undici-types "~6.19.2"
|
||||
|
||||
"@types/node@^18.7.10":
|
||||
"@types/node@*", "@types/node@^18.7.10":
|
||||
version "18.19.47"
|
||||
resolved "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz"
|
||||
integrity sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==
|
||||
@@ -449,19 +437,19 @@
|
||||
|
||||
"@types/qrcode@^1.5.5":
|
||||
version "1.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
|
||||
resolved "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz"
|
||||
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/resolve@1.20.2":
|
||||
version "1.20.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
|
||||
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
|
||||
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
|
||||
|
||||
"@yr/monotone-cubic-spline@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
|
||||
resolved "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz"
|
||||
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
|
||||
|
||||
acorn-walk@^8.1.1:
|
||||
@@ -513,7 +501,7 @@ anymatch@~3.1.2:
|
||||
|
||||
apexcharts@^3.53.0:
|
||||
version "3.53.0"
|
||||
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.53.0.tgz#9ea2b4d837d9faf2c0bff79d228db48e75b2220a"
|
||||
resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-3.53.0.tgz"
|
||||
integrity sha512-QESZHZY3w9LPQ64PGh1gEdfjYjJ5Jp+Dfy0D/CLjsLOPTpXzdxwlNMqRj+vPbTcP0nAHgjWv1maDqcEq6u5olw==
|
||||
dependencies:
|
||||
"@yr/monotone-cubic-spline" "^1.0.3"
|
||||
@@ -553,7 +541,7 @@ balanced-match@^1.0.0:
|
||||
|
||||
base64-arraybuffer@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
|
||||
resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz"
|
||||
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
@@ -600,7 +588,7 @@ buffer-crc32@^1.0.0:
|
||||
|
||||
builtin-modules@^3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
|
||||
resolved "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz"
|
||||
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
|
||||
|
||||
camelcase-css@^2.0.1:
|
||||
@@ -614,9 +602,9 @@ camelcase@^5.0.0:
|
||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||
|
||||
caniuse-lite@^1.0.30001646:
|
||||
version "1.0.30001653"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001653.tgz"
|
||||
integrity sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==
|
||||
version "1.0.30001703"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz"
|
||||
integrity sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==
|
||||
|
||||
chokidar@^3.4.1, chokidar@^3.5.3:
|
||||
version "3.6.0"
|
||||
@@ -680,7 +668,7 @@ cross-spawn@^7.0.0:
|
||||
|
||||
css-line-break@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
|
||||
resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz"
|
||||
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
|
||||
dependencies:
|
||||
utrie "^1.0.2"
|
||||
@@ -792,7 +780,7 @@ escalade@^3.1.2:
|
||||
|
||||
estree-walker@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
|
||||
resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz"
|
||||
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
||||
|
||||
fast-glob@^3.3.0:
|
||||
@@ -830,7 +818,7 @@ find-up@^4.1.0:
|
||||
|
||||
flowbite-datepicker@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/flowbite-datepicker/-/flowbite-datepicker-1.3.0.tgz#60b2423dfa1013e61c50babcf8512501d8b835ee"
|
||||
resolved "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.0.tgz"
|
||||
integrity sha512-CLVqzuoE2vkUvWYK/lJ6GzT0be5dlTbH3uuhVwyB67+PjqJWABm2wv68xhBf5BqjpBxvTSQ3mrmLHpPJ2tvrSQ==
|
||||
dependencies:
|
||||
"@rollup/plugin-node-resolve" "^15.2.3"
|
||||
@@ -838,12 +826,12 @@ flowbite-datepicker@^1.3.0:
|
||||
|
||||
flowbite-svelte-icons@^1.6.1:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/flowbite-svelte-icons/-/flowbite-svelte-icons-1.6.1.tgz#10da0f5aafdbe1f34dcc44293bd2c0020482262b"
|
||||
resolved "https://registry.npmjs.org/flowbite-svelte-icons/-/flowbite-svelte-icons-1.6.1.tgz"
|
||||
integrity sha512-Kw/7BzA6fqlFq7tBNudwX0KVU4cbyyXcMcgHTraMwGBtvBQan0RKMbvWwqm4JZNvLGAvRv1BM2EF7rzo/oam1Q==
|
||||
|
||||
flowbite-svelte@^0.46.16:
|
||||
version "0.46.16"
|
||||
resolved "https://registry.yarnpkg.com/flowbite-svelte/-/flowbite-svelte-0.46.16.tgz#f02dfd3ddf1a4f5cf9c837528ce0dacc34c02944"
|
||||
resolved "https://registry.npmjs.org/flowbite-svelte/-/flowbite-svelte-0.46.16.tgz"
|
||||
integrity sha512-NkyMS/d1EwuL1cqstSUflnG9vhhBiNyUiAw51D8lfPKDfUG1iXc4+HueQw01zhHv3uSXRJRToFBrg6npxeJ3jw==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.6.10"
|
||||
@@ -853,7 +841,7 @@ flowbite-svelte@^0.46.16:
|
||||
|
||||
flowbite@^2.0.0, flowbite@^2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/flowbite/-/flowbite-2.5.1.tgz#b2075c6a91b047e7514a41ec4c5437a7eaaf69e0"
|
||||
resolved "https://registry.npmjs.org/flowbite/-/flowbite-2.5.1.tgz"
|
||||
integrity sha512-7jP1jy9c3QP7y+KU9lc8ueMkTyUdMDvRP+lteSWgY5TigSZjf9K1kqZxmqjhbx2gBnFQxMl1GAjVThCa8cEpKA==
|
||||
dependencies:
|
||||
"@popperjs/core" "^2.9.3"
|
||||
@@ -880,7 +868,7 @@ fs.realpath@^1.0.0:
|
||||
|
||||
fsevents@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
function-bind@^1.1.2:
|
||||
@@ -945,7 +933,7 @@ hasown@^2.0.2:
|
||||
|
||||
html2canvas@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
|
||||
resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz"
|
||||
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
|
||||
dependencies:
|
||||
css-line-break "^2.1.0"
|
||||
@@ -973,7 +961,7 @@ is-binary-path@~2.1.0:
|
||||
|
||||
is-builtin-module@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
|
||||
resolved "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz"
|
||||
integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
|
||||
dependencies:
|
||||
builtin-modules "^3.3.0"
|
||||
@@ -1004,7 +992,7 @@ is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
||||
|
||||
is-module@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
|
||||
resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz"
|
||||
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
|
||||
|
||||
is-number@^7.0.0:
|
||||
@@ -1063,6 +1051,11 @@ lru-cache@^10.2.0:
|
||||
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
|
||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||
|
||||
lucide-svelte@^0.479.0:
|
||||
version "0.479.0"
|
||||
resolved "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.479.0.tgz"
|
||||
integrity sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==
|
||||
|
||||
magic-string@^0.30.3, magic-string@^0.30.5:
|
||||
version "0.30.11"
|
||||
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz"
|
||||
@@ -1095,7 +1088,7 @@ min-indent@^1.0.0:
|
||||
|
||||
mini-svg-data-uri@^1.4.3:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
|
||||
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz"
|
||||
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
|
||||
|
||||
minimatch@^3.1.1:
|
||||
@@ -1273,7 +1266,7 @@ postcss-import@^15.1.0:
|
||||
|
||||
postcss-js@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2"
|
||||
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
|
||||
integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
|
||||
dependencies:
|
||||
camelcase-css "^2.0.1"
|
||||
@@ -1550,40 +1543,40 @@ svelte@^3.54.0:
|
||||
|
||||
svg.draggable.js@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba"
|
||||
resolved "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz"
|
||||
integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==
|
||||
dependencies:
|
||||
svg.js "^2.0.1"
|
||||
|
||||
svg.easing.js@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12"
|
||||
resolved "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz"
|
||||
integrity sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==
|
||||
dependencies:
|
||||
svg.js ">=2.3.x"
|
||||
|
||||
svg.filter.js@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203"
|
||||
resolved "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz"
|
||||
integrity sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==
|
||||
dependencies:
|
||||
svg.js "^2.2.5"
|
||||
|
||||
svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5:
|
||||
version "2.7.1"
|
||||
resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d"
|
||||
resolved "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz"
|
||||
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
|
||||
|
||||
svg.pathmorphing.js@^0.1.3:
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65"
|
||||
resolved "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz"
|
||||
integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==
|
||||
dependencies:
|
||||
svg.js "^2.4.0"
|
||||
|
||||
svg.resize.js@^1.4.3:
|
||||
version "1.4.3"
|
||||
resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332"
|
||||
resolved "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz"
|
||||
integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==
|
||||
dependencies:
|
||||
svg.js "^2.6.5"
|
||||
@@ -1591,21 +1584,21 @@ svg.resize.js@^1.4.3:
|
||||
|
||||
svg.select.js@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73"
|
||||
resolved "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz"
|
||||
integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==
|
||||
dependencies:
|
||||
svg.js "^2.2.5"
|
||||
|
||||
svg.select.js@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917"
|
||||
resolved "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz"
|
||||
integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==
|
||||
dependencies:
|
||||
svg.js "^2.6.5"
|
||||
|
||||
tailwind-merge@^2.5.2:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.2.tgz#000f05a703058f9f9f3829c644235f81d4c08a1f"
|
||||
resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz"
|
||||
integrity sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==
|
||||
|
||||
tailwindcss@^3.3.0:
|
||||
@@ -1638,7 +1631,7 @@ tailwindcss@^3.3.0:
|
||||
|
||||
text-segmentation@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
|
||||
resolved "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz"
|
||||
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
|
||||
dependencies:
|
||||
utrie "^1.0.2"
|
||||
@@ -1708,11 +1701,6 @@ undici-types@~5.26.4:
|
||||
resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz"
|
||||
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
|
||||
|
||||
undici-types@~6.19.2:
|
||||
version "6.19.8"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
|
||||
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
|
||||
|
||||
update-browserslist-db@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz"
|
||||
@@ -1728,7 +1716,7 @@ util-deprecate@^1.0.2:
|
||||
|
||||
utrie@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
|
||||
resolved "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz"
|
||||
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
|
||||
dependencies:
|
||||
base64-arraybuffer "^1.0.2"
|
||||
|
||||