mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +08:00
优化界面,优化多版本切换逻辑
This commit is contained in:
@@ -164,7 +164,7 @@ export function MediaDetailMainContent({
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} mode="detail" />
|
||||
<MediaVersionSwitcher media={media} />
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media, PlaybackInfo, PlaybackQuality } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
@@ -40,8 +41,6 @@ import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import {
|
||||
detectVr360Profile,
|
||||
loadVr360Preference,
|
||||
@@ -1184,9 +1183,12 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [id, media])
|
||||
|
||||
// 选集列表按版本组折叠,行的 id 是组内最优版本;用户切到同一条目的其它
|
||||
// 版本后 media.id 与行 id 不再相等,这里必须按版本组比对,否则上一集/下一集
|
||||
// 与自动连播都会失效。
|
||||
const currentEpisodeIndex = useMemo(() => {
|
||||
if (!media || playlistEpisodes.length === 0) return -1
|
||||
return playlistEpisodes.findIndex((e) => e.id === media.id)
|
||||
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
|
||||
}, [media, playlistEpisodes])
|
||||
|
||||
const prevEpisode = useMemo(() => {
|
||||
@@ -1224,8 +1226,8 @@ export function PlayerPage() {
|
||||
[navigate, location.search, location.state],
|
||||
)
|
||||
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
|
||||
const versionList = useMemo(
|
||||
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本信息。
|
||||
const currentVersions = useMemo(
|
||||
() => (media?.id === id ? mediaVersionsOf(media) : []),
|
||||
[id, media],
|
||||
)
|
||||
@@ -1324,7 +1326,47 @@ export function PlayerPage() {
|
||||
playbackInfo?.provider === 'cloud115'
|
||||
? [...(playbackInfo.cloud_qualities ?? []), ...(playbackInfo.local_qualities ?? [])]
|
||||
: (playbackInfo?.local_qualities ?? [])
|
||||
const showQuality = mode !== 'direct' && qualityOptions.length > 0
|
||||
// 直连解码模式下宿主机不转码,档位选择没有意义(远程 Emby 挂载同理):直接隐藏。
|
||||
const showQuality = !directOnly && qualityOptions.length > 0
|
||||
// 清晰度按钮上显示的文字。直接播放时按「原画」呈现,和档位列表里的原画项一致,
|
||||
// 避免出现「明明是原文件却显示 1080P」这种误导。
|
||||
const originalQualityLabel =
|
||||
qualityOptions.find((quality) => quality.source === 'original')?.label || '原画'
|
||||
const selectedQualityLabel = findPlaybackQualityById(playbackInfo, selectedQuality)?.label || ''
|
||||
const qualityLabel =
|
||||
mode === 'direct' ? originalQualityLabel : selectedQualityLabel || '清晰度'
|
||||
// 播放方式只描述状态,切换入口在控制栏的「设置」面板里(见 onTogglePlaybackMode)。
|
||||
const playbackModeLabel = isDirectStream
|
||||
? isRemoteEmbyID(media?.id)
|
||||
? 'Emby 直连播放'
|
||||
: '直连播放'
|
||||
: directOnly
|
||||
? '客户端直连解码'
|
||||
: mode === 'hls'
|
||||
? 'HLS 转码'
|
||||
: '直接播放'
|
||||
const canTogglePlaybackMode = !isDirectStream && !directOnly
|
||||
|
||||
// 顶栏标题下的次要信息:集数进度与版本数量,让用户一眼知道「在看什么、在哪」。
|
||||
const topBarTitle = media?.title?.trim() || ''
|
||||
const topBarSubtitle = useMemo(() => {
|
||||
if (!media) return ''
|
||||
const parts: string[] = []
|
||||
if (prevEpisode || nextEpisode) {
|
||||
const total = playlistEpisodes.length
|
||||
const index = currentEpisodeIndex + 1
|
||||
parts.push(total > 0 ? `第 ${index} / ${total} 集` : `第 ${index} 集`)
|
||||
}
|
||||
if (currentVersions.length > 1) parts.push(`${currentVersions.length} 个版本`)
|
||||
return parts.join(' · ')
|
||||
}, [
|
||||
media,
|
||||
currentEpisodeIndex,
|
||||
currentVersions.length,
|
||||
nextEpisode,
|
||||
playlistEpisodes.length,
|
||||
prevEpisode,
|
||||
])
|
||||
|
||||
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
|
||||
useEffect(() => {
|
||||
@@ -1616,20 +1658,14 @@ export function PlayerPage() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||
<PlayerTopBar
|
||||
title={topBarTitle}
|
||||
subtitle={topBarSubtitle}
|
||||
directOnly={directOnly}
|
||||
isDirectStream={isDirectStream}
|
||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
onToggleMode={toggleMode}
|
||||
/>
|
||||
{versionList.length > 1 && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
|
||||
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
|
||||
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PlayerVideoStage
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
@@ -1659,7 +1695,8 @@ export function PlayerPage() {
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuSearchTrigger={danmakuSearchTrigger}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onOpenDanmaku={toggleDanmakuOpen}
|
||||
onToggleDanmakuEnabled={danmakuChangeEnabled}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||
@@ -1670,15 +1707,19 @@ export function PlayerPage() {
|
||||
prevEpisodeTitle={prevEpisodeTitle}
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={playlistEpisodes.length > 0}
|
||||
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||
hasVersions={currentVersions.length > 1}
|
||||
onTogglePlaylist={togglePlaylistOpen}
|
||||
knownDuration={media?.duration_sec || 0}
|
||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||
qualities={qualityOptions}
|
||||
qualityLabel={qualityLabel}
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={selectPlaybackQuality}
|
||||
showQuality={showQuality}
|
||||
playbackModeLabel={playbackModeLabel}
|
||||
onTogglePlaybackMode={canTogglePlaybackMode ? toggleMode : undefined}
|
||||
waiting={cloudWaiting}
|
||||
waitingMessage={cloudWaitMessage}
|
||||
vr360={vr360}
|
||||
@@ -1693,8 +1734,10 @@ export function PlayerPage() {
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
|
||||
@@ -1,66 +1,93 @@
|
||||
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { ArrowLeft, Gauge, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
import type { PlayerMode } from './playerPageModel'
|
||||
import { PLAYER_ICON_BUTTON } from '../components/playerTheme'
|
||||
|
||||
// PlayerTopBar — 播放器顶部的极简浮层。
|
||||
//
|
||||
// 以前这里只有「返回」和一个播放方式药丸按钮,用户看不到自己在看什么;而播放方式
|
||||
// 又要和控制栏里的画质/倍速混在一起点。现在改成 B 站那样:左边是返回图标 + 当前
|
||||
// 媒体标题,右边只留一个只读的播放方式状态标签(真正的切换入口收进了控制栏的
|
||||
// 「设置」面板),顶栏因此不再参与「一堆药丸按钮」的视觉噪音。
|
||||
|
||||
type PlayerTopBarProps = {
|
||||
/** 当前播放的媒体标题。 */
|
||||
title?: string
|
||||
/** 标题下的次要信息(集数、版本等)。 */
|
||||
subtitle?: string
|
||||
directOnly: boolean
|
||||
isDirectStream?: boolean
|
||||
directStreamLabel?: string
|
||||
mode: PlayerMode
|
||||
onBack: () => void
|
||||
onToggleMode: () => void
|
||||
}
|
||||
|
||||
export function PlayerTopBar({
|
||||
title,
|
||||
subtitle,
|
||||
directOnly,
|
||||
isDirectStream,
|
||||
directStreamLabel,
|
||||
mode,
|
||||
onBack,
|
||||
onToggleMode,
|
||||
}: PlayerTopBarProps) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
const status = isDirectStream
|
||||
? {
|
||||
icon: <Sparkles size={12} />,
|
||||
label: directStreamLabel || '直连播放',
|
||||
title: directStreamLabel
|
||||
? `${directStreamLabel},默认直连播放,不进行转码`
|
||||
: '远程直连播放,不进行转码',
|
||||
}
|
||||
: directOnly
|
||||
? {
|
||||
icon: <Sparkles size={12} />,
|
||||
label: '客户端直连解码',
|
||||
title: '宿主机不转码,由客户端本地解码直连',
|
||||
}
|
||||
: mode === 'hls'
|
||||
? {
|
||||
icon: <RefreshCw size={12} />,
|
||||
label: 'HLS 转码',
|
||||
title: '当前为 HLS 转码播放,可在控制栏「设置」里切回直接播放',
|
||||
}
|
||||
: {
|
||||
icon: <Gauge size={12} />,
|
||||
label: '直接播放',
|
||||
title: '当前直接播放原始文件,可在控制栏「设置」里切到 HLS 转码',
|
||||
}
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{isDirectStream ? (
|
||||
<span
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
|
||||
>
|
||||
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
|
||||
</span>
|
||||
) : directOnly ? (
|
||||
<span
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
>
|
||||
<Sparkles size={14} /> 客户端直连解码
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleMode}
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 bg-gradient-to-b from-black/60 via-black/20 to-transparent px-2.5 pb-10 pt-2.5 sm:px-3.5 sm:pt-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className={`${PLAYER_ICON_BUTTON} pointer-events-auto h-8 w-8 bg-black/35 backdrop-blur hover:bg-black/60 sm:h-9 sm:w-9`}
|
||||
title="返回 (Esc)"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
{title ? (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white/95 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||
{title}
|
||||
</p>
|
||||
{subtitle ? (
|
||||
<p className="truncate text-[11px] text-white/65 drop-shadow-[0_1px_4px_rgba(0,0,0,0.8)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="pointer-events-auto flex shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-[11px] font-medium text-white/80 backdrop-blur"
|
||||
title={status.title}
|
||||
>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import {
|
||||
CircleAlert,
|
||||
Hand,
|
||||
Loader2,
|
||||
Lock,
|
||||
@@ -184,7 +185,10 @@ type PlayerVideoStageProps = {
|
||||
danmakuEpisodeId: number | string | null
|
||||
danmakuSearchTrigger?: number
|
||||
danmakuOpen: boolean
|
||||
onToggleDanmaku: () => void
|
||||
/** 打开弹幕设置面板(搜索弹幕库、调整渲染参数)。 */
|
||||
onOpenDanmaku: () => void
|
||||
/** 操作栏上的「弹」开关:只控制画面上的弹幕是否渲染。 */
|
||||
onToggleDanmakuEnabled: (next: boolean) => void
|
||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||
@@ -200,14 +204,22 @@ type PlayerVideoStageProps = {
|
||||
nextEpisodeTitle?: string
|
||||
playlistOpen?: boolean
|
||||
hasPlaylist?: boolean
|
||||
/** 当前条目存在多个版本(选集面板里提供版本切换,按钮提示随之变化)。 */
|
||||
hasVersions?: boolean
|
||||
onTogglePlaylist?: () => void
|
||||
knownDuration?: number
|
||||
streamOffset?: number
|
||||
onSeekAbsolute?: (seconds: number) => boolean
|
||||
qualities?: PlaybackQuality[]
|
||||
/** 清晰度按钮上显示的文字(播放页按当前播放方式算好)。 */
|
||||
qualityLabel?: string
|
||||
selectedQuality?: string
|
||||
onSelectQuality?: (quality: PlaybackQuality) => void
|
||||
showQuality?: boolean
|
||||
/** 当前播放方式的文字描述,展示在设置面板里。 */
|
||||
playbackModeLabel?: string
|
||||
/** 可切换播放方式时提供;不提供则设置面板里只显示状态。 */
|
||||
onTogglePlaybackMode?: () => void
|
||||
/** VR 全景播放配置;非 null 表示当前处于 VR 模式。 */
|
||||
vr360?: Vr360Profile | null
|
||||
/** 是否由文件名/画幅自动识别出 VR 素材(用于在菜单里提示)。 */
|
||||
@@ -251,7 +263,8 @@ export function PlayerVideoStage({
|
||||
danmakuEpisodeId,
|
||||
danmakuSearchTrigger = 0,
|
||||
danmakuOpen,
|
||||
onToggleDanmaku,
|
||||
onOpenDanmaku,
|
||||
onToggleDanmakuEnabled,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
onDanmakuAlternatives,
|
||||
@@ -265,14 +278,18 @@ export function PlayerVideoStage({
|
||||
nextEpisodeTitle,
|
||||
playlistOpen,
|
||||
hasPlaylist,
|
||||
hasVersions = false,
|
||||
onTogglePlaylist,
|
||||
knownDuration,
|
||||
streamOffset,
|
||||
onSeekAbsolute,
|
||||
qualities = [],
|
||||
qualityLabel = '',
|
||||
selectedQuality = '',
|
||||
onSelectQuality,
|
||||
showQuality = false,
|
||||
playbackModeLabel = '',
|
||||
onTogglePlaybackMode,
|
||||
vr360 = null,
|
||||
vr360Detected = false,
|
||||
onToggleVr360,
|
||||
@@ -780,6 +797,7 @@ export function PlayerVideoStage({
|
||||
onReady={() => setVrReadyMediaId(media.id)}
|
||||
onError={(message) => onVr360Error?.(message)}
|
||||
onProfileChange={onVr360ProfileChange}
|
||||
onExitVr={onToggleVr360}
|
||||
onUiHoldChange={setVrUiHovered}
|
||||
/>
|
||||
) : null}
|
||||
@@ -858,7 +876,8 @@ export function PlayerVideoStage({
|
||||
onSubtitleStyleChange={onSubtitleStyleChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
onToggleDanmakuEnabled={onToggleDanmakuEnabled}
|
||||
onOpenDanmaku={onOpenDanmaku}
|
||||
hasPrevEpisode={hasPrevEpisode}
|
||||
hasNextEpisode={hasNextEpisode}
|
||||
onPrevEpisode={onPrevEpisode}
|
||||
@@ -867,14 +886,18 @@ export function PlayerVideoStage({
|
||||
nextEpisodeTitle={nextEpisodeTitle}
|
||||
playlistOpen={playlistOpen}
|
||||
hasPlaylist={hasPlaylist}
|
||||
hasVersions={hasVersions}
|
||||
onTogglePlaylist={onTogglePlaylist}
|
||||
knownDuration={knownDuration}
|
||||
streamOffset={streamOffset}
|
||||
onSeekAbsolute={onSeekAbsolute}
|
||||
qualities={qualities}
|
||||
qualityLabel={qualityLabel}
|
||||
selectedQuality={selectedQuality}
|
||||
onSelectQuality={onSelectQuality}
|
||||
showQuality={showQuality}
|
||||
playbackModeLabel={playbackModeLabel}
|
||||
onTogglePlaybackMode={onTogglePlaybackMode}
|
||||
vr360={vr360}
|
||||
vr360Detected={vr360Detected}
|
||||
onToggleVr360={onToggleVr360}
|
||||
@@ -889,7 +912,7 @@ export function PlayerVideoStage({
|
||||
)}
|
||||
{vr360 && media && !vrReady ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/80 px-5 py-3 text-sm text-white shadow-2xl backdrop-blur">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-[#0d0e12]/95 px-4 py-3 text-sm text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||
<Loader2 className="animate-spin text-rose-400" size={18} />
|
||||
正在启动 VR 全景渲染…
|
||||
</div>
|
||||
@@ -899,7 +922,7 @@ export function PlayerVideoStage({
|
||||
一边拖动画面试操作。 */}
|
||||
{vrGuideVisible ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/15 bg-black/85 px-5 py-4 text-white shadow-2xl backdrop-blur">
|
||||
<div className="pointer-events-auto w-[min(92vw,400px)] rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-5 py-4 text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Rotate3d size={16} className="text-rose-400" />
|
||||
VR 全景播放
|
||||
@@ -915,7 +938,7 @@ export function PlayerVideoStage({
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<MousePointerClick size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
单击画面:显示或收起操作栏(进度条、音量、画质、倍速、VR 设置)
|
||||
单击画面:显示或收起操作栏(进度条、清晰度、倍速、设置)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Lock size={14} className="mt-0.5 shrink-0 text-white/45" />
|
||||
@@ -957,34 +980,35 @@ export function PlayerVideoStage({
|
||||
tabIndex={lockVisible ? 0 : -1}
|
||||
aria-label={locked ? '解锁画面,恢复操作栏' : '锁定画面,隐藏操作栏和进度框'}
|
||||
title={locked ? '点一下解锁,恢复操作栏与进度框' : '锁上后画面轻触无效,只留这个锁'}
|
||||
className={`absolute left-2 top-1/2 z-30 -translate-y-1/2 rounded-full border p-3 shadow-2xl backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||
className={`absolute left-2.5 top-1/2 z-30 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-[0_6px_20px_rgba(0,0,0,0.45)] backdrop-blur transition-opacity duration-200 sm:left-4 ${
|
||||
lockVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
|
||||
} ${
|
||||
locked
|
||||
? // 锁住时按钮退到很淡的状态:既提示「这里是锁」,又不打扰观看。
|
||||
'border-white/10 bg-black/35 text-white/45 hover:bg-black/65 hover:text-white'
|
||||
: 'border-white/15 bg-black/65 text-white hover:bg-black/85'
|
||||
'border-white/5 bg-black/30 text-white/40 hover:bg-black/60 hover:text-white'
|
||||
: 'border-white/10 bg-black/50 text-white/85 hover:bg-black/75 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{locked ? <Lock size={20} /> : <LockOpen size={20} />}
|
||||
{locked ? <Lock size={18} /> : <LockOpen size={18} />}
|
||||
</button>
|
||||
{/* 解锁提示:操作栏此时已经藏起来了,用户点锁之后需要知道发生了什么。 */}
|
||||
{lockHint ? (
|
||||
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-full border border-white/15 bg-black/75 px-3 py-1.5 text-xs text-white/90 shadow-xl backdrop-blur sm:left-20">
|
||||
<div className="pointer-events-none absolute left-16 top-1/2 z-30 -translate-y-1/2 rounded-lg border border-white/10 bg-[#0d0e12]/95 px-3 py-1.5 text-xs text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl sm:left-20">
|
||||
已解锁,点画面可显示操作栏
|
||||
</div>
|
||||
) : null}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{playerError}
|
||||
<div className="absolute bottom-20 left-1/2 flex w-[min(92vw,720px)] -translate-x-1/2 items-start gap-2.5 rounded-xl border border-rose-500/25 bg-[#0d0e12]/95 px-4 py-3 text-xs leading-relaxed text-white/85 shadow-[0_10px_38px_rgba(0,0,0,0.6)] backdrop-blur-xl">
|
||||
<CircleAlert size={15} className="mt-0.5 shrink-0 text-rose-400" />
|
||||
<span className="min-w-0 flex-1">{playerError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{waiting ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60 px-4 backdrop-blur-sm">
|
||||
<div className="max-w-md rounded-xl border border-white/15 bg-black/85 px-6 py-5 text-center text-white shadow-2xl">
|
||||
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={28} />
|
||||
<div className="max-w-md rounded-2xl border border-white/10 bg-[#0d0e12]/95 px-6 py-5 text-center text-white shadow-[0_10px_38px_rgba(0,0,0,0.6)]">
|
||||
<Loader2 className="mx-auto mb-3 animate-spin text-rose-400" size={26} />
|
||||
<p className="text-sm font-medium">{waitingMessage || '115 正在转码…'}</p>
|
||||
<p className="mt-1 text-xs text-white/60">转码完成后会自动播放</p>
|
||||
<p className="mt-1 text-xs text-white/55">转码完成后会自动播放</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user