Files
DPM/src/pages/Screen.jsx
T

406 lines
13 KiB
React
Raw Normal View History

2026-05-12 23:19:37 +08:00
import { useState, useEffect, useRef, useCallback } from 'react';
import * as api from '../utils/api';
2026-05-13 01:19:41 +08:00
import { API_BASE } from '../utils/api';
2026-05-13 03:00:55 +08:00
import { getCurrentWindow } from '@tauri-apps/api/window';
2026-05-12 23:19:37 +08:00
import '../styles/screen.css';
/* ============ Loader ============ */
function Loader({ hidden, text }) {
return (
<div className={`loader${hidden ? ' hidden' : ''}`}>
<div className="loader-brand">
<div className="mark">
<div className="iso-right"></div>
<div className="iso-dot"></div>
</div>
<div className="label">PineSound</div>
</div>
<div className="loader-ring">
<div className="ring"></div>
<div className="ring"></div>
<div className="ring"></div>
</div>
<div className="loader-hint" dangerouslySetInnerHTML={{ __html: text }} />
</div>
);
}
/* =========================================================
Screen Page
========================================================= */
export default function Screen() {
const [list, setList] = useState([]);
const [index, setIndex] = useState(0);
const [volume, setVolume] = useState(0.8);
const [playMode, setPlayMode] = useState('sequential');
const [imageDuration, setImageDuration] = useState(5);
const [paused, setPaused] = useState(false);
const [soundBlocked, setSoundBlocked] = useState(false);
const [loaded, setLoaded] = useState(false);
const [loaderText, setLoaderText] = useState('加载播放列表中...');
2026-05-13 00:10:59 +08:00
const [mediaKey, setMediaKey] = useState(0); // 强制重新渲染,用于单项目循环
2026-05-12 23:19:37 +08:00
const [showVideo, setShowVideo] = useState(true);
const videoRef = useRef(null);
const imgRef = useRef(null);
const timerRef = useRef(null);
2026-05-13 00:10:59 +08:00
// 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]);
2026-05-12 23:19:37 +08:00
const getUrl = useCallback((item) => {
2026-05-13 01:19:41 +08:00
return item.source === 'url' ? item.relative_path : `${API_BASE}/file/${item.relative_path}`;
2026-05-12 23:19:37 +08:00
}, []);
2026-05-13 00:10:59 +08:00
// ============ 核心播放控制 ============
2026-05-12 23:19:37 +08:00
2026-05-13 00:10:59 +08:00
/** 稳定的 next — 始终推进索引(不检查 paused),永不停止 */
const next = useCallback(() => {
2026-05-12 23:19:37 +08:00
if (timerRef.current) clearTimeout(timerRef.current);
2026-05-13 00:10:59 +08:00
const len = listRef.current.length;
if (len === 0) return;
const mode = playModeRef.current;
2026-05-12 23:19:37 +08:00
setIndex(prev => {
2026-05-13 00:10:59 +08:00
if (mode === 'random') {
return Math.floor(Math.random() * len);
2026-05-12 23:19:37 +08:00
}
2026-05-13 00:10:59 +08:00
// 关键:始终循环,(prev + 1) % len 保证永不越界
return (prev + 1) % len;
2026-05-12 23:19:37 +08:00
});
2026-05-13 00:10:59 +08:00
// 单项目时 index 不变((0+1)%1=0),用 mediaKey 强制渲染
setMediaKey(k => k + 1);
}, []);
2026-05-12 23:19:37 +08:00
2026-05-13 00:10:59 +08:00
const skip = useCallback((delta) => {
2026-05-12 23:19:37 +08:00
if (timerRef.current) clearTimeout(timerRef.current);
2026-05-13 00:10:59 +08:00
const len = listRef.current.length;
if (len === 0) return;
const mode = playModeRef.current;
2026-05-12 23:19:37 +08:00
setIndex(prev => {
2026-05-13 00:10:59 +08:00
if (mode === 'random') {
return Math.floor(Math.random() * len);
2026-05-12 23:19:37 +08:00
}
2026-05-13 00:10:59 +08:00
return (prev + delta + len) % len;
2026-05-12 23:19:37 +08:00
});
2026-05-13 00:10:59 +08:00
setMediaKey(k => k + 1);
}, []);
2026-05-12 23:19:37 +08:00
2026-05-13 00:10:59 +08:00
// ============ 播放效果 ============
2026-05-12 23:19:37 +08:00
2026-05-13 00:10:59 +08:00
// index / mediaKey 变化时播放对应项
2026-05-12 23:19:37 +08:00
useEffect(() => {
2026-05-13 00:10:59 +08:00
if (!loaded || list.length === 0 || paused) return;
2026-05-12 23:19:37 +08:00
const item = list[index];
if (!item) return;
2026-05-13 01:19:41 +08:00
// 同步状态到后端(字段名 type 与 Rust PlaybackState 的 #[serde(rename = "type")] 匹配)
2026-05-13 00:10:59 +08:00
api.updateState({
status: 'playing',
index,
name: item.name || '',
2026-05-13 01:19:41 +08:00
type: item.type || '',
2026-05-13 00:10:59 +08:00
}).catch(() => {});
2026-05-12 23:19:37 +08:00
if (item.type === 'video') {
2026-05-13 00:10:59 +08:00
setShowVideo(true);
2026-05-12 23:19:37 +08:00
const video = videoRef.current;
2026-05-13 00:10:59 +08:00
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(() => {});
});
2026-05-12 23:19:37 +08:00
}
} else {
2026-05-13 00:10:59 +08:00
// 图片
setShowVideo(false);
const url = getUrl(item);
imgRef.current.src = url;
2026-05-12 23:19:37 +08:00
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(next, imageDuration * 1000);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-05-13 00:10:59 +08:00
}, [index, mediaKey, loaded, paused]);
// 恢复播放: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]);
// ============ 事件监听 ============
2026-05-12 23:19:37 +08:00
2026-05-13 00:10:59 +08:00
// 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]);
// 加载播放列表
2026-05-12 23:19:37 +08:00
useEffect(() => {
const load = async () => {
try {
const data = await api.getPlaylist();
const files = data.files || [];
const vol = (data.volume || 80) / 100;
setList(files);
setVolume(vol);
setPlayMode(data.play_mode || 'sequential');
setImageDuration(data.image_duration || 5);
if (videoRef.current) videoRef.current.volume = vol;
if (files.length === 0) {
setLoaderText('播放列表为空,<a href="/admin">前往管理后台添加</a>');
return;
}
setLoaded(true);
2026-05-13 00:10:59 +08:00
// 尝试启用声音
2026-05-12 23:19:37 +08:00
const video = videoRef.current;
if (video) {
video.muted = false;
const testPlay = video.play();
if (testPlay !== undefined) {
testPlay.then(() => {
setSoundBlocked(false);
video.pause();
}).catch(() => {
setSoundBlocked(true);
video.muted = true;
});
}
}
} catch {/* ignore */}
};
load();
}, []);
2026-05-13 00:10:59 +08:00
// SSE 连接
2026-05-12 23:19:37 +08:00
useEffect(() => {
2026-05-13 01:19:41 +08:00
const es = new EventSource(`${API_BASE}/api/events`);
2026-05-12 23:19:37 +08:00
es.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
switch (msg.action) {
case 'pause':
setPaused(true);
if (timerRef.current) clearTimeout(timerRef.current);
if (videoRef.current) videoRef.current.pause();
break;
case 'play':
setPaused(false);
2026-05-13 00:10:59 +08:00
// 由 paused → false 的 useEffect 自动恢复播放
2026-05-12 23:19:37 +08:00
break;
case 'next':
setPaused(false);
skip(1);
break;
case 'prev':
setPaused(false);
skip(-1);
break;
case 'playlist_changed':
reloadPlaylist();
break;
case 'settings_changed':
applySettings(msg);
break;
}
} catch {/* ignore */}
};
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-05-13 00:10:59 +08:00
}, [skip]);
2026-05-12 23:19:37 +08:00
const reloadPlaylist = useCallback(async () => {
try {
const data = await api.getPlaylist();
const newList = data.files || [];
2026-05-13 00:10:59 +08:00
const currentItem = listRef.current[indexRef.current] || null;
2026-05-12 23:19:37 +08:00
if (newList.length === 0) {
setList([]);
setLoaded(false);
setLoaderText('播放列表已清空,<a href="/admin">前往管理后台添加</a>');
const video = videoRef.current;
if (video) video.pause();
return;
}
setLoaded(true);
let newIndex = -1;
if (currentItem) {
newIndex = newList.findIndex(item => item.relative_path === currentItem.relative_path);
}
if (newIndex >= 0) {
setList(newList);
setIndex(newIndex);
} else {
setList(newList);
setIndex(prev => Math.min(prev, newList.length - 1));
}
} catch {/* ignore */}
2026-05-13 00:10:59 +08:00
}, []);
2026-05-12 23:19:37 +08:00
const applySettings = useCallback((msg) => {
if (msg.volume !== undefined) {
const vol = msg.volume / 100;
setVolume(vol);
const video = videoRef.current;
if (video) {
video.volume = vol;
2026-05-13 00:10:59 +08:00
if (soundBlockedRef.current) {
2026-05-12 23:19:37 +08:00
video.muted = false;
video.play().then(() => {
setSoundBlocked(false);
const hint = document.getElementById('soundHint');
if (hint) hint.style.display = 'none';
}).catch(() => {
video.muted = true;
video.play().catch(() => {});
});
}
}
}
if (msg.play_mode !== undefined) setPlayMode(msg.play_mode);
if (msg.image_duration !== undefined) setImageDuration(msg.image_duration);
2026-05-13 00:10:59 +08:00
}, []);
2026-05-12 23:19:37 +08:00
const enableSound = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.muted = false;
video.play().then(() => {
setSoundBlocked(false);
const hint = document.getElementById('soundHint');
if (hint) hint.style.display = 'none';
}).catch(() => {
video.muted = true;
video.play().catch(() => {});
});
}, []);
// Global click for sound unblock
useEffect(() => {
const handler = () => {
2026-05-13 00:10:59 +08:00
if (soundBlockedRef.current) enableSound();
2026-05-12 23:19:37 +08:00
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
2026-05-13 00:10:59 +08:00
}, [enableSound]);
2026-05-12 23:19:37 +08:00
2026-05-13 03:00:55 +08:00
// Escape 退出 Tauri 窗口全屏
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') {
getCurrentWindow().setFullscreen(false);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
2026-05-12 23:19:37 +08:00
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const currentItem = list[index] || null;
return (
<div className="screen-page">
{/* Perspective Grid */}
<div className="perspective-grid"></div>
{/* Ambient Orbs */}
<div className="ambient-orb orb-1"></div>
<div className="ambient-orb orb-2"></div>
<div className="ambient-orb orb-3"></div>
{/* Loader */}
<Loader hidden={loaded && list.length > 0} text={loaderText} />
2026-05-13 00:10:59 +08:00
{/* Media Players — key={mediaKey} 强制重建以实现单项目循环 */}
2026-05-12 23:19:37 +08:00
<video
ref={videoRef}
id="player"
2026-05-13 00:10:59 +08:00
key={mediaKey}
2026-05-12 23:19:37 +08:00
autoPlay
playsInline
controlsList="nodownload"
style={{ display: showVideo && loaded ? 'block' : 'none' }}
/>
<img
ref={imgRef}
id="imgPlayer"
alt=""
style={{ display: !showVideo && loaded ? 'block' : 'none' }}
/>
{/* Corner Info — Now Playing */}
<div className={`corner-info${loaded && currentItem ? ' visible' : ''}`}>
<div className="ci-dot"></div>
2026-05-13 01:19:41 +08:00
<span className="ci-text">{'昆明市大学生创业园'}</span>
2026-05-12 23:19:37 +08:00
</div>
{/* Connection Indicator */}
<div className={`conn-indicator${loaded ? ' visible' : ''}`}>
<div className="conn-rings">
<div className="cr-inner"></div>
<div className="cr-outer"></div>
</div>
<span className="conn-label">Live</span>
</div>
{/* Bottom Status */}
<div className={`status-bar${loaded ? ' visible' : ''}`}>
<div className={`sb-dot${paused ? ' idle' : ''}`}></div>
<span className="sb-text">{currentItem ? `${index + 1} / ${list.length}` : '0 / 0'}</span>
</div>
{/* Watermark */}
<div className="watermark">云南派音人工智能科技提供技术支持</div>
2026-05-12 23:19:37 +08:00
{/* Sound Hint */}
<div id="soundHint" style={{ display: soundBlocked && loaded ? 'block' : 'none' }} onClick={enableSound}>
2026-05-13 01:19:41 +08:00
声音
2026-05-12 23:19:37 +08:00
</div>
</div>
);
}