初步可用

This commit is contained in:
2025-08-18 08:46:39 +08:00
parent 5c6f074ef3
commit 108d4439db
5 changed files with 550 additions and 60 deletions

273
app/composables/useTauri.ts Normal file
View File

@@ -0,0 +1,273 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
// 类型定义,对应 Rust 端的结构
export interface ConnectionConfig {
host: string
port: number
timeout: number
autoReconnect: boolean
reconnectInterval: number
}
export interface VideoInfo {
path: string
title: string
duration?: number
size?: number
format?: string
}
export interface PlayerState {
connectionStatus: 'connected' | 'connecting' | 'disconnected' | { error: string }
playbackStatus: 'playing' | 'paused' | 'stopped' | 'loading'
currentVideo?: VideoInfo
position: number
duration: number
volume: number
isLooping: boolean
isFullscreen: boolean
playlist: VideoInfo[]
currentPlaylistIndex?: number
}
export interface ApiResponse<T> {
success: boolean
data?: T
message?: string
error?: string
}
export type PlaybackCommand =
| 'play'
| 'pause'
| 'stop'
| { seek: { position: number } }
| { setVolume: { volume: number } }
| { setLoop: { enabled: boolean } }
| 'toggleFullscreen'
| { loadVideo: { path: string } }
| { setPlaylist: { videos: string[] } }
| { playFromPlaylist: { index: number } }
export interface AppSettings {
connection: ConnectionConfig
defaultVolume: number
defaultLoop: boolean
autoFullscreen: boolean
playbackEndBehavior: 'stop' | 'next' | 'repeat'
theme: string
language: string
showNotifications: boolean
debugMode: boolean
cacheSize: number
proxy?: string
}
/**
* Tauri API composable
* 提供与 Tauri 后端通信的方法
*/
export function useTauri() {
const toast = useToast()
/**
* 安全地调用 Tauri API
*/
async function safeInvoke<T>(command: string, args?: any): Promise<T | null> {
try {
const result = await invoke<T>(command, args)
return result
} catch (error) {
console.error(`Tauri invoke error (${command}):`, error)
toast.add({
title: '操作失败',
description: `调用 ${command} 失败: ${error}`,
color: 'error'
})
return null
}
}
/**
* 连接到视频播放器
*/
async function connectToPlayer(config: ConnectionConfig): Promise<PlayerState | null> {
const response = await safeInvoke<ApiResponse<PlayerState>>('connect_to_player', { config })
if (response?.success && response.data) {
toast.add({
title: '连接成功',
description: response.message || '已成功连接到视频播放器',
color: 'success'
})
return response.data
}
const host = config?.host || '127.0.0.1'
const port = config?.port || 6666
const reason = response?.error || '无法连接到视频播放器,请检查地址与端口是否正确,以及播放器是否已启动'
toast.add({
title: '连接失败',
description: `${reason}(目标:${host}:${port}`,
color: 'error'
})
return null
}
/**
* 断开与视频播放器的连接
*/
async function disconnectFromPlayer(): Promise<boolean> {
const response = await safeInvoke<ApiResponse<void>>('disconnect_from_player')
if (response?.success) {
toast.add({
title: '已断开连接',
description: response.message || '已断开与视频播放器的连接',
color: 'warning'
})
return true
}
return false
}
/**
* 获取连接状态
*/
async function getConnectionStatus(): Promise<PlayerState | null> {
const response = await safeInvoke<ApiResponse<PlayerState>>('get_connection_status')
return response?.success && response.data ? response.data : null
}
/**
* 发送播放控制命令
*/
async function sendPlaybackCommand(command: PlaybackCommand): Promise<boolean> {
const response = await safeInvoke<ApiResponse<void>>('send_playback_command', { command })
if (response?.success) {
// 不显示成功消息,避免过多通知
return true
}
return false
}
/**
* 更新连接配置
*/
async function updateConnectionConfig(config: ConnectionConfig): Promise<boolean> {
const response = await safeInvoke<ApiResponse<void>>('update_connection_config', { config })
if (response?.success) {
toast.add({
title: '配置更新成功',
description: response.message || '连接配置已更新',
color: 'success'
})
return true
}
return false
}
/**
* 获取应用设置
*/
async function getSettings(): Promise<AppSettings | null> {
const response = await safeInvoke<ApiResponse<AppSettings>>('get_settings')
return response?.success && response.data ? response.data : null
}
/**
* 保存应用设置
*/
async function saveSettings(settings: AppSettings): Promise<boolean> {
const response = await safeInvoke<ApiResponse<void>>('save_settings', { settings })
if (response?.success) {
toast.add({
title: '设置保存成功',
description: response.message || '应用设置已保存',
color: 'success'
})
return true
}
return false
}
/**
* 选择文件
*/
async function selectFile(): Promise<string | null> {
const response = await safeInvoke<ApiResponse<string>>('select_file')
return response?.success && response.data ? response.data : null
}
/**
* 选择多个文件
*/
async function selectFiles(): Promise<string[] | null> {
const response = await safeInvoke<ApiResponse<string[]>>('select_files')
return response?.success && response.data ? response.data : null
}
/**
* 监听播放器状态更新事件
*/
async function listenPlayerStateUpdate(callback: (state: PlayerState) => void): Promise<UnlistenFn | null> {
try {
return await listen<PlayerState>('player-state-update', (event) => {
callback(event.payload)
})
} catch (error) {
console.error('监听播放器状态更新失败:', error)
return null
}
}
/**
* 监听播放命令发送事件
*/
async function listenPlaybackCommandSent(callback: (command: PlaybackCommand) => void): Promise<UnlistenFn | null> {
try {
return await listen<PlaybackCommand>('playback-command-sent', (event) => {
callback(event.payload)
})
} catch (error) {
console.error('监听播放命令发送失败:', error)
return null
}
}
/**
* 监听连接状态变化事件
*/
async function listenConnectionStatusChange(callback: (status: string) => void): Promise<UnlistenFn | null> {
try {
return await listen<string>('connection-status-change', (event) => {
callback(event.payload)
})
} catch (error) {
console.error('监听连接状态变化失败:', error)
return null
}
}
return {
// 连接管理
connectToPlayer,
disconnectFromPlayer,
getConnectionStatus,
updateConnectionConfig,
// 播放控制
sendPlaybackCommand,
// 设置管理
getSettings,
saveSettings,
// 文件操作
selectFile,
selectFiles,
// 事件监听
listenPlayerStateUpdate,
listenPlaybackCommandSent,
listenConnectionStatusChange,
}
}

View File

@@ -157,6 +157,9 @@
</template>
<script lang="ts" setup>
import type { UnlistenFn } from "@tauri-apps/api/event";
import type { ConnectionConfig, PlayerState } from "~/composables/useTauri";
interface VideoControllerState {
connectionStatus: "connected" | "connecting" | "disconnected"
currentVideo: string | null
@@ -167,6 +170,16 @@
playerAddress: string
}
// Tauri API
const {
connectToPlayer,
disconnectFromPlayer,
sendPlaybackCommand,
selectFile,
listenPlayerStateUpdate,
listenPlaybackCommandSent
} = useTauri();
// 响应式状态
const connectionStatus = ref<VideoControllerState["connectionStatus"]>("disconnected");
const currentVideo = ref<string | null>(null);
@@ -174,7 +187,16 @@
const volume = ref(50);
const isLooping = ref(false);
const playlist = ref<string[]>([]);
const playerAddress = ref("192.168.1.100:8080");
const playerAddress = ref("127.0.0.1:6666");
const playerState = ref<PlayerState | null>(null);
// 禁用不必要的自动更新,避免循环触发
const skipProgressUpdate = ref(false);
const skipVolumeUpdate = ref(false);
// 事件监听器去除函数
let unlistenPlayerState: UnlistenFn | null = null;
let unlistenCommandSent: UnlistenFn | null = null;
// 计算属性
const isConnected = computed(() => connectionStatus.value === "connected");
@@ -187,67 +209,163 @@
}
});
// 创建连接配置
const createConnectionConfig = (): ConnectionConfig => {
const address = playerAddress.value || "";
const parts = address.split(":");
const host = parts[0] || "192.168.1.197";
const port = Number.parseInt(parts[1] || "") || 6666;
return {
host,
port,
timeout: 10,
autoReconnect: true,
reconnectInterval: 5
};
};
// 更新UI状态
const updateUIFromPlayerState = (state: PlayerState) => {
playerState.value = state;
// 更新连接状态
if (typeof state.connectionStatus === "string") {
connectionStatus.value = state.connectionStatus as any;
} else if (state.connectionStatus && typeof state.connectionStatus === "object" && "error" in state.connectionStatus) {
connectionStatus.value = "disconnected";
}
// 更新播放状态
if (state.currentVideo) {
currentVideo.value = state.currentVideo.title || state.currentVideo.path;
}
// 避免循环更新
skipProgressUpdate.value = true;
skipVolumeUpdate.value = true;
// 更新进度和音量
if (state.duration > 0) {
progress.value = Math.round((state.position / state.duration) * 100);
}
volume.value = Math.round(state.volume);
isLooping.value = state.isLooping;
// 更新播放列表
if (state.playlist && state.playlist.length > 0) {
playlist.value = state.playlist.map((video) => video.title || video.path);
}
nextTick(() => {
skipProgressUpdate.value = false;
skipVolumeUpdate.value = false;
});
};
// 方法
const toggleConnection = async () => {
if (connectionStatus.value === "connected") {
// 断开连接
connectionStatus.value = "disconnected";
const success = await disconnectFromPlayer();
if (success) {
connectionStatus.value = "disconnected";
playerState.value = null;
currentVideo.value = null;
// 清理事件监听器
if (unlistenPlayerState) {
unlistenPlayerState();
unlistenPlayerState = null;
}
if (unlistenCommandSent) {
unlistenCommandSent();
unlistenCommandSent = null;
}
}
} else {
// 尝试连接
connectionStatus.value = "connecting";
// TODO: 在这里调用 Tauri API 进行实际连接
setTimeout(() => {
connectionStatus.value = "connected"; // 模拟连接成功
}, 1000);
const config = createConnectionConfig();
const state = await connectToPlayer(config);
if (state) {
connectionStatus.value = "connected";
updateUIFromPlayerState(state);
// 设置事件监听器
unlistenPlayerState = await listenPlayerStateUpdate(updateUIFromPlayerState);
unlistenCommandSent = await listenPlaybackCommandSent((command) => {
console.log("播放命令已发送:", command);
});
} else {
connectionStatus.value = "disconnected";
}
}
};
const playVideo = async () => {
// TODO: 调用 Tauri API
console.log("播放视频");
await sendPlaybackCommand("play");
};
const pauseVideo = async () => {
// TODO: 调用 Tauri API
console.log("暂停视频");
await sendPlaybackCommand("pause");
};
const stopVideo = async () => {
// TODO: 调用 Tauri API
console.log("停止视频");
await sendPlaybackCommand("stop");
};
const toggleLoop = async () => {
isLooping.value = !isLooping.value;
// TODO: 调用 Tauri API
console.log("循环播放:", isLooping.value);
const newLooping = !isLooping.value;
isLooping.value = newLooping;
await sendPlaybackCommand({ setLoop: { enabled: newLooping } });
};
const toggleFullscreen = async () => {
// TODO: 调用 Tauri API
console.log("切换全屏");
await sendPlaybackCommand("toggleFullscreen");
// 同步将控制端窗口也切换全屏,提升操控体验
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
const isFs = await win.isFullscreen();
await win.setFullscreen(!isFs);
} catch (e) {
console.warn("切换控制端全屏失败:", e);
}
};
const openVideoFile = async () => {
// TODO: 调用 Tauri API 打开文件选择器
console.log("打开文件");
// 模拟添加到播放列表
playlist.value.push(`示例视频${playlist.value.length + 1}.mp4`);
const filePath = await selectFile();
if (filePath) {
// 加载视频文件
await sendPlaybackCommand({ loadVideo: { path: filePath } });
// 添加到播放列表
const fileName = filePath.split("/").pop() || filePath;
if (!playlist.value.includes(fileName)) {
playlist.value.push(fileName);
}
// 更新当前视频
currentVideo.value = fileName;
}
};
const playVideoFromPlaylist = async (index: number) => {
await sendPlaybackCommand({ playFromPlaylist: { index } });
currentVideo.value = playlist.value[index] || null;
// TODO: 调用 Tauri API 播放指定视频
console.log("播放:", currentVideo.value);
};
const removeFromPlaylist = (index: number) => {
playlist.value.splice(index, 1);
// TODO: 同步更新播放器端播放列表
};
const clearPlaylist = () => {
const clearPlaylist = async () => {
playlist.value = [];
currentVideo.value = null;
// 发送空播放列表到播放器
await sendPlaybackCommand({ setPlaylist: { videos: [] } });
};
// 监听进度变化

View File

@@ -10,9 +10,9 @@
<div class="space-y-4">
<UFormGroup label="视频播放器地址" description="设置要连接的视频播放器的IP地址和端口">
<div class="flex space-x-2">
<UInput v-model="settings.playerHost" placeholder="192.168.1.100" class="flex-1 mb-3" />
<UInput v-model="settings.playerHost" placeholder="127.0.0.1" class="flex-1 mb-3" />
<span class="self-center text-gray-500">:</span>
<UInput v-model="settings.playerPort" type="number" placeholder="8080" class="w-24" />
<UInput v-model="settings.playerPort" type="number" placeholder="6666" class="w-24" />
</div>
</UFormGroup>
@@ -169,8 +169,8 @@
// 响应式设置数据
const settings = ref<AppSettings>({
playerHost: "192.168.1.100",
playerPort: 8080,
playerHost: "127.0.0.1",
playerPort: 6666,
connectionTimeout: 10,
autoReconnect: true,
reconnectInterval: 5,
@@ -222,8 +222,8 @@
const resetSettings = async () => {
// 重置为默认设置
settings.value = {
playerHost: "192.168.1.100",
playerPort: 8080,
playerHost: "127.0.0.1",
playerPort: 6666,
connectionTimeout: 10,
autoReconnect: true,
reconnectInterval: 5,

View File

@@ -79,7 +79,7 @@ impl Default for ConnectionConfig {
fn default() -> Self {
Self {
host: "192.168.1.100".to_string(),
port: 8080,
port: 6666,
timeout: 10,
auto_reconnect: true,
reconnect_interval: 5,

View File

@@ -3,17 +3,20 @@ use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex, RwLock};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::task::JoinHandle;
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>>>,
stop_signal: Arc<AtomicBool>,
task_handle: Option<JoinHandle<()>>,
}
impl ConnectionService {
@@ -23,12 +26,14 @@ impl ConnectionService {
state: Arc::new(RwLock::new(PlayerState::default())),
tx: None,
websocket: Arc::new(Mutex::new(None)),
stop_signal: Arc::new(AtomicBool::new(false)),
task_handle: 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);
let url = format!("ws://{}:{}", config.host, config.port);
info!("尝试连接到视频播放器: {}", url);
@@ -92,12 +97,17 @@ impl ConnectionService {
let websocket = self.websocket.clone();
let state = self.state.clone();
let config = self.config.clone();
let stop_flag = self.stop_signal.clone();
tokio::spawn(async move {
let handle = tokio::spawn(async move {
// 心跳检测
let mut heartbeat_interval = interval(Duration::from_secs(30));
loop {
if stop_flag.load(Ordering::Relaxed) {
info!("停止消息处理任务");
break;
}
tokio::select! {
// 处理发送的命令
command = rx.recv() => {
@@ -124,6 +134,7 @@ impl ConnectionService {
message = Self::receive_message(&websocket) => {
match message {
Ok(msg) => {
if msg.is_empty() { continue; }
if let Err(e) = Self::handle_player_message(&state, msg).await {
error!("处理播放器消息失败: {}", e);
}
@@ -139,6 +150,8 @@ impl ConnectionService {
}
}
});
self.task_handle = Some(handle);
}
async fn send_command(
@@ -148,10 +161,29 @@ impl ConnectionService {
let mut ws_guard = websocket.lock().await;
if let Some(ws) = ws_guard.as_mut() {
let command_json = serde_json::to_string(&command)
// Convert internal command enum to player-compatible wire format
let payload = match command.clone() {
PlaybackCommand::Play => serde_json::json!({"type": "play"}),
PlaybackCommand::Pause => serde_json::json!({"type": "pause"}),
PlaybackCommand::Stop => serde_json::json!({"type": "stop"}),
PlaybackCommand::Seek { position } => serde_json::json!({"type": "seek", "position": position}),
PlaybackCommand::SetVolume { volume } => serde_json::json!({"type": "setVolume", "volume": volume}),
PlaybackCommand::SetLoop { enabled } => serde_json::json!({"type": "setLoop", "enabled": enabled}),
PlaybackCommand::ToggleFullscreen => serde_json::json!({"type": "toggleFullscreen"}),
PlaybackCommand::LoadVideo { path } => serde_json::json!({"type": "loadVideo", "path": path}),
PlaybackCommand::SetPlaylist { videos } => serde_json::json!({"type": "setPlaylist", "videos": videos}),
PlaybackCommand::PlayFromPlaylist { index } => serde_json::json!({"type": "playFromPlaylist", "index": index}),
};
let message = serde_json::json!({
"type": "command",
"data": payload
});
let message_text = serde_json::to_string(&message)
.map_err(|e| format!("序列化命令失败: {}", e))?;
ws.send(Message::Text(command_json))
ws.send(Message::Text(message_text))
.await
.map_err(|e| format!("发送消息失败: {}", e))?;
@@ -203,7 +235,9 @@ impl ConnectionService {
}
}
} else {
Err("WebSocket 连接不可用".to_string())
// 未连接时避免刷屏日志:短暂等待并返回空消息
sleep(Duration::from_millis(300)).await;
Ok(String::new())
}
}
@@ -215,29 +249,83 @@ impl ConnectionService {
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(()) // 不是致命错误,继续运行
}
// 解析播放器发来的 { type: "status", data: { ... } }
let value: serde_json::Value = serde_json::from_str(&message)
.map_err(|e| format!("解析JSON失败: {}", e))?;
let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
if msg_type != "status" {
// 忽略非状态消息
return Ok(());
}
let data = value.get("data").ok_or_else(|| "缺少data字段".to_string())?;
// 将字符串状态映射为内部枚举
let playback_status = match data.get("playback_status").and_then(|v| v.as_str()).unwrap_or("stopped") {
"playing" => crate::models::PlaybackStatus::Playing,
"paused" => crate::models::PlaybackStatus::Paused,
"loading" => crate::models::PlaybackStatus::Loading,
_ => crate::models::PlaybackStatus::Stopped,
};
let connection_status = match data.get("connection_status").and_then(|v| v.as_str()).unwrap_or("disconnected") {
"connected" => crate::models::ConnectionStatus::Connected,
"connecting" => crate::models::ConnectionStatus::Connecting,
"disconnected" => crate::models::ConnectionStatus::Disconnected,
other => crate::models::ConnectionStatus::Error(other.to_string()),
};
let position = data.get("position").and_then(|v| v.as_f64()).unwrap_or(0.0);
let duration = data.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0);
let volume = data.get("volume").and_then(|v| v.as_f64()).unwrap_or(50.0);
let is_looping = data.get("is_looping").and_then(|v| v.as_bool()).unwrap_or(false);
let is_fullscreen = data.get("is_fullscreen").and_then(|v| v.as_bool()).unwrap_or(false);
let current_video = if let Some(v) = data.get("current_video") {
let path = v.get("path").and_then(|s| s.as_str()).unwrap_or("").to_string();
if path.is_empty() { None } else {
Some(crate::models::VideoInfo {
title: v.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string(),
path,
duration: v.get("duration").and_then(|n| n.as_f64()),
size: v.get("size").and_then(|n| n.as_u64()),
format: v.get("format").and_then(|s| s.as_str()).map(|s| s.to_string()),
})
}
} else { None };
let playlist = if let Some(arr) = data.get("playlist").and_then(|a| a.as_array()) {
arr.iter().filter_map(|v| {
let path = v.get("path").and_then(|s| s.as_str()).unwrap_or("").to_string();
if path.is_empty() { None } else {
Some(crate::models::VideoInfo {
title: v.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string(),
path,
duration: v.get("duration").and_then(|n| n.as_f64()),
size: v.get("size").and_then(|n| n.as_u64()),
format: v.get("format").and_then(|s| s.as_str()).map(|s| s.to_string()),
})
}
}).collect()
} else { vec![] };
let current_playlist_index = data.get("current_playlist_index").and_then(|n| n.as_u64()).map(|n| n as usize);
let mut current_state = state.write().await;
current_state.connection_status = connection_status;
current_state.playback_status = playback_status;
current_state.position = position;
current_state.duration = duration;
current_state.volume = volume;
current_state.is_looping = is_looping;
current_state.is_fullscreen = is_fullscreen;
current_state.current_video = current_video;
current_state.playlist = playlist;
current_state.current_playlist_index = current_playlist_index;
debug!("更新播放器状态");
Ok(())
}
async fn handle_reconnection(
@@ -274,6 +362,9 @@ impl ConnectionService {
pub async fn disconnect(&mut self) {
info!("断开连接");
// 通知后台任务停止并尝试结束
self.stop_signal.store(true, Ordering::Relaxed);
// 关闭 WebSocket 连接
{
let mut ws_guard = self.websocket.lock().await;
@@ -283,6 +374,14 @@ impl ConnectionService {
*ws_guard = None;
}
// 停止后台任务
if let Some(handle) = self.task_handle.take() {
handle.abort();
}
// 重置停止标志,方便下次连接
self.stop_signal.store(false, Ordering::Relaxed);
// 更新状态
{
let mut state = self.state.write().await;