feat: 播放控制移入页眉 + 媒体铺满智能裁切

页眉播放控制(仅媒体轮播页):
- 新增 MediaHeaderControls:播放/暂停、上一段/下一段、序号、当前文件名(点击进后台)、声音解锁
- 新增 mediaControl 共享状态桥:媒体页同步播放状态,页眉订阅渲染并下发指令
- 移除媒体页底部控制区,媒体区保持纯净全屏

媒体展示:
- .ms-media 改为 object-fit: cover,填满整个容器并智能居中裁切(原 contain 留黑边)

修复:
- MediaScreen useEffect 依赖数组引用后声明函数导致的 TDZ 崩溃
- 清理 MediaScreen 中不再使用的 Icon/cleanName/togglePlayPause
This commit is contained in:
Pine
2026-08-17 22:45:46 +08:00
parent 07dbd404a9
commit 29bdad316f
6 changed files with 266 additions and 209 deletions
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import Icon from './Icons';
import { onMediaUiState, mediaCommand } from '../utils/mediaControl';
/* =========================================================
页眉媒体控制区 —— 仅媒体轮播页显示
订阅媒体页播放状态(paused/index/total/name/soundBlocked),
点击通过 mediaCommand() 下发指令,由 MediaScreen 执行
========================================================= */
/** 展示名清洗:URL 只取文件名,不显示完整播放地址 */
function cleanName(name) {
if (!name) return '';
const text = String(name);
try {
const u = new URL(text);
const last = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '');
return last ? `${last}${u.search ? ' …' : ''}` : text;
} catch {
return text;
}
}
export default function MediaHeaderControls() {
const [st, setSt] = useState(null);
useEffect(() => onMediaUiState(setSt), []);
if (!st || !st.loaded) {
return (
<div className="bd-hd-media" title="媒体播放控制">
<Icon name="player-play" size={13} />
</div>
);
}
const showName = cleanName(st.name) || '等待播放…';
return (
<div className="bd-hd-media">
{/* 播放 / 暂停 */}
<button
className={`bd-hd-media-btn ${st.paused ? 'primary' : ''}`}
onClick={() => mediaCommand(st.paused ? 'play' : 'pause')}
title={st.paused ? '播放' : '暂停'}
>
<Icon name={st.paused ? 'play' : 'pause'} size={14} />
</button>
{/* 上一段 */}
<button className="bd-hd-media-btn" onClick={() => mediaCommand('prev')} title="上一段">
<Icon name="chevron-left" size={13} />
</button>
{/* 序号 */}
<span className="bd-hd-media-num">
<b>{st.index + 1}</b>/<em>{st.total}</em>
</span>
{/* 下一段 */}
<button className="bd-hd-media-btn" onClick={() => mediaCommand('next')} title="下一段">
<Icon name="chevron-right" size={13} />
</button>
{/* 当前播放名(点击进入后台) */}
<button
className="bd-hd-media-name"
onClick={() => mediaCommand('open_login')}
title="点击进入后台"
>
<span className="bd-hd-media-dot" />
<span>{showName}</span>
</button>
{/* 声音解锁 */}
{st.soundBlocked && (
<button className="bd-hd-media-btn warn" onClick={() => mediaCommand('enable_sound')} title="点击开启声音">
<Icon name="volume" size={14} />
</button>
)}
</div>
);
}
+3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import Icon from './Icons';
import MediaHeaderControls from './MediaHeaderControls';
import { PAGE_ORDER, usePageIndex } from '../utils/pageNav';
import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar'
@@ -52,6 +53,8 @@ export default function PageHeader() {
</div>
</div>
<div className="bd-header-right">
{/* 媒体轮播页:页眉内嵌播放控制 */}
{location.pathname === '/screen' && <MediaHeaderControls />}
<div className="bd-nav-keys" title="左右方向键或点击切换页面">
{left && (
<button className="bd-nav-key" onClick={() => navigate(left.path)}>
+51 -86
View File
@@ -4,9 +4,9 @@ import * as api from '../utils/api';
import { API_BASE } from '../utils/api';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { enable, disable } from '@tauri-apps/plugin-autostart';
import Icon from '../components/Icons';
import ParkOverviewStrip from '../components/ParkOverviewStrip';
import { useParkSim } from '../utils/parkData';
import { setMediaUiState } from '../utils/mediaControl';
import '../styles/datascreen.css';
import '../styles/mediascreen.css';
@@ -16,19 +16,6 @@ import '../styles/mediascreen.css';
页脚(园区概览条)按需保留,中间为全屏媒体播放器
========================================================= */
/** 展示名清洗:URL 只取文件名,不显示完整播放地址 */
function cleanName(name) {
if (!name) return '';
const text = String(name);
try {
const u = new URL(text);
const last = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '');
return last ? `${last}${u.search ? ' …' : ''}` : text;
} catch {
return text;
}
}
export default function MediaScreen() {
const d = useParkSim();
const navigate = useNavigate();
@@ -322,6 +309,48 @@ export default function MediaScreen() {
}
}, []);
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(() => {});
});
}, []);
// ============ 登录弹窗 ============
const handleTitleClick = useCallback(() => {
setLoginUsername('');
setLoginPassword('');
setLoginError('');
setShowLoginModal(true);
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
loginTimerRef.current = setTimeout(() => {
setShowLoginModal(false);
}, 30000);
setTimeout(() => {
if (loginInputRef.current) loginInputRef.current.focus();
}, 100);
}, []);
// 播放状态同步到页眉控制区
useEffect(() => {
const item = list[index];
setMediaUiState({
paused,
index,
total: list.length,
name: item ? item.name : '',
soundBlocked,
loaded,
});
}, [paused, index, list, soundBlocked, loaded]);
// MQTT 控制通道(后端 opc/display/command → window 事件)
useEffect(() => {
const handler = (e) => {
@@ -352,6 +381,12 @@ export default function MediaScreen() {
case 'minimize':
getCurrentWindow().minimize().catch(() => {});
break;
case 'open_login':
handleTitleClick();
break;
case 'enable_sound':
enableSound();
break;
case 'playlist_changed':
reloadPlaylist();
break;
@@ -364,36 +399,7 @@ export default function MediaScreen() {
};
window.addEventListener('dpm:media-control', handler);
return () => window.removeEventListener('dpm:media-control', handler);
}, [skip, reloadPlaylist, applySettings, playTarget]);
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(() => {});
});
}, []);
// ============ 登录弹窗 ============
const handleTitleClick = useCallback(() => {
setLoginUsername('');
setLoginPassword('');
setLoginError('');
setShowLoginModal(true);
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
loginTimerRef.current = setTimeout(() => {
setShowLoginModal(false);
}, 30000);
setTimeout(() => {
if (loginInputRef.current) loginInputRef.current.focus();
}, 100);
}, []);
}, [skip, reloadPlaylist, applySettings, playTarget, handleTitleClick, enableSound]);
const handleCloseLogin = useCallback(() => {
if (loginTimerRef.current) clearTimeout(loginTimerRef.current);
@@ -419,18 +425,6 @@ export default function MediaScreen() {
if (e.key === 'Enter') handleLoginSubmit();
}, [handleLoginSubmit]);
// ============ 播放/暂停 ============
const togglePlayPause = useCallback(() => {
if (pausedRef.current) {
setPaused(false);
} else {
setPaused(true);
const video = videoRef.current;
if (video) video.pause();
if (timerRef.current) clearTimeout(timerRef.current);
}
}, []);
// Global click for sound unblock
useEffect(() => {
const handler = () => {
@@ -509,36 +503,7 @@ export default function MediaScreen() {
</div>
)}
{/* 右下角轻量播放控制(弱化 */}
<div className={`ms-controls${loaded ? ' visible' : ''}`}>
{/* 播放/暂停 */}
<button className="ms-play" onClick={togglePlayPause} title={paused ? '播放' : '暂停'}>
<Icon name={paused ? 'play' : 'pause'} size={15} />
</button>
{/* 上一段 / 下一段 */}
<div className="ms-nav">
<button onClick={() => skip(-1)} title="上一段">
<Icon name="chevron-left" size={13} />
</button>
<span className="ms-nav-num">
<b>{currentItem ? `${index + 1}` : '0'}</b>
<em>/ {list.length}</em>
</span>
<button onClick={() => skip(1)} title="下一段">
<Icon name="chevron-right" size={13} />
</button>
</div>
{/* 当前播放(仅文件名,不显示完整地址;点击进入后台) */}
<div className="ms-now" onClick={handleTitleClick} title="点击进入后台" style={{ cursor: 'pointer' }}>
<span className="ms-now-dot" />
<span className="ms-now-name">{currentItem ? cleanName(currentItem.name) : '等待播放…'}</span>
</div>
{/* 声音 */}
<div id="soundHint" className="ms-sound" style={{ display: soundBlocked && loaded ? 'flex' : 'none' }} onClick={enableSound}>
<Icon name="volume" size={13} />
点击开启声音
</div>
</div>
{/* 播放控制已上移页眉(PageHeader 内渲染,媒体区保持纯净全屏 */}
</main>
{/* 页脚:园区概览(与数据大屏一致) */}
+93
View File
@@ -2461,3 +2461,96 @@
.bd-cell-jobs { grid-column: 5; }
/* 底部园区概览(见 .bd-strip 区块) */
/* ============ 页眉媒体播放控制(仅媒体轮播页) ============ */
.bd-hd-media {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(47, 107, 255, 0.14);
border-radius: 9px;
flex-shrink: 0;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.bd-hd-media-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--bd-blue);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.bd-hd-media-btn:hover {
background: rgba(47, 107, 255, 0.12);
}
.bd-hd-media-btn.primary {
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
box-shadow: 0 3px 8px rgba(47, 107, 255, 0.3);
}
.bd-hd-media-btn.warn {
color: var(--bd-orange);
}
.bd-hd-media-num {
font-family: "JetBrains Mono", "Oswald", monospace;
font-size: 11px;
color: var(--bd-sub);
display: inline-flex;
align-items: baseline;
gap: 2px;
padding: 0 2px;
}
.bd-hd-media-num b {
font-size: 12px;
color: var(--bd-ink);
}
.bd-hd-media-num em {
font-style: normal;
font-size: 10px;
}
.bd-hd-media-name {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 160px;
padding: 3px 8px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 11px;
font-weight: 600;
font-family: inherit;
color: var(--bd-sub);
cursor: pointer;
overflow: hidden;
}
.bd-hd-media-name > span:last-child {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bd-hd-media-name:hover {
background: rgba(47, 107, 255, 0.08);
color: var(--bd-ink);
}
.bd-hd-media-dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--bd-green);
box-shadow: 0 0 6px rgba(23, 178, 106, 0.8);
animation: bdPulse 1.6s ease-in-out infinite;
}
/* 窄屏:页眉控制区收起名称,保留核心按钮 */
@media (max-width: 1280px) {
.bd-hd-media-name { display: none; }
}
+4 -123
View File
@@ -67,13 +67,14 @@
-webkit-backdrop-filter: blur(14px);
}
/* 媒体播放器(铺满媒体区,等比适配 */
/* 媒体播放器(填满整个媒体区,等比裁切铺满 */
.ms-media {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
object-position: center;
background: transparent;
}
@@ -109,127 +110,7 @@
border-bottom: 1px solid rgba(47, 107, 255, 0.3);
}
/* 右下角轻量播放控制(弱化悬浮 */
.ms-controls {
position: absolute;
right: 16px;
bottom: 14px;
left: auto;
transform: none;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(120, 160, 220, 0.16);
border-radius: 30px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 6px 22px rgba(56, 100, 170, 0.12);
opacity: 0;
transition: opacity 0.5s ease;
}
.ms-controls.visible { opacity: 1; }
.ms-play {
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue, #2f6bff), var(--bd-cyan, #4cc2ff));
cursor: pointer;
box-shadow: 0 4px 12px rgba(47, 107, 255, 0.3);
transition: transform 0.2s;
}
.ms-play:hover { transform: scale(1.08); }
.ms-nav {
display: flex;
align-items: center;
gap: 6px;
}
.ms-nav button {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid rgba(47, 107, 255, 0.18);
background: rgba(255, 255, 255, 0.6);
color: var(--bd-blue, #2f6bff);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.ms-nav button:hover {
background: rgba(47, 107, 255, 0.12);
border-color: rgba(47, 107, 255, 0.5);
}
.ms-nav-num {
display: inline-flex;
align-items: baseline;
gap: 3px;
font-family: "JetBrains Mono", "Oswald", monospace;
}
.ms-nav-num b {
font-size: 13px;
color: var(--bd-ink, #1d2c44);
}
.ms-nav-num em {
font-style: normal;
font-size: 10.5px;
color: var(--bd-sub, #7d8fa9);
}
.ms-now {
display: flex;
align-items: center;
gap: 6px;
padding-left: 10px;
border-left: 1px solid rgba(47, 107, 255, 0.12);
}
.ms-now-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bd-green, #17b26a);
box-shadow: 0 0 8px rgba(23, 178, 106, 0.8);
animation: msPulse 1.6s ease-in-out infinite;
}
@keyframes msPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.ms-now-name {
font-size: 11px;
font-weight: 500;
color: var(--bd-sub, #7d8fa9);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 声音提示 */
.ms-sound {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
font-size: 11px;
font-weight: 500;
color: #4a5b76;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(47, 107, 255, 0.14);
border-radius: 30px;
cursor: pointer;
}
.ms-sound .appica-ico { color: var(--bd-blue, #2f6bff); }
/* 播放控制已上移页眉(见 datascreen.css .bd-hd-media */
/* 页脚 */
.ms-page .bd-strip { z-index: 10; position: relative; }
+37
View File
@@ -0,0 +1,37 @@
/* =========================================================
媒体播放控制桥 —— 页眉控制区与媒体页之间的共享状态
· 媒体页把播放状态同步进来(paused/index/total/name/soundBlocked/loaded
· 页眉控制区订阅状态渲染,并通过 mediaCommand() 下发指令
========================================================= */
let state = {
paused: false,
index: 0,
total: 0,
name: '',
soundBlocked: false,
loaded: false,
};
const listeners = new Set();
export function setMediaUiState(patch) {
state = { ...state, ...patch };
listeners.forEach((fn) => fn(state));
}
export function getMediaUiState() {
return state;
}
/** 订阅状态,返回取消函数 */
export function onMediaUiState(fn) {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
}
/** 页眉控制区下发指令(媒体页监听 dpm:media-control 执行) */
export function mediaCommand(action, params) {
window.dispatchEvent(new CustomEvent('dpm:media-control', { detail: { action, params } }));
}