diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70c007e..bc57f33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,9 @@ mod models; mod server; mod storage; +use std::io::{Read, Write}; +use std::time::Duration; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // 在后台线程启动 HTTP 服务器 @@ -13,6 +16,25 @@ pub fn run() { }); }); + // 等待 HTTP 服务器就绪后再打开窗口 + for i in 0..50 { + if let Ok(mut s) = std::net::TcpStream::connect_timeout( + &"127.0.0.1:8000".parse().unwrap(), + Duration::from_millis(500), + ) { + // 发送一个简单的 HTTP 请求测试 + let _ = s.write_all(b"GET /api/state HTTP/1.0\r\n\r\n"); + let mut buf = [0u8; 4]; + if s.read(&mut buf).is_ok() { + break; + } + } + if i == 0 { + eprintln!("[dpm] 等待 HTTP 服务器启动..."); + } + std::thread::sleep(Duration::from_millis(100)); + } + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .run(tauri::generate_context!()) diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index d6500e0..f6e8622 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -18,7 +18,7 @@ use std::fs; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; use tower_http::cors::CorsLayer; -use tower_http::services::{ServeDir, ServeFile}; +use tower_http::services::ServeDir; // ===================== 媒体目录 ===================== fn media_dir() -> PathBuf { @@ -46,8 +46,50 @@ impl AppState { } } +/// 智能查找 dist 目录(前端静态文件) +/// 支持多种运行场景: +/// - tauri dev 时 CWD = src-tauri/ → ../dist +/// - 二进制在 target/release/dpm → ../../dist +/// - 从项目根目录运行时 → dist +fn dist_dir() -> PathBuf { + let candidates: Vec = { + let mut c = Vec::new(); + // 1. 相对于当前工作目录 + c.push(PathBuf::from("dist")); + c.push(PathBuf::from("../dist")); + // 2. 相对于可执行文件路径 + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + // 二进制在 src-tauri/target/release/dpm → dist 在 dpm/dist/ + c.push(dir.join("../../../dist")); + // macOS .app bundle: dpm.app/Contents/MacOS/dpm + c.push(dir.join("../Resources/dist")); + } + } + c + }; + + for p in &candidates { + let canonical = p.canonicalize(); + if let Ok(path) = canonical { + if path.join("index.html").exists() { + return path; + } + } + } + + // Fallback:直接尝试最常见的路径 + let fallback = PathBuf::from("../dist"); + eprintln!("[dpm] 警告: 找不到 dist 目录 (已尝试 {:?}),使用: {:?}", candidates, fallback); + fallback +} + // ===================== 路由构建 ===================== pub fn create_router(state: AppState) -> Router { + let dist = dist_dir(); + println!("[dpm] 静态文件目录: {:?}", dist); + let dist_str = dist.to_string_lossy().to_string(); + Router::new() .route("/api/login", post(login_handler)) .route("/api/settings", get(get_settings).post(update_settings)) @@ -61,11 +103,15 @@ pub fn create_router(state: AppState) -> Router { .route("/api/control", post(control_handler)) .route("/api/state", get(get_state).post(update_state)) .route("/api/events", get(sse_handler)) + // 媒体文件:用户目录 ~/Downloads/Media/ .nest_service("/file", ServeDir::new(media_dir_str())) + // SPA 静待文件回退 .fallback_service( - ServeDir::new("../dist") + ServeDir::new(&dist_str) .append_index_html_on_directories(true) - .fallback(ServeFile::new("../dist/index.html")), + .fallback(tower_http::services::ServeFile::new( + format!("{}/index.html", dist_str), + )), ) .layer(CorsLayer::permissive()) .with_state(state) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f5e05d8..e9e43ab 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.1.0", "identifier": "com.pine.dpm", "build": { - "beforeDevCommand": "yarn dev", + "beforeDevCommand": "TAURI_DEV_HOST=0.0.0.0 yarn dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "yarn build", "frontendDist": "../dist" diff --git a/src/pages/Screen.jsx b/src/pages/Screen.jsx index 01c82ba..236972e 100644 --- a/src/pages/Screen.jsx +++ b/src/pages/Screen.jsx @@ -36,137 +36,138 @@ export default function Screen() { const [soundBlocked, setSoundBlocked] = useState(false); const [loaded, setLoaded] = useState(false); const [loaderText, setLoaderText] = useState('加载播放列表中...'); + const [mediaKey, setMediaKey] = useState(0); // 强制重新渲染,用于单项目循环 const [showVideo, setShowVideo] = useState(true); - const [currentSrc, setCurrentSrc] = useState(''); const videoRef = useRef(null); const imgRef = useRef(null); const timerRef = useRef(null); + // ref 存储可变值,避免回调中的闭包过期 + const listRef = useRef(list); + const indexRef = useRef(index); + const pausedRef = useRef(paused); + const playModeRef = useRef(playMode); + const imageDurationRef = useRef(imageDuration); + const soundBlockedRef = useRef(soundBlocked); + + useEffect(() => { listRef.current = list; }, [list]); + useEffect(() => { indexRef.current = index; }, [index]); + useEffect(() => { pausedRef.current = paused; }, [paused]); + useEffect(() => { playModeRef.current = playMode; }, [playMode]); + useEffect(() => { imageDurationRef.current = imageDuration; }, [imageDuration]); + useEffect(() => { soundBlockedRef.current = soundBlocked; }, [soundBlocked]); + const getUrl = useCallback((item) => { return item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`; }, []); - const reportState = useCallback((status) => { - const item = list[index] || null; - api.updateState({ - status, - index, - name: item ? item.name : '', - type: item ? item.type : '', - }).catch(() => {}); - }, [list, index]); + // ============ 核心播放控制 ============ - const updateStatusUI = useCallback(() => { }, []); // CSS handles visual feedback - - const play = useCallback(() => { + /** 稳定的 next — 始终推进索引(不检查 paused),永不停止 */ + const next = useCallback(() => { if (timerRef.current) clearTimeout(timerRef.current); - if (paused) return; - const item = list[index]; - if (!item) return; - const url = getUrl(item); - - if (item.type === 'video') { - setShowVideo(true); - setCurrentSrc(url); - const video = videoRef.current; - if (video) { - video.style.display = 'block'; - video.src = url; - video.load(); - video.muted = soundBlocked; - const p = video.play(); - if (p !== undefined) { - p.then(() => { - reportState('playing'); - }).catch(() => { - setSoundBlocked(true); - video.muted = true; - video.play().catch(() => {}); - reportState('playing'); - }); - } + const len = listRef.current.length; + if (len === 0) return; + const mode = playModeRef.current; + setIndex(prev => { + if (mode === 'random') { + return Math.floor(Math.random() * len); } - } else { - // Image - setShowVideo(false); - setCurrentSrc(url); - reportState('playing'); - timerRef.current = setTimeout(next, imageDuration * 1000); - } - }, [list, index, paused, getUrl, soundBlocked, imageDuration, reportState]); + // 关键:始终循环,(prev + 1) % len 保证永不越界 + return (prev + 1) % len; + }); + // 单项目时 index 不变((0+1)%1=0),用 mediaKey 强制渲染 + setMediaKey(k => k + 1); + }, []); const skip = useCallback((delta) => { if (timerRef.current) clearTimeout(timerRef.current); + const len = listRef.current.length; + if (len === 0) return; + const mode = playModeRef.current; setIndex(prev => { - if (playMode === 'random') { - return Math.floor(Math.random() * list.length); + if (mode === 'random') { + return Math.floor(Math.random() * len); } - return (prev + delta + list.length) % list.length; + return (prev + delta + len) % len; }); - }, [playMode, list.length]); + setMediaKey(k => k + 1); + }, []); - const next = useCallback(() => { - if (paused) return; - if (timerRef.current) clearTimeout(timerRef.current); - setIndex(prev => { - if (playMode === 'random') { - return Math.floor(Math.random() * list.length); - } - return (prev + 1) % list.length; - }); - }, [paused, playMode, list.length]); + // ============ 播放效果 ============ - // Play when index changes + // index / mediaKey 变化时播放对应项 useEffect(() => { - if (loaded && list.length > 0) { - const item = list[index]; - if (item) { - if (item.type === 'video') { - setShowVideo(true); - setCurrentSrc(getUrl(item)); - } else { - const url = getUrl(item); - setShowVideo(false); - setCurrentSrc(url); - } - } - } - }, [index, loaded]); - - // Separate effect for playback triggered by src changes - useEffect(() => { - if (!loaded || list.length === 0 || !currentSrc) return; + if (!loaded || list.length === 0 || paused) return; const item = list[index]; if (!item) return; + // 同步状态到后端 + api.updateState({ + status: 'playing', + index, + name: item.name || '', + media_type: item.type || '', + }).catch(() => {}); + if (item.type === 'video') { + setShowVideo(true); const video = videoRef.current; - if (video) { - video.style.display = 'block'; - video.src = currentSrc; - video.load(); - video.muted = soundBlocked; - const p = video.play(); - if (p !== undefined) { - p.then(() => reportState('playing')) - .catch(() => { - setSoundBlocked(true); - video.muted = true; - video.play().catch(() => {}); - reportState('playing'); - }); - } + if (!video) return; + const url = getUrl(item); + video.src = url; + video.load(); + video.muted = soundBlocked; + video.volume = volume; + const p = video.play(); + if (p !== undefined) { + p.catch(() => { + setSoundBlocked(true); + video.muted = true; + video.play().catch(() => {}); + }); } } else { + // 图片 + setShowVideo(false); + const url = getUrl(item); + imgRef.current.src = url; if (timerRef.current) clearTimeout(timerRef.current); - reportState('playing'); timerRef.current = setTimeout(next, imageDuration * 1000); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [index]); + }, [index, mediaKey, loaded, paused]); - // Load playlist on mount + // 恢复播放:paused 从 true → false 时触发 + useEffect(() => { + if (paused || !loaded || list.length === 0) return; + const item = list[index]; + if (!item || item.type !== 'video') return; + const video = videoRef.current; + if (video && video.paused && video.src) { + video.play().catch(() => { + setSoundBlocked(true); + video.muted = true; + video.play().catch(() => {}); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paused]); + + // ============ 事件监听 ============ + + // Video ended → next(稳定,永不失效) + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const handler = () => next(); + video.addEventListener('ended', handler); + return () => video.removeEventListener('ended', handler); + // mediaKey 变化时 video 元素被 key 强制重建,需要重新绑定 + }, [next, mediaKey]); + + // 加载播放列表 useEffect(() => { const load = async () => { try { @@ -185,7 +186,7 @@ export default function Screen() { } setLoaded(true); - // Try play with sound + // 尝试启用声音 const video = videoRef.current; if (video) { video.muted = false; @@ -194,21 +195,18 @@ export default function Screen() { testPlay.then(() => { setSoundBlocked(false); video.pause(); - reportState('idle'); }).catch(() => { setSoundBlocked(true); video.muted = true; - reportState('idle'); }); } } } catch {/* ignore */} }; load(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Connect SSE + // SSE 连接 useEffect(() => { const es = new EventSource('/api/events'); es.onmessage = (e) => { @@ -219,12 +217,10 @@ export default function Screen() { setPaused(true); if (timerRef.current) clearTimeout(timerRef.current); if (videoRef.current) videoRef.current.pause(); - reportState('paused'); break; case 'play': setPaused(false); - play(); - reportState('playing'); + // 由 paused → false 的 useEffect 自动恢复播放 break; case 'next': setPaused(false); @@ -245,13 +241,13 @@ export default function Screen() { }; return () => es.close(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [play, skip, reportState]); + }, [skip]); const reloadPlaylist = useCallback(async () => { try { const data = await api.getPlaylist(); const newList = data.files || []; - const currentItem = list[index] || null; + const currentItem = listRef.current[indexRef.current] || null; if (newList.length === 0) { setList([]); @@ -276,7 +272,7 @@ export default function Screen() { setIndex(prev => Math.min(prev, newList.length - 1)); } } catch {/* ignore */} - }, [list, index]); + }, []); const applySettings = useCallback((msg) => { if (msg.volume !== undefined) { @@ -285,7 +281,7 @@ export default function Screen() { const video = videoRef.current; if (video) { video.volume = vol; - if (soundBlocked) { + if (soundBlockedRef.current) { video.muted = false; video.play().then(() => { setSoundBlocked(false); @@ -300,7 +296,7 @@ export default function Screen() { } if (msg.play_mode !== undefined) setPlayMode(msg.play_mode); if (msg.image_duration !== undefined) setImageDuration(msg.image_duration); - }, [soundBlocked]); + }, []); const enableSound = useCallback(() => { const video = videoRef.current; @@ -316,23 +312,14 @@ export default function Screen() { }); }, []); - // Video ended handler - useEffect(() => { - const video = videoRef.current; - if (!video) return; - const handler = () => next(); - video.addEventListener('ended', handler); - return () => video.removeEventListener('ended', handler); - }, [next]); - // Global click for sound unblock useEffect(() => { const handler = () => { - if (soundBlocked) enableSound(); + if (soundBlockedRef.current) enableSound(); }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); - }, [soundBlocked, enableSound]); + }, [enableSound]); // Cleanup timer on unmount useEffect(() => { @@ -355,10 +342,11 @@ export default function Screen() { {/* Loader */}