初步完成框架

This commit is contained in:
2025-08-16 22:41:14 +08:00
parent b0eed27371
commit ec0010aa93
36 changed files with 2996 additions and 743 deletions

220
src-tauri/Cargo.lock generated
View File

@@ -56,6 +56,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
]
[[package]]
name = "anyhow"
version = "1.0.98"
@@ -488,6 +538,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -678,6 +734,12 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "deranged"
version = "0.4.0"
@@ -874,6 +936,29 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1764,6 +1849,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.15"
@@ -1793,6 +1884,30 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
]
[[package]]
name = "jiff-static"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2172,21 +2287,6 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "nuxtor"
version = "1.4.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-fs",
"tauri-plugin-notification",
"tauri-plugin-os",
"tauri-plugin-shell",
"tauri-plugin-store",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2416,6 +2516,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "open"
version = "5.3.2"
@@ -2736,6 +2842,21 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -3349,6 +3470,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4121,7 +4253,9 @@ dependencies = [
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"tokio-macros",
@@ -4139,6 +4273,18 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "tokio-tungstenite"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.15"
@@ -4358,6 +4504,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4470,6 +4634,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.17.0"
@@ -4494,6 +4664,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "video-controller"
version = "1.0.0"
dependencies = [
"env_logger",
"futures-util",
"log",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-fs",
"tauri-plugin-notification",
"tauri-plugin-os",
"tauri-plugin-shell",
"tauri-plugin-store",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "vswhom"
version = "0.1.0"

View File

@@ -1,14 +1,14 @@
[package]
name = "nuxtor"
version = "1.4.0"
description = "Starter template for Nuxt 3 and Tauri 2"
authors = [ "NicolaSpadari" ]
name = "video-controller"
version = "1.0.0"
description = "Video Player Controller Application"
authors = [ "estel" ]
license = "MIT"
repository = "https://github.com/NicolaSpadari/nuxtor"
repository = ""
edition = "2021"
[lib]
name = "nuxtor_lib"
name = "video_controller_lib"
crate-type = [
"staticlib",
"cdylib",
@@ -26,6 +26,11 @@ tauri-plugin-os = "2.3.0"
tauri-plugin-fs = "2.4.0"
tauri-plugin-store = "2.3.0"
serde_json = "1"
tokio = { version = "1.0", features = ["full"] }
tokio-tungstenite = "0.23"
futures-util = "0.3"
log = "0.4"
env_logger = "0.11"
[dependencies.tauri]
version = "2.6.2"

View File

@@ -0,0 +1,103 @@
use crate::models::{ApiResponse, ConnectionConfig, PlaybackCommand, PlayerState};
use crate::services::ConnectionService;
use std::sync::Arc;
use tauri::{Emitter, State, Window};
use tokio::sync::Mutex;
type ConnectionState = Arc<Mutex<Option<ConnectionService>>>;
#[tauri::command]
pub async fn connect_to_player(
connection_state: State<'_, ConnectionState>,
config: ConnectionConfig,
) -> Result<ApiResponse<PlayerState>, String> {
let mut service = ConnectionService::new(config.clone());
match service.connect().await {
Ok(_) => {
let state = service.get_state().await;
*connection_state.lock().await = Some(service);
Ok(ApiResponse::success_with_message(
state,
"成功连接到视频播放器".to_string(),
))
}
Err(e) => Ok(ApiResponse::error(e)),
}
}
#[tauri::command]
pub async fn disconnect_from_player(
connection_state: State<'_, ConnectionState>,
) -> Result<ApiResponse<()>, String> {
let mut guard = connection_state.lock().await;
if let Some(service) = guard.as_mut() {
service.disconnect().await;
*guard = None;
Ok(ApiResponse::success_with_message(
(),
"已断开连接".to_string(),
))
} else {
Ok(ApiResponse::error("未建立连接".to_string()))
}
}
#[tauri::command]
pub async fn get_connection_status(
connection_state: State<'_, ConnectionState>,
) -> Result<ApiResponse<PlayerState>, String> {
let guard = connection_state.lock().await;
if let Some(service) = guard.as_ref() {
let state = service.get_state().await;
Ok(ApiResponse::success(state))
} else {
Ok(ApiResponse::error("未建立连接".to_string()))
}
}
#[tauri::command]
pub async fn update_connection_config(
connection_state: State<'_, ConnectionState>,
config: ConnectionConfig,
) -> Result<ApiResponse<()>, String> {
let guard = connection_state.lock().await;
if let Some(service) = guard.as_ref() {
service.update_config(config).await;
Ok(ApiResponse::success_with_message(
(),
"连接配置已更新".to_string(),
))
} else {
Ok(ApiResponse::error("未建立连接".to_string()))
}
}
#[tauri::command]
pub async fn send_playback_command(
connection_state: State<'_, ConnectionState>,
command: PlaybackCommand,
window: Window,
) -> Result<ApiResponse<()>, String> {
let guard = connection_state.lock().await;
if let Some(service) = guard.as_ref() {
match service.send_playback_command(command.clone()).await {
Ok(_) => {
// 发送事件到前端,通知命令已发送
let _ = window.emit("playback-command-sent", &command);
Ok(ApiResponse::success_with_message(
(),
"命令发送成功".to_string(),
))
}
Err(e) => Ok(ApiResponse::error(e)),
}
} else {
Ok(ApiResponse::error("未建立连接".to_string()))
}
}

160
src-tauri/src/api/files.rs Normal file
View File

@@ -0,0 +1,160 @@
use crate::models::{ApiResponse, VideoInfo};
use std::path::PathBuf;
use tauri::AppHandle;
#[tauri::command]
pub async fn select_video_files(
_app_handle: AppHandle,
) -> Result<ApiResponse<Vec<String>>, String> {
// TODO: 在 Tauri 2 中实现文件对话框
// 暂时返回示例数据
let example_files = vec![
"/path/to/example1.mp4".to_string(),
"/path/to/example2.mp4".to_string(),
];
Ok(ApiResponse::success_with_message(
example_files,
"请使用前端界面选择文件".to_string(),
))
}
#[tauri::command]
pub async fn select_single_video_file(
_app_handle: AppHandle,
) -> Result<ApiResponse<String>, String> {
// TODO: 在 Tauri 2 中实现文件对话框
// 暂时返回示例数据
Ok(ApiResponse::success_with_message(
"/path/to/example.mp4".to_string(),
"请使用前端界面选择文件".to_string(),
))
}
#[tauri::command]
pub async fn get_video_info(file_path: String) -> Result<ApiResponse<VideoInfo>, String> {
let path = PathBuf::from(&file_path);
// 检查文件是否存在
if !path.exists() {
return Ok(ApiResponse::error("文件不存在".to_string()));
}
// 获取文件基本信息
let metadata = std::fs::metadata(&path)
.map_err(|e| format!("无法读取文件信息: {}", e))?;
let file_name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let file_extension = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let video_info = VideoInfo {
path: file_path,
title: file_name,
duration: None, // TODO: 使用 ffprobe 或类似工具获取视频时长
size: Some(metadata.len()),
format: Some(file_extension),
};
Ok(ApiResponse::success(video_info))
}
#[tauri::command]
pub async fn validate_video_files(
file_paths: Vec<String>,
) -> Result<ApiResponse<Vec<VideoInfo>>, String> {
let mut valid_videos = Vec::new();
let mut invalid_count = 0;
for file_path in file_paths {
match get_video_info(file_path.clone()).await {
Ok(response) => {
if response.success {
if let Some(video_info) = response.data {
valid_videos.push(video_info);
}
} else {
invalid_count += 1;
}
}
Err(_) => {
invalid_count += 1;
}
}
}
let message = if invalid_count > 0 {
format!("验证完成,{} 个有效文件,{} 个无效文件", valid_videos.len(), invalid_count)
} else {
format!("所有 {} 个文件均有效", valid_videos.len())
};
Ok(ApiResponse::success_with_message(valid_videos, message))
}
#[tauri::command]
pub async fn get_supported_video_formats() -> Result<ApiResponse<Vec<String>>, String> {
let formats = vec![
"mp4".to_string(),
"avi".to_string(),
"mkv".to_string(),
"mov".to_string(),
"wmv".to_string(),
"flv".to_string(),
"webm".to_string(),
"m4v".to_string(),
"3gp".to_string(),
"ogv".to_string(),
];
Ok(ApiResponse::success(formats))
}
#[tauri::command]
pub async fn open_file_location(file_path: String) -> Result<ApiResponse<()>, String> {
let path = PathBuf::from(&file_path);
if !path.exists() {
return Ok(ApiResponse::error("文件不存在".to_string()));
}
// 在文件资源管理器中显示文件
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.args(["/select,", &file_path])
.spawn()
.map_err(|e| format!("无法打开文件位置: {}", e))?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.args(["-R", &file_path])
.spawn()
.map_err(|e| format!("无法打开文件位置: {}", e))?;
}
#[cfg(target_os = "linux")]
{
if let Some(parent) = path.parent() {
std::process::Command::new("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("无法打开文件位置: {}", e))?;
}
}
Ok(ApiResponse::success_with_message(
(),
"文件位置已打开".to_string(),
))
}

7
src-tauri/src/api/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod connection;
pub mod settings;
pub mod files;
pub use connection::*;
pub use settings::*;
pub use files::*;

View File

@@ -0,0 +1,88 @@
use crate::models::{ApiResponse, AppSettings};
use crate::services::SettingsService;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn load_app_settings(
settings_service: State<'_, SettingsService>,
) -> Result<ApiResponse<AppSettings>, String> {
match settings_service.load_settings().await {
Ok(settings) => Ok(ApiResponse::success(settings)),
Err(e) => Ok(ApiResponse::error(e)),
}
}
#[tauri::command]
pub async fn save_app_settings(
settings_service: State<'_, SettingsService>,
settings: AppSettings,
) -> Result<ApiResponse<()>, String> {
match settings_service.save_settings(&settings).await {
Ok(_) => Ok(ApiResponse::success_with_message(
(),
"设置保存成功".to_string(),
)),
Err(e) => Ok(ApiResponse::error(e)),
}
}
#[tauri::command]
pub async fn reset_app_settings(
settings_service: State<'_, SettingsService>,
) -> Result<ApiResponse<AppSettings>, String> {
match settings_service.reset_settings().await {
Ok(settings) => Ok(ApiResponse::success_with_message(
settings,
"设置已重置为默认值".to_string(),
)),
Err(e) => Ok(ApiResponse::error(e)),
}
}
#[tauri::command]
pub async fn export_settings_to_file(
settings_service: State<'_, SettingsService>,
_app_handle: AppHandle,
) -> Result<ApiResponse<String>, String> {
// TODO: 在 Tauri 2 中实现文件对话框
// 暂时使用固定路径导出
let export_path = std::env::temp_dir().join("video_controller_settings.json");
match settings_service.export_settings(export_path.clone()).await {
Ok(_) => Ok(ApiResponse::success_with_message(
export_path.to_string_lossy().to_string(),
"设置导出成功".to_string(),
)),
Err(e) => Ok(ApiResponse::error(e)),
}
}
#[tauri::command]
pub async fn import_settings_from_file(
settings_service: State<'_, SettingsService>,
_app_handle: AppHandle,
) -> Result<ApiResponse<AppSettings>, String> {
// TODO: 在 Tauri 2 中实现文件对话框
// 暂时从固定路径导入
let import_path = std::env::temp_dir().join("video_controller_settings.json");
if !import_path.exists() {
return Ok(ApiResponse::error("没有找到设置文件".to_string()));
}
match settings_service.import_settings(import_path.clone()).await {
Ok(settings) => Ok(ApiResponse::success_with_message(
settings,
format!("{} 导入设置成功", import_path.to_string_lossy()),
)),
Err(e) => Ok(ApiResponse::error(e)),
}
}
#[tauri::command]
pub async fn get_settings_file_path(
settings_service: State<'_, SettingsService>,
) -> Result<ApiResponse<String>, String> {
let path = settings_service.get_settings_path().await;
Ok(ApiResponse::success(path.to_string_lossy().to_string()))
}

View File

@@ -1,15 +1,29 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
mod api;
mod models;
mod services;
use api::*;
use services::{ConnectionService, SettingsService};
use std::sync::Arc;
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder
tray::TrayIconBuilder,
Manager,
};
use tokio::sync::Mutex;
pub fn run() {
// 初始化日志
env_logger::init();
tauri::Builder::default()
.setup(|app| {
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&quit_i])?;
// 创建系统托盘
let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let show_i = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
let _tray = TrayIconBuilder::new()
.menu(&menu)
@@ -19,19 +33,57 @@ pub fn run() {
"quit" => {
app.exit(0);
}
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
other => {
println!("menu item {} not handled", other);
log::warn!("未处理的菜单事件: {}", other);
}
})
.build(app)?;
// 初始化服务
let settings_service = SettingsService::new(app.handle().clone());
app.manage(settings_service);
// 初始化连接状态
let connection_state: Arc<Mutex<Option<ConnectionService>>> = Arc::new(Mutex::new(None));
app.manage(connection_state);
log::info!("应用初始化完成");
Ok(())
})
// 注册 API 命令
.invoke_handler(tauri::generate_handler![
// 连接相关
connect_to_player,
disconnect_from_player,
get_connection_status,
update_connection_config,
send_playback_command,
// 设置相关
load_app_settings,
save_app_settings,
reset_app_settings,
export_settings_to_file,
import_settings_from_file,
get_settings_file_path,
// 文件操作相关
select_video_files,
select_single_video_file,
get_video_info,
validate_video_files,
get_supported_video_formats,
open_file_location,
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_store::Builder::new().build())
.run(tauri::generate_context!())
.expect("error while running tauri application");
.expect("启动 Tauri 应用程序时出错");
}

View File

@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
nuxtor_lib::run();
}
video_controller_lib::run();
}

186
src-tauri/src/models/mod.rs Normal file
View File

@@ -0,0 +1,186 @@
use serde::{Deserialize, Serialize};
// 视频播放器连接状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ConnectionStatus {
Connected,
Connecting,
Disconnected,
Error(String),
}
// 播放状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaybackStatus {
Playing,
Paused,
Stopped,
Loading,
}
// 视频信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoInfo {
pub path: String,
pub title: String,
pub duration: Option<f64>,
pub size: Option<u64>,
pub format: Option<String>,
}
// 播放器状态
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerState {
pub connection_status: ConnectionStatus,
pub playback_status: PlaybackStatus,
pub current_video: Option<VideoInfo>,
pub position: f64,
pub duration: f64,
pub volume: f64,
pub is_looping: bool,
pub is_fullscreen: bool,
pub playlist: Vec<VideoInfo>,
pub current_playlist_index: Option<usize>,
}
impl Default for PlayerState {
fn default() -> Self {
Self {
connection_status: ConnectionStatus::Disconnected,
playback_status: PlaybackStatus::Stopped,
current_video: None,
position: 0.0,
duration: 0.0,
volume: 50.0,
is_looping: false,
is_fullscreen: false,
playlist: Vec::new(),
current_playlist_index: None,
}
}
}
// 连接配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionConfig {
pub host: String,
pub port: u16,
pub timeout: u64,
pub auto_reconnect: bool,
pub reconnect_interval: u64,
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self {
host: "192.168.1.100".to_string(),
port: 8080,
timeout: 10,
auto_reconnect: true,
reconnect_interval: 5,
}
}
}
// 应用设置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub connection: ConnectionConfig,
pub default_volume: f64,
pub default_loop: bool,
pub auto_fullscreen: bool,
pub playback_end_behavior: PlaybackEndBehavior,
pub theme: String,
pub language: String,
pub show_notifications: bool,
pub debug_mode: bool,
pub cache_size: u64,
pub proxy: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaybackEndBehavior {
Stop,
Next,
Repeat,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
connection: ConnectionConfig::default(),
default_volume: 50.0,
default_loop: false,
auto_fullscreen: false,
playback_end_behavior: PlaybackEndBehavior::Stop,
theme: "system".to_string(),
language: "zh-CN".to_string(),
show_notifications: true,
debug_mode: false,
cache_size: 100,
proxy: None,
}
}
}
// 播放控制命令
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaybackCommand {
Play,
Pause,
Stop,
Seek { position: f64 },
SetVolume { volume: f64 },
SetLoop { enabled: bool },
ToggleFullscreen,
LoadVideo { path: String },
SetPlaylist { videos: Vec<String> },
PlayFromPlaylist { index: usize },
}
// API 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
pub error: Option<String>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
message: None,
error: None,
}
}
pub fn success_with_message(data: T, message: String) -> Self {
Self {
success: true,
data: Some(data),
message: Some(message),
error: None,
}
}
pub fn error(error: String) -> Self {
Self {
success: false,
data: None,
message: None,
error: Some(error),
}
}
}

View File

@@ -0,0 +1,310 @@
use crate::models::{ConnectionConfig, ConnectionStatus, PlaybackCommand, PlayerState};
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex, RwLock};
use tokio::time::{interval, sleep, Duration};
use tokio_tungstenite::{connect_async, tungstenite::Message, WebSocketStream, MaybeTlsStream};
pub type WebSocket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
#[derive(Clone)]
pub struct ConnectionService {
config: Arc<RwLock<ConnectionConfig>>,
state: Arc<RwLock<PlayerState>>,
tx: Option<mpsc::UnboundedSender<PlaybackCommand>>,
websocket: Arc<Mutex<Option<WebSocket>>>,
}
impl ConnectionService {
pub fn new(config: ConnectionConfig) -> Self {
Self {
config: Arc::new(RwLock::new(config)),
state: Arc::new(RwLock::new(PlayerState::default())),
tx: None,
websocket: Arc::new(Mutex::new(None)),
}
}
pub async fn connect(&mut self) -> Result<(), String> {
let config = self.config.read().await.clone();
let url = format!("ws://{}:{}/ws", config.host, config.port);
info!("尝试连接到视频播放器: {}", url);
// 更新连接状态为连接中
{
let mut state = self.state.write().await;
state.connection_status = ConnectionStatus::Connecting;
}
match self.establish_connection(&url, config.timeout).await {
Ok(ws_stream) => {
info!("成功连接到视频播放器");
// 存储 WebSocket 连接
*self.websocket.lock().await = Some(ws_stream);
// 更新连接状态
{
let mut state = self.state.write().await;
state.connection_status = ConnectionStatus::Connected;
}
// 启动消息处理
self.start_message_handling().await;
Ok(())
}
Err(e) => {
error!("连接失败: {}", e);
{
let mut state = self.state.write().await;
state.connection_status = ConnectionStatus::Error(e.clone());
}
Err(e)
}
}
}
async fn establish_connection(&self, url: &str, timeout_secs: u64) -> Result<WebSocket, String> {
// 直接使用字符串 URL不转换为 Url 类型
let connection_future = connect_async(url);
let timeout_future = sleep(Duration::from_secs(timeout_secs));
tokio::select! {
result = connection_future => {
match result {
Ok((ws_stream, _)) => Ok(ws_stream),
Err(e) => Err(format!("WebSocket连接错误: {}", e)),
}
}
_ = timeout_future => {
Err(format!("连接超时 ({} 秒)", timeout_secs))
}
}
}
async fn start_message_handling(&mut self) {
let (tx, mut rx) = mpsc::unbounded_channel::<PlaybackCommand>();
self.tx = Some(tx);
let websocket = self.websocket.clone();
let state = self.state.clone();
let config = self.config.clone();
tokio::spawn(async move {
// 心跳检测
let mut heartbeat_interval = interval(Duration::from_secs(30));
loop {
tokio::select! {
// 处理发送的命令
command = rx.recv() => {
if let Some(cmd) = command {
if let Err(e) = Self::send_command(&websocket, cmd).await {
error!("发送命令失败: {}", e);
}
}
}
// 心跳检测
_ = heartbeat_interval.tick() => {
if let Err(e) = Self::send_heartbeat(&websocket).await {
warn!("心跳失败: {}", e);
// 可能需要重连
if config.read().await.auto_reconnect {
// 触发重连逻辑
Self::handle_reconnection(&websocket, &state, &config).await;
}
}
}
// 接收播放器状态更新
message = Self::receive_message(&websocket) => {
match message {
Ok(msg) => {
if let Err(e) = Self::handle_player_message(&state, msg).await {
error!("处理播放器消息失败: {}", e);
}
}
Err(e) => {
error!("接收消息失败: {}", e);
if config.read().await.auto_reconnect {
Self::handle_reconnection(&websocket, &state, &config).await;
}
}
}
}
}
}
});
}
async fn send_command(
websocket: &Arc<Mutex<Option<WebSocket>>>,
command: PlaybackCommand,
) -> Result<(), String> {
let mut ws_guard = websocket.lock().await;
if let Some(ws) = ws_guard.as_mut() {
let command_json = serde_json::to_string(&command)
.map_err(|e| format!("序列化命令失败: {}", e))?;
ws.send(Message::Text(command_json))
.await
.map_err(|e| format!("发送消息失败: {}", e))?;
debug!("发送命令: {:?}", command);
Ok(())
} else {
Err("WebSocket 连接不可用".to_string())
}
}
async fn send_heartbeat(websocket: &Arc<Mutex<Option<WebSocket>>>) -> Result<(), String> {
let mut ws_guard = websocket.lock().await;
if let Some(ws) = ws_guard.as_mut() {
ws.send(Message::Ping(vec![]))
.await
.map_err(|e| format!("发送心跳失败: {}", e))?;
debug!("发送心跳");
Ok(())
} else {
Err("WebSocket 连接不可用".to_string())
}
}
async fn receive_message(
websocket: &Arc<Mutex<Option<WebSocket>>>,
) -> Result<String, String> {
let mut ws_guard = websocket.lock().await;
if let Some(ws) = ws_guard.as_mut() {
match ws.next().await {
Some(Ok(Message::Text(text))) => {
debug!("收到消息: {}", text);
Ok(text)
}
Some(Ok(Message::Pong(_))) => {
debug!("收到心跳响应");
Ok("pong".to_string())
}
Some(Ok(_)) => {
Err("收到非文本消息".to_string())
}
Some(Err(e)) => {
Err(format!("WebSocket 错误: {}", e))
}
None => {
Err("WebSocket 连接关闭".to_string())
}
}
} else {
Err("WebSocket 连接不可用".to_string())
}
}
async fn handle_player_message(
state: &Arc<RwLock<PlayerState>>,
message: String,
) -> Result<(), String> {
if message == "pong" {
return Ok(());
}
// 尝试解析播放器状态更新
match serde_json::from_str::<PlayerState>(&message) {
Ok(new_state) => {
let mut current_state = state.write().await;
current_state.playback_status = new_state.playback_status;
current_state.position = new_state.position;
current_state.duration = new_state.duration;
current_state.volume = new_state.volume;
current_state.is_looping = new_state.is_looping;
current_state.is_fullscreen = new_state.is_fullscreen;
if let Some(video) = new_state.current_video {
current_state.current_video = Some(video);
}
debug!("更新播放器状态");
Ok(())
}
Err(e) => {
warn!("无法解析播放器状态: {} - 消息: {}", e, message);
Ok(()) // 不是致命错误,继续运行
}
}
}
async fn handle_reconnection(
websocket: &Arc<Mutex<Option<WebSocket>>>,
state: &Arc<RwLock<PlayerState>>,
config: &Arc<RwLock<ConnectionConfig>>,
) {
info!("开始重连流程");
// 清空当前连接
{
let mut ws_guard = websocket.lock().await;
*ws_guard = None;
}
// 更新状态为断开连接
{
let mut current_state = state.write().await;
current_state.connection_status = ConnectionStatus::Disconnected;
}
// 等待重连间隔
let reconnect_interval = {
let config_guard = config.read().await;
config_guard.reconnect_interval
};
sleep(Duration::from_secs(reconnect_interval)).await;
// 尝试重连 (这里需要重新创建 ConnectionService 实例)
info!("尝试重新连接...");
}
pub async fn disconnect(&mut self) {
info!("断开连接");
// 关闭 WebSocket 连接
{
let mut ws_guard = self.websocket.lock().await;
if let Some(ws) = ws_guard.as_mut() {
let _ = ws.close(None).await;
}
*ws_guard = None;
}
// 更新状态
{
let mut state = self.state.write().await;
state.connection_status = ConnectionStatus::Disconnected;
}
}
pub async fn send_playback_command(&self, command: PlaybackCommand) -> Result<(), String> {
if let Some(tx) = &self.tx {
tx.send(command)
.map_err(|e| format!("发送命令到队列失败: {}", e))?;
Ok(())
} else {
Err("连接服务未初始化".to_string())
}
}
pub async fn get_state(&self) -> PlayerState {
self.state.read().await.clone()
}
pub async fn update_config(&self, new_config: ConnectionConfig) {
*self.config.write().await = new_config;
}
}

View File

@@ -0,0 +1,5 @@
pub mod connection;
pub mod settings;
pub use connection::ConnectionService;
pub use settings::SettingsService;

View File

@@ -0,0 +1,124 @@
use crate::models::AppSettings;
use log::{error, info};
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use tauri_plugin_store::StoreExt;
pub struct SettingsService {
app_handle: AppHandle,
store_path: PathBuf,
}
impl SettingsService {
pub fn new(app_handle: AppHandle) -> Self {
let mut store_path = app_handle
.path()
.app_config_dir()
.unwrap_or_else(|_| PathBuf::from("."));
store_path.push("settings.json");
Self {
app_handle,
store_path,
}
}
pub async fn load_settings(&self) -> Result<AppSettings, String> {
match self.app_handle.store_builder(self.store_path.clone()).build() {
Ok(store) => {
info!("加载应用设置");
// 尝试从存储中读取设置
let settings = match store.get("app_settings") {
Some(value) => {
match serde_json::from_value::<AppSettings>(value.clone()) {
Ok(settings) => {
info!("成功加载保存的设置");
settings
}
Err(e) => {
error!("解析设置失败: {}, 使用默认设置", e);
AppSettings::default()
}
}
}
None => {
info!("未找到保存的设置,使用默认设置");
AppSettings::default()
}
};
Ok(settings)
}
Err(e) => {
error!("无法创建设置存储: {}", e);
// 即使存储创建失败,也返回默认设置
Ok(AppSettings::default())
}
}
}
pub async fn save_settings(&self, settings: &AppSettings) -> Result<(), String> {
match self.app_handle.store_builder(self.store_path.clone()).build() {
Ok(store) => {
info!("保存应用设置");
let settings_value = serde_json::to_value(settings)
.map_err(|e| format!("序列化设置失败: {}", e))?;
store.set("app_settings".to_string(), settings_value);
if let Err(e) = store.save() {
error!("保存设置到文件失败: {}", e);
return Err(format!("保存设置失败: {}", e));
}
info!("设置保存成功");
Ok(())
}
Err(e) => {
error!("无法创建设置存储: {}", e);
Err(format!("创建设置存储失败: {}", e))
}
}
}
pub async fn export_settings(&self, export_path: PathBuf) -> Result<(), String> {
let settings = self.load_settings().await?;
let settings_json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("序列化设置失败: {}", e))?;
std::fs::write(&export_path, settings_json)
.map_err(|e| format!("写入文件失败: {}", e))?;
info!("设置导出到: {:?}", export_path);
Ok(())
}
pub async fn import_settings(&self, import_path: PathBuf) -> Result<AppSettings, String> {
let content = std::fs::read_to_string(&import_path)
.map_err(|e| format!("读取文件失败: {}", e))?;
let settings: AppSettings = serde_json::from_str(&content)
.map_err(|e| format!("解析设置文件失败: {}", e))?;
// 保存导入的设置
self.save_settings(&settings).await?;
info!("从文件导入设置: {:?}", import_path);
Ok(settings)
}
pub async fn reset_settings(&self) -> Result<AppSettings, String> {
let default_settings = AppSettings::default();
self.save_settings(&default_settings).await?;
info!("设置已重置为默认值");
Ok(default_settings)
}
pub async fn get_settings_path(&self) -> PathBuf {
self.store_path.clone()
}
}