mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f57759ce | |||
| 6ec9adc976 | |||
| f4abdf78e2 | |||
| ae8db26c1e |
@@ -410,14 +410,10 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
}
|
||||
return s.serveEmbedded(ctx, m, index, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
abs, err := readExternalSubtitlePath(m, sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
|
||||
if !pathWithin(abs, mediaDir) {
|
||||
return fmt.Errorf("path escape")
|
||||
}
|
||||
|
||||
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||
if err != nil {
|
||||
@@ -428,14 +424,17 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 非 UTF-8 的外挂字幕(UTF-16、GBK/Big5 等)必须先归一化:浏览器只能按
|
||||
// UTF-8 解析 <track> 内容,否则整篇都会变成替换字符。
|
||||
text := decodeSubtitleText(body)
|
||||
|
||||
switch strings.ToLower(filepath.Ext(abs)) {
|
||||
case ".vtt":
|
||||
_, err = w.Write(body)
|
||||
_, err = io.WriteString(w, text)
|
||||
case ".srt":
|
||||
_, err = w.Write([]byte(srtToVTT(string(body))))
|
||||
_, err = io.WriteString(w, srtToVTT(text))
|
||||
case ".ass", ".ssa":
|
||||
_, err = w.Write([]byte(assToVTT(string(body))))
|
||||
_, err = io.WriteString(w, assToVTT(text))
|
||||
default:
|
||||
return errors.New("unsupported subtitle format")
|
||||
}
|
||||
@@ -517,12 +516,42 @@ func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w i
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(sub)) {
|
||||
case ".ass", ".ssa":
|
||||
return s.ServeRaw(ctx, mediaID, sub, w)
|
||||
// 外挂 ASS 同样要归一化成 UTF-8:libass 只认 UTF-8,UTF-16/GBK 的
|
||||
// 字幕交给它会解析不到任何事件,表现为「字幕选中了却不显示」。
|
||||
abs, err := readExternalSubtitlePath(m, sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
body, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, decodeSubtitleText(body))
|
||||
return err
|
||||
default:
|
||||
return errors.New("subtitle is not ASS/SSA")
|
||||
}
|
||||
}
|
||||
|
||||
// readExternalSubtitlePath 校验外挂字幕路径(必须落在媒体文件所在目录内)并返回
|
||||
// 绝对路径。Serve 与 ServeASS 共用,避免两处各自实现出现安全口径不一致。
|
||||
func readExternalSubtitlePath(m *model.Media, sub string) (string, error) {
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
|
||||
if !pathWithin(abs, mediaDir) {
|
||||
return "", fmt.Errorf("path escape")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
|
||||
input, err := s.resolveInput(ctx, media)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/japanese"
|
||||
"golang.org/x/text/encoding/korean"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
)
|
||||
|
||||
// 外挂字幕文件的编码归一化。
|
||||
//
|
||||
// 字幕文件由字幕组/工具生成,编码并不统一:除 UTF-8 外,中文圈常见 GBK/GB18030,
|
||||
// 港台常见 Big5,部分 Windows 工具(Aegisub 早期版本、Subtitle Edit 的某些导出)
|
||||
// 会写成带 BOM 的 UTF-16LE。浏览器 <track> 与 libass(JASSUB)都只按 UTF-8 解析,
|
||||
// 后端如果原样下发字节,非 UTF-8 的字幕会整篇变成替换字符——表现就是「字幕轨已经
|
||||
// 选中,但画面上一个字都不显示」。所以外挂字幕在服务端就要统一解码成 UTF-8。
|
||||
//
|
||||
// 注意:只用于下发给浏览器/播放器的文本路径。Emby 兼容层的 /subtitles 原始字节
|
||||
// 接口(ServeRaw)必须保持字节一致,不能经过这里。
|
||||
|
||||
// 各编码的 BOM 前缀。
|
||||
var (
|
||||
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||
utf16BEBOM = []byte{0xFE, 0xFF}
|
||||
utf16LEBOM = []byte{0xFF, 0xFE}
|
||||
)
|
||||
|
||||
// subtitleLegacyEncodings 是无 BOM 时的候选编码,按优先级排列。
|
||||
// GB18030 是 GBK/GB2312 的超集,中文外挂字幕绝大多数是它,因此排在最前——
|
||||
// 多个候选都能解出合法字幕时,靠前的优先。
|
||||
var subtitleLegacyEncodings = []struct {
|
||||
name string
|
||||
enc encoding.Encoding
|
||||
}{
|
||||
{"gb18030", simplifiedchinese.GB18030},
|
||||
{"big5", traditionalchinese.Big5},
|
||||
{"shift_jis", japanese.ShiftJIS},
|
||||
{"euc-kr", korean.EUCKR},
|
||||
}
|
||||
|
||||
// subtitleTimecodePattern 匹配 SRT(00:00:01,000 --> ...)与 WebVTT
|
||||
// (00:00:01.000 --> ...)的时间行。
|
||||
var subtitleTimecodePattern = regexp.MustCompile(`\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}\s*-->`)
|
||||
|
||||
// decodeSubtitleText 把字幕文件字节归一化成 UTF-8 文本。识别不出编码时原样返回,
|
||||
// 保证不会比「直接透传」更糟。
|
||||
func decodeSubtitleText(raw []byte) string {
|
||||
switch {
|
||||
case bytes.HasPrefix(raw, utf8BOM):
|
||||
return string(raw[len(utf8BOM):])
|
||||
case bytes.HasPrefix(raw, utf16LEBOM):
|
||||
return decodeUTF16(raw[len(utf16LEBOM):], false)
|
||||
case bytes.HasPrefix(raw, utf16BEBOM):
|
||||
return decodeUTF16(raw[len(utf16BEBOM):], true)
|
||||
}
|
||||
|
||||
// 无 BOM 且是合法 UTF-8:这是绝大多数情况,直接返回。
|
||||
if utf8.Valid(raw) {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
best := ""
|
||||
bestScore := math.MinInt
|
||||
consider := func(text string) {
|
||||
if !utf8.ValidString(text) || !looksLikeSubtitle(text) {
|
||||
return
|
||||
}
|
||||
score := subtitlePlausibility(text)
|
||||
if score > bestScore {
|
||||
best, bestScore = text, score
|
||||
}
|
||||
}
|
||||
|
||||
// 无 BOM 的 UTF-16 少见(多来自手工编辑),但一旦是它,所有单字节编码都会
|
||||
// 解出带大量 NUL 的垃圾,因此先用「格式特征」筛掉这些候选。
|
||||
consider(decodeUTF16(raw, false))
|
||||
consider(decodeUTF16(raw, true))
|
||||
|
||||
for _, candidate := range subtitleLegacyEncodings {
|
||||
text, err := candidate.enc.NewDecoder().Bytes(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
consider(string(text))
|
||||
}
|
||||
if best != "" {
|
||||
return best
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// decodeUTF16 按给定字节序把 UTF-16 字节解码为字符串(含代理对)。
|
||||
func decodeUTF16(raw []byte, bigEndian bool) string {
|
||||
units := make([]uint16, 0, len(raw)/2)
|
||||
for i := 0; i+1 < len(raw); i += 2 {
|
||||
if bigEndian {
|
||||
units = append(units, binary.BigEndian.Uint16(raw[i:i+2]))
|
||||
} else {
|
||||
units = append(units, binary.LittleEndian.Uint16(raw[i:i+2]))
|
||||
}
|
||||
}
|
||||
return string(utf16.Decode(units))
|
||||
}
|
||||
|
||||
// looksLikeSubtitle 判断解码结果是否真的像一份字幕。解码器「没报错」并不代表
|
||||
// 解对了——很多编码能把任意字节解成合法文本,只有格式特征才能确认。
|
||||
func looksLikeSubtitle(text string) bool {
|
||||
head := text
|
||||
if len(head) > 4096 {
|
||||
head = head[:4096]
|
||||
}
|
||||
if strings.Contains(head, "[Script Info]") || strings.Contains(head, "Dialogue:") {
|
||||
return true // ASS/SSA
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(head), "WEBVTT") {
|
||||
return true
|
||||
}
|
||||
return subtitleTimecodePattern.MatchString(head) // SRT / WebVTT 时间行
|
||||
}
|
||||
|
||||
// subtitlePlausibility 给解码结果打分,返回 -100..100 的可信度:正常文本字符加分,
|
||||
// 替换字符(解码失败)、控制字符、未分配码位扣分。
|
||||
//
|
||||
// 刻意用比例而不是字符数量:单字节回退会把一个双字节字符拆成两个字符,字符多的
|
||||
// 候选看起来「更正常」,实际是解错了——GBK 中文字幕被当成 Shift_JIS 解,就会得到
|
||||
// 一串半角片假名,数量比正确结果还多。半角片假名单独扣分正是为了压住这种情况。
|
||||
func subtitlePlausibility(text string) int {
|
||||
total := 0
|
||||
weight := 0
|
||||
for _, r := range text {
|
||||
total++
|
||||
switch {
|
||||
case r == utf8.RuneError:
|
||||
weight -= 8
|
||||
case r == '\n' || r == '\r' || r == '\t':
|
||||
// 正常换行不计分也不扣分。
|
||||
case r < 0x20 || r == 0x7F:
|
||||
weight -= 8
|
||||
case unicode.Is(unicode.Co, r) || unicode.Is(unicode.Cn, r):
|
||||
weight -= 4
|
||||
case r >= 0xFF61 && r <= 0xFF9F:
|
||||
// 半角片假名:GBK 汉字被按 Shift_JIS 解出来的典型产物。
|
||||
weight -= 2
|
||||
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul),
|
||||
unicode.Is(unicode.Latin, r), unicode.IsDigit(r), unicode.IsPunct(r):
|
||||
weight++
|
||||
case unicode.IsSpace(r):
|
||||
// 空格(含全角空格)不参与打分。
|
||||
default:
|
||||
weight--
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return weight * 100 / total
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
const assFixture = "[Script Info]\n" +
|
||||
"Title:Railgun 01 BD\n" +
|
||||
"ScriptType:v4.00+\n" +
|
||||
"\n" +
|
||||
"[Events]\n" +
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" +
|
||||
"Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,放て!心に刻んだ夢を\n"
|
||||
|
||||
const srtFixture = "1\n" +
|
||||
"00:00:01,000 --> 00:00:02,000\n" +
|
||||
"只有我的超电磁炮\n" +
|
||||
"\n"
|
||||
|
||||
func encodeUTF16(s string, bigEndian bool, withBOM bool) []byte {
|
||||
units := utf16.Encode([]rune(s))
|
||||
out := make([]byte, 0, len(units)*2+2)
|
||||
if withBOM {
|
||||
if bigEndian {
|
||||
out = append(out, 0xFE, 0xFF)
|
||||
} else {
|
||||
out = append(out, 0xFF, 0xFE)
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 2)
|
||||
for _, unit := range units {
|
||||
if bigEndian {
|
||||
binary.BigEndian.PutUint16(buf, unit)
|
||||
} else {
|
||||
binary.LittleEndian.PutUint16(buf, unit)
|
||||
}
|
||||
out = append(out, buf...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeGBK(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
encoded, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatalf("encode GBK: %v", err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
// 这是本次线上问题的核心场景:字幕组给的 ASS 是带 BOM 的 UTF-16LE,
|
||||
// 之前原样下发,浏览器按 UTF-8 解开后整篇是替换字符,一个字都渲染不出来。
|
||||
func TestDecodeSubtitleTextNormalisesLegacyEncodings(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
}{
|
||||
{"utf8", []byte(assFixture)},
|
||||
{"utf8-bom", append([]byte{0xEF, 0xBB, 0xBF}, []byte(assFixture)...)},
|
||||
{"utf16le-bom", encodeUTF16(assFixture, false, true)},
|
||||
{"utf16be-bom", encodeUTF16(assFixture, true, true)},
|
||||
{"utf16le-no-bom", encodeUTF16(assFixture, false, false)},
|
||||
{"gb18030", encodeGBK(t, assFixture)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := decodeSubtitleText(tc.raw)
|
||||
if !strings.Contains(got, "[Script Info]") {
|
||||
t.Fatalf("decoded text lost ASS header: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Dialogue:") {
|
||||
t.Fatalf("decoded text lost Dialogue line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "放て!心に刻んだ夢を") {
|
||||
t.Fatalf("decoded text lost non-ASCII content: %q", got)
|
||||
}
|
||||
if strings.ContainsRune(got, 0) || strings.ContainsRune(got, '\uFFFD') {
|
||||
t.Fatalf("decoded text still contains NUL or replacement runes: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSubtitleTextKeepsUnknownBytes(t *testing.T) {
|
||||
raw := []byte("not a subtitle at all: \x81\x82\x83\x84")
|
||||
if got := decodeSubtitleText(raw); got != string(raw) {
|
||||
t.Fatalf("decodeSubtitleText mangled non-subtitle bytes: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 接口层回归:外挂 UTF-16 字幕经 /subtitles/:id/ass(libass 路径)与
|
||||
// /subtitles/:id(WebVTT 降级路径)下发时都必须是可解析的 UTF-8。
|
||||
func TestSubtitleHandlersNormaliseUTF16Sidecar(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:subtitle-encoding?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
videoPath := filepath.Join(dir, "Railgun S01E01.mkv")
|
||||
assPath := filepath.Join(dir, "Railgun S01E01.ass")
|
||||
srtPath := filepath.Join(dir, "Railgun S01E01.zh.srt")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(assPath, encodeUTF16(assFixture, false, true), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(srtPath, encodeGBK(t, srtFixture), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
media := model.Media{Title: "Railgun", Path: videoPath}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ServeASS(t.Context(), media.ID, assPath, &buf); err != nil {
|
||||
t.Fatalf("ServeASS: %v", err)
|
||||
}
|
||||
ass := buf.String()
|
||||
if !bytes.HasPrefix(buf.Bytes(), []byte("[Script Info]")) {
|
||||
t.Fatalf("ServeASS output is not UTF-8 ASS: %q", ass)
|
||||
}
|
||||
if !strings.Contains(ass, "放て!心に刻んだ夢を") {
|
||||
t.Fatalf("ServeASS output lost dialogue text: %q", ass)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := svc.Serve(t.Context(), media.ID, assPath, &buf); err != nil {
|
||||
t.Fatalf("Serve(ass): %v", err)
|
||||
}
|
||||
vtt := buf.String()
|
||||
if !strings.HasPrefix(vtt, "WEBVTT") || !strings.Contains(vtt, "-->") {
|
||||
t.Fatalf("ASS->WebVTT fallback produced no cues: %q", vtt)
|
||||
}
|
||||
if !strings.Contains(vtt, "放て!心に刻んだ夢を") {
|
||||
t.Fatalf("ASS->WebVTT fallback lost dialogue text: %q", vtt)
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := svc.Serve(t.Context(), media.ID, srtPath, &buf); err != nil {
|
||||
t.Fatalf("Serve(srt): %v", err)
|
||||
}
|
||||
vtt = buf.String()
|
||||
if !strings.Contains(vtt, "只有我的超电磁炮") {
|
||||
t.Fatalf("GBK SRT was not transcoded: %q", vtt)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Captions,
|
||||
CaptionsOff,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FastForward,
|
||||
Layers,
|
||||
ListVideo,
|
||||
@@ -46,6 +47,9 @@ import {
|
||||
PLAYER_MENU_LABEL,
|
||||
PLAYER_POPOVER,
|
||||
PLAYER_RANGE_OVERLAY,
|
||||
PLAYER_SHEET,
|
||||
PLAYER_SHEET_BODY,
|
||||
PLAYER_SHEET_HEADER,
|
||||
PLAYER_TEXT_BUTTON,
|
||||
PLAYER_TRACK,
|
||||
PLAYER_TRACK_FILL,
|
||||
@@ -193,10 +197,17 @@ type PlayerControlsProps = {
|
||||
streamOffset?: number
|
||||
/** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */
|
||||
onSeekAbsolute?: (seconds: number) => boolean
|
||||
/**
|
||||
* 竖屏剧场模式:控制栏按钮放大到触控尺寸(44px 起),清晰度/倍速/
|
||||
* 选集/字幕弹层改为视频区底部的动作面板,避免右侧抽屉与悬浮小弹层
|
||||
* 在窄屏下被裁剪。桌面端保持原样。
|
||||
*/
|
||||
theater?: boolean
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
theater = false,
|
||||
volume: volumeProp = 1,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
@@ -271,6 +282,8 @@ export function PlayerControls({
|
||||
const speedMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
|
||||
const settingsMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
/** 竖屏剧场模式的设置面板(相对整个屏幕固定)——点击面板内部不应把它关掉。 */
|
||||
const settingsSheetRef = useRef<HTMLDivElement | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isScrubbingRef = useRef(false)
|
||||
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
|
||||
@@ -382,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)
|
||||
}
|
||||
@@ -910,7 +927,7 @@ export function PlayerControls({
|
||||
const compactOnlySections = (
|
||||
<>
|
||||
{showQuality && hasQualityGroups && (
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>清晰度</p>
|
||||
<QualityItems
|
||||
groups={qualityGroups}
|
||||
@@ -919,7 +936,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>播放速度</p>
|
||||
<SpeedItems
|
||||
playbackRate={playbackRateProp}
|
||||
@@ -927,7 +944,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
{onTogglePlaylist && (
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>选集</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -947,7 +964,7 @@ export function PlayerControls({
|
||||
</div>
|
||||
)}
|
||||
{subs.length > 0 && (
|
||||
<div className="sm:hidden">
|
||||
<div className={theater ? undefined : 'sm:hidden'}>
|
||||
<p className={PLAYER_MENU_LABEL}>字幕</p>
|
||||
<SubtitleItems
|
||||
subs={subs}
|
||||
@@ -965,7 +982,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:hidden">{PLAYER_MENU_DIVIDER}</div>
|
||||
<div className={theater ? PLAYER_MENU_DIVIDER : `sm:hidden ${PLAYER_MENU_DIVIDER}`} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1028,6 +1045,28 @@ export function PlayerControls({
|
||||
<>
|
||||
{seekOverlay}
|
||||
{playbackRateOverlay}
|
||||
{theater && settingsMenuOpen ? (
|
||||
<div
|
||||
ref={settingsSheetRef}
|
||||
className={PLAYER_SHEET}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={PLAYER_SHEET_HEADER}>
|
||||
<span className="text-[13px] font-semibold text-white/90">播放器设置</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsMenuOpen(false)}
|
||||
className={`${PLAYER_ICON_BUTTON} h-10 w-10`}
|
||||
title="关闭设置"
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={PLAYER_SHEET_BODY}>{settingsPanel}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-2.5 pb-1.5 pt-12 transition-opacity duration-300 sm:px-3.5 sm:pb-2.5 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
@@ -1042,11 +1081,11 @@ export function PlayerControls({
|
||||
>
|
||||
{/* 进度条:单独占一行贴着画面底部,轨道只有 3px,悬停才变粗并露出圆点手柄 */}
|
||||
<div
|
||||
className="group/seek relative flex h-5 w-full items-center"
|
||||
className={`group/seek relative flex w-full items-center ${theater ? 'h-9' : 'h-5'}`}
|
||||
onMouseMove={handleSeekHover}
|
||||
onMouseLeave={() => setSeekHover(null)}
|
||||
>
|
||||
<div className={`${PLAYER_TRACK} group-hover/seek:h-[5px]`}>
|
||||
<div className={`${PLAYER_TRACK} ${theater ? 'h-[5px]' : ''} group-hover/seek:h-[5px]`}>
|
||||
<div
|
||||
className={`${PLAYER_TRACK_FILL} bg-white/30`}
|
||||
style={{ width: `${bufferedRatio * 100}%` }}
|
||||
@@ -1057,7 +1096,7 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow-md transition-opacity duration-150 group-hover/seek:opacity-100"
|
||||
className={`pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white shadow-md transition-opacity duration-150 group-hover/seek:opacity-100 ${theater ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ left: `${playedRatio * 100}%` }}
|
||||
/>
|
||||
{tooltipVisible && durationSafe > 0 ? (
|
||||
@@ -1085,14 +1124,14 @@ export function PlayerControls({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-0.5 sm:mt-1 sm:gap-1">
|
||||
<div className={`flex items-center ${theater ? 'mt-1 gap-1' : 'mt-0.5 gap-0.5 sm:mt-1 sm:gap-1'}`}>
|
||||
{/* 播放 / 暂停 */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={`${PLAYER_ICON_BUTTON} h-8 w-8 sm:h-9 sm:w-9`}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : 'h-8 w-8 sm:h-9 sm:w-9'}`}
|
||||
title={playing ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{playing ? <Pause size={19} /> : <Play size={19} />}
|
||||
{playing ? <Pause size={theater ? 24 : 19} /> : <Play size={theater ? 24 : 19} />}
|
||||
</button>
|
||||
|
||||
{/* 上一集 / 下一集:单条媒体(电影)没有上下集时整组隐藏,少两个死按钮 */}
|
||||
@@ -1101,7 +1140,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
disabled={!hasPrevEpisode}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
|
||||
title={
|
||||
hasPrevEpisode
|
||||
? prevEpisodeTitle
|
||||
@@ -1115,7 +1154,7 @@ export function PlayerControls({
|
||||
<button
|
||||
onClick={onNextEpisode}
|
||||
disabled={!hasNextEpisode}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
|
||||
title={
|
||||
hasNextEpisode
|
||||
? nextEpisodeTitle
|
||||
@@ -1223,7 +1262,7 @@ export function PlayerControls({
|
||||
type="button"
|
||||
onClick={onOpenDanmaku}
|
||||
disabled={!onOpenDanmaku}
|
||||
className={`${PLAYER_ICON_BUTTON} ${danmakuOpen ? 'bg-white/15' : ''}`}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''} ${danmakuOpen ? 'bg-white/15' : ''}`}
|
||||
title={danmakuOpen ? '弹幕设置(已打开)' : '弹幕设置'}
|
||||
>
|
||||
<span
|
||||
@@ -1330,16 +1369,16 @@ export function PlayerControls({
|
||||
setSubtitleMenuOpen(false)
|
||||
setSettingsMenuOpen((v) => !v)
|
||||
}}
|
||||
className={`${PLAYER_ICON_BUTTON} ${settingsMenuOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''} ${settingsMenuOpen ? 'bg-white/15 text-white' : ''}`}
|
||||
title="播放器设置"
|
||||
>
|
||||
<Settings2 size={17} />
|
||||
<Settings2 size={theater ? 21 : 17} />
|
||||
</button>
|
||||
{/* 识别到 VR 素材但还没进 VR 时,用一个小圆点提示设置里有东西可开 */}
|
||||
{vr360Detected && !vr360 && (
|
||||
<span className="pointer-events-none absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-amber-300" />
|
||||
)}
|
||||
{settingsMenuOpen && (
|
||||
{settingsMenuOpen && !theater && (
|
||||
<div className={`${PLAYER_POPOVER} absolute bottom-11 right-0 max-h-[70vh] w-[15rem] overflow-y-auto`}>
|
||||
{settingsPanel}
|
||||
</div>
|
||||
@@ -1358,10 +1397,10 @@ export function PlayerControls({
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className={PLAYER_ICON_BUTTON}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
|
||||
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={17} /> : <Maximize size={17} />}
|
||||
{fullscreen ? <Minimize size={theater ? 21 : 17} /> : <Maximize size={theater ? 21 : 17} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1502,7 +1541,7 @@ function SubtitleItems({
|
||||
|
||||
{canAdjustSelectedSubtitle && (
|
||||
<>
|
||||
{PLAYER_MENU_DIVIDER}
|
||||
<div className={PLAYER_MENU_DIVIDER} />
|
||||
<p className={PLAYER_MENU_LABEL}>字幕位置</p>
|
||||
<div className="flex flex-wrap gap-1 px-2.5 pb-1">
|
||||
{SUBTITLE_POSITION_OPTIONS.map(([value, label]) => (
|
||||
@@ -1548,7 +1587,7 @@ function SubtitleItems({
|
||||
|
||||
{canConvertSelectedSubtitle && (
|
||||
<>
|
||||
{PLAYER_MENU_DIVIDER}
|
||||
<div className={PLAYER_MENU_DIVIDER} />
|
||||
<p className={PLAYER_MENU_LABEL}>外挂字幕简繁转换</p>
|
||||
{(
|
||||
[
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
|
||||
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
|
||||
// the matched danmaku details (anime title, episode title, comment count,
|
||||
@@ -56,6 +56,11 @@ type PlayerDanmakuPanelProps = {
|
||||
danmakuInfo?: DanmakuLoadedInfo | null
|
||||
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
||||
onResetAuto: () => void
|
||||
/**
|
||||
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
|
||||
* 窄屏下搜索框与滑杆不再被压扁。
|
||||
*/
|
||||
theater?: boolean
|
||||
}
|
||||
|
||||
export function PlayerDanmakuPanel({
|
||||
@@ -90,6 +95,7 @@ export function PlayerDanmakuPanel({
|
||||
danmakuInfo,
|
||||
onSelectEpisode,
|
||||
onResetAuto,
|
||||
theater = false,
|
||||
}: PlayerDanmakuPanelProps) {
|
||||
const [draft, setDraft] = useState(search)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
@@ -201,20 +207,26 @@ export function PlayerDanmakuPanel({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||
className={
|
||||
theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||
}
|
||||
>
|
||||
<div className={PLAYER_PANEL_HEADER}>
|
||||
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
|
||||
<span className="truncate">弹幕设置</span>
|
||||
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
|
||||
</div>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X size={theater ? 20 : 16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5">
|
||||
<div className={theater ? PLAYER_SHEET_BODY : 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5'}>
|
||||
{/* 弹幕总开关。从操作栏的「弹」按钮直接进来就到了这里,所以这一项就是
|
||||
用户找的「开关弹幕」;文案跟旧的按钮提示保持一致,避免换个说法让人找不到。 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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'
|
||||
|
||||
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
|
||||
//
|
||||
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
|
||||
// 下面这块区域接管标题、选集、版本切换和简介,信息密度与
|
||||
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
|
||||
//
|
||||
// 选集刻意放在这里而不是弹层里:从操作栏点「选集」只做「展开 + 滚到这里」,
|
||||
// 播放画面始终可见,不会像浮层那样盖住正在看的内容。
|
||||
|
||||
type PlayerMobileTheaterInfoProps = {
|
||||
media: Media | null
|
||||
title: string
|
||||
subtitle?: string
|
||||
playbackModeLabel?: string
|
||||
qualityLabel?: string
|
||||
episodes: Media[]
|
||||
currentMediaId: string
|
||||
currentEpisodeIndex: number
|
||||
currentVersions?: Media[]
|
||||
onSelectEpisode: (media: Media) => void
|
||||
onSelectVersion?: (media: Media) => void
|
||||
/** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */
|
||||
playlistOpen: boolean
|
||||
onTogglePlaylist: () => void
|
||||
/** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */
|
||||
playlistPanel?: ReactNode
|
||||
/**
|
||||
* 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。
|
||||
*/
|
||||
revealToken?: number
|
||||
}
|
||||
|
||||
function episodeShortLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) return `${ep.episode_num}`
|
||||
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
|
||||
return title ? title.slice(0, 4) : '·'
|
||||
}
|
||||
|
||||
function episodeFullLabel(ep: Media): string {
|
||||
if (ep.episode_num > 0) {
|
||||
const title = ep.episode_title?.trim()
|
||||
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
|
||||
}
|
||||
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
|
||||
}
|
||||
|
||||
export function PlayerMobileTheaterInfo({
|
||||
media,
|
||||
title,
|
||||
subtitle,
|
||||
playbackModeLabel,
|
||||
qualityLabel,
|
||||
episodes,
|
||||
currentMediaId,
|
||||
currentEpisodeIndex,
|
||||
currentVersions = [],
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
playlistOpen,
|
||||
onTogglePlaylist,
|
||||
playlistPanel,
|
||||
revealToken = 0,
|
||||
}: PlayerMobileTheaterInfoProps) {
|
||||
const episodeSectionRef = useRef<HTMLElement | null>(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 (
|
||||
<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>
|
||||
{subtitle ? <p className="mt-1 text-xs text-white/55">{subtitle}</p> : null}
|
||||
{(playbackModeLabel || qualityLabel) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{qualityLabel ? (
|
||||
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] font-medium text-white/80">
|
||||
{qualityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{playbackModeLabel ? (
|
||||
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] text-white/60">
|
||||
{playbackModeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEpisodeList ? (
|
||||
<section ref={episodeSectionRef} className="mt-4 scroll-mt-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||
<ListVideo size={14} className="text-rose-400" />
|
||||
选集
|
||||
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||
{currentEpisodeIndex >= 0
|
||||
? `${currentEpisodeIndex + 1}/${episodes.length}`
|
||||
: `${episodes.length} 集`}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlaylist}
|
||||
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"
|
||||
>
|
||||
{playlistOpen ? '收起' : '全部'}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`transition-transform ${playlistOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
{/* 多版本切换:展开选集后由选集面板顶部的版本区接管,这里不重复一份 */}
|
||||
{versionsToSwitch.length > 0 && !playlistOpen ? (
|
||||
<section className="mt-4">
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
|
||||
<Layers size={13} className="text-rose-400" />
|
||||
版本
|
||||
<span className="font-mono text-[11px] font-normal text-white/45">
|
||||
{versionsToSwitch.length} 个
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-1.5">
|
||||
{versionsToSwitch.map((version) => {
|
||||
const active = mediaVersionMatches(version, currentMediaId)
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => onSelectVersion?.(version)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition disabled:cursor-default ${
|
||||
active
|
||||
? 'border-rose-500/50 bg-rose-500/15 text-white'
|
||||
: 'border-white/5 bg-white/5 text-white/85 active:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{mediaVersionLabel(version)}
|
||||
</span>
|
||||
{active ? <span className="shrink-0 text-[10px] text-rose-300">播放中</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{overview ? (
|
||||
<section className="mt-4">
|
||||
<h2 className="mb-1.5 text-[13px] font-semibold text-white/90">简介</h2>
|
||||
<p className="text-xs leading-relaxed text-white/60">{overview}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<p className="mt-5 text-center text-[10px] text-white/30">将手机横放可获得全屏沉浸式播放体验</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,11 @@ type PlayerPlaylistPanelProps = {
|
||||
onSelectEpisode: (media: Media) => void
|
||||
/** 切换到同一集/同一条目的另一个版本。 */
|
||||
onSelectVersion?: (media: Media) => void
|
||||
/**
|
||||
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
|
||||
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
|
||||
*/
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +55,7 @@ export function PlayerPlaylistPanel({
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
onSelectVersion,
|
||||
inline = false,
|
||||
}: PlayerPlaylistPanelProps) {
|
||||
const [filterText, setFilterText] = useState('')
|
||||
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
|
||||
@@ -133,19 +139,29 @@ export function PlayerPlaylistPanel({
|
||||
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
|
||||
className={
|
||||
inline
|
||||
? 'flex flex-col'
|
||||
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
|
||||
}
|
||||
>
|
||||
<div className={PLAYER_PANEL_HEADER}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
|
||||
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
|
||||
</span>
|
||||
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
|
||||
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
|
||||
{inline ? null : (
|
||||
<div className={PLAYER_PANEL_HEADER}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<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>
|
||||
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
|
||||
|
||||
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
|
||||
{versionsToSwitch.length > 0 && (
|
||||
@@ -206,7 +222,7 @@ export function PlayerPlaylistPanel({
|
||||
|
||||
{/* 季选择 Tabs(若有多季) */}
|
||||
{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 }) => {
|
||||
const isSelected = selectedSeason === season
|
||||
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
|
||||
@@ -255,15 +271,21 @@ export function PlayerPlaylistPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数宫格 */}
|
||||
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
|
||||
{hasEpisodeList ? (
|
||||
<div className="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 ? (
|
||||
<div className="py-10 text-center text-xs text-white/40">
|
||||
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
|
||||
</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) => {
|
||||
const isPlaying = mediaVersionMatches(ep, currentMediaId)
|
||||
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
|
||||
@@ -285,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'
|
||||
@@ -308,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'
|
||||
@@ -326,11 +348,12 @@ export function PlayerPlaylistPanel({
|
||||
</div>
|
||||
) : (
|
||||
versionsToSwitch.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center px-4 text-center text-xs text-white/40">
|
||||
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
|
||||
暂无可切换的内容
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,27 @@ export const PLAYER_POPOVER =
|
||||
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`
|
||||
|
||||
/**
|
||||
* 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口
|
||||
* 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。
|
||||
*
|
||||
* 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块,
|
||||
* 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。
|
||||
*/
|
||||
export const PLAYER_SHEET =
|
||||
`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-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-2 [&_button]:min-h-10'
|
||||
|
||||
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
|
||||
export const PLAYER_ICON_BUTTON =
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const THEATER_MAX_WIDTH = 768
|
||||
|
||||
function queryTheater(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
// 窄屏 + 竖屏才进剧场模式:横屏手机自动回到全屏沉浸式布局。
|
||||
return window.innerWidth < THEATER_MAX_WIDTH && window.innerHeight >= window.innerWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否使用移动端竖屏剧场布局。
|
||||
*
|
||||
* 桌面端与横屏手机保持原来的全屏居中播放器;只有竖屏手机进
|
||||
* 「视频贴顶 + 下方内容区」的剧场模式,避免 16:9 视频在竖屏里
|
||||
* 上下大黑边、控制栏远离画面的问题。
|
||||
*/
|
||||
export function useIsMobileTheater(): boolean {
|
||||
const [theater, setTheater] = useState(queryTheater)
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setTheater(queryTheater())
|
||||
window.addEventListener('resize', onChange)
|
||||
window.addEventListener('orientationchange', onChange)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onChange)
|
||||
window.removeEventListener('orientationchange', onChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return theater
|
||||
}
|
||||
Vendored
+8
@@ -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 {
|
||||
|
||||
@@ -39,6 +39,8 @@ import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerMobileTheaterInfo } from '../components/PlayerMobileTheaterInfo'
|
||||
import { useIsMobileTheater } from '../hooks/useIsMobileTheater'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
import {
|
||||
@@ -197,6 +199,8 @@ export function PlayerPage() {
|
||||
// 选集 / 播放列表状态
|
||||
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
// 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。
|
||||
const [episodeRevealToken, setEpisodeRevealToken] = useState(0)
|
||||
|
||||
const teardownHls = useCallback(() => {
|
||||
if (hlsRef.current) {
|
||||
@@ -1257,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(() => {
|
||||
@@ -1655,6 +1669,10 @@ export function PlayerPage() {
|
||||
media?.title?.trim() ||
|
||||
''
|
||||
|
||||
// 竖屏手机走剧场布局:视频贴顶按 16:9 自适应,下方是标题/选集/简介。
|
||||
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
|
||||
const isMobileTheater = useIsMobileTheater()
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||
<PlayerTopBar
|
||||
@@ -1667,6 +1685,7 @@ export function PlayerPage() {
|
||||
onBack={goBack}
|
||||
/>
|
||||
<PlayerVideoStage
|
||||
theater={isMobileTheater}
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
playerError={playerError}
|
||||
@@ -1708,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}
|
||||
@@ -1729,18 +1748,21 @@ export function PlayerPage() {
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
playlistPanel={
|
||||
<PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
isMobileTheater
|
||||
? undefined
|
||||
: <PlayerPlaylistPanel
|
||||
open={playlistOpen}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentVersions={currentVersions}
|
||||
episodes={playlistEpisodes}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
/>
|
||||
}
|
||||
danmakuPanel={
|
||||
<PlayerDanmakuPanel
|
||||
theater={isMobileTheater}
|
||||
open={danmakuOpen}
|
||||
onClose={() => setDanmakuOpen(false)}
|
||||
enabled={danmakuEnabled}
|
||||
@@ -1775,6 +1797,36 @@ export function PlayerPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isMobileTheater ? (
|
||||
<PlayerMobileTheaterInfo
|
||||
media={media?.id === id ? media : null}
|
||||
title={topBarTitle}
|
||||
subtitle={topBarSubtitle}
|
||||
playbackModeLabel={playbackModeLabel}
|
||||
qualityLabel={showQuality ? qualityLabel : undefined}
|
||||
episodes={playlistEpisodes}
|
||||
currentMediaId={media?.id ?? ''}
|
||||
currentEpisodeIndex={currentEpisodeIndex}
|
||||
currentVersions={currentVersions}
|
||||
onSelectEpisode={playEpisode}
|
||||
onSelectVersion={switchVersion}
|
||||
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}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ function buildSubtitleRenderGroups(
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
/**
|
||||
* 竖屏剧场模式:舞台不再全屏居中,而是贴顶按 16:9 自适应高度;
|
||||
* 弹层改为底部动作面板,避免右侧抽屉与悬浮小弹层在窄屏下被裁剪。
|
||||
*/
|
||||
theater?: boolean
|
||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||
loadError?: string
|
||||
playerError: string
|
||||
@@ -234,6 +239,7 @@ type PlayerVideoStageProps = {
|
||||
|
||||
export function PlayerVideoStage({
|
||||
media,
|
||||
theater = false,
|
||||
loadError,
|
||||
playerError,
|
||||
subs,
|
||||
@@ -725,12 +731,22 @@ export function PlayerVideoStage({
|
||||
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
|
||||
// 不再按视频宽高比留黑边。
|
||||
//
|
||||
// 竖屏剧场模式(theater):舞台不再全屏居中,视频贴顶按容器宽度
|
||||
// 16:9 自适应高度,下方留给标题/选集/简介内容区。stage 本体的
|
||||
// 高度只包住视频区,控制栏与弹层都相对这块区域定位。
|
||||
const wrapperStyle = vr360
|
||||
? {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
: videoRatio
|
||||
: theater
|
||||
? {
|
||||
aspectRatio: '16 / 9',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
}
|
||||
: videoRatio
|
||||
? {
|
||||
aspectRatio: `${videoRatio}`,
|
||||
width: isWiderThanStage ? '100%' : 'auto',
|
||||
@@ -747,7 +763,11 @@ export function PlayerVideoStage({
|
||||
<div
|
||||
ref={stageRef}
|
||||
data-player-stage
|
||||
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
className={
|
||||
theater
|
||||
? 'relative flex w-full shrink-0 items-start justify-center overflow-hidden bg-black'
|
||||
: 'relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black'
|
||||
}
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onMouseLeave={() => setLockHovered(false)}
|
||||
@@ -851,6 +871,7 @@ export function PlayerVideoStage({
|
||||
) : null}
|
||||
</div>
|
||||
<PlayerControls
|
||||
theater={theater}
|
||||
videoRef={videoRef}
|
||||
volume={playerVolume}
|
||||
onVolumeChange={onPlayerVolumeChange}
|
||||
|
||||
Reference in New Issue
Block a user