mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 22:36:37 +08:00
优化移动端播放
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
Captions,
|
Captions,
|
||||||
CaptionsOff,
|
CaptionsOff,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
FastForward,
|
FastForward,
|
||||||
Layers,
|
Layers,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
@@ -281,6 +282,8 @@ export function PlayerControls({
|
|||||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
|
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
|
||||||
const settingsMenuRef = useRef<HTMLDivElement | null>(null)
|
const settingsMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
/** 竖屏剧场模式的设置面板(相对整个屏幕固定)——点击面板内部不应把它关掉。 */
|
||||||
|
const settingsSheetRef = useRef<HTMLDivElement | null>(null)
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isScrubbingRef = useRef(false)
|
const isScrubbingRef = useRef(false)
|
||||||
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
|
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
|
||||||
@@ -392,6 +395,10 @@ export function PlayerControls({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen && !settingsMenuOpen) return
|
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen && !settingsMenuOpen) return
|
||||||
const onDocClick = (e: MouseEvent) => {
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
// 剧场模式的设置面板挂在操作栏之外,先判断:点面板内部不关任何弹层。
|
||||||
|
if (settingsSheetRef.current && settingsSheetRef.current.contains(e.target as Node)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||||
setSubtitleMenuOpen(false)
|
setSubtitleMenuOpen(false)
|
||||||
}
|
}
|
||||||
@@ -1040,29 +1047,24 @@ export function PlayerControls({
|
|||||||
{playbackRateOverlay}
|
{playbackRateOverlay}
|
||||||
{theater && settingsMenuOpen ? (
|
{theater && settingsMenuOpen ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 z-40 flex items-end justify-center"
|
ref={settingsSheetRef}
|
||||||
|
className={PLAYER_SHEET}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerUp={(e) => e.stopPropagation()}
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div
|
<div className={PLAYER_SHEET_HEADER}>
|
||||||
className="absolute inset-0"
|
<span className="text-[13px] font-semibold text-white/90">播放器设置</span>
|
||||||
onClick={() => setSettingsMenuOpen(false)}
|
<button
|
||||||
aria-hidden
|
type="button"
|
||||||
/>
|
onClick={() => setSettingsMenuOpen(false)}
|
||||||
<div className={`relative ${PLAYER_SHEET}`}>
|
className={`${PLAYER_ICON_BUTTON} h-10 w-10`}
|
||||||
<div className={PLAYER_SHEET_HEADER}>
|
title="关闭设置"
|
||||||
<span className="text-[13px] font-semibold text-white/90">播放器设置</span>
|
>
|
||||||
<button
|
<ChevronDown size={20} />
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => setSettingsMenuOpen(false)}
|
|
||||||
className={PLAYER_ICON_BUTTON}
|
|
||||||
title="关闭设置"
|
|
||||||
>
|
|
||||||
<Minimize size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={PLAYER_SHEET_BODY}>{settingsPanel}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={PLAYER_SHEET_BODY}>{settingsPanel}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -208,9 +208,7 @@ export function PlayerDanmakuPanel({
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerUp={(e) => e.stopPropagation()}
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
className={
|
className={
|
||||||
theater
|
theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||||
? `absolute inset-x-0 bottom-0 z-40 ${PLAYER_SHEET}`
|
|
||||||
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
||||||
@@ -219,8 +217,12 @@ export function PlayerDanmakuPanel({
|
|||||||
<span className="truncate">弹幕设置</span>
|
<span className="truncate">弹幕设置</span>
|
||||||
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
<button
|
||||||
<X size={16} />
|
onClick={onClose}
|
||||||
|
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
|
||||||
|
title="关闭 (Esc)"
|
||||||
|
>
|
||||||
|
<X size={theater ? 20 : 16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Layers, ListVideo } from 'lucide-react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
||||||
@@ -6,8 +8,11 @@ import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
|
|||||||
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||||
//
|
//
|
||||||
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
|
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
|
||||||
// 下面这块区域接管标题、选集横滑、版本切换和简介,信息密度与
|
// 下面这块区域接管标题、选集、版本切换和简介,信息密度与
|
||||||
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
|
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
|
||||||
|
//
|
||||||
|
// 选集刻意放在这里而不是弹层里:从操作栏点「选集」只做「展开 + 滚到这里」,
|
||||||
|
// 播放画面始终可见,不会像浮层那样盖住正在看的内容。
|
||||||
|
|
||||||
type PlayerMobileTheaterInfoProps = {
|
type PlayerMobileTheaterInfoProps = {
|
||||||
media: Media | null
|
media: Media | null
|
||||||
@@ -21,7 +26,15 @@ type PlayerMobileTheaterInfoProps = {
|
|||||||
currentVersions?: Media[]
|
currentVersions?: Media[]
|
||||||
onSelectEpisode: (media: Media) => void
|
onSelectEpisode: (media: Media) => void
|
||||||
onSelectVersion?: (media: Media) => void
|
onSelectVersion?: (media: Media) => void
|
||||||
onOpenPlaylist?: () => void
|
/** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */
|
||||||
|
playlistOpen: boolean
|
||||||
|
onTogglePlaylist: () => void
|
||||||
|
/** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */
|
||||||
|
playlistPanel?: ReactNode
|
||||||
|
/**
|
||||||
|
* 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。
|
||||||
|
*/
|
||||||
|
revealToken?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function episodeShortLabel(ep: Media): string {
|
function episodeShortLabel(ep: Media): string {
|
||||||
@@ -50,12 +63,22 @@ export function PlayerMobileTheaterInfo({
|
|||||||
currentVersions = [],
|
currentVersions = [],
|
||||||
onSelectEpisode,
|
onSelectEpisode,
|
||||||
onSelectVersion,
|
onSelectVersion,
|
||||||
onOpenPlaylist,
|
playlistOpen,
|
||||||
|
onTogglePlaylist,
|
||||||
|
playlistPanel,
|
||||||
|
revealToken = 0,
|
||||||
}: PlayerMobileTheaterInfoProps) {
|
}: PlayerMobileTheaterInfoProps) {
|
||||||
|
const episodeSectionRef = useRef<HTMLElement | null>(null)
|
||||||
const hasEpisodeList = episodes.length > 1
|
const hasEpisodeList = episodes.length > 1
|
||||||
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
|
||||||
const overview = media?.overview?.trim() || ''
|
const overview = media?.overview?.trim() || ''
|
||||||
|
|
||||||
|
// 从操作栏点「选集」:把选集区滚回视野内,用户不用自己往上翻。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!revealToken) return
|
||||||
|
episodeSectionRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||||
|
}, [revealToken])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-black px-4 pb-8 pt-3 text-white">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-black px-4 pb-8 pt-3 text-white">
|
||||||
<h1 className="text-[15px] font-semibold leading-snug text-white/95">{title || '加载中…'}</h1>
|
<h1 className="text-[15px] font-semibold leading-snug text-white/95">{title || '加载中…'}</h1>
|
||||||
@@ -76,49 +99,59 @@ export function PlayerMobileTheaterInfo({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasEpisodeList ? (
|
{hasEpisodeList ? (
|
||||||
<section className="mt-4">
|
<section ref={episodeSectionRef} className="mt-4 scroll-mt-2">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||||
<ListVideo size={14} className="text-rose-400" />
|
<ListVideo size={14} className="text-rose-400" />
|
||||||
选集
|
选集
|
||||||
<span className="font-mono text-[11px] font-normal text-white/45">
|
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||||
{currentEpisodeIndex >= 0 ? `${currentEpisodeIndex + 1}/${episodes.length}` : `${episodes.length} 集`}
|
{currentEpisodeIndex >= 0
|
||||||
|
? `${currentEpisodeIndex + 1}/${episodes.length}`
|
||||||
|
: `${episodes.length} 集`}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
{onOpenPlaylist ? (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={onTogglePlaylist}
|
||||||
onClick={onOpenPlaylist}
|
className="flex min-h-8 items-center gap-1 rounded-md px-2 text-[11px] text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||||
className="rounded-md px-2 py-1 text-[11px] text-white/55 transition hover:bg-white/10 hover:text-white"
|
>
|
||||||
>
|
{playlistOpen ? '收起' : '全部'}
|
||||||
全部 >
|
<ChevronDown
|
||||||
</button>
|
size={13}
|
||||||
) : null}
|
className={`transition-transform ${playlistOpen ? 'rotate-180' : ''}`}
|
||||||
</div>
|
/>
|
||||||
<div className="scrollbar-hide -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1">
|
</button>
|
||||||
{episodes.map((ep) => {
|
|
||||||
const active = mediaVersionMatches(ep, currentMediaId)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={ep.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectEpisode(ep)}
|
|
||||||
title={episodeFullLabel(ep)}
|
|
||||||
className={`flex h-10 min-w-12 shrink-0 items-center justify-center rounded-lg px-2 text-xs font-medium tabular-nums transition ${
|
|
||||||
active
|
|
||||||
? 'bg-rose-500 text-white'
|
|
||||||
: 'bg-white/[0.07] text-white/80 active:bg-white/15'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{episodeShortLabel(ep)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{playlistOpen && playlistPanel ? (
|
||||||
|
playlistPanel
|
||||||
|
) : (
|
||||||
|
<div className="scrollbar-hide -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1">
|
||||||
|
{episodes.map((ep) => {
|
||||||
|
const active = mediaVersionMatches(ep, currentMediaId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ep.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectEpisode(ep)}
|
||||||
|
title={episodeFullLabel(ep)}
|
||||||
|
className={`flex h-10 min-w-12 shrink-0 items-center justify-center rounded-lg px-2 text-xs font-medium tabular-nums transition ${
|
||||||
|
active
|
||||||
|
? 'bg-rose-500 text-white'
|
||||||
|
: 'bg-white/[0.07] text-white/80 active:bg-white/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{episodeShortLabel(ep)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{versionsToSwitch.length > 0 ? (
|
{/* 多版本切换:展开选集后由选集面板顶部的版本区接管,这里不重复一份 */}
|
||||||
|
{versionsToSwitch.length > 0 && !playlistOpen ? (
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||||
<Layers size={13} className="text-rose-400" />
|
<Layers size={13} className="text-rose-400" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
mediaVersionMatches,
|
mediaVersionMatches,
|
||||||
mediaVersionSourceLabel,
|
mediaVersionSourceLabel,
|
||||||
} from '../utils/mediaVersion'
|
} from '../utils/mediaVersion'
|
||||||
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
|
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
|
||||||
|
|
||||||
export type SeasonGroup = {
|
export type SeasonGroup = {
|
||||||
season: number
|
season: number
|
||||||
@@ -28,10 +28,10 @@ type PlayerPlaylistPanelProps = {
|
|||||||
/** 切换到同一集/同一条目的另一个版本。 */
|
/** 切换到同一集/同一条目的另一个版本。 */
|
||||||
onSelectVersion?: (media: Media) => void
|
onSelectVersion?: (media: Media) => void
|
||||||
/**
|
/**
|
||||||
* 竖屏剧场模式:选集面板改为视频区底部的动作面板,而不是右侧抽屉,
|
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
|
||||||
* 避免窄屏下抽屉遮挡返回按钮、宫格被压扁。
|
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
|
||||||
*/
|
*/
|
||||||
theater?: boolean
|
inline?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +55,7 @@ export function PlayerPlaylistPanel({
|
|||||||
episodes,
|
episodes,
|
||||||
onSelectEpisode,
|
onSelectEpisode,
|
||||||
onSelectVersion,
|
onSelectVersion,
|
||||||
theater = false,
|
inline = false,
|
||||||
}: PlayerPlaylistPanelProps) {
|
}: PlayerPlaylistPanelProps) {
|
||||||
const [filterText, setFilterText] = useState('')
|
const [filterText, setFilterText] = useState('')
|
||||||
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||||
@@ -140,24 +140,28 @@ export function PlayerPlaylistPanel({
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerUp={(e) => e.stopPropagation()}
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
className={
|
className={
|
||||||
theater
|
inline
|
||||||
? `absolute inset-x-0 bottom-0 z-40 ${PLAYER_SHEET}`
|
? 'flex flex-col'
|
||||||
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
|
||||||
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
|
{inline ? null : (
|
||||||
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
|
<div className={PLAYER_PANEL_HEADER}>
|
||||||
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
|
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||||
</span>
|
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
|
||||||
|
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
)}
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={theater ? PLAYER_SHEET_BODY : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
|
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
|
||||||
|
|
||||||
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||||
{versionsToSwitch.length > 0 && (
|
{versionsToSwitch.length > 0 && (
|
||||||
@@ -218,7 +222,7 @@ export function PlayerPlaylistPanel({
|
|||||||
|
|
||||||
{/* 季选择 Tabs(若有多季) */}
|
{/* 季选择 Tabs(若有多季) */}
|
||||||
{hasEpisodeList && seasonGroups.length > 1 && (
|
{hasEpisodeList && seasonGroups.length > 1 && (
|
||||||
<div className="no-scrollbar flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
<div className="scrollbar-hide flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
|
||||||
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
{seasonGroups.map(({ season, episodes: sesEps }) => {
|
||||||
const isSelected = selectedSeason === season
|
const isSelected = selectedSeason === season
|
||||||
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
||||||
@@ -267,15 +271,21 @@ export function PlayerPlaylistPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 集数宫格:剧场模式下底部面板只做单层滚动,宫格区不再自己滚动 */}
|
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
|
||||||
{hasEpisodeList ? (
|
{hasEpisodeList ? (
|
||||||
<div className={theater ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
|
<div className={inline ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
|
||||||
{filteredEpisodes.length === 0 ? (
|
{filteredEpisodes.length === 0 ? (
|
||||||
<div className="py-10 text-center text-xs text-white/40">
|
<div className="py-10 text-center text-xs text-white/40">
|
||||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-5 gap-1.5 sm:grid-cols-6">
|
<div
|
||||||
|
className={
|
||||||
|
inline
|
||||||
|
? 'grid grid-cols-6 gap-1.5'
|
||||||
|
: 'grid grid-cols-5 gap-1.5 sm:grid-cols-6'
|
||||||
|
}
|
||||||
|
>
|
||||||
{filteredEpisodes.map((ep) => {
|
{filteredEpisodes.map((ep) => {
|
||||||
const isPlaying = mediaVersionMatches(ep, currentMediaId)
|
const isPlaying = mediaVersionMatches(ep, currentMediaId)
|
||||||
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
|
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
|
||||||
@@ -297,7 +307,7 @@ export function PlayerPlaylistPanel({
|
|||||||
ref={isPlaying ? activeItemRef : null}
|
ref={isPlaying ? activeItemRef : null}
|
||||||
onClick={() => onSelectEpisode(ep)}
|
onClick={() => onSelectEpisode(ep)}
|
||||||
title={cellTitle}
|
title={cellTitle}
|
||||||
className={`flex h-9 w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
|
className={`flex ${inline ? 'h-10' : 'h-9'} w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
|
||||||
isPlaying
|
isPlaying
|
||||||
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
|
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
|
||||||
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
|
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
|
||||||
@@ -320,7 +330,7 @@ export function PlayerPlaylistPanel({
|
|||||||
current === ep.id ? null : ep.id,
|
current === ep.id ? null : ep.id,
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
className={`absolute right-0 top-0 flex h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] font-semibold leading-none transition ${
|
className={`absolute right-0 top-0 flex ${inline ? 'h-5 w-5 text-[10px]' : 'h-3.5 w-3.5 text-[9px]'} items-center justify-center rounded-bl-md rounded-tr-md font-semibold leading-none transition ${
|
||||||
versionsForEpisodeId === ep.id
|
versionsForEpisodeId === ep.id
|
||||||
? 'bg-amber-300 text-black'
|
? 'bg-amber-300 text-black'
|
||||||
: 'bg-black/45 text-amber-200 hover:bg-black/70'
|
: 'bg-black/45 text-amber-200 hover:bg-black/70'
|
||||||
|
|||||||
@@ -29,20 +29,25 @@ export const PLAYER_DRAWER =
|
|||||||
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
|
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 底部动作面板(竖屏剧场模式):贴着视频区底部向上弹出,内容最多占
|
* 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口
|
||||||
* 视频区高度的 72%,留出一些画面可见;底部垫上安全区高度,避免
|
* 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。
|
||||||
* iPhone 小横条遮挡最后一行选项。
|
*
|
||||||
|
* 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块,
|
||||||
|
* 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。
|
||||||
*/
|
*/
|
||||||
export const PLAYER_SHEET =
|
export const PLAYER_SHEET =
|
||||||
`z-40 flex max-h-[72%] min-h-[8rem] w-full flex-col overflow-hidden rounded-t-2xl border border-white/10 border-b-0 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
`player-sheet-height fixed inset-x-0 bottom-0 z-40 flex min-h-[10rem] flex-col overflow-hidden rounded-t-2xl border border-b-0 border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl`
|
||||||
|
|
||||||
/** 底部动作面板的标题栏(带关闭按钮)。 */
|
/** 底部动作面板的标题栏(带关闭按钮)。 */
|
||||||
export const PLAYER_SHEET_HEADER =
|
export const PLAYER_SHEET_HEADER =
|
||||||
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5'
|
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5'
|
||||||
|
|
||||||
/** 底部动作面板的可滚动内容区,底部垫上安全区高度。 */
|
/**
|
||||||
|
* 底部动作面板的可滚动内容区:底部垫上安全区高度避免 iPhone 小横条遮挡,
|
||||||
|
* 并把面板内所有按钮抬到 40px,手机上手指点得准。
|
||||||
|
*/
|
||||||
export const PLAYER_SHEET_BODY =
|
export const PLAYER_SHEET_BODY =
|
||||||
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-1'
|
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-2 [&_button]:min-h-10'
|
||||||
|
|
||||||
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||||
export const PLAYER_ICON_BUTTON =
|
export const PLAYER_ICON_BUTTON =
|
||||||
|
|||||||
Vendored
+8
@@ -272,6 +272,14 @@ body {
|
|||||||
}
|
}
|
||||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* ── 播放器底部动作面板的高度上限 ──
|
||||||
|
手机上按视口高度算(86%),老浏览器不认识 dvh 时退回 vh,避免面板
|
||||||
|
在 flex 容器里被内容撑出屏幕。 */
|
||||||
|
.player-sheet-height {
|
||||||
|
max-height: 86vh;
|
||||||
|
max-height: 86dvh;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Reduced Motion ─── */
|
/* ─── Reduced Motion ─── */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export function PlayerPage() {
|
|||||||
// 选集 / 播放列表状态
|
// 选集 / 播放列表状态
|
||||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||||
|
// 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。
|
||||||
|
const [episodeRevealToken, setEpisodeRevealToken] = useState(0)
|
||||||
|
|
||||||
const teardownHls = useCallback(() => {
|
const teardownHls = useCallback(() => {
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
@@ -1259,6 +1261,16 @@ export function PlayerPage() {
|
|||||||
if (next) setDanmakuOpen(false)
|
if (next) setDanmakuOpen(false)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
setEpisodeRevealToken((token) => token + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 操作栏 / 设置面板里的「选集」入口。竖屏剧场模式下选集区就在视频下方,
|
||||||
|
// 这里只负责展开并滚过去,不把已经展开的列表收起来(收起由选集区自己的
|
||||||
|
// 「收起」按钮负责),这样播放画面永远不会被浮层挡住。
|
||||||
|
const openPlaylistEntry = useCallback(() => {
|
||||||
|
setPlaylistOpen(true)
|
||||||
|
setDanmakuOpen(false)
|
||||||
|
setEpisodeRevealToken((token) => token + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleDanmakuOpen = useCallback(() => {
|
const toggleDanmakuOpen = useCallback(() => {
|
||||||
@@ -1715,7 +1727,7 @@ export function PlayerPage() {
|
|||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
|
||||||
hasVersions={currentVersions.length > 1}
|
hasVersions={currentVersions.length > 1}
|
||||||
onTogglePlaylist={togglePlaylistOpen}
|
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
|
||||||
knownDuration={media?.duration_sec || 0}
|
knownDuration={media?.duration_sec || 0}
|
||||||
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
|
||||||
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
|
||||||
@@ -1736,16 +1748,17 @@ export function PlayerPage() {
|
|||||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||||
onDismissVr360Guide={dismissVr360Guide}
|
onDismissVr360Guide={dismissVr360Guide}
|
||||||
playlistPanel={
|
playlistPanel={
|
||||||
<PlayerPlaylistPanel
|
isMobileTheater
|
||||||
theater={isMobileTheater}
|
? undefined
|
||||||
open={playlistOpen}
|
: <PlayerPlaylistPanel
|
||||||
onClose={() => setPlaylistOpen(false)}
|
open={playlistOpen}
|
||||||
currentMediaId={media?.id ?? ''}
|
onClose={() => setPlaylistOpen(false)}
|
||||||
currentVersions={currentVersions}
|
currentMediaId={media?.id ?? ''}
|
||||||
episodes={playlistEpisodes}
|
currentVersions={currentVersions}
|
||||||
onSelectEpisode={playEpisode}
|
episodes={playlistEpisodes}
|
||||||
onSelectVersion={switchVersion}
|
onSelectEpisode={playEpisode}
|
||||||
/>
|
onSelectVersion={switchVersion}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
danmakuPanel={
|
danmakuPanel={
|
||||||
<PlayerDanmakuPanel
|
<PlayerDanmakuPanel
|
||||||
@@ -1797,7 +1810,21 @@ export function PlayerPage() {
|
|||||||
currentVersions={currentVersions}
|
currentVersions={currentVersions}
|
||||||
onSelectEpisode={playEpisode}
|
onSelectEpisode={playEpisode}
|
||||||
onSelectVersion={switchVersion}
|
onSelectVersion={switchVersion}
|
||||||
onOpenPlaylist={togglePlaylistOpen}
|
playlistOpen={playlistOpen}
|
||||||
|
onTogglePlaylist={togglePlaylistOpen}
|
||||||
|
revealToken={episodeRevealToken}
|
||||||
|
playlistPanel={
|
||||||
|
<PlayerPlaylistPanel
|
||||||
|
inline
|
||||||
|
open={playlistOpen}
|
||||||
|
onClose={() => setPlaylistOpen(false)}
|
||||||
|
currentMediaId={media?.id ?? ''}
|
||||||
|
currentVersions={currentVersions}
|
||||||
|
episodes={playlistEpisodes}
|
||||||
|
onSelectEpisode={playEpisode}
|
||||||
|
onSelectVersion={switchVersion}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user