- tauri 设计完成
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import * as api from '../utils/api';
|
||||
import '../styles/admin.css';
|
||||
|
||||
/* ============ SVG Icons ============ */
|
||||
const IconPrev = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11 3L6 8L11 13" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
const IconPlay = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<polygon points="5,3 15,9 5,15" />
|
||||
</svg>
|
||||
);
|
||||
const IconPause = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<rect x="4" y="3" width="3" height="12" rx="1" />
|
||||
<rect x="11" y="3" width="3" height="12" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
const IconNext = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 3L10 8L5 13" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ============ Badge ============ */
|
||||
function Badge({ type, label }) {
|
||||
const map = { video: 'badge-video', image: 'badge-image', url: 'badge-url', local: 'badge-local' };
|
||||
return <span className={`badge ${map[type] || ''}`}>{label || type}</span>;
|
||||
}
|
||||
|
||||
/* ============ Toast Container ============ */
|
||||
function ToastContainer({ toasts }) {
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className={`toast ${t.type}`}>{t.msg}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ Preview Modal ============ */
|
||||
function PreviewModal({ item, onClose, onAddToPlaylist, onDelete }) {
|
||||
const [closing, setClosing] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.removeAttribute('src');
|
||||
}
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setClosing(false);
|
||||
}, 200);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') handleClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [handleClose]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const previewUrl = item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`;
|
||||
const overlayClass = `modal-overlay${closing ? ' closing' : ''}`;
|
||||
|
||||
return (
|
||||
<div className={overlayClass} onClick={(e) => { if (e.target.className.includes('modal-overlay')) handleClose(); }}>
|
||||
<div className="modal-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<span className="modal-title">{item.name}</span>
|
||||
<button className="modal-close" onClick={handleClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{item.type === 'image' ? (
|
||||
<img src={previewUrl} alt={item.name} />
|
||||
) : (
|
||||
<video ref={videoRef} src={previewUrl} controls autoPlay playsInline />
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<span className="modal-info">
|
||||
<Badge type={item.source} label={item.source === 'url' ? '远程' : '本地'} />
|
||||
<Badge type={item.type} label={item.type === 'video' ? '视频' : '图片'} />
|
||||
</span>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-accent btn-sm" onClick={() => { onAddToPlaylist(item.relative_path); handleClose(); }}>加入播放</button>
|
||||
<button className="btn-danger btn-sm" onClick={() => { onDelete(item.relative_path); handleClose(); }}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ Media Card ============ */
|
||||
function MediaCard({ item, onPreview, onAddToPlaylist, onDelete, showRemove, onRemove }) {
|
||||
const thumbUrl = item.source === 'url' ? item.relative_path : `/file/${item.relative_path}`;
|
||||
|
||||
return (
|
||||
<div className="media-card">
|
||||
{item.type === 'image' ? (
|
||||
<img src={thumbUrl} loading="lazy" className="thumb" alt={item.name} onClick={() => onPreview(item)} />
|
||||
) : (
|
||||
<video src={thumbUrl} preload="metadata" className="thumb" onClick={() => onPreview(item)} />
|
||||
)}
|
||||
<div className="body">
|
||||
<span className="name" title={item.name}>{item.name}</span>
|
||||
<div className="meta-row">
|
||||
<Badge type={item.source} label={item.source === 'url' ? '远程' : '本地'} />
|
||||
<Badge type={item.type} label={item.type === 'video' ? '视频' : '图片'} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
{showRemove ? (
|
||||
<button className="btn-danger btn-sm" onClick={(e) => { e.stopPropagation(); onRemove(item.relative_path); }}>移出</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-accent btn-sm" onClick={(e) => { e.stopPropagation(); onAddToPlaylist(item.relative_path); }}>加入播放</button>
|
||||
<button className="btn-danger btn-sm" onClick={(e) => { e.stopPropagation(); onDelete(item.relative_path); }}>删除</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Admin Page
|
||||
========================================================= */
|
||||
export default function Admin() {
|
||||
// Auth state
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(() => localStorage.getItem('token') === 'admin_logged');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Data state
|
||||
const [files, setFiles] = useState([]);
|
||||
const [playlistItems, setPlaylistItems] = useState([]);
|
||||
const [playMode, setPlayMode] = useState('sequential');
|
||||
const [imageDuration, setImageDuration] = useState(5);
|
||||
const [volume, setVolume] = useState(80);
|
||||
|
||||
// UI state
|
||||
const [statusText, setStatusText] = useState('等待大屏连接...');
|
||||
const [playState, setPlayState] = useState({ status: 'idle' });
|
||||
const [previewItem, setPreviewItem] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const toastIdRef = useRef(0);
|
||||
|
||||
// ============ Toast ============
|
||||
const addToast = useCallback((msg, type = 'success') => {
|
||||
const id = ++toastIdRef.current;
|
||||
setToasts(prev => [...prev, { id, msg, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 2800);
|
||||
}, []);
|
||||
|
||||
// ============ Auth ============
|
||||
const handleLogin = async () => {
|
||||
const data = await api.login(username, password);
|
||||
if (data.success) {
|
||||
localStorage.setItem('token', 'admin_logged');
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
addToast('登录失败,请检查账号密码', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => { if (e.key === 'Enter') handleLogin(); };
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
// ============ Data Loading ============
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.getSettings();
|
||||
setVolume(d.volume ?? 80);
|
||||
setPlayMode(d.play_mode || 'sequential');
|
||||
setImageDuration(d.image_duration || 5);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getMediaFiles();
|
||||
setFiles(data.files || []);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadPlaylist = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getPlaylist();
|
||||
setPlaylistItems(data.files || []);
|
||||
setPlayMode(data.play_mode || 'sequential');
|
||||
setImageDuration(data.image_duration ?? 5);
|
||||
setVolume(data.volume ?? 80);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(() => {
|
||||
loadSettings();
|
||||
loadFiles();
|
||||
loadPlaylist();
|
||||
}, [loadSettings, loadFiles, loadPlaylist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) loadAll();
|
||||
}, [isLoggedIn, loadAll]);
|
||||
|
||||
// ============ SSE ============
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
const es = new EventSource('/api/events');
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.action === 'state_update' && msg.state) {
|
||||
setPlayState(msg.state);
|
||||
const s = msg.state;
|
||||
if (s.status === 'idle' || !s.name) {
|
||||
setStatusText('等待大屏连接...');
|
||||
} else if (s.status === 'playing') {
|
||||
setStatusText(`正在播放 — ${s.name}`);
|
||||
} else if (s.status === 'paused') {
|
||||
setStatusText(`已暂停 — ${s.name}`);
|
||||
}
|
||||
}
|
||||
if (msg.action === 'playlist_changed') {
|
||||
loadPlaylist();
|
||||
}
|
||||
if (msg.action === 'settings_changed') {
|
||||
if (msg.volume !== undefined) setVolume(msg.volume);
|
||||
if (msg.play_mode !== undefined) setPlayMode(msg.play_mode);
|
||||
if (msg.image_duration !== undefined) setImageDuration(msg.image_duration);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
return () => es.close();
|
||||
}, [isLoggedIn, loadPlaylist]);
|
||||
|
||||
// ============ Handlers ============
|
||||
const handleUpload = async () => {
|
||||
const inp = document.getElementById('fileInput');
|
||||
const fileList = Array.from(inp.files);
|
||||
if (fileList.length === 0) { addToast('请选择文件', 'error'); return; }
|
||||
await api.uploadFiles(fileList);
|
||||
inp.value = '';
|
||||
addToast('上传完成');
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
const inp = document.getElementById('urlInput');
|
||||
const url = inp.value.trim();
|
||||
if (!url) { addToast('请输入 URL', 'error'); return; }
|
||||
const data = await api.addUrlMedia(url);
|
||||
inp.value = '';
|
||||
addToast(data.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
await api.updateSettings({ play_mode: playMode, image_duration: imageDuration, volume });
|
||||
};
|
||||
|
||||
const handleAddToPlaylist = async (path) => {
|
||||
await api.addToPlaylist(path);
|
||||
addToast('已加入播放列表');
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleRemoveFromPlaylist = async (path) => {
|
||||
await api.removeFromPlaylist(path);
|
||||
addToast('已移出播放列表');
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (path) => {
|
||||
if (!window.confirm('确定删除该文件?')) return;
|
||||
await api.deleteMedia(path);
|
||||
addToast('已删除');
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleSendControl = async (action) => {
|
||||
await api.sendControl(action);
|
||||
};
|
||||
|
||||
// ============ Render ============
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="login-wrapper">
|
||||
<div className="login-card">
|
||||
<div className="login-brand">
|
||||
<div className="icon">
|
||||
<div className="iso-top"></div>
|
||||
<div className="iso-left"></div>
|
||||
<div className="iso-right"></div>
|
||||
<div className="iso-dot"></div>
|
||||
</div>
|
||||
<h2>昆明市大学生创业园</h2>
|
||||
<p>大屏幕轮播控制系统</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>账号</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入账号" autoComplete="username" onKeyDown={handleKeyDown} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>密码</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码" autoComplete="current-password" onKeyDown={handleKeyDown} />
|
||||
</div>
|
||||
<button className="btn-login" onClick={handleLogin}>登 录</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToastContainer toasts={toasts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPlaying = playState.status === 'playing';
|
||||
const isPaused = playState.status === 'paused';
|
||||
const hasFiles = files.length > 0;
|
||||
const hasPlaylist = playlistItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className={`dashboard active`}>
|
||||
{/* Topbar */}
|
||||
<div className="topbar">
|
||||
<div className="topbar-left">
|
||||
<div className="brand-dot"></div>
|
||||
<h1>昆明市大学生创业园</h1>
|
||||
</div>
|
||||
<div className="topbar-center">
|
||||
<span className="status-bar-text">{statusText}</span>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<button className="btn-control" onClick={() => handleSendControl('prev')} title="上一个">
|
||||
<IconPrev />
|
||||
</button>
|
||||
<button className={`btn-control play-btn${isPlaying ? ' active' : ''}`} id="btnPlay" onClick={() => handleSendControl('play')} title="播放">
|
||||
<IconPlay />
|
||||
</button>
|
||||
<button className={`btn-control play-btn${isPaused ? ' active' : ''}`} id="btnPause" onClick={() => handleSendControl('pause')} title="暂停">
|
||||
<IconPause />
|
||||
</button>
|
||||
<button className="btn-control" onClick={() => handleSendControl('next')} title="下一个">
|
||||
<IconNext />
|
||||
</button>
|
||||
<span className="topbar-divider"></span>
|
||||
<button className="btn-logout" onClick={handleLogout}>退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="main-grid">
|
||||
{/* Settings Card */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-icon">{'\u2699'}</div>
|
||||
<h3>播放设置</h3>
|
||||
</div>
|
||||
<div className="form-inline">
|
||||
<div className="form-group">
|
||||
<label>播放模式</label>
|
||||
<select value={playMode} onChange={e => { setPlayMode(e.target.value); setTimeout(handleSaveSettings, 0); }}>
|
||||
<option value="sequential">顺序播放</option>
|
||||
<option value="random">随机播放</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>图片时长(秒)</label>
|
||||
<input type="number" value={imageDuration} min="1" max="300" onChange={e => { setImageDuration(Number(e.target.value)); setTimeout(handleSaveSettings, 0); }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>音量</label>
|
||||
<input type="range" min="0" max="100" value={volume} onChange={e => { setVolume(Number(e.target.value)); setTimeout(handleSaveSettings, 0); }} />
|
||||
<div className="volume-label">{volume}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Card */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="card-icon">{'\uD83D\uDCE4'}</div>
|
||||
<h3>添加媒体</h3>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>本地上传</label>
|
||||
<div className="form-row">
|
||||
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple />
|
||||
<button className="btn-accent" onClick={handleUpload}>上传</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>远程 URL</label>
|
||||
<div className="form-row">
|
||||
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4" />
|
||||
<button className="btn-accent" onClick={handleAddUrl}>添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Library */}
|
||||
<div className="card full">
|
||||
<div className="card-header">
|
||||
<div className="card-icon">{'\uD83D\uDCC1'}</div>
|
||||
<h3>媒体库</h3>
|
||||
</div>
|
||||
<div className="media-grid">
|
||||
{!hasFiles ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">{'\uD83D\uDCF7'}</div>
|
||||
暂无媒体文件<br />请上传或通过 URL 添加
|
||||
</div>
|
||||
) : (
|
||||
files.map((item, i) => (
|
||||
<MediaCard
|
||||
key={`file-${i}`}
|
||||
item={item}
|
||||
onPreview={setPreviewItem}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
onDelete={handleDeleteFile}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist */}
|
||||
<div className="card full">
|
||||
<div className="card-header">
|
||||
<div className="card-icon">{'\u25B6'}</div>
|
||||
<h3>播放列表</h3>
|
||||
</div>
|
||||
<div className="media-grid" id="playlistGrid">
|
||||
{!hasPlaylist ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">{'\u25B6'}</div>
|
||||
播放列表为空<br />从上方媒体库添加内容
|
||||
</div>
|
||||
) : (
|
||||
playlistItems.map((item, i) => (
|
||||
<MediaCard
|
||||
key={`pl-${i}`}
|
||||
item={item}
|
||||
onPreview={setPreviewItem}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
onDelete={handleDeleteFile}
|
||||
showRemove
|
||||
onRemove={handleRemoveFromPlaylist}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<PreviewModal
|
||||
item={previewItem}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
onAddToPlaylist={handleAddToPlaylist}
|
||||
onDelete={handleDeleteFile}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
<ToastContainer toasts={toasts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as api from '../utils/api';
|
||||
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('加载播放列表中...');
|
||||
const [showVideo, setShowVideo] = useState(true);
|
||||
const [currentSrc, setCurrentSrc] = useState('');
|
||||
|
||||
const videoRef = useRef(null);
|
||||
const imgRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
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(() => {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Image
|
||||
setShowVideo(false);
|
||||
setCurrentSrc(url);
|
||||
reportState('playing');
|
||||
timerRef.current = setTimeout(next, imageDuration * 1000);
|
||||
}
|
||||
}, [list, index, paused, getUrl, soundBlocked, imageDuration, reportState]);
|
||||
|
||||
const skip = useCallback((delta) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setIndex(prev => {
|
||||
if (playMode === 'random') {
|
||||
return Math.floor(Math.random() * list.length);
|
||||
}
|
||||
return (prev + delta + list.length) % list.length;
|
||||
});
|
||||
}, [playMode, list.length]);
|
||||
|
||||
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
|
||||
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;
|
||||
const item = list[index];
|
||||
if (!item) return;
|
||||
|
||||
if (item.type === 'video') {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
reportState('playing');
|
||||
timerRef.current = setTimeout(next, imageDuration * 1000);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index]);
|
||||
|
||||
// Load playlist on mount
|
||||
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);
|
||||
|
||||
// Try play with sound
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.muted = false;
|
||||
const testPlay = video.play();
|
||||
if (testPlay !== undefined) {
|
||||
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
|
||||
useEffect(() => {
|
||||
const es = new EventSource('/api/events');
|
||||
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();
|
||||
reportState('paused');
|
||||
break;
|
||||
case 'play':
|
||||
setPaused(false);
|
||||
play();
|
||||
reportState('playing');
|
||||
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
|
||||
}, [play, skip, reportState]);
|
||||
|
||||
const reloadPlaylist = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getPlaylist();
|
||||
const newList = data.files || [];
|
||||
const currentItem = list[index] || null;
|
||||
|
||||
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 */}
|
||||
}, [list, index]);
|
||||
|
||||
const applySettings = useCallback((msg) => {
|
||||
if (msg.volume !== undefined) {
|
||||
const vol = msg.volume / 100;
|
||||
setVolume(vol);
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.volume = vol;
|
||||
if (soundBlocked) {
|
||||
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);
|
||||
}, [soundBlocked]);
|
||||
|
||||
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(() => {});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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();
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, [soundBlocked, enableSound]);
|
||||
|
||||
// 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} />
|
||||
|
||||
{/* Media Players */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
id="player"
|
||||
autoPlay
|
||||
playsInline
|
||||
controlsList="nodownload"
|
||||
style={{ display: showVideo && loaded ? 'block' : 'none' }}
|
||||
/>
|
||||
<img
|
||||
ref={imgRef}
|
||||
id="imgPlayer"
|
||||
alt=""
|
||||
src={!showVideo && loaded && currentItem ? getUrl(currentItem) : undefined}
|
||||
style={{ display: !showVideo && loaded ? 'block' : 'none' }}
|
||||
/>
|
||||
|
||||
{/* Corner Info — Now Playing */}
|
||||
<div className={`corner-info${loaded && currentItem ? ' visible' : ''}`}>
|
||||
<div className="ci-dot"></div>
|
||||
<span className="ci-text">{currentItem ? currentItem.name : '就绪'}</span>
|
||||
</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>
|
||||
|
||||
{/* Sound Hint */}
|
||||
<div id="soundHint" style={{ display: soundBlocked && loaded ? 'block' : 'none' }} onClick={enableSound}>
|
||||
点击启用声音
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user