暂时无法自动播放

This commit is contained in:
2025-08-17 22:07:37 +08:00
parent 196ee46a8d
commit 90e4a01e62
13 changed files with 410 additions and 21 deletions

2
API.md
View File

@@ -33,7 +33,7 @@ connect_to_player(config: ConnectionConfig) -> Result<ApiResponse<PlayerState>,
**参数:**
```json
{
"host": "192.168.1.100",
"host": "127.0.0.1",
"port": 8080,
"timeout": 10,
"autoReconnect": true,

165
CLAUDE.md Normal file
View File

@@ -0,0 +1,165 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Video Player with Remote Control** - A desktop video player built with Nuxt 4 and Tauri 2 that supports WebSocket-based remote control. The application automatically searches for and plays `video.mp4` files from multiple locations and provides a fullscreen video experience with remote control capabilities.
## Architecture
### Tech Stack
- **Frontend**: Nuxt 4 (Vue 3) with TypeScript
- **Backend**: Rust with Tauri 2 framework
- **UI Framework**: NuxtUI v3 with TailwindCSS v4
- **Communication**: WebSocket server (port 6666) for remote control
- **Package Manager**: pnpm (enforced)
### Key Components
#### Frontend (`app/`)
- **Main Page**: `app/pages/index.vue` - Fullscreen video player with connection status
- **Tauri Module**: `app/modules/tauri.ts` - Auto-imports Tauri APIs
- **Assets**: `app/assets/` - CSS and icons
- **Layouts**: `app/layouts/` - Application layouts
- **Components**: `app/components/` - Reusable UI components
#### Backend (`src-tauri/`)
- **Main Entry**: `src-tauri/src/main.rs` - Application bootstrap
- **Library**: `src-tauri/src/lib.rs` - Tauri application setup and state management
- **WebSocket Server**: `src-tauri/src/websocket_server.rs` - Remote control server on port 6666
- **Player State**: `src-tauri/src/player_state.rs` - Video playback state management
- **Commands**: `src-tauri/src/commands.rs` - Tauri commands for frontend interaction
- **Permissions**: `src-tauri/capabilities/main.json` - Tauri v2 permission configuration
### Communication Flow
1. **Backend-to-Frontend**: Tauri events (`player-command`, `connection-status`)
2. **Frontend-to-Backend**: Tauri commands (`get_player_state`, `load_video`, etc.)
3. **Remote Control**: WebSocket server accepts JSON commands for playback control
4. **Auto-discovery**: Backend searches for `video.mp4` in multiple directories on startup
## Development Commands
### Setup & Installation
```bash
# Install dependencies (requires Node.js 23+)
pnpm install
# Verify Rust toolchain is installed for Tauri
rustc --version # Should be >= 1.70
cargo --version
```
### Development
```bash
# Start development server with Tauri
pnpm tauri:dev
# Frontend only (port 3000)
pnpm dev
# Lint and fix code
pnpm lint
```
### Build & Distribution
```bash
# Build production application
pnpm tauri:build
# Build with debug symbols
pnpm tauri:build:debug
# Generate static site only
pnpm generate
# Version bump
pnpm bump
```
### Testing WebSocket Control
```bash
# Test WebSocket connection (requires Node.js)
node test_websocket.js
```
## Key Features
### Video Playback
- **Auto-loading**: Searches for `video.mp4` in current directory, public folder, and executable path
- **Fullscreen**: Auto-fullscreen mode configurable via `app.config.ts`
- **Controls**: Play/pause, seek, volume, loop, fullscreen toggle
- **Format Support**: MP4 with fallback handling
### Remote Control
- **WebSocket Server**: Runs on port 6666
- **JSON Protocol**: Standardized message format for all commands
- **Connection Status**: Real-time connection indicator in UI
- **Playlist Support**: Load and play from video playlists
### State Management
- **PlayerState**: Comprehensive video state including position, volume, loop status
- **VideoInfo**: Metadata tracking for loaded videos
- **Real-time Sync**: Frontend/backend state synchronization via Tauri events
## Configuration Files
### Application Config
- `app.config.ts` - Player settings (volume, fullscreen, etc.)
- `player_config.json` - Runtime configuration
- `nuxt.config.ts` - Nuxt and Tauri integration settings
### Build Configuration
- `src-tauri/tauri.conf.json` - Tauri application settings
- `src-tauri/Cargo.toml` - Rust dependencies and features
- `package.json` - Node.js dependencies and scripts
## Important Notes
### Permissions
- Tauri v2 uses capabilities-based permissions in `src-tauri/capabilities/main.json`
- Required permissions: file system access, notifications, OS info, shell commands
- WebSocket server runs on port 6666 (configurable in `src-tauri/src/lib.rs`)
### Development Gotchas
- **SSR Disabled**: `ssr: false` in nuxt.config.ts is required for Tauri
- **Asset URLs**: Uses `asset://localhost/` protocol for local file access
- **Auto-fullscreen**: Configurable but may be disabled in some environments
- **Video Discovery**: Searches up to 6 directory levels up from executable
### File Structure
```
video-player/
├── app/ # Nuxt frontend
│ ├── pages/index.vue # Main video player
│ ├── modules/tauri.ts # Tauri API auto-imports
│ └── assets/ # Static assets
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── lib.rs # Main Tauri app
│ │ ├── websocket_server.rs # Remote control
│ │ └── player_state.rs # Video state
│ └── capabilities/ # Tauri permissions
├── public/ # Static files (video.mp4)
└── types/ # TypeScript definitions
```
### Environment Requirements
- **Node.js**: >= 23.0.0
- **Rust**: >= 1.70.0
- **pnpm**: >= 10.13.1 (enforced)
- **Platform**: Windows, macOS, or Linux with Tauri prerequisites
## Common Development Tasks
### Adding New Tauri Commands
1. Add command function in `src-tauri/src/commands.rs`
2. Register command in `src-tauri/src/lib.rs` invoke_handler
3. Add TypeScript bindings in frontend
4. Update capabilities if new permissions needed
### WebSocket Protocol
- **Connect**: `ws://localhost:6666`
- **Commands**: JSON format `{"type":"command","data":{"type":"play"}}`
- **Status**: Real-time updates after each command
- **Playlist**: Support for multiple video files

View File

@@ -9,7 +9,7 @@ export default defineAppConfig({
defaultVolume: 50,
autoFullscreen: true,
showControls: false,
webSocketPort: 8080,
webSocketPort: 6666,
reconnectInterval: 5000
},
ui: {

View File

@@ -15,9 +15,12 @@
v-if="currentVideo"
ref="videoElement"
class="w-full h-full object-contain"
:src="currentVideo.path"
:src="videoSrc"
:volume="volume / 100"
:loop="isLooping"
playsinline
:muted="isMuted"
preload="auto"
autoplay
@loadedmetadata="onVideoLoaded"
@timeupdate="onTimeUpdate"
@@ -75,6 +78,7 @@
</template>
<script setup>
// remove direct import that breaks vite in dev; we will fallback to file:// URLs
const appConfig = useAppConfig()
const isDev = process.env.NODE_ENV === 'development'
@@ -93,10 +97,46 @@ const volume = ref(appConfig.player.defaultVolume)
const isLooping = ref(false)
const isFullscreen = ref(false)
const showControls = ref(false)
const isMuted = ref(true)
// Video element ref
const videoElement = ref(null)
// Video source URL - reactive ref that gets updated when currentVideo changes
const videoSrc = ref('')
// Convert file path to Tauri-compatible URL
async function updateVideoSrc() {
const raw = currentVideo.value?.path || ''
if (!raw) {
console.log('🎥 No video path available')
videoSrc.value = ''
return
}
const filePath = raw.startsWith('file://') ? raw.slice(7) : raw
console.log('🎥 Converting video path:', { raw, filePath })
// Use file:// protocol for local files in Tauri
const absolutePath = filePath.startsWith('/') ? filePath : `/${filePath}`
const fileUrl = `file://${absolutePath}`
videoSrc.value = fileUrl
console.log('🎥 Using file:// URL:')
console.log(' - Original path:', raw)
console.log(' - File path:', filePath)
console.log(' - File URL:', fileUrl)
}
// Watch for changes in currentVideo and update videoSrc
watch(currentVideo, updateVideoSrc, { immediate: true })
// Watch for changes in videoSrc to debug
watch(videoSrc, (newSrc, oldSrc) => {
console.log('🎥 videoSrc changed:')
console.log(' - Old:', oldSrc)
console.log(' - New:', newSrc)
})
// Computed properties
const connectionStatusClass = computed(() => {
switch (connectionStatus.value) {
@@ -167,9 +207,57 @@ function setVolume(newVolume) {
// Video event handlers
function onVideoLoaded() {
console.log('🎬 Video loaded event triggered')
if (videoElement.value) {
duration.value = videoElement.value.duration
videoElement.value.volume = volume.value / 100
console.log('🎬 Video metadata:', {
duration: duration.value,
volume: volume.value,
currentSrc: videoElement.value.currentSrc,
readyState: videoElement.value.readyState
})
// 尝试主动播放;若被策略阻止则静音后重试
console.log('🎬 Attempting to play video...')
// Always start muted to comply with autoplay policies, then unmute
isMuted.value = true
// Small delay to ensure video is ready
setTimeout(() => {
if (videoElement.value) {
const playPromise = videoElement.value.play()
if (playPromise && typeof playPromise.then === 'function') {
playPromise.then(() => {
console.log('✅ Video started playing successfully (muted)')
isPlaying.value = true
// Try to unmute after successful playback
setTimeout(() => {
if (videoElement.value) {
videoElement.value.muted = false
isMuted.value = false
console.log('🔊 Video unmuted')
}
}, 1000)
}).catch((err) => {
console.error('❌ Autoplay failed:', err)
// Show user a play button if autoplay fails
showControls.value = true
})
} else {
// Fallback for browsers without promise-based play
try {
videoElement.value.play()
console.log('✅ Video started playing (fallback)')
isPlaying.value = true
} catch (err) {
console.error('❌ Autoplay failed (fallback):', err)
showControls.value = true
}
}
}
}, 500)
}
}
@@ -186,14 +274,22 @@ function onVideoEnded() {
}
function onVideoError(event) {
console.error('Video playback error:', event)
console.error('Video playback error:', event)
console.error('❌ Video error details:', {
error: event.target?.error,
networkState: event.target?.networkState,
readyState: event.target?.readyState,
currentSrc: event.target?.currentSrc
})
currentVideo.value = null
isPlaying.value = false
}
// Initialize fullscreen if configured
onMounted(async () => {
console.log('🔧 Component mounted, initializing...')
if (appConfig.player.autoFullscreen) {
console.log('🔧 Auto fullscreen enabled')
// Request fullscreen
nextTick(() => {
document.documentElement.requestFullscreen?.() ||
@@ -202,10 +298,14 @@ onMounted(async () => {
}
// Setup Tauri event listeners
console.log('🔧 Setting up Tauri listeners...')
await setupTauriListeners()
// Get initial player state
console.log('🔧 Getting initial player state...')
await updatePlayerState()
console.log('✅ Component initialization complete')
})
// Setup Tauri event listeners
@@ -227,6 +327,7 @@ async function setupTauriListeners() {
// Listen for player commands from backend
await listen('player-command', async (event) => {
console.log('📡 Backend player command received:', event.payload)
const command = event.payload
await handlePlayerCommand(command)
})
@@ -267,6 +368,7 @@ async function updatePlayerState() {
// Handle player commands from WebSocket
async function handlePlayerCommand(command) {
console.log('🎮 Received player command:', command)
switch (command.type) {
case 'play':
if (videoElement.value) {
@@ -313,25 +415,27 @@ async function handlePlayerCommand(command) {
// Load video from file path
async function loadVideoFromPath(path) {
console.log('📥 loadVideoFromPath called with:', path)
try {
// Convert file path to URL for video element
const videoUrl = `file://${path}`
const rawPath = path.startsWith('file://') ? path.slice(7) : path
console.log('📥 Processing video path:', { originalPath: path, rawPath })
// Create video info
const title = path.split('/').pop() || path
const title = rawPath.split('/').pop() || rawPath
currentVideo.value = {
path: videoUrl,
path: rawPath,
title,
duration: null,
size: null,
format: null
}
console.log('📹 currentVideo.value set to:', currentVideo.value)
// The videoSrc will be updated automatically via the watcher
// Update backend state if invoke is available
if (invoke) {
await invoke('load_video', { path })
await invoke('load_video', { path: rawPath })
}
console.log('📹 Video loaded:', title)
console.log('📹 Video loaded successfully:', title)
} catch (error) {
console.error('❌ Failed to load video:', error)
}

View File

@@ -2,7 +2,7 @@
"connection": {
"controller_host": "127.0.0.1",
"controller_port": 8081,
"websocket_port": 8080,
"websocket_port": 6666,
"auto_connect": true,
"reconnect_interval": 5000,
"connection_timeout": 10000

BIN
public/video.mp4 Normal file

Binary file not shown.

View File

@@ -39,6 +39,10 @@
"os:allow-locale",
"fs:allow-document-read",
"fs:allow-document-write",
"fs:default",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-stat",
"store:default",
"core:webview:allow-create-webview",
"core:webview:allow-create-webview-window"

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Capabilities for the app window","local":true,"windows":["main","secondary"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":["-c",{"validator":"\\S+"}],"cmd":"sh","name":"exec-sh","sidecar":false}]},"notification:default","os:allow-platform","os:allow-arch","os:allow-family","os:allow-version","os:allow-locale","fs:allow-document-read","fs:allow-document-write","store:default","core:webview:allow-create-webview","core:webview:allow-create-webview-window"]}}
{"main":{"identifier":"main","description":"Capabilities for the app window","local":true,"windows":["main","secondary"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":["-c",{"validator":"\\S+"}],"cmd":"sh","name":"exec-sh","sidecar":false}]},"notification:default","os:allow-platform","os:allow-arch","os:allow-family","os:allow-version","os:allow-locale","fs:allow-document-read","fs:allow-document-write","fs:default","fs:allow-read-dir","fs:allow-read-file","fs:allow-stat","store:default","core:webview:allow-create-webview","core:webview:allow-create-webview-window"]}}

View File

@@ -7,11 +7,12 @@ mod commands;
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
Manager
Manager,
Emitter
};
use tokio::sync::Mutex;
use std::sync::Arc;
use player_state::PlayerState;
use player_state::{PlayerState, VideoInfo};
use websocket_server::WebSocketServer;
pub fn run() {
@@ -22,7 +23,7 @@ pub fn run() {
app.manage(player_state.clone());
// Start WebSocket server in background using tauri's async runtime
let ws_server = WebSocketServer::new(8080, player_state.clone());
let ws_server = WebSocketServer::new(6666, player_state.clone());
let app_handle = app.handle().clone();
// Use tauri's runtime handle to spawn the async task
@@ -32,6 +33,93 @@ pub fn run() {
}
});
// Try to auto-load video.mp4 from common locations (cwd, public/, executable dir)
let app_handle_load = app.handle().clone();
let player_state_for_load = player_state.clone();
tauri::async_runtime::spawn(async move {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
log::info!("🔍 Searching in current working directory: {}", cwd.display());
candidates.push(cwd.join("video.mp4"));
candidates.push(cwd.join("public").join("video.mp4"));
}
if let Ok(exe_path) = std::env::current_exe() {
log::info!("📁 Executable path: {}", exe_path.display());
let mut ancestor_opt = exe_path.parent();
let mut steps = 0;
while let Some(dir) = ancestor_opt {
log::info!("🔍 Searching in ancestor directory: {}", dir.display());
candidates.push(dir.join("video.mp4"));
candidates.push(dir.join("public").join("video.mp4"));
steps += 1;
if steps > 6 { break; }
ancestor_opt = dir.parent();
}
}
log::info!("📋 Total search candidates: {}", candidates.len());
for (i, candidate) in candidates.iter().enumerate() {
log::info!(" {}. {}", i + 1, candidate.display());
}
for candidate in candidates {
if tokio::fs::metadata(&candidate).await.is_ok() {
let path_string = candidate.to_string_lossy().to_string();
log::info!("✅ Found video file: {}", path_string);
// Update backend player state
{
let mut state = player_state_for_load.lock().await;
let title = candidate.file_name().and_then(|n| n.to_str()).unwrap_or("video.mp4").to_string();
let video_info = VideoInfo {
path: path_string.clone(),
title,
duration: None,
size: None,
format: None,
};
state.load_video(video_info);
// 默认启用循环并开始播放
state.set_loop(true);
state.play();
}
// Notify frontend to load and play the video (use absolute path; frontend will add file:// if needed)
let _ = app_handle_load.emit("player-command", serde_json::json!({
"type": "loadVideo",
"path": path_string
}));
let _ = app_handle_load.emit("player-command", serde_json::json!({
"type": "setLoop",
"enabled": true
}));
let _ = app_handle_load.emit("player-command", serde_json::json!({
"type": "play"
}));
// Re-emit after a short delay to ensure frontend listeners are ready in dev mode
let app_handle_clone = app_handle_load.clone();
let delayed_path = path_string.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
let _ = app_handle_clone.emit("player-command", serde_json::json!({
"type": "loadVideo",
"path": delayed_path
}));
let _ = app_handle_clone.emit("player-command", serde_json::json!({
"type": "setLoop",
"enabled": true
}));
let _ = app_handle_clone.emit("player-command", serde_json::json!({
"type": "play"
}));
});
return; // Exit early since we found a video
}
}
log::warn!("❌ No video.mp4 file found in any search location");
});
// Setup tray
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&quit_i])?;
@@ -50,7 +138,7 @@ pub fn run() {
})
.build(app)?;
log::info!("Video Player initialized, WebSocket server starting on port 8080");
log::info!("Video Player initialized, WebSocket server starting on port 6666");
Ok(())
})
.invoke_handler(tauri::generate_handler![

View File

@@ -1,6 +1,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use env_logger::Env;
fn main() {
env_logger::init();
let env = Env::default().default_filter_or("info");
let _ = env_logger::Builder::from_env(env)
.format_timestamp_millis()
.try_init();
video_player_lib::run();
}

View File

@@ -17,7 +17,7 @@ impl WebSocketServer {
}
pub async fn start(&self, app_handle: AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let addr = format!("127.0.0.1:{}", self.port);
let addr = format!("0.0.0.0:{}", self.port);
let listener = TcpListener::bind(&addr).await?;
log::info!("WebSocket server listening on: {}", addr);
@@ -67,6 +67,23 @@ async fn handle_connection(
let status_update = StatusUpdate::new(state.clone());
let message = serde_json::to_string(&status_update)?;
ws_sender.send(Message::Text(message)).await?;
if let Some(video) = &state.current_video {
let load_msg = serde_json::json!({
"type": "command",
"data": { "type": "loadVideo", "path": video.path }
});
ws_sender.send(Message::Text(load_msg.to_string())).await.ok();
let loop_msg = serde_json::json!({
"type": "command",
"data": { "type": "setLoop", "enabled": state.is_looping }
});
ws_sender.send(Message::Text(loop_msg.to_string())).await.ok();
let play_msg = serde_json::json!({
"type": "command",
"data": { "type": "play" }
});
ws_sender.send(Message::Text(play_msg.to_string())).await.ok();
}
}
// Emit connection status to frontend
@@ -85,6 +102,12 @@ async fn handle_connection(
if ws_message.msg_type == "command" {
if let Ok(command) = serde_json::from_value::<PlaybackCommand>(ws_message.data) {
handle_playback_command(command, &player_state, &app_handle).await?;
// Send updated status to client after handling command
let state = player_state.lock().await;
let status_update = StatusUpdate::new(state.clone());
let message = serde_json::to_string(&status_update)?;
ws_sender.send(Message::Text(message)).await?;
}
}
}

View File

@@ -3,7 +3,7 @@
// Simple WebSocket client to test our player
const WebSocket = require('ws');
const ws = new WebSocket('ws://127.0.0.1:8080');
const ws = new WebSocket('ws://127.0.0.1:6666');
ws.on('open', function open() {
console.log('✅ Connected to video player WebSocket server');

BIN
video.mp4 Normal file

Binary file not shown.