diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index c0ac6b0..e21aab7 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -4,6 +4,7 @@ import { Captions, CaptionsOff, Check, + ChevronDown, FastForward, Layers, ListVideo, @@ -281,6 +282,8 @@ export function PlayerControls({ const speedMenuRef = useRef(null) const [settingsMenuOpen, setSettingsMenuOpen] = useState(false) const settingsMenuRef = useRef(null) + /** 竖屏剧场模式的设置面板(相对整个屏幕固定)——点击面板内部不应把它关掉。 */ + const settingsSheetRef = useRef(null) const hideTimerRef = useRef | null>(null) const isScrubbingRef = useRef(false) /** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */ @@ -392,6 +395,10 @@ export function PlayerControls({ useEffect(() => { if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen && !settingsMenuOpen) return 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)) { setSubtitleMenuOpen(false) } @@ -1040,29 +1047,24 @@ export function PlayerControls({ {playbackRateOverlay} {theater && settingsMenuOpen ? (
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} > -
setSettingsMenuOpen(false)} - aria-hidden - /> -
-
- 播放器设置 - -
-
{settingsPanel}
+
+ 播放器设置 +
+
{settingsPanel}
) : null}
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} className={ - theater - ? `absolute inset-x-0 bottom-0 z-40 ${PLAYER_SHEET}` - : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}` + theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}` } >
@@ -219,8 +217,12 @@ export function PlayerDanmakuPanel({ 弹幕设置 {settingsSaving && }
-
diff --git a/web/src/components/PlayerMobileTheaterInfo.tsx b/web/src/components/PlayerMobileTheaterInfo.tsx index 1508dfb..82b1605 100644 --- a/web/src/components/PlayerMobileTheaterInfo.tsx +++ b/web/src/components/PlayerMobileTheaterInfo.tsx @@ -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 { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion' @@ -6,8 +8,11 @@ import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion' // PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。 // // 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度, -// 下面这块区域接管标题、选集横滑、版本切换和简介,信息密度与 +// 下面这块区域接管标题、选集、版本切换和简介,信息密度与 // 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。 +// +// 选集刻意放在这里而不是弹层里:从操作栏点「选集」只做「展开 + 滚到这里」, +// 播放画面始终可见,不会像浮层那样盖住正在看的内容。 type PlayerMobileTheaterInfoProps = { media: Media | null @@ -21,7 +26,15 @@ type PlayerMobileTheaterInfoProps = { currentVersions?: Media[] onSelectEpisode: (media: Media) => void onSelectVersion?: (media: Media) => void - onOpenPlaylist?: () => void + /** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */ + playlistOpen: boolean + onTogglePlaylist: () => void + /** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */ + playlistPanel?: ReactNode + /** + * 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。 + */ + revealToken?: number } function episodeShortLabel(ep: Media): string { @@ -50,12 +63,22 @@ export function PlayerMobileTheaterInfo({ currentVersions = [], onSelectEpisode, onSelectVersion, - onOpenPlaylist, + playlistOpen, + onTogglePlaylist, + playlistPanel, + revealToken = 0, }: PlayerMobileTheaterInfoProps) { + const episodeSectionRef = useRef(null) const hasEpisodeList = episodes.length > 1 const versionsToSwitch = currentVersions.length > 1 ? currentVersions : [] const overview = media?.overview?.trim() || '' + // 从操作栏点「选集」:把选集区滚回视野内,用户不用自己往上翻。 + useEffect(() => { + if (!revealToken) return + episodeSectionRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }) + }, [revealToken]) + return (

{title || '加载中…'}

@@ -76,49 +99,59 @@ export function PlayerMobileTheaterInfo({ )} {hasEpisodeList ? ( -
+

选集 - {currentEpisodeIndex >= 0 ? `${currentEpisodeIndex + 1}/${episodes.length}` : `${episodes.length} 集`} + {currentEpisodeIndex >= 0 + ? `${currentEpisodeIndex + 1}/${episodes.length}` + : `${episodes.length} 集`}

- {onOpenPlaylist ? ( - - ) : null} -
-
- {episodes.map((ep) => { - const active = mediaVersionMatches(ep, currentMediaId) - return ( - - ) - })} +
+ + {playlistOpen && playlistPanel ? ( + playlistPanel + ) : ( +
+ {episodes.map((ep) => { + const active = mediaVersionMatches(ep, currentMediaId) + return ( + + ) + })} +
+ )}
) : null} - {versionsToSwitch.length > 0 ? ( + {/* 多版本切换:展开选集后由选集面板顶部的版本区接管,这里不重复一份 */} + {versionsToSwitch.length > 0 && !playlistOpen ? (

diff --git a/web/src/components/PlayerPlaylistPanel.tsx b/web/src/components/PlayerPlaylistPanel.tsx index fea5ae4..63f0e3e 100644 --- a/web/src/components/PlayerPlaylistPanel.tsx +++ b/web/src/components/PlayerPlaylistPanel.tsx @@ -10,7 +10,7 @@ import { mediaVersionMatches, mediaVersionSourceLabel, } 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 = { season: number @@ -28,10 +28,10 @@ type PlayerPlaylistPanelProps = { /** 切换到同一集/同一条目的另一个版本。 */ onSelectVersion?: (media: Media) => void /** - * 竖屏剧场模式:选集面板改为视频区底部的动作面板,而不是右侧抽屉, - * 避免窄屏下抽屉遮挡返回按钮、宫格被压扁。 + * 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的 + * 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。 */ - theater?: boolean + inline?: boolean } /** @@ -55,7 +55,7 @@ export function PlayerPlaylistPanel({ episodes, onSelectEpisode, onSelectVersion, - theater = false, + inline = false, }: PlayerPlaylistPanelProps) { const [filterText, setFilterText] = useState('') /** 正在挑选版本的剧集 id;null = 宫格直接播放。 */ @@ -140,24 +140,28 @@ export function PlayerPlaylistPanel({ onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} className={ - theater - ? `absolute inset-x-0 bottom-0 z-40 ${PLAYER_SHEET}` + inline + ? 'flex flex-col' : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}` } > -
-
- {hasEpisodeList ? '选集' : '版本'} - - {hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`} - + {/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了 + 「选集 / 收起」标题,重复一层只会挤掉内容。 */} + {inline ? null : ( +
+
+ {hasEpisodeList ? '选集' : '版本'} + + {hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`} + +
+
- -
+ )} -
+
{/* 当前条目的版本切换:多版本时置顶,随时可换 */} {versionsToSwitch.length > 0 && ( @@ -218,7 +222,7 @@ export function PlayerPlaylistPanel({ {/* 季选择 Tabs(若有多季) */} {hasEpisodeList && seasonGroups.length > 1 && ( -
+
{seasonGroups.map(({ season, episodes: sesEps }) => { const isSelected = selectedSeason === season const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId)) @@ -267,15 +271,21 @@ export function PlayerPlaylistPanel({
)} - {/* 集数宫格:剧场模式下底部面板只做单层滚动,宫格区不再自己滚动 */} + {/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */} {hasEpisodeList ? ( -
+
{filteredEpisodes.length === 0 ? (
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
) : ( -
+
{filteredEpisodes.map((ep) => { const isPlaying = mediaVersionMatches(ep, currentMediaId) const versions = ep.versions && ep.versions.length > 1 ? ep.versions : [] @@ -297,7 +307,7 @@ export function PlayerPlaylistPanel({ ref={isPlaying ? activeItemRef : null} onClick={() => onSelectEpisode(ep)} 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 ? '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' @@ -320,7 +330,7 @@ export function PlayerPlaylistPanel({ 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 ? 'bg-amber-300 text-black' : 'bg-black/45 text-amber-200 hover:bg-black/70' diff --git a/web/src/components/playerTheme.ts b/web/src/components/playerTheme.ts index ad894e2..81033a1 100644 --- a/web/src/components/playerTheme.ts +++ b/web/src/components/playerTheme.ts @@ -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` /** - * 底部动作面板(竖屏剧场模式):贴着视频区底部向上弹出,内容最多占 - * 视频区高度的 72%,留出一些画面可见;底部垫上安全区高度,避免 - * iPhone 小横条遮挡最后一行选项。 + * 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口 + * 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。 + * + * 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块, + * 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。 */ 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 = '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 = - '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 = diff --git a/web/src/index.css b/web/src/index.css index aadda3f..833f115 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -272,6 +272,14 @@ body { } .scrollbar-hide::-webkit-scrollbar { display: none; } +/* ── 播放器底部动作面板的高度上限 ── + 手机上按视口高度算(86%),老浏览器不认识 dvh 时退回 vh,避免面板 + 在 flex 容器里被内容撑出屏幕。 */ +.player-sheet-height { + max-height: 86vh; + max-height: 86dvh; +} + /* ─── Reduced Motion ─── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index ff5b763..63f37ef 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -199,6 +199,8 @@ export function PlayerPage() { // 选集 / 播放列表状态 const [playlistEpisodes, setPlaylistEpisodes] = useState([]) const [playlistOpen, setPlaylistOpen] = useState(false) + // 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。 + const [episodeRevealToken, setEpisodeRevealToken] = useState(0) const teardownHls = useCallback(() => { if (hlsRef.current) { @@ -1259,6 +1261,16 @@ export function PlayerPage() { if (next) setDanmakuOpen(false) return next }) + setEpisodeRevealToken((token) => token + 1) + }, []) + + // 操作栏 / 设置面板里的「选集」入口。竖屏剧场模式下选集区就在视频下方, + // 这里只负责展开并滚过去,不把已经展开的列表收起来(收起由选集区自己的 + // 「收起」按钮负责),这样播放画面永远不会被浮层挡住。 + const openPlaylistEntry = useCallback(() => { + setPlaylistOpen(true) + setDanmakuOpen(false) + setEpisodeRevealToken((token) => token + 1) }, []) const toggleDanmakuOpen = useCallback(() => { @@ -1715,7 +1727,7 @@ export function PlayerPage() { playlistOpen={playlistOpen} hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1} hasVersions={currentVersions.length > 1} - onTogglePlaylist={togglePlaylistOpen} + onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen} knownDuration={media?.duration_sec || 0} streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0} onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined} @@ -1736,16 +1748,17 @@ export function PlayerPage() { showVr360Guide={Boolean(vr360) && vr360GuideSeen === false} onDismissVr360Guide={dismissVr360Guide} playlistPanel={ - setPlaylistOpen(false)} - currentMediaId={media?.id ?? ''} - currentVersions={currentVersions} - episodes={playlistEpisodes} - onSelectEpisode={playEpisode} - onSelectVersion={switchVersion} - /> + isMobileTheater + ? undefined + : setPlaylistOpen(false)} + currentMediaId={media?.id ?? ''} + currentVersions={currentVersions} + episodes={playlistEpisodes} + onSelectEpisode={playEpisode} + onSelectVersion={switchVersion} + /> } danmakuPanel={ setPlaylistOpen(false)} + currentMediaId={media?.id ?? ''} + currentVersions={currentVersions} + episodes={playlistEpisodes} + onSelectEpisode={playEpisode} + onSelectVersion={switchVersion} + /> + } /> ) : null}