mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 12:29:24 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d053a3462 | ||
|
|
280e540f4f | ||
|
|
824cfd23ed | ||
|
|
695728df2e | ||
|
|
24deca75d2 | ||
|
|
8a1184f161 | ||
|
|
d61096d1b1 | ||
|
|
3b9d1be002 | ||
|
|
13262f8f10 | ||
|
|
9f05fc4954 | ||
|
|
3fce06ef63 | ||
|
|
3d13f69e5c | ||
|
|
deb19c6223 | ||
|
|
7466127832 | ||
|
|
af982c5fe0 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "1.0.6",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -329,7 +329,6 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT);
|
||||
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct VideoRow {
|
||||
@@ -351,21 +350,34 @@ pub struct VideoRow {
|
||||
impl Database {
|
||||
pub async fn get_videos(&self, room_id: u64) -> Result<Vec<VideoRow>, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;")
|
||||
.bind(room_id as i64)
|
||||
.fetch_all(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_video(&self, id: i64) -> Result<VideoRow, DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
Ok(sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&lock)
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&lock)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn update_video(&self, video_id: i64, status: i64, bvid: &str, title: &str, desc: &str, tags: &str, area: u64) -> Result<(), DatabaseError> {
|
||||
pub async fn update_video(
|
||||
&self,
|
||||
video_id: i64,
|
||||
status: i64,
|
||||
bvid: &str,
|
||||
title: &str,
|
||||
desc: &str,
|
||||
tags: &str,
|
||||
area: u64,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let lock = self.db.read().await.clone().unwrap();
|
||||
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
|
||||
.bind(status)
|
||||
|
||||
@@ -11,13 +11,15 @@ use db::{AccountRow, Database, MessageRow, RecordRow, VideoRow};
|
||||
use recorder::bilibili::errors::BiliClientError;
|
||||
use recorder::bilibili::profile::Profile;
|
||||
use recorder::bilibili::{BiliClient, QrInfo, QrStatus};
|
||||
use recorder::danmu::DanmuEntry;
|
||||
use recorder_manager::{RecorderInfo, RecorderList, RecorderManager};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tauri::utils::config::WindowEffectsConfig;
|
||||
use tauri::{Manager, Theme, WindowEvent};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -366,7 +368,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
// Copy old cache to new cache
|
||||
log::info!("Start copy old cache to new cache");
|
||||
state.db.new_message("缓存目录切换", "缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用").await?;
|
||||
state
|
||||
.db
|
||||
.new_message(
|
||||
"缓存目录切换",
|
||||
"缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用",
|
||||
)
|
||||
.await?;
|
||||
if let Err(e) = copy_dir_all(&old_cache_path, &cache_path) {
|
||||
log::error!("Copy old cache to new cache error: {}", e);
|
||||
}
|
||||
@@ -382,7 +390,13 @@ async fn set_cache_path(state: tauri::State<'_, State>, cache_path: String) -> R
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_notify(state: tauri::State<'_, State>, live_start_notify: bool, live_end_notify: bool, clip_notify: bool, post_notify: bool) -> Result<(), ()> {
|
||||
async fn update_notify(
|
||||
state: tauri::State<'_, State>,
|
||||
live_start_notify: bool,
|
||||
live_end_notify: bool,
|
||||
clip_notify: bool,
|
||||
post_notify: bool,
|
||||
) -> Result<(), ()> {
|
||||
state.config.write().await.live_start_notify = live_start_notify;
|
||||
state.config.write().await.live_end_notify = live_end_notify;
|
||||
state.config.write().await.clip_notify = clip_notify;
|
||||
@@ -472,7 +486,14 @@ async fn clip_range(
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.clip_notify {
|
||||
state.app_handle.notification().builder().title("BiliShadowReplay - 切片完成").body(format!("生成了房间 {} 的切片: {}", room_id, filename)).show().unwrap();
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 切片完成")
|
||||
.body(format!("生成了房间 {} 的切片: {}", room_id, filename))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(video)
|
||||
}
|
||||
@@ -519,7 +540,14 @@ async fn upload_procedure(
|
||||
)
|
||||
.await?;
|
||||
if state.config.read().await.post_notify {
|
||||
state.app_handle.notification().builder().title("BiliShadowReplay - 投稿成功").body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid)).show().unwrap();
|
||||
state
|
||||
.app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("BiliShadowReplay - 投稿成功")
|
||||
.body(format!("投稿了房间 {} 的切片: {}", room_id, ret.bvid))
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
Ok(ret.bvid)
|
||||
} else {
|
||||
@@ -592,6 +620,15 @@ async fn send_danmaku(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_danmu_record(
|
||||
state: tauri::State<'_, State>,
|
||||
room_id: u64,
|
||||
ts: u64,
|
||||
) -> Result<Vec<DanmuEntry>, String> {
|
||||
Ok(state.recorder_manager.get_danmu(room_id, ts).await?)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AccountInfo {
|
||||
pub primary_uid: u64,
|
||||
@@ -707,12 +744,19 @@ async fn delete_video(state: tauri::State<'_, State>, id: i64) -> Result<(), Str
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Setup log
|
||||
simplelog::CombinedLogger::init(vec![simplelog::TermLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
simplelog::TerminalMode::Mixed,
|
||||
simplelog::ColorChoice::Auto,
|
||||
)])
|
||||
simplelog::CombinedLogger::init(vec![
|
||||
simplelog::TermLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
simplelog::TerminalMode::Mixed,
|
||||
simplelog::ColorChoice::Auto,
|
||||
),
|
||||
simplelog::WriteLogger::new(
|
||||
simplelog::LevelFilter::Info,
|
||||
simplelog::Config::default(),
|
||||
File::create("bsr.log").unwrap(),
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
// Setup ffmpeg
|
||||
@@ -814,12 +858,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Ok(account) = account {
|
||||
for room in initial_rooms {
|
||||
if let Err(e) = recorder_manager_clone
|
||||
.add_recorder(
|
||||
&webid,
|
||||
&db_clone,
|
||||
&account,
|
||||
room.room_id,
|
||||
)
|
||||
.add_recorder(&webid, &db_clone, &account, room.room_id)
|
||||
.await
|
||||
{
|
||||
log::error!("error when adding initial rooms: {}", e);
|
||||
@@ -879,6 +918,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
get_disk_info,
|
||||
send_danmaku,
|
||||
update_notify,
|
||||
get_danmu_record,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
pub mod bilibili;
|
||||
pub mod danmu;
|
||||
use async_std::{fs, stream::StreamExt};
|
||||
use bilibili::{errors::BiliClientError, RoomInfo};
|
||||
use bilibili::{BiliClient, UserInfo};
|
||||
use chrono::prelude::*;
|
||||
use custom_error::custom_error;
|
||||
use danmu::{DanmuEntry, DanmuStorage};
|
||||
use dashmap::DashMap;
|
||||
use felgens::{ws_socket_object, FelgensError, WsStreamMessageType};
|
||||
use ffmpeg_sidecar::{
|
||||
command::FfmpegCommand,
|
||||
@@ -12,10 +15,10 @@ use ffmpeg_sidecar::{
|
||||
use futures::future::join_all;
|
||||
use m3u8_rs::Playlist;
|
||||
use regex::Regex;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
@@ -25,8 +28,9 @@ use crate::Config;
|
||||
#[derive(Clone)]
|
||||
pub struct TsEntry {
|
||||
pub url: String,
|
||||
pub offset: u64,
|
||||
pub sequence: u64,
|
||||
pub _length: f64,
|
||||
pub length: f64,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
@@ -55,6 +59,8 @@ pub struct BiliRecorder {
|
||||
header: Arc<RwLock<Option<TsEntry>>>,
|
||||
stream_type: Arc<RwLock<StreamType>>,
|
||||
cache_size: Arc<RwLock<u64>>,
|
||||
danmu_storage: Arc<RwLock<Option<DanmuStorage>>>,
|
||||
m3u8_cache: DashMap<u64, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -133,6 +139,8 @@ impl BiliRecorder {
|
||||
header: Arc::new(RwLock::new(None)),
|
||||
stream_type: Arc::new(RwLock::new(stream_type)),
|
||||
cache_size: Arc::new(RwLock::new(0)),
|
||||
danmu_storage: Arc::new(RwLock::new(None)),
|
||||
m3u8_cache: DashMap::new(),
|
||||
};
|
||||
log::info!("Recorder for room {} created.", room_id);
|
||||
Ok(recorder)
|
||||
@@ -144,6 +152,7 @@ impl BiliRecorder {
|
||||
self.ts_entries.lock().await.clear();
|
||||
*self.header.write().await = None;
|
||||
*self.timestamp.write().await = 0;
|
||||
*self.danmu_storage.write().await = None;
|
||||
}
|
||||
|
||||
async fn check_status(&self) -> bool {
|
||||
@@ -165,14 +174,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 {
|
||||
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.
|
||||
@@ -271,8 +291,20 @@ impl BiliRecorder {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if let WsStreamMessageType::DanmuMsg(msg) = msg {
|
||||
self.app_handle
|
||||
.emit(&format!("danmu:{}", room), msg.msg.clone())
|
||||
.emit(
|
||||
&format!("danmu:{}", room),
|
||||
DanmuEntry {
|
||||
ts: msg.timestamp,
|
||||
content: msg.msg.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
if *self.live_status.read().await {
|
||||
// save danmu
|
||||
if let Some(storage) = self.danmu_storage.write().await.as_ref() {
|
||||
storage.add_line(msg.timestamp, &msg.msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -360,7 +392,12 @@ impl BiliRecorder {
|
||||
async fn update_entries(&self) -> Result<(), RecorderError> {
|
||||
let parsed = self.get_playlist().await;
|
||||
let mut timestamp = *self.timestamp.read().await;
|
||||
let mut work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
|
||||
let mut work_dir = format!(
|
||||
"{}/{}/{}/",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp
|
||||
);
|
||||
// Check header if None
|
||||
if self.header.read().await.is_none() && *self.stream_type.read().await == StreamType::FMP4
|
||||
{
|
||||
@@ -382,7 +419,12 @@ impl BiliRecorder {
|
||||
)
|
||||
.await?;
|
||||
// now work dir is confirmed
|
||||
work_dir = format!("{}/{}/{}/", self.config.read().await.cache, self.room_id, timestamp);
|
||||
work_dir = format!(
|
||||
"{}/{}/{}/",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp
|
||||
);
|
||||
// if folder is exisited, need to load previous data into cache
|
||||
if let Ok(meta) = fs::metadata(&work_dir).await {
|
||||
if meta.is_dir() {
|
||||
@@ -396,11 +438,18 @@ impl BiliRecorder {
|
||||
// make sure work_dir is created
|
||||
fs::create_dir_all(&work_dir).await.unwrap();
|
||||
}
|
||||
// danmau file
|
||||
let danmu_file_path = format!("{}{}", work_dir, "danmu.txt");
|
||||
self.danmu_storage
|
||||
.write()
|
||||
.await
|
||||
.replace(DanmuStorage::new(&danmu_file_path).await);
|
||||
let full_header_url = self.ts_url(&header_url).await?;
|
||||
let mut header = TsEntry {
|
||||
url: full_header_url.clone(),
|
||||
offset: 0,
|
||||
sequence: 0,
|
||||
_length: 0.0,
|
||||
length: 0.0,
|
||||
size: 0,
|
||||
};
|
||||
let file_name = header_url.split('/').last().unwrap();
|
||||
@@ -433,27 +482,51 @@ impl BiliRecorder {
|
||||
sequence += 1;
|
||||
continue;
|
||||
}
|
||||
let mut ts_entry = TsEntry {
|
||||
url: ts.uri,
|
||||
sequence,
|
||||
_length: ts.duration as f64,
|
||||
size: 0,
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let ts_url = self.ts_url(&ts_entry.url).await?;
|
||||
ts_entry.url = ts_url.clone();
|
||||
let mut offset_hex: String = "".into();
|
||||
let mut seg_offset: u64 = 0;
|
||||
for tag in ts.unknown_tags {
|
||||
if tag.tag == "BILI-AUX" {
|
||||
if let Some(rest) = tag.rest {
|
||||
let parts: Vec<&str> = rest.split('|').collect();
|
||||
if parts.len() == 0 {
|
||||
continue;
|
||||
}
|
||||
offset_hex = parts.get(0).unwrap().to_string();
|
||||
seg_offset = u64::from_str_radix(&offset_hex, 16).unwrap();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let ts_url = self.ts_url(&ts.uri).await?;
|
||||
if ts_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// encode segment offset into filename
|
||||
let mut entries = self.ts_entries.lock().await;
|
||||
let file_name =
|
||||
format!("{}-{}", &offset_hex, ts_url.split('/').last().unwrap());
|
||||
let mut ts_length = 1.0;
|
||||
// calculate entry length using offset
|
||||
// the default #EXTINF is 1.0, which is not accurate
|
||||
if !entries.is_empty() {
|
||||
ts_length = (seg_offset - entries.last().unwrap().offset) as f64 / 1000.0;
|
||||
}
|
||||
let ts_entry = TsEntry {
|
||||
url: file_name.clone(),
|
||||
offset: seg_offset,
|
||||
sequence,
|
||||
length: ts_length,
|
||||
size: 0,
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let work_dir = work_dir.clone();
|
||||
let cache_size_clone = self.cache_size.clone();
|
||||
handles.push(tokio::task::spawn(async move {
|
||||
let ts_url_clone = ts_url.clone();
|
||||
let file_name = ts_url_clone.split('/').last().unwrap();
|
||||
let file_name_clone = file_name.clone();
|
||||
match client
|
||||
.read()
|
||||
.await
|
||||
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name))
|
||||
.download_ts(&ts_url, &format!("{}/{}", work_dir, file_name_clone))
|
||||
.await
|
||||
{
|
||||
Ok(size) => {
|
||||
@@ -464,7 +537,6 @@ impl BiliRecorder {
|
||||
}
|
||||
}
|
||||
}));
|
||||
let mut entries = self.ts_entries.lock().await;
|
||||
entries.push(ts_entry);
|
||||
*self.last_sequence.write().await = sequence;
|
||||
let mut total_length = self.ts_length.write().await;
|
||||
@@ -473,7 +545,7 @@ impl BiliRecorder {
|
||||
}
|
||||
join_all(handles).await.into_iter().for_each(|e| {
|
||||
if let Err(e) = e {
|
||||
log::error!("download ts failed: {:?}", e);
|
||||
log::error!("Download ts failed: {:?}", e);
|
||||
}
|
||||
});
|
||||
// currently we take every segement's length as 1.0s.
|
||||
@@ -533,7 +605,7 @@ impl BiliRecorder {
|
||||
y: f64,
|
||||
output_path: &str,
|
||||
) -> Result<String, RecorderError> {
|
||||
log::info!("create archive clip for range [{}, {}]", x, y);
|
||||
log::info!("Create archive clip for range [{}, {}]", x, y);
|
||||
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, ts);
|
||||
let entries = self.get_fs_entries(&work_dir).await;
|
||||
if entries.is_empty() {
|
||||
@@ -544,19 +616,20 @@ impl BiliRecorder {
|
||||
file_list += &format!("{}/h{}.m4s", work_dir, ts);
|
||||
file_list += "|";
|
||||
// add body entries
|
||||
let mut offset = 0.0;
|
||||
// seconds to ms
|
||||
let begin = (x * 1000.0) as u64;
|
||||
let end = (y * 1000.0) as u64;
|
||||
let offset = entries.first().unwrap().offset;
|
||||
if !entries.is_empty() {
|
||||
for e in entries {
|
||||
if offset < x {
|
||||
offset += 1.0;
|
||||
if e.offset - offset < begin {
|
||||
continue;
|
||||
}
|
||||
file_list += &format!("{}/{}", work_dir, e.url);
|
||||
file_list += "|";
|
||||
if offset > y {
|
||||
if e.offset - offset > end {
|
||||
break;
|
||||
}
|
||||
offset += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +643,7 @@ impl BiliRecorder {
|
||||
y - x
|
||||
);
|
||||
log::info!("{}", file_name);
|
||||
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
|
||||
let args = format!("-i concat:{} -c copy", file_list);
|
||||
FfmpegCommand::new()
|
||||
.args(args.split(' '))
|
||||
.output(file_name.clone())
|
||||
@@ -592,29 +665,25 @@ impl BiliRecorder {
|
||||
y: f64,
|
||||
output_path: &str,
|
||||
) -> Result<String, RecorderError> {
|
||||
log::info!("create live clip for range [{}, {}]", x, y);
|
||||
log::info!("Create live clip for range [{}, {}]", x, y);
|
||||
let mut to_combine = Vec::new();
|
||||
let header_copy = self.header.read().await.clone();
|
||||
let entry_copy = self.ts_entries.lock().await.clone();
|
||||
if entry_copy.is_empty() {
|
||||
return Err(RecorderError::EmptyCache);
|
||||
}
|
||||
let mut start = x;
|
||||
let mut end = y;
|
||||
if start > end {
|
||||
std::mem::swap(&mut start, &mut end);
|
||||
}
|
||||
let mut offset = 0.0;
|
||||
let begin = (x * 1000.0) as u64;
|
||||
let end = (y * 1000.0) as u64;
|
||||
let offset = entry_copy.first().unwrap().offset;
|
||||
// TODO using binary search
|
||||
for e in entry_copy.iter() {
|
||||
if offset < start {
|
||||
offset += 1.0;
|
||||
if e.offset - offset < begin {
|
||||
continue;
|
||||
}
|
||||
to_combine.push(e);
|
||||
if offset >= end {
|
||||
if e.offset - offset > end {
|
||||
break;
|
||||
}
|
||||
offset += 1.0;
|
||||
}
|
||||
if *self.stream_type.read().await == StreamType::FMP4 {
|
||||
// add header to vec
|
||||
@@ -627,7 +696,10 @@ impl BiliRecorder {
|
||||
let file_name = e.url.split('/').last().unwrap();
|
||||
let file_path = format!(
|
||||
"{}/{}/{}/{}",
|
||||
self.config.read().await.cache, self.room_id, timestamp, file_name
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp,
|
||||
file_name
|
||||
);
|
||||
file_list += &file_path;
|
||||
file_list += "|";
|
||||
@@ -641,10 +713,10 @@ impl BiliRecorder {
|
||||
self.room_id,
|
||||
title,
|
||||
Utc::now().format("%m%d%H%M%S"),
|
||||
end - start
|
||||
y - x
|
||||
);
|
||||
log::info!("{}", file_name);
|
||||
let args = format!("-i concat:{} -c:v libx264 -c:a aac", file_list);
|
||||
let args = format!("-i concat:{} -c copy", file_list);
|
||||
FfmpegCommand::new()
|
||||
.args(args.split(' '))
|
||||
.output(file_name.clone())
|
||||
@@ -670,6 +742,9 @@ impl BiliRecorder {
|
||||
}
|
||||
|
||||
async fn generate_archive_m3u8(&self, timestamp: u64) -> String {
|
||||
if self.m3u8_cache.contains_key(×tamp) {
|
||||
return self.m3u8_cache.get(×tamp).unwrap().clone();
|
||||
}
|
||||
let mut m3u8_content = "#EXTM3U\n".to_string();
|
||||
m3u8_content += "#EXT-X-VERSION:6\n";
|
||||
m3u8_content += "#EXT-X-TARGETDURATION:1\n";
|
||||
@@ -679,22 +754,35 @@ impl BiliRecorder {
|
||||
let header_url = format!("/{}/{}/h{}.m4s", self.room_id, timestamp, timestamp);
|
||||
m3u8_content += &format!("#EXT-X-MAP:URI=\"{}\"\n", header_url);
|
||||
// add entries from read_dir
|
||||
let work_dir = format!("{}/{}/{}", self.config.read().await.cache, self.room_id, timestamp);
|
||||
let work_dir = format!(
|
||||
"{}/{}/{}",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
timestamp
|
||||
);
|
||||
let entries = self.get_fs_entries(&work_dir).await;
|
||||
if entries.is_empty() {
|
||||
return m3u8_content;
|
||||
}
|
||||
let mut last_sequence = entries.first().unwrap().sequence;
|
||||
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
|
||||
for e in entries {
|
||||
let current_seq = e.sequence;
|
||||
if current_seq - last_sequence > 1 {
|
||||
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
||||
}
|
||||
last_sequence = current_seq;
|
||||
m3u8_content += "#EXTINF:1,\n";
|
||||
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
|
||||
let ts = timestamp + e.offset / 1000;
|
||||
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
|
||||
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
|
||||
m3u8_content += &format!("#EXTINF:{:.2},\n", e.length);
|
||||
m3u8_content += &format!("/{}/{}/{}\n", self.room_id, timestamp, e.url);
|
||||
|
||||
last_sequence = current_seq;
|
||||
}
|
||||
m3u8_content += "#EXT-X-ENDLIST";
|
||||
// cache this
|
||||
self.m3u8_cache.insert(timestamp, m3u8_content.clone());
|
||||
m3u8_content
|
||||
}
|
||||
|
||||
@@ -719,18 +807,54 @@ impl BiliRecorder {
|
||||
if !etype.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(file_ext) = e.path().extension() {
|
||||
let file_ext = file_ext.to_str().unwrap().to_string();
|
||||
// need to exclude other files, such as danmu file
|
||||
if file_ext != "m4s" {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
let file_name = e.file_name().to_str().unwrap().to_string();
|
||||
if file_name.starts_with("h") {
|
||||
continue;
|
||||
}
|
||||
let meta_info: &str = file_name.split('.').next().unwrap();
|
||||
let infos: Vec<&str> = meta_info.split('-').collect();
|
||||
let offset: u64;
|
||||
let sequence: u64;
|
||||
// BREAKCHANGE do not support legacy files that not named with offset
|
||||
if infos.len() == 1 {
|
||||
continue;
|
||||
} else {
|
||||
if let Ok(parsed_offset) = u64::from_str_radix(infos.get(0).unwrap(), 16) {
|
||||
offset = parsed_offset;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
sequence = infos.get(1).unwrap().parse().unwrap();
|
||||
}
|
||||
ret.push(TsEntry {
|
||||
url: file_name.clone(),
|
||||
sequence: file_name.split('.').next().unwrap().parse().unwrap(),
|
||||
_length: 1.0,
|
||||
offset,
|
||||
sequence,
|
||||
length: 1.0,
|
||||
size: e.metadata().await.unwrap().len(),
|
||||
});
|
||||
}
|
||||
ret.sort_by(|a, b| a.sequence.cmp(&b.sequence));
|
||||
if ret.is_empty() {
|
||||
return ret;
|
||||
}
|
||||
let mut last_offset = ret.first().unwrap().offset;
|
||||
for (i, entry) in ret.iter_mut().enumerate() {
|
||||
if i == 0 {
|
||||
continue;
|
||||
}
|
||||
entry.length = (entry.offset - last_offset) as f64 / 1000.0;
|
||||
last_offset = entry.offset;
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
@@ -755,16 +879,23 @@ impl BiliRecorder {
|
||||
}
|
||||
let entries = self.ts_entries.lock().await.clone();
|
||||
if entries.is_empty() {
|
||||
m3u8_content += "#EXT-X-OFFSET:0\n";
|
||||
return m3u8_content;
|
||||
}
|
||||
let timestamp = *self.timestamp.read().await;
|
||||
let mut last_sequence = entries.first().unwrap().sequence;
|
||||
m3u8_content += &format!("#EXT-X-OFFSET:{}\n", entries.first().unwrap().offset);
|
||||
for entry in entries.iter() {
|
||||
if entry.sequence - last_sequence > 1 {
|
||||
// discontinuity happens
|
||||
m3u8_content += "#EXT-X-DISCONTINUITY\n"
|
||||
}
|
||||
// add #EXT-X-PROGRAM-DATE-TIME with ISO 8601 date
|
||||
let ts = timestamp + entry.offset / 1000;
|
||||
let date_str = Utc.timestamp_opt(ts as i64, 0).unwrap().to_rfc3339();
|
||||
m3u8_content += &format!("#EXT-X-PROGRAM-DATE-TIME:{}\n", date_str);
|
||||
m3u8_content += &format!("#EXTINF:{:.2},\n", entry.length,);
|
||||
last_sequence = entry.sequence;
|
||||
m3u8_content += "#EXTINF:1,\n";
|
||||
let file_name = entry.url.split('/').last().unwrap();
|
||||
let local_url = format!("/{}/{}/{}", self.room_id, timestamp, file_name);
|
||||
m3u8_content += &format!("{}\n", local_url);
|
||||
@@ -775,4 +906,30 @@ impl BiliRecorder {
|
||||
}
|
||||
m3u8_content
|
||||
}
|
||||
|
||||
pub async fn get_danmu_record(&self, ts: u64) -> Vec<DanmuEntry> {
|
||||
if ts == *self.timestamp.read().await {
|
||||
// just return current cache content
|
||||
match self.danmu_storage.read().await.as_ref() {
|
||||
Some(storage) => {
|
||||
return storage.get_entries().await;
|
||||
}
|
||||
None => {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// load disk cache
|
||||
let cache_file_path = format!(
|
||||
"{}/{}/{}/{}",
|
||||
self.config.read().await.cache,
|
||||
self.room_id,
|
||||
ts,
|
||||
"danmu.txt"
|
||||
);
|
||||
log::info!("loading danmu cache from {}", cache_file_path);
|
||||
let storage = DanmuStorage::new(&cache_file_path).await;
|
||||
return storage.get_entries().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
src-tauri/src/recorder/danmu.rs
Normal file
66
src-tauri/src/recorder/danmu.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
sync::RwLock,
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct DanmuEntry {
|
||||
pub ts: u64,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub struct DanmuStorage {
|
||||
cache: RwLock<Vec<DanmuEntry>>,
|
||||
file: RwLock<File>,
|
||||
}
|
||||
|
||||
impl DanmuStorage {
|
||||
pub async fn new(file_path: &str) -> DanmuStorage {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(file_path)
|
||||
.await
|
||||
.expect("create danmu.txt failed");
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut preload_cache: Vec<DanmuEntry> = Vec::new();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
let ts: u64 = parts[0].parse().unwrap();
|
||||
let content = parts[1].to_string();
|
||||
preload_cache.push(DanmuEntry { ts, content })
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(file_path)
|
||||
.await
|
||||
.expect("create danmu.txt failed");
|
||||
return DanmuStorage {
|
||||
cache: RwLock::new(preload_cache),
|
||||
file: RwLock::new(file),
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn add_line(&self, ts: u64, content: &str) {
|
||||
self.cache.write().await.push(DanmuEntry {
|
||||
ts,
|
||||
content: content.to_string(),
|
||||
});
|
||||
let _ = self
|
||||
.file
|
||||
.write()
|
||||
.await
|
||||
.write(format!("{}:{}\n", ts, content).as_bytes())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn get_entries(&self) -> Vec<DanmuEntry> {
|
||||
self.cache.read().await.clone()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::db::{AccountRow, Database, RecordRow};
|
||||
use crate::recorder::bilibili::UserInfo;
|
||||
use crate::recorder::danmu::DanmuEntry;
|
||||
use crate::recorder::RecorderError;
|
||||
use crate::recorder::{bilibili::RoomInfo, BiliRecorder};
|
||||
use crate::Config;
|
||||
use custom_error::custom_error;
|
||||
use dashmap::DashMap;
|
||||
use hyper::Method;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
@@ -70,7 +72,6 @@ impl From<RecorderManagerError> for String {
|
||||
}
|
||||
|
||||
impl RecorderManager {
|
||||
|
||||
pub fn new(app_handle: AppHandle, config: Arc<RwLock<Config>>) -> RecorderManager {
|
||||
RecorderManager {
|
||||
app_handle,
|
||||
@@ -123,11 +124,7 @@ impl RecorderManager {
|
||||
return Err(RecorderManagerError::NotFound { room_id });
|
||||
}
|
||||
// remove related cache folder
|
||||
let cache_folder = format!(
|
||||
"{}/{}",
|
||||
self.config.read().await.cache,
|
||||
room_id
|
||||
);
|
||||
let cache_folder = format!("{}/{}", self.config.read().await.cache, room_id);
|
||||
tokio::fs::remove_dir_all(cache_folder).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -231,6 +228,18 @@ impl RecorderManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_danmu(
|
||||
&self,
|
||||
room_id: u64,
|
||||
live_id: u64,
|
||||
) -> Result<Vec<DanmuEntry>, RecorderManagerError> {
|
||||
if let Some(recorder) = self.recorders.get(&room_id) {
|
||||
Ok(recorder.get_danmu_record(live_id).await)
|
||||
} else {
|
||||
Err(RecorderManagerError::NotFound { room_id })
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_hls_server(
|
||||
&self,
|
||||
listener: TcpListener,
|
||||
@@ -245,6 +254,18 @@ impl RecorderManager {
|
||||
let recorders = recorders.clone();
|
||||
let config = config.clone();
|
||||
async move {
|
||||
// handle cors preflight request
|
||||
if req.method() == Method::OPTIONS {
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "Content-Type")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let cache_path = config.read().await.cache.clone();
|
||||
let path = req.uri().path();
|
||||
let path_segs: Vec<&str> = path.split('/').collect();
|
||||
@@ -287,7 +308,7 @@ impl RecorderManager {
|
||||
} else {
|
||||
// try to find requested ts file in recorder's cache
|
||||
// cache files are stored in {cache_dir}/{room_id}/{timestamp}/{ts_file}
|
||||
let ts_file = format!("{}/{}", cache_path, path);
|
||||
let ts_file = format!("{}/{}", cache_path, path.replace("%7C", "|"));
|
||||
let recorder = recorders.get(&room_id);
|
||||
if recorder.is_none() {
|
||||
return Ok::<_, Infallible>(
|
||||
|
||||
@@ -171,22 +171,26 @@
|
||||
loading = true;
|
||||
let new_cover = generateCover();
|
||||
update_title(`切片生成中`);
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
update_title(`切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
loading = false;
|
||||
try {
|
||||
let new_video = (await invoke("clip_range", {
|
||||
roomId: room_id,
|
||||
cover: new_cover,
|
||||
ts: ts,
|
||||
x: start,
|
||||
y: end,
|
||||
})) as VideoItem;
|
||||
update_title(`切片生成成功`);
|
||||
console.log("video file generatd:", video);
|
||||
await get_video_list();
|
||||
video_selected = new_video.id;
|
||||
video = videos.find((v) => {
|
||||
return v.value == new_video.id;
|
||||
});
|
||||
cover = new_video.cover;
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
alert("Err generating clip: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function do_post() {
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
站直播流,并生成视频投稿的工具。
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
项目地址: <a href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
项目地址: <a
|
||||
target="_blank"
|
||||
href="https://github.com/Xinrea/bili-shadowreplay"
|
||||
>https://github.com/Xinrea/bili-shadowreplay</a
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
TableBodyCell,
|
||||
Modal,
|
||||
ButtonGroup,
|
||||
SpeedDial,
|
||||
Listgroup,
|
||||
ListgroupItem,
|
||||
Textarea,
|
||||
Hr,
|
||||
} from "flowbite-svelte";
|
||||
import Image from "./Image.svelte";
|
||||
import QRCode from "qrcode";
|
||||
@@ -32,6 +37,9 @@
|
||||
let oauth_key = "";
|
||||
let check_interval = null;
|
||||
|
||||
let manualModal = false;
|
||||
let cookie_str = "";
|
||||
|
||||
async function handle_qr() {
|
||||
if (check_interval) {
|
||||
clearInterval(check_interval);
|
||||
@@ -52,7 +60,7 @@
|
||||
async function check_qr() {
|
||||
let qr_status: { code: number; cookies: string } = await invoke(
|
||||
"get_qr_status",
|
||||
{ qrcodeKey: oauth_key }
|
||||
{ qrcodeKey: oauth_key },
|
||||
);
|
||||
if (qr_status.code == 0) {
|
||||
clearInterval(check_interval);
|
||||
@@ -61,6 +69,20 @@
|
||||
addModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add_cookie() {
|
||||
if (cookie_str == "") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("add_account", { cookies: cookie_str });
|
||||
await update_accounts();
|
||||
cookie_str = "";
|
||||
manualModal = false;
|
||||
} catch (e) {
|
||||
alert("Err adding cookie:" + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 pt-12 h-full overflow-auto">
|
||||
@@ -116,16 +138,23 @@
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<Button
|
||||
pill={true}
|
||||
class="!p-2"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}><UserAddSolid class="w-8 h-8" /></Button
|
||||
>
|
||||
</div>
|
||||
<SpeedDial defaultClass="absolute end-6 bottom-6" placement="top-end">
|
||||
<Listgroup active>
|
||||
<ListgroupItem
|
||||
class="flex gap-2 md:px-5"
|
||||
on:click={() => {
|
||||
addModal = true;
|
||||
requestAnimationFrame(handle_qr);
|
||||
}}>扫码添加</ListgroupItem
|
||||
>
|
||||
<ListgroupItem
|
||||
class="flex gap-2 md:px-5"
|
||||
on:click={() => {
|
||||
manualModal = true;
|
||||
}}>手动添加</ListgroupItem
|
||||
>
|
||||
</Listgroup>
|
||||
</SpeedDial>
|
||||
|
||||
<Modal
|
||||
title="请使用 BiliBili App 扫码登录"
|
||||
@@ -137,3 +166,20 @@
|
||||
<canvas id="qr" />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="请粘贴 BiliBili 账号 Cookie"
|
||||
bind:open={manualModal}
|
||||
size="sm"
|
||||
autoclose
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center h-full">
|
||||
<Textarea bind:value={cookie_str} />
|
||||
<Button
|
||||
class="mt-4"
|
||||
on:click={() => {
|
||||
add_cookie();
|
||||
}}>添加</Button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -3,12 +3,38 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AccountInfo, AccountItem } from "./db";
|
||||
|
||||
interface DanmuEntry {
|
||||
ts: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export let port;
|
||||
export let room_id;
|
||||
export let ts;
|
||||
export let start = 0;
|
||||
export let end = 0;
|
||||
let show_detail = false;
|
||||
let global_offset = 0;
|
||||
|
||||
// TODO get custom tag from shaka player instead of manual parsing
|
||||
async function meta_parse() {
|
||||
fetch(`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`)
|
||||
.then((response) => response.text())
|
||||
.then((m3u8Content) => {
|
||||
const offsetRegex = /#EXT-X-OFFSET:(\d+)/;
|
||||
const match = m3u8Content.match(offsetRegex);
|
||||
|
||||
if (match && match[1]) {
|
||||
global_offset = parseInt(match[1], 10);
|
||||
} else {
|
||||
console.warn("No #EXT-X-OFFSET found");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching M3U8 file:", error);
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const video = document.getElementById("video") as HTMLVideoElement;
|
||||
const ui = video["ui"];
|
||||
@@ -26,9 +52,17 @@
|
||||
// Attach player and UI to the window to make it easy to access in the JS console.
|
||||
(window as any).player = player;
|
||||
(window as any).ui = ui;
|
||||
|
||||
player.addEventListener("ended", async () => {
|
||||
location.reload();
|
||||
});
|
||||
player.addEventListener("manifestloaded", (event) => {
|
||||
console.log("Manifest loaded:", event);
|
||||
});
|
||||
|
||||
try {
|
||||
await player.load(
|
||||
`http://127.0.0.1:${port}/${room_id}/${ts}/playlist.m3u8`
|
||||
`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!");
|
||||
@@ -39,15 +73,12 @@
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
player.addEventListener("ended", async () => {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
document.getElementsByClassName("shaka-overflow-menu-button")[0].remove();
|
||||
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 +97,37 @@
|
||||
// add to shaka-spacer
|
||||
const shakaSpacer = document.querySelector(".shaka-spacer") as HTMLElement;
|
||||
|
||||
let danmu_enabled = true;
|
||||
// get danmaku record
|
||||
let danmu_records: DanmuEntry[] = (await invoke("get_danmu_record", {
|
||||
roomId: room_id,
|
||||
ts: ts,
|
||||
})) as DanmuEntry[];
|
||||
|
||||
console.log("danmu loaded:", danmu_records.length);
|
||||
|
||||
// history danmaku sender
|
||||
setInterval(() => {
|
||||
if (video.paused) {
|
||||
return;
|
||||
}
|
||||
if (danmu_records.length == 0) {
|
||||
return;
|
||||
}
|
||||
// using live source
|
||||
if (isLive() && get_total() - video.currentTime <= 5) {
|
||||
return;
|
||||
}
|
||||
const cur = Math.floor(
|
||||
(video.currentTime + global_offset / 1000 + ts) * 1000,
|
||||
);
|
||||
console.log(new Date(cur).toString());
|
||||
let danmus = danmu_records.filter((v) => {
|
||||
return v.ts >= cur - 1000 && v.ts < cur;
|
||||
});
|
||||
danmus.forEach((v) => danmu_handler(v.content));
|
||||
}, 1000);
|
||||
|
||||
if (isLive()) {
|
||||
// add a account select
|
||||
const accountSelect = document.createElement("select");
|
||||
@@ -115,98 +177,104 @@
|
||||
}
|
||||
});
|
||||
|
||||
let danmu_enabled = true;
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
danmakuToggle.style.height = "30px";
|
||||
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
danmakuToggle.style.color = "white";
|
||||
danmakuToggle.style.border = "1px solid gray";
|
||||
danmakuToggle.style.padding = "0 10px";
|
||||
danmakuToggle.style.boxSizing = "border-box";
|
||||
danmakuToggle.style.fontSize = "1em";
|
||||
danmakuToggle.addEventListener("click", async () => {
|
||||
danmu_enabled = !danmu_enabled;
|
||||
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
|
||||
// clear background color
|
||||
danmakuToggle.style.backgroundColor = danmu_enabled
|
||||
? "rgba(0, 128, 255, 0.5)"
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "30";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
overlay.style.paddingTop = "10%";
|
||||
// place overlay to the top of the video
|
||||
video.parentElement.appendChild(overlay);
|
||||
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
|
||||
// listen to danmaku event
|
||||
listen("danmu:" + room_id, (event: { payload: string }) => {
|
||||
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;
|
||||
}
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
|
||||
// Calculate a random position for the danmaku
|
||||
let topPosition;
|
||||
let attempts = 0;
|
||||
do {
|
||||
topPosition = Math.random() * 30;
|
||||
attempts++;
|
||||
} while (
|
||||
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
|
||||
attempts < 10
|
||||
);
|
||||
|
||||
// Record the position
|
||||
danmakuPositions.push(topPosition);
|
||||
if (danmakuPositions.length > 10) {
|
||||
danmakuPositions.shift(); // Keep the last 10 positions
|
||||
}
|
||||
|
||||
danmaku.style.top = `${topPosition}%`;
|
||||
danmaku.style.right = "0";
|
||||
danmaku.style.color = "white";
|
||||
danmaku.style.fontSize = "1.2em";
|
||||
danmaku.style.whiteSpace = "nowrap";
|
||||
danmaku.style.transform = "translateX(100%)";
|
||||
danmaku.style.transition = "transform 10s linear";
|
||||
danmaku.style.pointerEvents = "none";
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
danmaku.style.zIndex = "500";
|
||||
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
|
||||
danmaku.innerText = event.payload;
|
||||
overlay.appendChild(danmaku);
|
||||
requestAnimationFrame(() => {
|
||||
danmaku.style.transform = `translateX(-${overlay.clientWidth + danmaku.clientWidth}px)`;
|
||||
});
|
||||
danmaku.addEventListener("transitionend", () => {
|
||||
overlay.removeChild(danmaku);
|
||||
});
|
||||
danmu_handler(event.payload.content);
|
||||
});
|
||||
|
||||
shakaSpacer.appendChild(accountSelect);
|
||||
shakaSpacer.appendChild(danmakuInput);
|
||||
shakaSpacer.appendChild(danmakuToggle);
|
||||
}
|
||||
|
||||
// create a danmaku toggle button
|
||||
const danmakuToggle = document.createElement("button");
|
||||
danmakuToggle.innerText = "弹幕已开启";
|
||||
danmakuToggle.style.height = "30px";
|
||||
danmakuToggle.style.backgroundColor = "rgba(0, 128, 255, 0.5)";
|
||||
danmakuToggle.style.color = "white";
|
||||
danmakuToggle.style.border = "1px solid gray";
|
||||
danmakuToggle.style.padding = "0 10px";
|
||||
danmakuToggle.style.boxSizing = "border-box";
|
||||
danmakuToggle.style.fontSize = "1em";
|
||||
danmakuToggle.addEventListener("click", async () => {
|
||||
danmu_enabled = !danmu_enabled;
|
||||
danmakuToggle.innerText = danmu_enabled ? "弹幕已开启" : "弹幕已关闭";
|
||||
// clear background color
|
||||
danmakuToggle.style.backgroundColor = danmu_enabled
|
||||
? "rgba(0, 128, 255, 0.5)"
|
||||
: "rgba(255, 0, 0, 0.5)";
|
||||
});
|
||||
|
||||
// create a area that overlay half top of the video, which shows danmakus floating from right to left
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.pointerEvents = "none";
|
||||
overlay.style.zIndex = "30";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.flexDirection = "column";
|
||||
overlay.style.paddingTop = "10%";
|
||||
// place overlay to the top of the video
|
||||
video.parentElement.appendChild(overlay);
|
||||
|
||||
// Store the positions of the last few danmakus to avoid overlap
|
||||
const danmakuPositions = [];
|
||||
|
||||
function danmu_handler(content: string) {
|
||||
const danmaku = document.createElement("p");
|
||||
danmaku.style.position = "absolute";
|
||||
|
||||
// Calculate a random position for the danmaku
|
||||
let topPosition;
|
||||
let attempts = 0;
|
||||
do {
|
||||
topPosition = Math.random() * 30;
|
||||
attempts++;
|
||||
} while (
|
||||
danmakuPositions.some((pos) => Math.abs(pos - topPosition) < 5) &&
|
||||
attempts < 10
|
||||
);
|
||||
|
||||
// Record the position
|
||||
danmakuPositions.push(topPosition);
|
||||
if (danmakuPositions.length > 10) {
|
||||
danmakuPositions.shift(); // Keep the last 10 positions
|
||||
}
|
||||
|
||||
danmaku.style.top = `${topPosition}%`;
|
||||
danmaku.style.right = "0";
|
||||
danmaku.style.color = "white";
|
||||
danmaku.style.fontSize = "1.2em";
|
||||
danmaku.style.whiteSpace = "nowrap";
|
||||
danmaku.style.transform = "translateX(100%)";
|
||||
danmaku.style.transition = "transform 10s linear";
|
||||
danmaku.style.pointerEvents = "none";
|
||||
danmaku.style.margin = "0";
|
||||
danmaku.style.padding = "0";
|
||||
danmaku.style.zIndex = "500";
|
||||
danmaku.style.textShadow = "1px 1px 2px rgba(0, 0, 0, 0.6)";
|
||||
danmaku.innerText = 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");
|
||||
playbackRateSelect.style.height = "30px";
|
||||
@@ -317,7 +385,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
|
||||
@@ -332,6 +400,9 @@
|
||||
}
|
||||
requestAnimationFrame(updateSeekbar);
|
||||
}
|
||||
|
||||
meta_parse();
|
||||
|
||||
// receive tauri emit
|
||||
document.addEventListener("shaka-ui-loaded", init);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user