feat: 播放控制移入页眉 + 媒体铺满智能裁切
页眉播放控制(仅媒体轮播页): - 新增 MediaHeaderControls:播放/暂停、上一段/下一段、序号、当前文件名(点击进后台)、声音解锁 - 新增 mediaControl 共享状态桥:媒体页同步播放状态,页眉订阅渲染并下发指令 - 移除媒体页底部控制区,媒体区保持纯净全屏 媒体展示: - .ms-media 改为 object-fit: cover,填满整个容器并智能居中裁切(原 contain 留黑边) 修复: - MediaScreen useEffect 依赖数组引用后声明函数导致的 TDZ 崩溃 - 清理 MediaScreen 中不再使用的 Icon/cleanName/togglePlayPause
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
{/* 页脚:园区概览(与数据大屏一致) */}
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
Reference in New Issue
Block a user