- 修复循环播放

This commit is contained in:
Pine
2026-05-13 00:10:59 +08:00
parent 4ad266f6c4
commit 7794012299
4 changed files with 182 additions and 127 deletions
+22
View File
@@ -3,6 +3,9 @@ mod models;
mod server; mod server;
mod storage; mod storage;
use std::io::{Read, Write};
use std::time::Duration;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// 在后台线程启动 HTTP 服务器 // 在后台线程启动 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() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+49 -3
View File
@@ -18,7 +18,7 @@ use std::fs;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile}; use tower_http::services::ServeDir;
// ===================== 媒体目录 ===================== // ===================== 媒体目录 =====================
fn media_dir() -> PathBuf { 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<PathBuf> = {
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 { 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() Router::new()
.route("/api/login", post(login_handler)) .route("/api/login", post(login_handler))
.route("/api/settings", get(get_settings).post(update_settings)) .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/control", post(control_handler))
.route("/api/state", get(get_state).post(update_state)) .route("/api/state", get(get_state).post(update_state))
.route("/api/events", get(sse_handler)) .route("/api/events", get(sse_handler))
// 媒体文件:用户目录 ~/Downloads/Media/
.nest_service("/file", ServeDir::new(media_dir_str())) .nest_service("/file", ServeDir::new(media_dir_str()))
// SPA 静待文件回退
.fallback_service( .fallback_service(
ServeDir::new("../dist") ServeDir::new(&dist_str)
.append_index_html_on_directories(true) .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()) .layer(CorsLayer::permissive())
.with_state(state) .with_state(state)
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.pine.dpm", "identifier": "com.pine.dpm",
"build": { "build": {
"beforeDevCommand": "yarn dev", "beforeDevCommand": "TAURI_DEV_HOST=0.0.0.0 yarn dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "yarn build", "beforeBuildCommand": "yarn build",
"frontendDist": "../dist" "frontendDist": "../dist"
+110 -123
View File
@@ -36,137 +36,138 @@ export default function Screen() {
const [soundBlocked, setSoundBlocked] = useState(false); const [soundBlocked, setSoundBlocked] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [loaderText, setLoaderText] = useState('加载播放列表中...'); const [loaderText, setLoaderText] = useState('加载播放列表中...');
const [mediaKey, setMediaKey] = useState(0); // 强制重新渲染,用于单项目循环
const [showVideo, setShowVideo] = useState(true); const [showVideo, setShowVideo] = useState(true);
const [currentSrc, setCurrentSrc] = useState('');
const videoRef = useRef(null); const videoRef = useRef(null);
const imgRef = useRef(null); const imgRef = useRef(null);
const timerRef = 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) => { const getUrl = useCallback((item) => {
return item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`; 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 /** 稳定的 next — 始终推进索引(不检查 paused),永不停止 */
const next = useCallback(() => {
const play = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
if (paused) return; const len = listRef.current.length;
const item = list[index]; if (len === 0) return;
if (!item) return; const mode = playModeRef.current;
const url = getUrl(item); setIndex(prev => {
if (mode === 'random') {
if (item.type === 'video') { return Math.floor(Math.random() * len);
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');
});
}
} }
} else { // 关键:始终循环,(prev + 1) % len 保证永不越界
// Image return (prev + 1) % len;
setShowVideo(false); });
setCurrentSrc(url); // 单项目时 index 不变((0+1)%1=0),用 mediaKey 强制渲染
reportState('playing'); setMediaKey(k => k + 1);
timerRef.current = setTimeout(next, imageDuration * 1000); }, []);
}
}, [list, index, paused, getUrl, soundBlocked, imageDuration, reportState]);
const skip = useCallback((delta) => { const skip = useCallback((delta) => {
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
const len = listRef.current.length;
if (len === 0) return;
const mode = playModeRef.current;
setIndex(prev => { setIndex(prev => {
if (playMode === 'random') { if (mode === 'random') {
return Math.floor(Math.random() * list.length); 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(() => { useEffect(() => {
if (loaded && list.length > 0) { if (!loaded || list.length === 0 || paused) return;
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;
const item = list[index]; const item = list[index];
if (!item) return; if (!item) return;
// 同步状态到后端
api.updateState({
status: 'playing',
index,
name: item.name || '',
media_type: item.type || '',
}).catch(() => {});
if (item.type === 'video') { if (item.type === 'video') {
setShowVideo(true);
const video = videoRef.current; const video = videoRef.current;
if (video) { if (!video) return;
video.style.display = 'block'; const url = getUrl(item);
video.src = currentSrc; video.src = url;
video.load(); video.load();
video.muted = soundBlocked; video.muted = soundBlocked;
const p = video.play(); video.volume = volume;
if (p !== undefined) { const p = video.play();
p.then(() => reportState('playing')) if (p !== undefined) {
.catch(() => { p.catch(() => {
setSoundBlocked(true); setSoundBlocked(true);
video.muted = true; video.muted = true;
video.play().catch(() => {}); video.play().catch(() => {});
reportState('playing'); });
});
}
} }
} else { } else {
// 图片
setShowVideo(false);
const url = getUrl(item);
imgRef.current.src = url;
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
reportState('playing');
timerRef.current = setTimeout(next, imageDuration * 1000); timerRef.current = setTimeout(next, imageDuration * 1000);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
@@ -185,7 +186,7 @@ export default function Screen() {
} }
setLoaded(true); setLoaded(true);
// Try play with sound // 尝试启用声音
const video = videoRef.current; const video = videoRef.current;
if (video) { if (video) {
video.muted = false; video.muted = false;
@@ -194,21 +195,18 @@ export default function Screen() {
testPlay.then(() => { testPlay.then(() => {
setSoundBlocked(false); setSoundBlocked(false);
video.pause(); video.pause();
reportState('idle');
}).catch(() => { }).catch(() => {
setSoundBlocked(true); setSoundBlocked(true);
video.muted = true; video.muted = true;
reportState('idle');
}); });
} }
} }
} catch {/* ignore */} } catch {/* ignore */}
}; };
load(); load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Connect SSE // SSE 连接
useEffect(() => { useEffect(() => {
const es = new EventSource('/api/events'); const es = new EventSource('/api/events');
es.onmessage = (e) => { es.onmessage = (e) => {
@@ -219,12 +217,10 @@ export default function Screen() {
setPaused(true); setPaused(true);
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
if (videoRef.current) videoRef.current.pause(); if (videoRef.current) videoRef.current.pause();
reportState('paused');
break; break;
case 'play': case 'play':
setPaused(false); setPaused(false);
play(); // 由 paused → false 的 useEffect 自动恢复播放
reportState('playing');
break; break;
case 'next': case 'next':
setPaused(false); setPaused(false);
@@ -245,13 +241,13 @@ export default function Screen() {
}; };
return () => es.close(); return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [play, skip, reportState]); }, [skip]);
const reloadPlaylist = useCallback(async () => { const reloadPlaylist = useCallback(async () => {
try { try {
const data = await api.getPlaylist(); const data = await api.getPlaylist();
const newList = data.files || []; const newList = data.files || [];
const currentItem = list[index] || null; const currentItem = listRef.current[indexRef.current] || null;
if (newList.length === 0) { if (newList.length === 0) {
setList([]); setList([]);
@@ -276,7 +272,7 @@ export default function Screen() {
setIndex(prev => Math.min(prev, newList.length - 1)); setIndex(prev => Math.min(prev, newList.length - 1));
} }
} catch {/* ignore */} } catch {/* ignore */}
}, [list, index]); }, []);
const applySettings = useCallback((msg) => { const applySettings = useCallback((msg) => {
if (msg.volume !== undefined) { if (msg.volume !== undefined) {
@@ -285,7 +281,7 @@ export default function Screen() {
const video = videoRef.current; const video = videoRef.current;
if (video) { if (video) {
video.volume = vol; video.volume = vol;
if (soundBlocked) { if (soundBlockedRef.current) {
video.muted = false; video.muted = false;
video.play().then(() => { video.play().then(() => {
setSoundBlocked(false); setSoundBlocked(false);
@@ -300,7 +296,7 @@ export default function Screen() {
} }
if (msg.play_mode !== undefined) setPlayMode(msg.play_mode); if (msg.play_mode !== undefined) setPlayMode(msg.play_mode);
if (msg.image_duration !== undefined) setImageDuration(msg.image_duration); if (msg.image_duration !== undefined) setImageDuration(msg.image_duration);
}, [soundBlocked]); }, []);
const enableSound = useCallback(() => { const enableSound = useCallback(() => {
const video = videoRef.current; 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 // Global click for sound unblock
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
if (soundBlocked) enableSound(); if (soundBlockedRef.current) enableSound();
}; };
document.addEventListener('click', handler); document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler); return () => document.removeEventListener('click', handler);
}, [soundBlocked, enableSound]); }, [enableSound]);
// Cleanup timer on unmount // Cleanup timer on unmount
useEffect(() => { useEffect(() => {
@@ -355,10 +342,11 @@ export default function Screen() {
{/* Loader */} {/* Loader */}
<Loader hidden={loaded && list.length > 0} text={loaderText} /> <Loader hidden={loaded && list.length > 0} text={loaderText} />
{/* Media Players */} {/* Media Players — key={mediaKey} 强制重建以实现单项目循环 */}
<video <video
ref={videoRef} ref={videoRef}
id="player" id="player"
key={mediaKey}
autoPlay autoPlay
playsInline playsInline
controlsList="nodownload" controlsList="nodownload"
@@ -368,7 +356,6 @@ export default function Screen() {
ref={imgRef} ref={imgRef}
id="imgPlayer" id="imgPlayer"
alt="" alt=""
src={!showVideo && loaded && currentItem ? getUrl(currentItem) : undefined}
style={{ display: !showVideo && loaded ? 'block' : 'none' }} style={{ display: !showVideo && loaded ? 'block' : 'none' }}
/> />