Compare commits

...

31 Commits

Author SHA1 Message Date
Xinrea
9f05fc4954 release: bump version to 1.1.0 2024-10-30 21:32:49 +08:00
Xinrea
3fce06ef63 chore: code format 2024-10-30 21:30:40 +08:00
Xinrea
3d13f69e5c feat: log output to file 2024-10-30 21:28:43 +08:00
Xinrea
deb19c6223 feat: change clip encoding to copy for speed 2024-10-30 21:01:58 +08:00
Xinrea
7466127832 feat: add account using cookie str (close #19) 2024-10-30 20:55:35 +08:00
Xinrea
af982c5fe0 fix: project url on about page 2024-10-30 18:38:25 +08:00
Xinrea
b03f0150d8 release: bump version to 1.0.6 2024-10-26 12:43:07 +08:00
Xinrea
d61ddafb44 fix: remove window effects
Tauri currently does not provide an API to retrieve the active window effects, making it impossible to adjust the window style based on that information. Therefore, we have removed the window effects to maintain UI consistency.

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

View File

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

BIN
doc/accounts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

BIN
doc/archives.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

BIN
doc/header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
doc/livewindow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

BIN
doc/messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

BIN
doc/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

BIN
doc/summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

View File

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

35
src-tauri/Cargo.lock generated
View File

@@ -1440,11 +1440,12 @@ dependencies = [
[[package]]
name = "ffmpeg-sidecar"
version = "1.1.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec830aba6cd3da7621c58e8f06727e3b750e8383bcd934d789f2b9c3c4ea595"
checksum = "d67d09bdb90406a420b30ba06d464a976c9642081c2ecdf09e35ec80bd7eb9b1"
dependencies = [
"anyhow",
"reqwest 0.12.8",
]
[[package]]
@@ -2293,6 +2294,22 @@ dependencies = [
"tokio-native-tls",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.4.1",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.8"
@@ -4019,7 +4036,7 @@ dependencies = [
"http 0.2.9",
"http-body 0.4.5",
"hyper 0.14.25",
"hyper-tls",
"hyper-tls 0.5.0",
"ipnet",
"js-sys",
"log",
@@ -4043,15 +4060,16 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.7"
version = "0.12.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63"
checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.6",
@@ -4060,11 +4078,13 @@ dependencies = [
"http-body-util",
"hyper 1.4.1",
"hyper-rustls",
"hyper-tls 0.6.0",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -4078,6 +4098,7 @@ dependencies = [
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower-service",
@@ -5161,7 +5182,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.12.7",
"reqwest 0.12.8",
"serde",
"serde_json",
"serde_repr",
@@ -5311,7 +5332,7 @@ dependencies = [
"data-url",
"http 1.1.0",
"regex",
"reqwest 0.12.7",
"reqwest 0.12.8",
"schemars",
"serde",
"serde_json",

View File

@@ -22,7 +22,7 @@ sysinfo = "0.32.0"
m3u8-rs = "5.0.3"
async-std = "1.12.0"
futures = "0.3.28"
ffmpeg-sidecar = "1.1"
ffmpeg-sidecar = "1.2.0"
chrono = { version = "0.4.24", features = ["serde"] }
toml = "0.7.3"
custom_error = "1.9.2"

View File

@@ -5004,6 +5004,171 @@
"type": "string",
"const": "http:deny-fetch-send"
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n",
"type": "string",
"const": "notification:default"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch"
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel"
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions"
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel"
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel"
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active"
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending"
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted"
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels"
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify"
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state"
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types"
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener"
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active"
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission"
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show"
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch"
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel"
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions"
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel"
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel"
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active"
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending"
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted"
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels"
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify"
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state"
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types"
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener"
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active"
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission"
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show"
},
{
"description": "This permission set configures which\noperating system information are available\nto gather from the frontend.\n\n#### Granted Permissions\n\nAll information except the host name are available.\n\n",
"type": "string",

View File

@@ -329,7 +329,6 @@ impl Database {
}
}
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct VideoRow {
@@ -351,21 +350,34 @@ pub struct VideoRow {
impl Database {
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
.bind(room_id as i64)
.fetch_all(&lock)
.await?)
.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")
Ok(
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
.bind(id)
.fetch_one(&lock)
.await?)
.await?,
)
}
pub async fn update_video(&self, video_id: i64, status: i64, bvid: &str, title: &str, desc: &str, tags: &str, area: u64) -> Result<(), DatabaseError> {
pub async fn update_video(
&self,
video_id: i64,
status: i64,
bvid: &str,
title: &str,
desc: &str,
tags: &str,
area: u64,
) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
.bind(status)

View File

@@ -12,12 +12,13 @@ use recorder::bilibili::errors::BiliClientError;
use recorder::bilibili::profile::Profile;
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
use tauri_plugin_notification::NotificationExt;
use std::fs::File;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tauri::utils::config::WindowEffectsConfig;
use tauri::{Manager, Theme, WindowEvent};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_sql::{Migration, MigrationKind};
use tokio::sync::RwLock;
@@ -366,7 +367,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
std::thread::sleep(std::time::Duration::from_secs(2));
// Copy old cache to new cache
log::info!("Start copy old cache to new cache");
state.db.new_message("缓存目录切换", "缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用").await?;
state
.db
.new_message(
"缓存目录切换",
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
)
.await?;
if let Err(e) = copy_dir_all(&old_cache_path, &cache_path) {
log::error!("Copy old cache to new cache error: {}", e);
}
@@ -382,7 +389,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
}
#[tauri::command]
async fn update_notify(state: tauri::State<'_, State>, live_start_notify: bool, live_end_notify: bool, clip_notify: bool, post_notify: bool) -> Result<(), ()> {
async fn update_notify(
state: tauri::State<'_, State>,
live_start_notify: bool,
live_end_notify: bool,
clip_notify: bool,
post_notify: bool,
) -> Result<(), ()> {
state.config.write().await.live_start_notify = live_start_notify;
state.config.write().await.live_end_notify = live_end_notify;
state.config.write().await.clip_notify = clip_notify;
@@ -472,7 +485,14 @@ async fn clip_range(
)
.await?;
if state.config.read().await.clip_notify {
state.app_handle.notification().builder().title("BiliShadowReplay - 切片完成").body(format!("生成了房间 {} 的切片: {}", room_id, filename)).show().unwrap();
state
.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 切片完成")
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
.show()
.unwrap();
}
Ok(video)
}
@@ -519,7 +539,14 @@ async fn upload_procedure(
)
.await?;
if state.config.read().await.post_notify {
state.app_handle.notification().builder().title("BiliShadowReplay - 投稿成功").body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid)).show().unwrap();
state
.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 投稿成功")
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
.show()
.unwrap();
}
Ok(ret.bvid)
} else {
@@ -707,12 +734,19 @@ async fn delete_video(state: tauri::State<'_, State>, id: i64) -> Result<(), Str
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Setup log
simplelog::CombinedLogger::init(vec![simplelog::TermLogger::new(
simplelog::CombinedLogger::init(vec![
simplelog::TermLogger::new(
simplelog::LevelFilter::Info,
simplelog::Config::default(),
simplelog::TerminalMode::Mixed,
simplelog::ColorChoice::Auto,
)])
),
simplelog::WriteLogger::new(
simplelog::LevelFilter::Info,
simplelog::Config::default(),
File::create("bsr.log").unwrap(),
),
])
.unwrap();
// Setup ffmpeg
@@ -814,12 +848,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(account) = account {
for room in initial_rooms {
if let Err(e) = recorder_manager_clone
.add_recorder(
&webid,
&db_clone,
&account,
room.room_id,
)
.add_recorder(&webid, &db_clone, &account, room.room_id)
.await
{
log::error!("error when adding initial rooms: {}", e);

View File

@@ -12,10 +12,10 @@ use ffmpeg_sidecar::{
use futures::future::join_all;
use m3u8_rs::Playlist;
use regex::Regex;
use tauri_plugin_notification::NotificationExt;
use std::sync::Arc;
use std::thread;
use tauri::{AppHandle, Emitter};
use tauri_plugin_notification::NotificationExt;
use tokio::sync::mpsc::{self, UnboundedReceiver};
use tokio::sync::{Mutex, RwLock};
@@ -165,16 +165,25 @@ impl BiliRecorder {
.notification()
.builder()
.title("BiliShadowReplay - 直播开始")
.body(format!("{} 开启了直播:{}",self.user_info.read().await.user_name, room_info.room_title)).show().unwrap();
.body(format!(
"{} 开启了直播:{}",
self.user_info.read().await.user_name,
room_info.room_title
))
.show()
.unwrap();
}
} else {
if self.config.read().await.live_end_notify {
} else if self.config.read().await.live_end_notify {
self.app_handle
.notification()
.builder()
.title("BiliShadowReplay - 直播结束")
.body(format!("{} 的直播结束了",self.user_info.read().await.user_name)).show().unwrap();
}
.body(format!(
"{} 的直播结束了",
self.user_info.read().await.user_name
))
.show()
.unwrap();
}
}
// if stream is confirmed to be closed, live stream cache is cleaned.
@@ -362,7 +371,12 @@ impl BiliRecorder {
async fn update_entries(&self) -> Result<(), RecorderError> {
let parsed = self.get_playlist().await;
let mut timestamp = *self.timestamp.read().await;
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
let mut work_dir = format!(
"{}/{}/{}/",
self.config.read().await.cache,
self.room_id,
timestamp
);
// Check header if None
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
{
@@ -384,7 +398,12 @@ impl BiliRecorder {
)
.await?;
// now work dir is confirmed
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
work_dir = format!(
"{}/{}/{}/",
self.config.read().await.cache,
self.room_id,
timestamp
);
// if folder is exisited, need to load previous data into cache
if let Ok(meta) = fs::metadata(&work_dir).await {
if meta.is_dir() {
@@ -572,7 +591,7 @@ impl BiliRecorder {
y - x
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
let args = format!("-i concat:{} -c copy", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
@@ -608,12 +627,12 @@ impl BiliRecorder {
}
let mut offset = 0.0;
for e in entry_copy.iter() {
if (offset as f64) < start {
if offset < start {
offset += 1.0;
continue;
}
to_combine.push(e);
if (offset as f64) >= end {
if offset >= end {
break;
}
offset += 1.0;
@@ -629,7 +648,10 @@ impl BiliRecorder {
let file_name = e.url.split('/').last().unwrap();
let file_path = format!(
"{}/{}/{}/{}",
self.config.read().await.cache, self.room_id, timestamp, file_name
self.config.read().await.cache,
self.room_id,
timestamp,
file_name
);
file_list += &file_path;
file_list += "|";
@@ -646,7 +668,7 @@ impl BiliRecorder {
end - start
);
log::info!("{}", file_name);
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
let args = format!("-i concat:{} -c copy", file_list);
FfmpegCommand::new()
.args(args.split(' '))
.output(file_name.clone())
@@ -681,7 +703,12 @@ impl BiliRecorder {
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
// add entries from read_dir
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
let work_dir = format!(
"{}/{}/{}",
self.config.read().await.cache,
self.room_id,
timestamp
);
let entries = self.get_fs_entries(&work_dir).await;
if entries.is_empty() {
return m3u8_content;

View File

@@ -70,7 +70,6 @@ impl From<RecorderManagerError> for String {
}
impl RecorderManager {
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
RecorderManager {
app_handle,
@@ -122,6 +121,9 @@ impl RecorderManager {
if recorder.is_none() {
return Err(RecorderManagerError::NotFound { room_id });
}
// remove related cache folder
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
tokio::fs::remove_dir_all(cache_folder).await?;
Ok(())
}

View File

@@ -8,12 +8,9 @@
"title": "BiliBili ShadowReplay",
"width": 1300,
"height": 600,
"transparent": true,
"transparent": false,
"decorations": false,
"theme": "Light",
"windowEffects": {
"effects": ["tabbed", "mica"]
}
"theme": "Light"
}
]
}

View File

@@ -80,5 +80,6 @@
.content {
height: 100vh;
background-color: #e5e7eb;
}
</style>

View File

@@ -122,10 +122,21 @@
(a: RecordItem) => {
console.log(a);
archive = a;
appWindow.setTitle(`[${room_id}][${ts}]${archive.title}`);
appWindow.setTitle(`[${room_id}][${format_ts(ts)}]${archive.title}`);
},
);
function update_title(str: string) {
appWindow.setTitle(
`[${room_id}][${format_ts(ts)}]${archive.title} - ${str}`,
);
}
function format_ts(ts: number) {
const date = new Date(ts * 1000);
return date.toLocaleString();
}
async function get_video_list() {
videos = (
(await invoke("get_videos", { roomId: room_id })) as VideoItem[]
@@ -159,7 +170,8 @@
}
loading = true;
let new_cover = generateCover();
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成中`);
update_title(`切片生成中`);
try {
let new_video = (await invoke("clip_range", {
roomId: room_id,
cover: new_cover,
@@ -167,7 +179,7 @@
x: start,
y: end,
})) as VideoItem;
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 切片生成成功`);
update_title(`切片生成成功`);
console.log("video file generatd:", video);
await get_video_list();
video_selected = new_video.id;
@@ -176,13 +188,16 @@
});
cover = new_video.cover;
loading = false;
} catch (e) {
alert("Err generating clip: " + e);
}
}
async function do_post() {
if (!video) {
return;
}
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿上传中`);
update_title(`投稿上传中`);
loading = true;
// render cover with text
const ecapture = document.getElementById("capture");
@@ -201,13 +216,13 @@
})
.then(async () => {
loading = false;
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿成功`);
update_title(`投稿成功`);
video_selected = 0;
await get_video_list();
})
.catch((e) => {
loading = false;
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 投稿失败`);
update_title(`投稿失败`);
alert(e);
});
}
@@ -217,9 +232,9 @@
return;
}
loading = true;
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除中`);
update_title(`删除中`);
await invoke("delete_video", { id: video_selected });
appWindow.setTitle(`[${room_id}][${ts}]${archive.title} - 删除成功`);
update_title(`删除成功`);
loading = false;
video_selected = 0;
video = null;
@@ -246,7 +261,7 @@
<TitleBar dark />
{/if}
<div class="flex flex-row">
<div class="w-3/4">
<div class="w-3/4 overflow-hidden">
<Player bind:start bind:end {port} {room_id} {ts} />
<Modal title="预览" bind:open={preview} autoclose>
<!-- svelte-ignore a11y-media-has-caption -->
@@ -254,11 +269,11 @@
</Modal>
</div>
<div
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[49]"
class="w-1/4 h-screen overflow-hidden border-solid bg-gray-50 border-l-2 border-slate-200 z-[39]"
>
<div
id="post-panel"
class="mt-6 overflow-auto p-6"
class="mt-6 overflow-y-auto overflow-x-hidden p-6"
class:titlebar={use_titlebar}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -271,7 +286,7 @@
>
<div id="capture" class="cover-wrap relative cursor-pointer">
<div
class="cover-text absolute py-2 px-8"
class="cover-text absolute py-1 px-8"
class:play-icon={false}
>
{cover_text}
@@ -309,20 +324,53 @@
</div>
<Hr />
<Label class="mt-4">标题</Label>
<Input size="sm" bind:value={profile.title} />
<Input
size="sm"
bind:value={profile.title}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">封面文本</Label>
<Textarea bind:value={cover_text} />
<Label class="mt-2">描述</Label>
<Textarea bind:value={profile.desc} />
<Textarea
bind:value={profile.desc}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">标签</Label>
<Input size="sm" bind:value={profile.tag} />
<Input
size="sm"
bind:value={profile.tag}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">动态</Label>
<Textarea bind:value={profile.dynamic} />
<Textarea
bind:value={profile.dynamic}
on:change={() => {
window.localStorage.setItem(
"profile-" + room_id,
JSON.stringify(profile),
);
}}
/>
<Label class="mt-2">视频分区</Label>
<Input size="sm" value="动画 - 综合" disabled />
<Label class="mt-2">投稿账号</Label>
<Select size="sm" items={accounts} bind:value={uid_selected} />
</div>
{#if video}
<div class="flex mt-4 justify-center w-full">
<Button on:click={do_post} disabled={loading}>
@@ -335,6 +383,7 @@
{/if}
</div>
</div>
</div>
</main>
<style>
@@ -351,6 +400,7 @@
.cover-text {
white-space: pre-wrap;
font-size: 24px;
line-height: 1.3;
font-weight: bold;
color: rgb(255, 127, 0);
text-shadow:

View File

@@ -26,7 +26,9 @@
站直播流,并生成视频投稿的工具。
</p>
<p class="mt-4">
项目地址: <a href="https://github.com/Xinrea/bili-shadowreplay"
项目地址: <a
target="_blank"
href="https://github.com/Xinrea/bili-shadowreplay"
>https://github.com/Xinrea/bili-shadowreplay</a
>
</p>

View File

@@ -11,6 +11,11 @@
TableBodyCell,
Modal,
ButtonGroup,
SpeedDial,
Listgroup,
ListgroupItem,
Textarea,
Hr,
} from "flowbite-svelte";
import Image from "./Image.svelte";
import QRCode from "qrcode";
@@ -32,6 +37,9 @@
let oauth_key = "";
let check_interval = null;
let manualModal = false;
let cookie_str = "";
async function handle_qr() {
if (check_interval) {
clearInterval(check_interval);
@@ -52,7 +60,7 @@
async function check_qr() {
let qr_status: { code: number; cookies: string } = await invoke(
"get_qr_status",
{ qrcodeKey: oauth_key }
{ qrcodeKey: oauth_key },
);
if (qr_status.code == 0) {
clearInterval(check_interval);
@@ -61,6 +69,20 @@
addModal = false;
}
}
async function add_cookie() {
if (cookie_str == "") {
return;
}
try {
await invoke("add_account", { cookies: cookie_str });
await update_accounts();
cookie_str = "";
manualModal = false;
} catch (e) {
alert("Err adding cookie:" + e);
}
}
</script>
<div class="p-8 pt-12 h-full overflow-auto">
@@ -116,16 +138,23 @@
</Table>
</div>
<div class="fixed end-4 bottom-4">
<Button
pill={true}
class="!p-2"
<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);
}}><UserAddSolid class="w-8 h-8" /></Button
}}>扫码添加</ListgroupItem
>
</div>
<ListgroupItem
class="flex gap-2 md:px-5"
on:click={() => {
manualModal = true;
}}>手动添加</ListgroupItem
>
</Listgroup>
</SpeedDial>
<Modal
title="请使用 BiliBili App 扫码登录"
@@ -137,3 +166,20 @@
<canvas id="qr" />
</div>
</Modal>
<Modal
title="请粘贴 BiliBili 账号 Cookie"
bind:open={manualModal}
size="sm"
autoclose
>
<div class="flex flex-col justify-center items-center h-full">
<Textarea bind:value={cookie_str} />
<Button
class="mt-4"
on:click={() => {
add_cookie();
}}>添加</Button
>
</div>
</Modal>

View File

@@ -28,7 +28,7 @@
(window as any).ui = ui;
try {
await player.load(
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`,
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`
);
// This runs if the asynchronous load is successful.
console.log("The video has now been loaded!");
@@ -44,13 +44,10 @@
});
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
document.querySelector(
".shaka-back-to-overflow-button .material-icons-round",
).innerHTML = "arrow_back_ios_new";
document.getElementsByClassName("shaka-fullscreen-button")[0].remove();
// add self-defined element in shaka-bottom-controls.shaka-no-propagation (second seekbar)
const shakaBottomControls = document.querySelector(
".shaka-bottom-controls.shaka-no-propagation",
".shaka-bottom-controls.shaka-no-propagation"
);
const selfSeekbar = document.createElement("div");
selfSeekbar.className = "shaka-seek-bar shaka-no-propagation";
@@ -66,6 +63,10 @@
`;
shakaBottomControls.appendChild(selfSeekbar);
// add to shaka-spacer
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
if (isLive()) {
// add a account select
const accountSelect = document.createElement("select");
accountSelect.style.height = "30px";
@@ -134,15 +135,6 @@
: "rgba(255, 0, 0, 0.5)";
});
// add to shaka-spacer
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
shakaSpacer.appendChild(danmakuToggle);
// shaka-spacer should be flex-direction: column
shakaSpacer.style.flexDirection = "column";
// create a area that overlay half top of the video, which shows danmakus floating from right to left
const overlay = document.createElement("div");
overlay.style.width = "100%";
@@ -151,7 +143,7 @@
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.pointerEvents = "none";
overlay.style.zIndex = "40";
overlay.style.zIndex = "30";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.flexDirection = "column";
@@ -164,8 +156,8 @@
// listen to danmaku event
listen("danmu:" + room_id, (event: { payload: string }) => {
console.log("danmu", event.payload);
if (!danmu_enabled) {
// if not enabled or playback is not keep up with live, ignore the danmaku
if (!danmu_enabled || get_total() - video.currentTime > 5) {
return;
}
const danmaku = document.createElement("p");
@@ -199,6 +191,7 @@
danmaku.style.margin = "0";
danmaku.style.padding = "0";
danmaku.style.zIndex = "500";
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
danmaku.innerText = event.payload;
overlay.appendChild(danmaku);
requestAnimationFrame(() => {
@@ -209,20 +202,48 @@
});
});
function isLive() {
let total = video.duration;
if (total == Infinity || total >= 4294967296) {
return true;
shakaSpacer.appendChild(accountSelect);
shakaSpacer.appendChild(danmakuInput);
shakaSpacer.appendChild(danmakuToggle);
}
return false;
// create a playback rate select to of shaka-spacer
const playbackRateSelect = document.createElement("select");
playbackRateSelect.style.height = "30px";
playbackRateSelect.style.minWidth = "60px";
playbackRateSelect.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
playbackRateSelect.style.color = "white";
playbackRateSelect.style.border = "1px solid gray";
playbackRateSelect.style.padding = "0 10px";
playbackRateSelect.style.boxSizing = "border-box";
playbackRateSelect.style.fontSize = "1em";
playbackRateSelect.style.right = "10px";
playbackRateSelect.style.position = "absolute";
playbackRateSelect.innerHTML = `
<option value="0.5">0.5x</option>
<option value="1">1x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
<option value="5">5x</option>
`;
// default playback rate is 1
playbackRateSelect.value = "1";
playbackRateSelect.addEventListener("change", () => {
const rate = parseFloat(playbackRateSelect.value);
video.playbackRate = rate;
});
shakaSpacer.appendChild(playbackRateSelect);
// shaka-spacer should be flex-direction: column
shakaSpacer.style.flexDirection = "column";
function isLive() {
return player.isLive();
}
function get_total() {
let total = video.duration;
if (total == Infinity || total >= 4294967296) {
total = (Date.now() - player.getPresentationStartTimeAsDate()) / 1000;
}
return total;
return player.seekRange().end;
}
// add keydown event listener for '[' and ']' to control range
document.addEventListener("keydown", async (e) => {
@@ -235,34 +256,14 @@
}
switch (e.key) {
case "[":
if (isLive()) {
start = parseFloat(
(
(player.getPlayheadTimeAsDate() -
player.getPresentationStartTimeAsDate()) /
1000
).toFixed(2),
);
} else {
start = parseFloat(video.currentTime.toFixed(2));
}
if (end < start) {
end = get_total();
}
console.log(start, end);
break;
case "]":
if (isLive()) {
end = parseFloat(
(
(player.getPlayheadTimeAsDate() -
player.getPresentationStartTimeAsDate()) /
1000
).toFixed(2),
);
} else {
end = parseFloat(video.currentTime.toFixed(2));
}
if (start > end) {
start = 0;
}
@@ -316,7 +317,7 @@
const second_point = end / total;
// set background color for self-defined seekbar between first_point and second_point using linear-gradient
const seekbarContainer = selfSeekbar.querySelector(
".shaka-seek-bar-container.self-defined",
".shaka-seek-bar-container.self-defined"
) as HTMLElement;
seekbarContainer.style.background = `linear-gradient(to right, rgba(255, 255, 255, 0.4) ${
first_point * 100
@@ -361,8 +362,8 @@
<p><kbd>]</kbd>设定选区结束</p>
<p><kbd>q</kbd>跳转到选区开始</p>
<p><kbd>e</kbd>跳转到选区结束</p>
<p><kbd>Alt</kbd><kbd></kbd>前进</p>
<p><kbd>Alt</kbd><kbd></kbd>后退</p>
<p><kbd></kbd>前进</p>
<p><kbd></kbd>后退</p>
<p><kbd>c</kbd>清除选区</p>
<p><kbd>m</kbd>静音</p>
</span>

View File

@@ -145,9 +145,7 @@
<Badge color="dark">未直播</Badge>
{/if}
</TableBodyCell>
<TableBodyCell
>{format_time(room.total_length)}</TableBodyCell
>
<TableBodyCell>{format_time(room.total_length)}</TableBodyCell>
<TableBodyCell>
<Button size="sm" color="dark"
>操作<ChevronDownOutline
@@ -164,13 +162,13 @@
});
}}>打开直播流</DropdownItem
>
<DropdownItem
<!-- <DropdownItem
on:click={() => {
quickClipRoom = room.room_id;
quickClipSelected = 30;
quickClipModal = true;
}}>快速切片</DropdownItem
>
> -->
{/if}
<DropdownItem
on:click={() => {
@@ -207,9 +205,7 @@
<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 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
确定要移除这个直播间吗?
</h3>
<Button
@@ -277,11 +273,8 @@
on:click={() => {
invoke("add_recorder", { roomId: Number(addRoom) }).catch(
async (e) => {
await message(
"请检查房间号是否有效:" + e,
"添加失败",
);
},
await message("请检查房间号是否有效:" + e, "添加失败");
}
);
}}>确定</Button
>
@@ -301,13 +294,9 @@
<TableBody tableBodyClass="divide-y">
{#each archives as archive}
<TableBodyRow>
<TableBodyCell
>{format_ts(archive.created_at)}</TableBodyCell
>
<TableBodyCell>{format_ts(archive.created_at)}</TableBodyCell>
<TableBodyCell>{archive.title}</TableBodyCell>
<TableBodyCell
>{format_duration(archive.length)}</TableBodyCell
>
<TableBodyCell>{format_duration(archive.length)}</TableBodyCell>
<TableBodyCell>
<span>{format_size(archive.size)}</span>
</TableBodyCell>
@@ -328,12 +317,9 @@
roomId: archiveRoom.room_id,
ts: archive.live_id,
}).then(async () => {
archives = await invoke(
"get_archives",
{
archives = await invoke("get_archives", {
roomId: archiveRoom.room_id,
},
);
});
});
}}>移除</Button
>