初步可用
This commit is contained in:
273
app/composables/useTauri.ts
Normal file
273
app/composables/useTauri.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
@@ -157,6 +157,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import type { ConnectionConfig, PlayerState } from "~/composables/useTauri";
|
||||||
|
|
||||||
interface VideoControllerState {
|
interface VideoControllerState {
|
||||||
connectionStatus: "connected" | "connecting" | "disconnected"
|
connectionStatus: "connected" | "connecting" | "disconnected"
|
||||||
currentVideo: string | null
|
currentVideo: string | null
|
||||||
@@ -167,6 +170,16 @@
|
|||||||
playerAddress: string
|
playerAddress: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tauri API
|
||||||
|
const {
|
||||||
|
connectToPlayer,
|
||||||
|
disconnectFromPlayer,
|
||||||
|
sendPlaybackCommand,
|
||||||
|
selectFile,
|
||||||
|
listenPlayerStateUpdate,
|
||||||
|
listenPlaybackCommandSent
|
||||||
|
} = useTauri();
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
const connectionStatus = ref<VideoControllerState["connectionStatus"]>("disconnected");
|
const connectionStatus = ref<VideoControllerState["connectionStatus"]>("disconnected");
|
||||||
const currentVideo = ref<string | null>(null);
|
const currentVideo = ref<string | null>(null);
|
||||||
@@ -174,7 +187,16 @@
|
|||||||
const volume = ref(50);
|
const volume = ref(50);
|
||||||
const isLooping = ref(false);
|
const isLooping = ref(false);
|
||||||
const playlist = ref<string[]>([]);
|
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");
|
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 () => {
|
const toggleConnection = async () => {
|
||||||
if (connectionStatus.value === "connected") {
|
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 {
|
} else {
|
||||||
// 尝试连接
|
// 尝试连接
|
||||||
connectionStatus.value = "connecting";
|
connectionStatus.value = "connecting";
|
||||||
// TODO: 在这里调用 Tauri API 进行实际连接
|
const config = createConnectionConfig();
|
||||||
setTimeout(() => {
|
const state = await connectToPlayer(config);
|
||||||
connectionStatus.value = "connected"; // 模拟连接成功
|
|
||||||
}, 1000);
|
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 () => {
|
const playVideo = async () => {
|
||||||
// TODO: 调用 Tauri API
|
await sendPlaybackCommand("play");
|
||||||
console.log("播放视频");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pauseVideo = async () => {
|
const pauseVideo = async () => {
|
||||||
// TODO: 调用 Tauri API
|
await sendPlaybackCommand("pause");
|
||||||
console.log("暂停视频");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopVideo = async () => {
|
const stopVideo = async () => {
|
||||||
// TODO: 调用 Tauri API
|
await sendPlaybackCommand("stop");
|
||||||
console.log("停止视频");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleLoop = async () => {
|
const toggleLoop = async () => {
|
||||||
isLooping.value = !isLooping.value;
|
const newLooping = !isLooping.value;
|
||||||
// TODO: 调用 Tauri API
|
isLooping.value = newLooping;
|
||||||
console.log("循环播放:", isLooping.value);
|
await sendPlaybackCommand({ setLoop: { enabled: newLooping } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFullscreen = async () => {
|
const toggleFullscreen = async () => {
|
||||||
// TODO: 调用 Tauri API
|
await sendPlaybackCommand("toggleFullscreen");
|
||||||
console.log("切换全屏");
|
// 同步将控制端窗口也切换全屏,提升操控体验
|
||||||
|
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 () => {
|
const openVideoFile = async () => {
|
||||||
// TODO: 调用 Tauri API 打开文件选择器
|
const filePath = await selectFile();
|
||||||
console.log("打开文件");
|
if (filePath) {
|
||||||
// 模拟添加到播放列表
|
// 加载视频文件
|
||||||
playlist.value.push(`示例视频${playlist.value.length + 1}.mp4`);
|
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) => {
|
const playVideoFromPlaylist = async (index: number) => {
|
||||||
|
await sendPlaybackCommand({ playFromPlaylist: { index } });
|
||||||
currentVideo.value = playlist.value[index] || null;
|
currentVideo.value = playlist.value[index] || null;
|
||||||
// TODO: 调用 Tauri API 播放指定视频
|
|
||||||
console.log("播放:", currentVideo.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromPlaylist = (index: number) => {
|
const removeFromPlaylist = (index: number) => {
|
||||||
playlist.value.splice(index, 1);
|
playlist.value.splice(index, 1);
|
||||||
|
// TODO: 同步更新播放器端播放列表
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearPlaylist = () => {
|
const clearPlaylist = async () => {
|
||||||
playlist.value = [];
|
playlist.value = [];
|
||||||
currentVideo.value = null;
|
currentVideo.value = null;
|
||||||
|
// 发送空播放列表到播放器
|
||||||
|
await sendPlaybackCommand({ setPlaylist: { videos: [] } });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听进度变化
|
// 监听进度变化
|
||||||
|
@@ -10,9 +10,9 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UFormGroup label="视频播放器地址" description="设置要连接的视频播放器的IP地址和端口">
|
<UFormGroup label="视频播放器地址" description="设置要连接的视频播放器的IP地址和端口">
|
||||||
<div class="flex space-x-2">
|
<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>
|
<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>
|
</div>
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
@@ -169,8 +169,8 @@
|
|||||||
|
|
||||||
// 响应式设置数据
|
// 响应式设置数据
|
||||||
const settings = ref<AppSettings>({
|
const settings = ref<AppSettings>({
|
||||||
playerHost: "192.168.1.100",
|
playerHost: "127.0.0.1",
|
||||||
playerPort: 8080,
|
playerPort: 6666,
|
||||||
connectionTimeout: 10,
|
connectionTimeout: 10,
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
reconnectInterval: 5,
|
reconnectInterval: 5,
|
||||||
@@ -222,8 +222,8 @@
|
|||||||
const resetSettings = async () => {
|
const resetSettings = async () => {
|
||||||
// 重置为默认设置
|
// 重置为默认设置
|
||||||
settings.value = {
|
settings.value = {
|
||||||
playerHost: "192.168.1.100",
|
playerHost: "127.0.0.1",
|
||||||
playerPort: 8080,
|
playerPort: 6666,
|
||||||
connectionTimeout: 10,
|
connectionTimeout: 10,
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
reconnectInterval: 5,
|
reconnectInterval: 5,
|
||||||
|
@@ -79,7 +79,7 @@ impl Default for ConnectionConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
host: "192.168.1.100".to_string(),
|
host: "192.168.1.100".to_string(),
|
||||||
port: 8080,
|
port: 6666,
|
||||||
timeout: 10,
|
timeout: 10,
|
||||||
auto_reconnect: true,
|
auto_reconnect: true,
|
||||||
reconnect_interval: 5,
|
reconnect_interval: 5,
|
||||||
|
@@ -3,17 +3,20 @@ use futures_util::{SinkExt, StreamExt};
|
|||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tokio::time::{interval, sleep, Duration};
|
use tokio::time::{interval, sleep, Duration};
|
||||||
use tokio_tungstenite::{connect_async, tungstenite::Message, WebSocketStream, MaybeTlsStream};
|
use tokio_tungstenite::{connect_async, tungstenite::Message, WebSocketStream, MaybeTlsStream};
|
||||||
|
|
||||||
pub type WebSocket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
pub type WebSocket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ConnectionService {
|
pub struct ConnectionService {
|
||||||
config: Arc<RwLock<ConnectionConfig>>,
|
config: Arc<RwLock<ConnectionConfig>>,
|
||||||
state: Arc<RwLock<PlayerState>>,
|
state: Arc<RwLock<PlayerState>>,
|
||||||
tx: Option<mpsc::UnboundedSender<PlaybackCommand>>,
|
tx: Option<mpsc::UnboundedSender<PlaybackCommand>>,
|
||||||
websocket: Arc<Mutex<Option<WebSocket>>>,
|
websocket: Arc<Mutex<Option<WebSocket>>>,
|
||||||
|
stop_signal: Arc<AtomicBool>,
|
||||||
|
task_handle: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionService {
|
impl ConnectionService {
|
||||||
@@ -23,12 +26,14 @@ impl ConnectionService {
|
|||||||
state: Arc::new(RwLock::new(PlayerState::default())),
|
state: Arc::new(RwLock::new(PlayerState::default())),
|
||||||
tx: None,
|
tx: None,
|
||||||
websocket: Arc::new(Mutex::new(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> {
|
pub async fn connect(&mut self) -> Result<(), String> {
|
||||||
let config = self.config.read().await.clone();
|
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);
|
info!("尝试连接到视频播放器: {}", url);
|
||||||
|
|
||||||
@@ -92,12 +97,17 @@ impl ConnectionService {
|
|||||||
let websocket = self.websocket.clone();
|
let websocket = self.websocket.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let config = self.config.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));
|
let mut heartbeat_interval = interval(Duration::from_secs(30));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
if stop_flag.load(Ordering::Relaxed) {
|
||||||
|
info!("停止消息处理任务");
|
||||||
|
break;
|
||||||
|
}
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// 处理发送的命令
|
// 处理发送的命令
|
||||||
command = rx.recv() => {
|
command = rx.recv() => {
|
||||||
@@ -124,6 +134,7 @@ impl ConnectionService {
|
|||||||
message = Self::receive_message(&websocket) => {
|
message = Self::receive_message(&websocket) => {
|
||||||
match message {
|
match message {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
|
if msg.is_empty() { continue; }
|
||||||
if let Err(e) = Self::handle_player_message(&state, msg).await {
|
if let Err(e) = Self::handle_player_message(&state, msg).await {
|
||||||
error!("处理播放器消息失败: {}", e);
|
error!("处理播放器消息失败: {}", e);
|
||||||
}
|
}
|
||||||
@@ -139,6 +150,8 @@ impl ConnectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.task_handle = Some(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_command(
|
async fn send_command(
|
||||||
@@ -148,10 +161,29 @@ impl ConnectionService {
|
|||||||
let mut ws_guard = websocket.lock().await;
|
let mut ws_guard = websocket.lock().await;
|
||||||
|
|
||||||
if let Some(ws) = ws_guard.as_mut() {
|
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))?;
|
.map_err(|e| format!("序列化命令失败: {}", e))?;
|
||||||
|
|
||||||
ws.send(Message::Text(command_json))
|
ws.send(Message::Text(message_text))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("发送消息失败: {}", e))?;
|
.map_err(|e| format!("发送消息失败: {}", e))?;
|
||||||
|
|
||||||
@@ -203,7 +235,9 @@ impl ConnectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err("WebSocket 连接不可用".to_string())
|
// 未连接时避免刷屏日志:短暂等待并返回空消息
|
||||||
|
sleep(Duration::from_millis(300)).await;
|
||||||
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,29 +249,83 @@ impl ConnectionService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试解析播放器状态更新
|
// 解析播放器发来的 { type: "status", data: { ... } }
|
||||||
match serde_json::from_str::<PlayerState>(&message) {
|
let value: serde_json::Value = serde_json::from_str(&message)
|
||||||
Ok(new_state) => {
|
.map_err(|e| format!("解析JSON失败: {}", e))?;
|
||||||
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 {
|
let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
current_state.current_video = Some(video);
|
if msg_type != "status" {
|
||||||
}
|
// 忽略非状态消息
|
||||||
|
return Ok(());
|
||||||
debug!("更新播放器状态");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("无法解析播放器状态: {} - 消息: {}", e, message);
|
|
||||||
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(
|
async fn handle_reconnection(
|
||||||
@@ -274,6 +362,9 @@ impl ConnectionService {
|
|||||||
pub async fn disconnect(&mut self) {
|
pub async fn disconnect(&mut self) {
|
||||||
info!("断开连接");
|
info!("断开连接");
|
||||||
|
|
||||||
|
// 通知后台任务停止并尝试结束
|
||||||
|
self.stop_signal.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
// 关闭 WebSocket 连接
|
// 关闭 WebSocket 连接
|
||||||
{
|
{
|
||||||
let mut ws_guard = self.websocket.lock().await;
|
let mut ws_guard = self.websocket.lock().await;
|
||||||
@@ -283,6 +374,14 @@ impl ConnectionService {
|
|||||||
*ws_guard = None;
|
*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;
|
let mut state = self.state.write().await;
|
||||||
|
Reference in New Issue
Block a user