mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
添加弹幕功能
添加弹幕功能
This commit is contained in:
@@ -12,16 +12,19 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/sys v0.20.0
|
||||
gorm.io/driver/postgres v1.5.7
|
||||
gorm.io/gorm v1.25.7
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
@@ -48,6 +51,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
@@ -67,8 +71,7 @@ require (
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
@@ -171,15 +175,15 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -187,8 +191,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package handler — danmaku endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the
|
||||
// search keyword (used by the player's manual search box); ?episodeId= forces a
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
|
||||
// size, area, enabled) so the player can initialize its control panel without
|
||||
// admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
||||
authed.GET("/media/recent", recentMediaHandler(svc))
|
||||
authed.GET("/media/stats", mediaStatsHandler(svc))
|
||||
|
||||
authed.GET("/danmaku/:id", getDanmakuHandler(svc))
|
||||
authed.GET("/danmaku/config", getDanmakuConfigHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// Danmaku setting keys, managed through the admin settings UI (PUT
|
||||
// /admin/settings). They are stored in the Setting table so the playback
|
||||
// page can pull them without admin privileges.
|
||||
const (
|
||||
DanmakuEnabledKey = "danmaku.enabled"
|
||||
DanmakuSourceKey = "danmaku.source"
|
||||
DanmakuOpacityKey = "danmaku.opacity"
|
||||
DanmakuFontSizeKey = "danmaku.font_size"
|
||||
DanmakuAreaKey = "danmaku.area"
|
||||
)
|
||||
|
||||
// DanmakuDefaultSource is the official dandanplay endpoint used when the
|
||||
// admin leaves the source address empty. Any server implementing the
|
||||
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
|
||||
const DanmakuDefaultSource = "https://api.dandanplay.net"
|
||||
|
||||
// DanmakuRenderConfig carries the renderer knobs to the web player.
|
||||
type DanmakuRenderConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Opacity string `json:"opacity"`
|
||||
FontSize string `json:"font_size"`
|
||||
Area string `json:"area"`
|
||||
}
|
||||
|
||||
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
|
||||
// comment payload; parsing happens client-side. The dandanplay protocol has
|
||||
// two payload shapes in the wild — the classic Bilibili-style XML and the
|
||||
// newer dandanplay JSON ({"count":N,"comments":[{p,m,t,...}]}) — so
|
||||
// source_type is sniffed from the body rather than fixed.
|
||||
//
|
||||
// Candidates is non-nil when multiple anime matched the search and the player
|
||||
// must ask the user which one to use (disambiguation); Raw is empty then.
|
||||
type DanmakuFetchResult struct {
|
||||
DanmakuRenderConfig
|
||||
SourceType string `json:"source_type"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Candidates []DanmakuAnime `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
// DanmakuAnime is one search hit (an anime) with its episode list, mirroring
|
||||
// the dandanplay SearchEpisodesResponse shape.
|
||||
type DanmakuAnime struct {
|
||||
AnimeID int64 `json:"animeId"`
|
||||
AnimeTitle string `json:"animeTitle"`
|
||||
Episodes []DanmakuEpisode `json:"episodes"`
|
||||
}
|
||||
|
||||
// DanmakuEpisode is one selectable danmaku library inside an anime.
|
||||
type DanmakuEpisode struct {
|
||||
EpisodeID int64 `json:"episodeId"`
|
||||
EpisodeTitle string `json:"episodeTitle"`
|
||||
}
|
||||
|
||||
// DanmakuService fetches danmaku for a media item through the dandanplay
|
||||
// protocol: search for an episode id by the video's name, then fetch the
|
||||
// comment library XML. The React player parses and renders it.
|
||||
type DanmakuService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func danmakuHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuService {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &DanmakuService{log: log, repo: repo, client: danmakuHTTPClient()}
|
||||
}
|
||||
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
Opacity: "1",
|
||||
FontSize: "24",
|
||||
Area: "1",
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return cfg
|
||||
}
|
||||
read := func(key, fallback string) string {
|
||||
v, err := s.repo.Setting.Get(ctx, key)
|
||||
if err != nil || v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
cfg.Enabled = ParseBoolSetting(read(DanmakuEnabledKey, "true"), true)
|
||||
cfg.Source = read(DanmakuSourceKey, "")
|
||||
cfg.Opacity = read(DanmakuOpacityKey, "1")
|
||||
cfg.FontSize = read(DanmakuFontSizeKey, "24")
|
||||
cfg.Area = read(DanmakuAreaKey, "1")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Fetch retrieves danmaku for the given media. keyword overrides the
|
||||
// media-derived search term (empty = use the video's own name); pass it from
|
||||
// the player when the user searches for a custom title. episodeID forces a
|
||||
// specific danmaku library chosen by the user (from a previous disambiguation
|
||||
// response); empty means auto-resolution. The danmaku library is resolved
|
||||
// through the dandanplay protocol:
|
||||
//
|
||||
// 1. search episodes by title (+ season/episode number)
|
||||
// 2a. exactly one hit → fetch that episode's comment library
|
||||
// 2b. several hits → return candidates (Raw empty) so the player asks the user
|
||||
// 2c. explicit episodeID → fetch it directly
|
||||
// 3. fetch the comment library XML
|
||||
//
|
||||
// When danmaku is disabled the result carries Enabled=false so the player can
|
||||
// silently skip rendering.
|
||||
func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) {
|
||||
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
|
||||
if !res.Enabled {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
base := strings.TrimRight(strings.TrimSpace(res.Source), "/")
|
||||
if base == "" {
|
||||
base = DanmakuDefaultSource
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(episodeID)
|
||||
if target == "" {
|
||||
// 名称与集数优先来自媒体(original_name → title → 文件名),
|
||||
// 手动搜索关键词时仍沿用当前媒体的集数(同一部番剧同名搜索)。
|
||||
term, err := s.searchTerms(ctx, mediaID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||
term.name = kw
|
||||
}
|
||||
if strings.TrimSpace(term.name) == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
candidates, err := s.searchCandidates(ctx, base, term.name, term.episode)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
// 多结果歧义:把候选交回播放器让用户选择(disambiguation)。
|
||||
if len(candidates) != 1 {
|
||||
res.Candidates = candidates
|
||||
return res, nil
|
||||
}
|
||||
if len(candidates[0].Episodes) == 0 {
|
||||
return res, errors.New("no danmaku library found for this video")
|
||||
}
|
||||
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
|
||||
}
|
||||
|
||||
raw, err := s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
|
||||
return res, err
|
||||
}
|
||||
res.Raw = raw
|
||||
res.SourceType = detectDanmakuSourceType(raw)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// detectDanmakuSourceType guesses the comment payload format from its body.
|
||||
// The dandanplay protocol historically returned Bilibili-style XML, but newer
|
||||
// and self-hosted implementations return the dandanplay JSON shape
|
||||
// ({"count":N,"comments":[{p,m,t,...}]}); sniff the leading byte instead of
|
||||
// trusting the source. Unknown/empty bodies fall back to "auto" so the player
|
||||
// can try both parsers.
|
||||
func detectDanmakuSourceType(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "auto"
|
||||
}
|
||||
if trimmed[0] == '{' || trimmed[0] == '[' {
|
||||
return "json"
|
||||
}
|
||||
return "xml"
|
||||
}
|
||||
|
||||
type danmakuSearchTerms struct {
|
||||
name string // 作品标题(original_name → title → 文件名)
|
||||
episode string // 集数编号(dandanplay episode 参数,正整数)
|
||||
}
|
||||
|
||||
// searchTerms resolves the name and episode number used to look up the
|
||||
// dandanplay library. Episode 0 (movies / unknown) is left empty so the
|
||||
// search does not filter by episode.
|
||||
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, error) {
|
||||
var term danmakuSearchTerms
|
||||
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||
return term, errors.New("media repository unavailable")
|
||||
}
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return term, err
|
||||
}
|
||||
if m == nil {
|
||||
return term, errors.New("media not found")
|
||||
}
|
||||
if name := strings.TrimSpace(m.OriginalName); name != "" {
|
||||
term.name = name
|
||||
} else if name := strings.TrimSpace(m.Title); name != "" {
|
||||
term.name = name
|
||||
} else {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
}
|
||||
if m.EpisodeNum > 0 {
|
||||
term.episode = strconv.Itoa(m.EpisodeNum)
|
||||
}
|
||||
return term, nil
|
||||
}
|
||||
|
||||
// searchCandidates returns every anime hit for a name via the dandanplay
|
||||
// search endpoint, with the episode list per anime. The optional episode
|
||||
// number filters the result to that specific episode. Responses follow
|
||||
// SearchEpisodesResponse: { animes: [{ animeId, animeTitle, episodes:
|
||||
// [{ episodeId, episodeTitle }] }] }.
|
||||
func (s *DanmakuService) searchCandidates(ctx context.Context, base, name, episode string) ([]DanmakuAnime, error) {
|
||||
u := fmt.Sprintf("%s/api/v2/search/episodes?anime=%s&v2=true", base, url.QueryEscape(name))
|
||||
if episode != "" {
|
||||
u += "&episode=" + url.QueryEscape(episode)
|
||||
}
|
||||
body, err := s.fetchBody(ctx, u, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Animes []struct {
|
||||
AnimeID int64 `json:"animeId"`
|
||||
AnimeTitle string `json:"animeTitle"`
|
||||
Episodes []struct {
|
||||
EpisodeID int64 `json:"episodeId"`
|
||||
EpisodeTitle string `json:"episodeTitle"`
|
||||
} `json:"episodes"`
|
||||
} `json:"animes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil {
|
||||
return nil, fmt.Errorf("danmaku search returned invalid JSON: %w", err)
|
||||
}
|
||||
var out []DanmakuAnime
|
||||
for _, anime := range resp.Animes {
|
||||
item := DanmakuAnime{AnimeID: anime.AnimeID, AnimeTitle: anime.AnimeTitle}
|
||||
for _, ep := range anime.Episodes {
|
||||
if ep.EpisodeID <= 0 {
|
||||
continue
|
||||
}
|
||||
item.Episodes = append(item.Episodes, DanmakuEpisode{
|
||||
EpisodeID: ep.EpisodeID,
|
||||
EpisodeTitle: ep.EpisodeTitle,
|
||||
})
|
||||
}
|
||||
if len(item.Episodes) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, followRedirect bool) (string, error) {
|
||||
client := s.client
|
||||
if !followRedirect {
|
||||
// Search returns plain JSON; the comment endpoint 302-redirects to a
|
||||
// danmaku accelerator CDN which the default client follows.
|
||||
copied := *s.client
|
||||
copied.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
client = &copied
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "MediaStationGo/danmaku (+https://github.com/ShukeBta/MediaStationGo)")
|
||||
req.Header.Set("Accept", "application/json, application/xml, */*")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("danmaku source returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) // 8 MiB cap
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func newDanmakuTestService(t *testing.T) *DanmakuService {
|
||||
t.Helper()
|
||||
// 独立临时文件库,避免测试间通过共享内存库串数据。
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{}))
|
||||
repos := repository.New(db)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return NewDanmakuService(zap.NewNop(), repos)
|
||||
}
|
||||
|
||||
// seedDanmakuMedia inserts a media row so the service can resolve a name and
|
||||
// episode number.
|
||||
func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName string, episodeNum int) {
|
||||
t.Helper()
|
||||
m := model.Media{Title: title}
|
||||
if id != "" {
|
||||
m.ID = id
|
||||
}
|
||||
if originalName != "" {
|
||||
m.OriginalName = originalName
|
||||
}
|
||||
if episodeNum > 0 {
|
||||
m.EpisodeNum = episodeNum
|
||||
}
|
||||
require.NoError(t, svc.repo.DB.Create(&m).Error)
|
||||
}
|
||||
|
||||
// danmakuSourceServer records the search request (name + episode params) and
|
||||
// serves episodes + Bilibili-format XML comments, following the dandanplay
|
||||
// protocol. The search payload can be customized per test (default: one anime,
|
||||
// one episode).
|
||||
type danmakuSourceServer struct {
|
||||
server *httptest.Server
|
||||
lastSearch string // full query (anime=...&episode=...)
|
||||
searchResponse string // JSON body served for /api/v2/search/episodes
|
||||
}
|
||||
|
||||
func newDanmakuSourceServer(t *testing.T) *danmakuSourceServer {
|
||||
return newDanmakuSourceServerWithSearch(
|
||||
t,
|
||||
`{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`,
|
||||
)
|
||||
}
|
||||
|
||||
func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danmakuSourceServer {
|
||||
t.Helper()
|
||||
ds := &danmakuSourceServer{searchResponse: searchResponse}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
ds.lastSearch = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, ds.searchResponse)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><i><d p="0.5,1,16777215,user1">弹幕A</d><d p="1.0,5,255,user2">弹幕B</d><d p="1.5,4,65280,user3">弹幕C</d></i>`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><i><d p="2.0,1,16777215,userX">显式指定弹幕</d></i>`)
|
||||
})
|
||||
ds.server = httptest.NewServer(mux)
|
||||
t.Cleanup(ds.server.Close)
|
||||
return ds
|
||||
}
|
||||
|
||||
func (ds *danmakuSourceServer) URL() string { return ds.server.URL }
|
||||
|
||||
func TestDanmakuConfigDefaults(t *testing.T) {
|
||||
svc := newDanmakuTestService(t)
|
||||
cfg := svc.Config(context.Background())
|
||||
require.True(t, cfg.Enabled)
|
||||
require.Equal(t, "1", cfg.Opacity)
|
||||
require.Equal(t, "24", cfg.FontSize)
|
||||
require.Equal(t, "1", cfg.Area)
|
||||
require.Empty(t, cfg.Source)
|
||||
}
|
||||
|
||||
func TestDanmakuConfigReadsPersistedValues(t *testing.T) {
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuEnabledKey, "false"))
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, "https://dm.example.com"))
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuOpacityKey, "0.7"))
|
||||
|
||||
cfg := svc.Config(ctx)
|
||||
require.False(t, cfg.Enabled)
|
||||
require.Equal(t, "https://dm.example.com", cfg.Source)
|
||||
require.Equal(t, "0.7", cfg.Opacity)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchDisablesWhenToggleOff(t *testing.T) {
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuEnabledKey, "false"))
|
||||
seedDanmakuMedia(t, svc, "m1", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "m1", "", "")
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.Enabled)
|
||||
require.Empty(t, res.Raw)
|
||||
// 禁用时无弹幕数据可探测,source_type 保持未知(交给播放器兜底)。
|
||||
require.Equal(t, "auto", res.SourceType)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchWithDandanplaySource(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
|
||||
seedDanmakuMedia(t, svc, "m2", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "m2", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Equal(t, "xml", res.SourceType)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
require.Contains(t, res.Raw, `p="0.5,1,16777215,user1"`)
|
||||
}
|
||||
|
||||
func TestDanmakuFetchUsesOriginalNameForSearch(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
|
||||
seedDanmakuMedia(t, svc, "m3", "刮削标题A", "日文原名B", 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "m3", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
// 集数为 0 时不附加 episode 过滤参数。
|
||||
require.NotContains(t, srv.lastSearch, "episode=")
|
||||
}
|
||||
|
||||
func TestDanmakuFetchSearchesByEpisodeNumber(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
|
||||
// 第 3 话:搜索时应带 episode=3,命中该集弹幕库。
|
||||
seedDanmakuMedia(t, svc, "m2", "测试动画", "", 3)
|
||||
|
||||
res, err := svc.Fetch(ctx, "m2", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, srv.lastSearch, "anime=")
|
||||
require.Contains(t, srv.lastSearch, "episode=3")
|
||||
require.Contains(t, res.Raw, "弹幕A")
|
||||
}
|
||||
|
||||
func TestDanmakuFetchKeywordKeepsEpisode(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
|
||||
seedDanmakuMedia(t, svc, "m5", "测试动画", "", 5)
|
||||
|
||||
// 手动搜索关键词时保留媒体集数,仍只命中该集。
|
||||
res, err := svc.Fetch(ctx, "m5", "另一个名字", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, srv.lastSearch, "anime=")
|
||||
require.Contains(t, srv.lastSearch, "episode=5")
|
||||
}
|
||||
|
||||
func TestDanmakuFetchUsesDefaultSourceWhenEmpty(t *testing.T) {
|
||||
require.Equal(t, DanmakuDefaultSource, "https://api.dandanplay.net")
|
||||
}
|
||||
|
||||
func TestDanmakuFetchHandlesSearch404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
|
||||
seedDanmakuMedia(t, svc, "m4", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "m4", "", "")
|
||||
require.Error(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
}
|
||||
|
||||
// 多部番剧命中时返回候选列表(disambiguation),不擅自选第一个。
|
||||
func TestDanmakuFetchReturnsCandidatesOnAmbiguity(t *testing.T) {
|
||||
srv := newDanmakuSourceServerWithSearch(
|
||||
t,
|
||||
`{"hasMore":false,"animes":[`+
|
||||
`{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]},`+
|
||||
`{"animeId":2002,"animeTitle":"测试动画 剧场版","episodes":[{"episodeId":30001,"episodeTitle":"正片"}]}`+
|
||||
`]}`,
|
||||
)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
|
||||
seedDanmakuMedia(t, svc, "m6", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "m6", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
// 歧义时不取弹幕,返回候选交由播放器选择。
|
||||
require.Empty(t, res.Raw)
|
||||
require.Len(t, res.Candidates, 2)
|
||||
require.Equal(t, int64(1001), res.Candidates[0].AnimeID)
|
||||
require.Equal(t, "测试动画", res.Candidates[0].AnimeTitle)
|
||||
require.Len(t, res.Candidates[0].Episodes, 1)
|
||||
require.Equal(t, int64(25484), res.Candidates[0].Episodes[0].EpisodeID)
|
||||
}
|
||||
|
||||
// 显式指定 episodeId 时跳过搜索,直接拉取该弹幕库。
|
||||
func TestDanmakuFetchWithExplicitEpisodeID(t *testing.T) {
|
||||
srv := newDanmakuSourceServer(t)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL()))
|
||||
// 媒体名与搜索无关也能用显式 episodeId 命中。
|
||||
res, err := svc.Fetch(ctx, "m2", "", "99999")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Contains(t, res.Raw, "显式指定弹幕")
|
||||
require.Empty(t, res.Candidates)
|
||||
}
|
||||
func TestDetectDanmakuSourceType(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{`<?xml version="1.0"?><i><d p="1,1,25,16777215,x,y,z,w">text</d></i>`, "xml"},
|
||||
{`<d p="1,1,16777215,u">text</d>`, "xml"},
|
||||
{`{"count":2,"comments":[{"p":"0.00,1,16777215,[u]","m":"hi","t":0}]}`, "json"},
|
||||
{`[{"time":1,"text":"x"}]`, "json"},
|
||||
{"", "auto"},
|
||||
{" \n ", "auto"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := detectDanmakuSourceType(c.raw); got != c.want {
|
||||
t.Errorf("detectDanmakuSourceType(%q) = %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 弹弹play JSON 源:comment 接口返回 {"count":N,"comments":[{p,m,t}]},
|
||||
// Fetch 应自动探测 source_type=json 并原样透传。
|
||||
func TestDanmakuFetchDetectsJSONSource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/search/episodes":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`)
|
||||
case "/api/v2/comment/25484":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"count":2,"comments":[{"cid":1,"p":"0.00,1,16777215,[u]","m":"第一弹","t":0},{"cid":2,"p":"3.50,5,16711680,[u]","m":"顶部弹幕","t":3.5}]}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc := newDanmakuTestService(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL))
|
||||
seedDanmakuMedia(t, svc, "mjson", "测试动画", "", 0)
|
||||
|
||||
res, err := svc.Fetch(ctx, "mjson", "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Equal(t, "json", res.SourceType)
|
||||
require.Contains(t, res.Raw, `"m":"第一弹"`)
|
||||
require.Empty(t, res.Candidates)
|
||||
|
||||
// XML 源仍按 XML 探测。
|
||||
srvXML := newDanmakuSourceServer(t)
|
||||
require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srvXML.URL()))
|
||||
res2, err := svc.Fetch(ctx, "mjson", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "xml", res2.SourceType)
|
||||
require.Contains(t, res2.Raw, "弹幕A")
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type Container struct {
|
||||
Cache *RuntimeCacheService
|
||||
Sessions *SessionTrackerService
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
@@ -65,6 +65,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
|
||||
b.c.Douban = NewDoubanProvider(b.cfg, b.log)
|
||||
b.c.Fanart = NewFanartProvider(b.cfg, b.log)
|
||||
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
|
||||
b.c.Danmaku = NewDanmakuService(b.log, b.repos)
|
||||
|
||||
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
|
||||
b.c.Scraper = NewScraperService(
|
||||
|
||||
Generated
+37
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "mediastationgo-web",
|
||||
"name": "mmtl-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediastationgo-web",
|
||||
"name": "mmtl-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"lucide-react": "^0.379.0",
|
||||
@@ -1763,6 +1764,15 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aidly": {
|
||||
"version": "1.37.0",
|
||||
"resolved": "https://registry.npmjs.org/aidly/-/aidly-1.37.0.tgz",
|
||||
"integrity": "sha512-IMbw5e7KKnacRh9PuuHN/65iFibo8NLtqM9NS1QTn7ySqnCVAcc72/Bz0CHnMvwC6FxLtICZWLVIfPddMr9vwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"small-queue": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2113,6 +2123,16 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/danmu": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/danmu/-/danmu-0.20.0.tgz",
|
||||
"integrity": "sha512-J58V1tQZwvEOc8/APOPq9r7Z78+SfGIznPp75zgEsYBVQDQddcOyPuPqMgEzT9XmZk66QKR8ezGGAjzn2gr92w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aidly": "^1.36.0",
|
||||
"hooks-plugin": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2852,6 +2872,15 @@
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hooks-plugin": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/hooks-plugin/-/hooks-plugin-1.3.4.tgz",
|
||||
"integrity": "sha512-2cQFChc7gYjM1A2B7N9fmb3iG+0Ns8Fi9FOEg7P3doH9scDSSJf423V7mxPewc/acshGP1StrQynOgSBM8Pkwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aidly": "^1.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@@ -3855,6 +3884,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/small-queue": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/small-queue/-/small-queue-1.1.2.tgz",
|
||||
"integrity": "sha512-K6zQPh7cRjkzv+upcwrOTbR4kU3neY3KZR2F1nc8Fuez8uJyh4BguOUs4KGKHcuzU1LJmK8yfCkMUZbconLe8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.20.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"lucide-react": "^0.379.0",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { api } from './client'
|
||||
|
||||
// DanmakuEpisode / DanmakuAnime mirror the dandanplay search response. When
|
||||
// multiple anime match, the backend returns them as candidates and the player
|
||||
// asks the user which to load (disambiguation).
|
||||
export interface DanmakuEpisode {
|
||||
episodeId: number
|
||||
episodeTitle: string
|
||||
}
|
||||
|
||||
export interface DanmakuAnime {
|
||||
animeId: number
|
||||
animeTitle: string
|
||||
episodes: DanmakuEpisode[]
|
||||
}
|
||||
|
||||
// DanmakuFetchResult mirrors the backend /api/danmaku/:id response: renderer
|
||||
// knobs (resolved from runtime settings) plus the raw upstream payload. The
|
||||
// dandanplay protocol delivers Bilibili-format XML or its own JSON shape
|
||||
// ({comments:[{p,m,t}]}); source_type is sniffed from the body by the backend
|
||||
// ("auto" when there is nothing to sniff). When several anime matched,
|
||||
// candidates is non-empty and raw is empty.
|
||||
export interface DanmakuFetchResult {
|
||||
enabled: boolean
|
||||
source_type: 'auto' | 'xml' | 'json'
|
||||
opacity: string
|
||||
font_size: string
|
||||
area: string
|
||||
raw?: string
|
||||
candidates?: DanmakuAnime[]
|
||||
}
|
||||
|
||||
export type DanmakuFetchOptions = {
|
||||
/** Overrides the media-derived search keyword (manual search). */
|
||||
kw?: string
|
||||
/** Forces a specific danmaku library chosen by the user. */
|
||||
episodeId?: number | string
|
||||
}
|
||||
|
||||
// danmakuAPI fetches danmaku comments for a media item. The backend resolves
|
||||
// the configured dandanplay source by the video's name (search for episode,
|
||||
// then fetch its comment library) and returns the raw Bilibili-format XML;
|
||||
// parsing into comment objects happens client-side in utils/parseDanmaku.
|
||||
export const danmakuAPI = {
|
||||
fetch: (mediaId: string, options: DanmakuFetchOptions = {}) =>
|
||||
api
|
||||
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
|
||||
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
|
||||
timeout: 20_000,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// config returns the renderer knobs so the player can initialize its
|
||||
// danmaku control panel without admin privileges.
|
||||
config: () =>
|
||||
api
|
||||
.get<{
|
||||
enabled: boolean
|
||||
source?: string
|
||||
opacity: string
|
||||
font_size: string
|
||||
area: string
|
||||
}>('/danmaku/config')
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { create, type Manager, type ManagerPlugin } from 'danmu'
|
||||
|
||||
import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import { parseDanmaku, type Comment } from '../utils/parseDanmaku'
|
||||
|
||||
// DanmakuStage overlays danmu (the same engine behind dan-player) onto the
|
||||
// <video> element. The upstream payload (dandanplay Bilibili-format XML) is
|
||||
// fetched by the backend keyed on the video's name; parsing happens here.
|
||||
//
|
||||
// Loading follows danmaku-anywhere's flow: the backend auto-resolves when the
|
||||
// search hits exactly one anime; when several anime match it returns
|
||||
// candidates and the player shows a picker (`onCandidates`). After the user
|
||||
// picks, `episodeId` forces that library.
|
||||
//
|
||||
// The component is controlled by the player page: enabled toggles loading,
|
||||
// opacity / fontSize / area are applied live via the engine's setters, and
|
||||
// `search` re-fetches with a custom keyword (null = use the media's own name).
|
||||
type DanmakuStageProps = {
|
||||
media: Media | null
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
enabled?: boolean
|
||||
opacity?: number
|
||||
fontSize?: number
|
||||
area?: number
|
||||
/** Custom search keyword; null/undefined uses the video name. */
|
||||
search?: string | null
|
||||
/** Explicit danmaku library chosen by the user; null = auto-resolve. */
|
||||
episodeId?: number | string | null
|
||||
/** Called after each fetch attempt (success or error) finishes. */
|
||||
onLoaded?: () => void
|
||||
/** Called when multiple anime matched and the user must pick one. */
|
||||
onCandidates?: (candidates: DanmakuAnime[]) => void
|
||||
}
|
||||
|
||||
// Average of the engine's durationRange (ms). Used to compute how far a
|
||||
// comment should already have flown when it is pushed late after a seek.
|
||||
const TRAVEL_MS = 5000
|
||||
// How long a fixed top/bottom comment stays visible.
|
||||
const FIXED_HOLD_MS = 4500
|
||||
const MAX_VIEW = 200
|
||||
|
||||
export function DanmakuStage({
|
||||
media,
|
||||
videoRef,
|
||||
enabled = true,
|
||||
opacity = 1,
|
||||
fontSize = 24,
|
||||
area = 1,
|
||||
search = null,
|
||||
episodeId = null,
|
||||
onLoaded,
|
||||
onCandidates,
|
||||
}: DanmakuStageProps) {
|
||||
const holderRef = useRef<HTMLDivElement>(null)
|
||||
const managerRef = useRef<Manager<Comment> | null>(null)
|
||||
const fontPxRef = useRef(fontSize)
|
||||
const liveRef = useRef({ opacity, area })
|
||||
|
||||
// Live knobs in refs so the engine effect reads current values without
|
||||
// depending on them (avoiding engine recreation on every slider move).
|
||||
useEffect(() => {
|
||||
fontPxRef.current = fontSize
|
||||
liveRef.current = { opacity, area }
|
||||
}, [opacity, area, fontSize])
|
||||
|
||||
// Engine lifecycle: create once per media + video element, keep alive while
|
||||
// danmaku is enabled.
|
||||
useEffect(() => {
|
||||
const holder = holderRef.current
|
||||
const video = videoRef.current
|
||||
if (!holder || !media || !video || !enabled) return
|
||||
|
||||
let disposed = false
|
||||
let frozen = false
|
||||
let nextIndex = 0
|
||||
let comments: Comment[] = []
|
||||
|
||||
const renderer: ManagerPlugin<Comment> = {
|
||||
name: 'danmaku-stage-render',
|
||||
$createNode: (dm, node) => {
|
||||
const base = fontPxRef.current
|
||||
node.textContent = dm.data.text
|
||||
node.style.color = dm.data.color
|
||||
node.style.fontSize = `${Math.round(base * dm.data.size)}px`
|
||||
node.style.fontWeight = '600'
|
||||
node.style.textShadow = '0 1px 2px rgba(0,0,0,.7)'
|
||||
node.style.whiteSpace = 'nowrap'
|
||||
node.style.lineHeight = '1.2'
|
||||
node.style.userSelect = 'none'
|
||||
node.style.pointerEvents = 'none'
|
||||
node.style.willChange = 'transform'
|
||||
},
|
||||
}
|
||||
|
||||
const manager = create<Comment>({
|
||||
plugin: renderer,
|
||||
mode: 'strict',
|
||||
interval: 250,
|
||||
gap: 4,
|
||||
trackHeight: `${Math.max(20, Math.round(fontPxRef.current * 1.4))}px`,
|
||||
durationRange: [Math.round(TRAVEL_MS * 0.8), Math.round(TRAVEL_MS * 1.2)],
|
||||
direction: 'right',
|
||||
limits: { view: MAX_VIEW, stash: Infinity },
|
||||
})
|
||||
managerRef.current = manager
|
||||
manager.mount(holder)
|
||||
// 弹幕层不拦截播放器控制栏的点击。
|
||||
holder.style.pointerEvents = 'none'
|
||||
|
||||
const applyLiveSettings = () => {
|
||||
const { opacity: liveOpacity, area: liveArea } = liveRef.current
|
||||
manager.setOpacity(liveOpacity)
|
||||
// 显示区域:area ∈ (0,1] 表示弹幕占视频高度的比例。
|
||||
const a = Math.min(1, Math.max(0.05, liveArea))
|
||||
manager.setArea({ y: { start: 0, end: `${Math.round(a * 100)}%` } })
|
||||
}
|
||||
applyLiveSettings()
|
||||
|
||||
const loadDanmaku = async () => {
|
||||
try {
|
||||
const res = await danmakuAPI.fetch(media.id, {
|
||||
kw: search ?? undefined,
|
||||
episodeId: episodeId ?? undefined,
|
||||
})
|
||||
if (disposed) return
|
||||
if (res.candidates && res.candidates.length > 0) {
|
||||
// 多番剧命中:交回播放器展示候选让用户选择(disambiguation)。
|
||||
comments = []
|
||||
onCandidates?.(res.candidates)
|
||||
return
|
||||
}
|
||||
if (res.enabled) {
|
||||
comments = parseDanmaku(res.raw || '', res.source_type)
|
||||
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
nextIndex = 0
|
||||
} else {
|
||||
comments = []
|
||||
}
|
||||
} catch {
|
||||
// 拉取失败时静默关闭弹幕,不打断播放。
|
||||
comments = []
|
||||
} finally {
|
||||
if (!disposed) onLoaded?.()
|
||||
}
|
||||
}
|
||||
|
||||
const pushFlex = (comment: Comment, progress?: number) => {
|
||||
const isTop = comment.mode === 'top'
|
||||
manager.pushFlexibleDanmaku(comment, {
|
||||
duration: FIXED_HOLD_MS,
|
||||
direction: 'none',
|
||||
progress,
|
||||
position: (dm, container) => ({
|
||||
x: Math.max(4, (container.width - dm.getWidth()) / 2),
|
||||
y: isTop
|
||||
? Math.max(4, Math.round(fontPxRef.current / 2))
|
||||
: Math.max(4, container.height - dm.getHeight() - 4),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// 推入所有时间已到的弹幕;跳转后 `progress` 让弹幕从屏幕中部出现。
|
||||
const pushDue = (now: number) => {
|
||||
while (nextIndex < comments.length && comments[nextIndex].time <= now) {
|
||||
const c = comments[nextIndex]
|
||||
const elapsed = now - c.time
|
||||
if (elapsed <= TRAVEL_MS / 1000) {
|
||||
const progress = Math.min(0.95, Math.max(0, elapsed / (TRAVEL_MS / 1000)))
|
||||
if (c.mode === 'scroll') {
|
||||
manager.push(c, { progress })
|
||||
} else {
|
||||
pushFlex(c, progress)
|
||||
}
|
||||
}
|
||||
// 已飞出屏幕(超过 TRAVEL_MS)的弹幕直接丢弃,不进入队列。
|
||||
nextIndex++
|
||||
}
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
if (!disposed && !frozen && videoRef.current && !videoRef.current.paused) {
|
||||
pushDue(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const onPlay = () => {
|
||||
frozen = false
|
||||
manager.unfreeze()
|
||||
tick()
|
||||
}
|
||||
const onPause = () => {
|
||||
frozen = true
|
||||
manager.freeze()
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
const onSeeked = () => {
|
||||
// 跳转后屏幕上的弹幕瞬间失效:清空画面与待发布队列,
|
||||
// 由 rAF tick 从新位置按需重新推入(含 progress 中段补放)。
|
||||
manager.clear()
|
||||
nextIndex = 0
|
||||
}
|
||||
|
||||
video.addEventListener('play', onPlay)
|
||||
video.addEventListener('playing', onPlay)
|
||||
video.addEventListener('pause', onPause)
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
|
||||
void loadDanmaku()
|
||||
if (!video.paused) onPlay()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
cancelAnimationFrame(raf)
|
||||
video.removeEventListener('play', onPlay)
|
||||
video.removeEventListener('playing', onPlay)
|
||||
video.removeEventListener('pause', onPause)
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
manager.freeze()
|
||||
manager.clear()
|
||||
manager.unmount()
|
||||
managerRef.current = null
|
||||
}
|
||||
// search / episodeId 变化时重新拉取弹幕(含媒体/开关切换)。
|
||||
}, [media, videoRef, enabled, search, episodeId, onLoaded, onCandidates])
|
||||
|
||||
// Live renderer knobs: opacity / area / font size without recreating the
|
||||
// engine. font size additionally rescales currently visible comments.
|
||||
useEffect(() => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
manager.setOpacity(opacity)
|
||||
const a = Math.min(1, Math.max(0.05, area))
|
||||
manager.setArea({ y: { start: 0, end: `${Math.round(a * 100)}%` } })
|
||||
manager.each((dm) => {
|
||||
if (dm.node) {
|
||||
dm.node.style.fontSize = `${Math.round(fontSize * dm.data.size)}px`
|
||||
}
|
||||
})
|
||||
}, [opacity, area, fontSize])
|
||||
|
||||
return <div ref={holderRef} className="pointer-events-none absolute inset-0" />
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Maximize,
|
||||
MessageSquareText,
|
||||
Minimize,
|
||||
Pause,
|
||||
PictureInPicture,
|
||||
Play,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
// next to the volume control. The bar auto-hides while playing and reappears
|
||||
// on mouse movement; it stays visible while paused.
|
||||
//
|
||||
// Native keyboard shortcuts (space / arrows) still work because they are
|
||||
// element-level defaults on <video>. Subtitles from <track> elements keep
|
||||
// rendering (the default track shows as before); only the track picker UI is
|
||||
// not re-implemented here.
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) s = 0
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
type PlayerControlsProps = {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
videoRef,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
}: PlayerControlsProps) {
|
||||
const video = () => videoRef.current
|
||||
const container = () => videoRef.current?.parentElement ?? null
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [pip, setPip] = useState(false)
|
||||
const [uiVisible, setUiVisible] = useState(true)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
|
||||
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
|
||||
useEffect(() => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
const parent = el.parentElement
|
||||
const onMove = () => {
|
||||
setUiVisible(true)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
if (!el.paused) {
|
||||
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
|
||||
}
|
||||
}
|
||||
const onLeave = () => {
|
||||
if (el.paused) return
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
setUiVisible(false)
|
||||
}
|
||||
const syncPlay = () => {
|
||||
setPlaying(!el.paused)
|
||||
onMove()
|
||||
}
|
||||
const syncTime = () => setCurrentTime(el.currentTime)
|
||||
const syncMeta = () => {
|
||||
setDuration(el.duration || 0)
|
||||
setCurrentTime(el.currentTime)
|
||||
}
|
||||
const syncVolume = () => {
|
||||
setVolume(el.volume)
|
||||
setMuted(el.muted)
|
||||
}
|
||||
const syncFullscreen = () => setFullscreen(document.fullscreenElement === parent)
|
||||
const syncPip = () => setPip(document.pictureInPictureElement === el)
|
||||
|
||||
parent?.addEventListener('mousemove', onMove)
|
||||
parent?.addEventListener('mouseleave', onLeave)
|
||||
|
||||
el.addEventListener('play', syncPlay)
|
||||
el.addEventListener('playing', syncPlay)
|
||||
el.addEventListener('pause', syncPlay)
|
||||
el.addEventListener('timeupdate', syncTime)
|
||||
el.addEventListener('durationchange', syncMeta)
|
||||
el.addEventListener('loadedmetadata', syncMeta)
|
||||
el.addEventListener('volumechange', syncVolume)
|
||||
document.addEventListener('fullscreenchange', syncFullscreen)
|
||||
el.addEventListener('enterpictureinpicture', syncPip)
|
||||
el.addEventListener('leavepictureinpicture', syncPip)
|
||||
|
||||
syncMeta()
|
||||
syncVolume()
|
||||
syncFullscreen()
|
||||
return () => {
|
||||
parent?.removeEventListener('mousemove', onMove)
|
||||
parent?.removeEventListener('mouseleave', onLeave)
|
||||
el.removeEventListener('play', syncPlay)
|
||||
el.removeEventListener('playing', syncPlay)
|
||||
el.removeEventListener('pause', syncPlay)
|
||||
el.removeEventListener('timeupdate', syncTime)
|
||||
el.removeEventListener('durationchange', syncMeta)
|
||||
el.removeEventListener('loadedmetadata', syncMeta)
|
||||
el.removeEventListener('volumechange', syncVolume)
|
||||
document.removeEventListener('fullscreenchange', syncFullscreen)
|
||||
el.removeEventListener('enterpictureinpicture', syncPip)
|
||||
el.removeEventListener('leavepictureinpicture', syncPip)
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoRef])
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (el.paused) void el.play()?.catch(() => undefined)
|
||||
else el.pause()
|
||||
}
|
||||
|
||||
const seek = (v: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.currentTime = v
|
||||
setCurrentTime(v)
|
||||
}
|
||||
|
||||
const changeVolume = (v: number) => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.volume = v
|
||||
el.muted = v === 0
|
||||
setVolume(v)
|
||||
setMuted(v === 0)
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
el.muted = !el.muted
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = container()
|
||||
if (!el) return
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen()
|
||||
} else {
|
||||
void el.requestFullscreen?.()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePip = () => {
|
||||
const el = video()
|
||||
if (!el) return
|
||||
if (document.pictureInPictureElement) {
|
||||
void document.exitPictureInPicture()
|
||||
} else {
|
||||
void el.requestPictureInPicture?.()
|
||||
}
|
||||
}
|
||||
|
||||
const pipSupported =
|
||||
typeof document !== 'undefined' &&
|
||||
'pictureInPictureEnabled' in document &&
|
||||
document.pictureInPictureEnabled
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 px-3 pb-2 pt-12 transition-opacity duration-300 ${
|
||||
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{playing ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={currentTime}
|
||||
onChange={(e) => seek(Number(e.target.value))}
|
||||
className="min-w-0 flex-1 accent-rose-500"
|
||||
aria-label="播放进度"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-white/85">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
{pipSupported && (
|
||||
<button
|
||||
onClick={togglePip}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={pip ? '退出画中画' : '画中画'}
|
||||
>
|
||||
<PictureInPicture size={18} className={pip ? 'text-rose-400' : ''} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onToggleDanmaku}
|
||||
className={
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium transition ' +
|
||||
(danmakuOpen || danmakuEnabled
|
||||
? 'bg-rose-500/90 text-white hover:bg-rose-500'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20')
|
||||
}
|
||||
title="弹幕设置"
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
弹幕
|
||||
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={muted || volume === 0 ? '取消静音' : '静音'}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||
className="w-16 accent-rose-500"
|
||||
aria-label="音量"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronRight, Loader2, MessageSquareText, Search, X } from 'lucide-react'
|
||||
|
||||
import type { DanmakuAnime, DanmakuEpisode } from '../api/danmaku'
|
||||
|
||||
// PlayerDanmakuPanel — the on-player danmaku control panel. It toggles
|
||||
// loading, lets the user re-search by a custom keyword, and adjusts the
|
||||
// renderer knobs (display area / opacity / font size) live. Values are
|
||||
// controlled by PlayerPage so the panel is purely presentational.
|
||||
//
|
||||
// The knobs change immediately on drag; a "re-search" only fires when the
|
||||
// user clicks the search button (or presses Enter) so sliders don't trigger
|
||||
// network requests. When the backend returns multiple anime candidates
|
||||
// (disambiguation), the panel shows the picker so the user can choose the
|
||||
// right danmaku library by hand — mirroring danmaku-anywhere's selector.
|
||||
type PlayerDanmakuPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
enabled: boolean
|
||||
onToggleEnabled: (v: boolean) => void
|
||||
search: string
|
||||
onSearch: (kw: string) => void
|
||||
searching: boolean
|
||||
area: number
|
||||
onAreaChange: (v: number) => void
|
||||
opacity: number
|
||||
onOpacityChange: (v: number) => void
|
||||
fontSize: number
|
||||
onFontSizeChange: (v: number) => void
|
||||
/** Multiple anime matched — user must pick one. */
|
||||
candidates: DanmakuAnime[]
|
||||
/** Human-readable label of the currently selected library. */
|
||||
selectedSource: string
|
||||
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
|
||||
onResetAuto: () => void
|
||||
}
|
||||
|
||||
export function PlayerDanmakuPanel({
|
||||
open,
|
||||
onClose,
|
||||
enabled,
|
||||
onToggleEnabled,
|
||||
search,
|
||||
onSearch,
|
||||
searching,
|
||||
area,
|
||||
onAreaChange,
|
||||
opacity,
|
||||
onOpacityChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
candidates,
|
||||
selectedSource,
|
||||
onSelectEpisode,
|
||||
onResetAuto,
|
||||
}: PlayerDanmakuPanelProps) {
|
||||
const [draft, setDraft] = useState(search)
|
||||
// 展开的番剧(动画 → 集数两级树),默认全展开便于选择。
|
||||
const [openAnime, setOpenAnime] = useState<Set<number>>(new Set())
|
||||
|
||||
// 面板打开时同步外部搜索词到输入框草稿,并展开全部候选。
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(search)
|
||||
setOpenAnime(new Set(candidates.map((c) => c.animeId)))
|
||||
}
|
||||
}, [open, search, candidates])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const toggleAnime = (animeId: number) => {
|
||||
setOpenAnime((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(animeId)) {
|
||||
next.delete(animeId)
|
||||
} else {
|
||||
next.add(animeId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 z-30 w-72 rounded-2xl border border-white/15 bg-black/80 p-4 text-white shadow-2xl backdrop-blur"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<MessageSquareText size={16} /> 弹幕设置
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 是否加载弹幕 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center justify-between text-sm">
|
||||
<span className="text-white/85">加载弹幕</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onToggleEnabled(e.target.checked)}
|
||||
className="h-4 w-4 accent-rose-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 搜索弹幕 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 text-xs text-white/60">搜索弹幕(留空 = 按视频名)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch(draft.trim())
|
||||
}}
|
||||
placeholder="输入番剧名…"
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-sm outline-none placeholder:text-white/35 focus:border-rose-400/60"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onSearch(draft.trim())}
|
||||
disabled={searching}
|
||||
className="flex items-center gap-1 rounded-lg bg-rose-500/90 px-2.5 py-1.5 text-xs font-medium transition hover:bg-rose-500 disabled:opacity-50"
|
||||
>
|
||||
{searching ? <Loader2 size={13} className="animate-spin" /> : <Search size={13} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前来源 / 候选手动选取 */}
|
||||
{selectedSource && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs">
|
||||
<span className="min-w-0 truncate text-white/75" title={selectedSource}>
|
||||
当前: {selectedSource}
|
||||
</span>
|
||||
<button
|
||||
onClick={onResetAuto}
|
||||
className="ml-2 shrink-0 text-rose-300 transition hover:text-rose-200"
|
||||
title="清除手动选择,恢复自动匹配"
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-amber-400/25 bg-amber-400/5 p-2">
|
||||
<div className="mb-1.5 px-1 text-xs font-medium text-amber-200">
|
||||
搜到多部番剧,请选择弹幕来源:
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
{candidates.map((anime, i) => (
|
||||
<div key={anime.animeId} className="mb-1">
|
||||
<button
|
||||
onClick={() => toggleAnime(anime.animeId)}
|
||||
className="flex w-full items-center gap-1 rounded-md px-1.5 py-1 text-left text-sm text-white/85 transition hover:bg-white/10"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={
|
||||
'shrink-0 transition-transform ' +
|
||||
(openAnime.has(anime.animeId) ? 'rotate-90' : '')
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{anime.animeTitle}
|
||||
{anime.animeTitle === '' && `番剧 ${i + 1}`}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-white/40">
|
||||
{anime.episodes.length} 集
|
||||
</span>
|
||||
</button>
|
||||
{openAnime.has(anime.animeId) && (
|
||||
<div className="ml-5 border-l border-white/10 pl-2">
|
||||
{anime.episodes.map((ep) => (
|
||||
<EpisodeRow
|
||||
key={ep.episodeId}
|
||||
episode={ep}
|
||||
onSelect={() =>
|
||||
onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 屏幕占比(显示区域) */}
|
||||
<SliderRow
|
||||
label="屏幕占比"
|
||||
value={area}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
onChange={onAreaChange}
|
||||
/>
|
||||
{/* 透明度 */}
|
||||
<SliderRow
|
||||
label="透明度"
|
||||
value={opacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
format={(v) => `${Math.round(v * 100)}%`}
|
||||
onChange={onOpacityChange}
|
||||
/>
|
||||
{/* 字体大小 */}
|
||||
<SliderRow
|
||||
label="字体大小"
|
||||
value={fontSize}
|
||||
min={14}
|
||||
max={48}
|
||||
step={1}
|
||||
format={(v) => `${Math.round(v)}px`}
|
||||
onChange={onFontSizeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SliderRow({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
format,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
format: (v: number) => string
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-white/60">{label}</span>
|
||||
<span className="font-mono text-white/85">{format(value)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full accent-rose-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EpisodeRow({
|
||||
episode,
|
||||
onSelect,
|
||||
}: {
|
||||
episode: DanmakuEpisode
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-white/70 transition hover:bg-rose-500/20 hover:text-white"
|
||||
title={`选择《${episode.episodeTitle}》的弹幕`}
|
||||
>
|
||||
<Check size={12} className="shrink-0 text-rose-400" />
|
||||
<span className="min-w-0 flex-1 truncate">{episode.episodeTitle}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { api, hlsURL, streamURL } from '../api/client'
|
||||
import { danmakuAPI, type DanmakuAnime } from '../api/danmaku'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
@@ -13,6 +14,7 @@ import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -43,6 +45,21 @@ export function PlayerPage() {
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
const [danmakuEnabled, setDanmakuEnabled] = useState(true)
|
||||
const [danmakuSearch, setDanmakuSearch] = useState<string | null>(null)
|
||||
const [danmakuSearching, setDanmakuSearching] = useState(false)
|
||||
// 用户从候选列表选定的弹幕库;null = 自动匹配。
|
||||
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
|
||||
// 自动匹配歧义(多番剧命中)时的候选列表。
|
||||
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
|
||||
// 用户当前选定的弹幕来源描述(面板中展示)。
|
||||
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
|
||||
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
@@ -75,6 +92,57 @@ export function PlayerPage() {
|
||||
.catch(() => setDirectOnly(false))
|
||||
}, [])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||
useEffect(() => {
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
setDanmakuEnabled(cfg.enabled)
|
||||
setDanmakuOpacity(Number(cfg.opacity) || 1)
|
||||
setDanmakuFontSize(Number(cfg.font_size) || 24)
|
||||
setDanmakuArea(Number(cfg.area) || 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
|
||||
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
|
||||
const searchDanmaku = useCallback((kw: string) => {
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuSearch(kw || null)
|
||||
}, [])
|
||||
|
||||
const danmakuLoaded = useCallback(() => {
|
||||
setDanmakuSearching(false)
|
||||
}, [])
|
||||
|
||||
// 多番剧命中(disambiguation):展示候选让用户选择。
|
||||
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
|
||||
setDanmakuCandidates(candidates)
|
||||
setDanmakuSearching(false)
|
||||
// 候选是静默返回的(此时没有任何弹幕);自动打开面板提示用户选择来源。
|
||||
if (candidates.length > 0) setDanmakuOpen(true)
|
||||
}, [])
|
||||
|
||||
// 用户选定某个弹幕库:显式 episodeId 重新拉取。
|
||||
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
|
||||
setDanmakuEpisodeId(episodeId)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuSearching(true)
|
||||
// 展示当前所选来源(面板标题处可见)。
|
||||
setDanmakuSelectedSource(`${animeTitle}・${episodeTitle}`)
|
||||
}, [])
|
||||
|
||||
// 回到自动匹配(清除用户手动选择)。
|
||||
const danmakuResetAuto = useCallback(() => {
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearch(null)
|
||||
}, [])
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -215,6 +283,37 @@ export function PlayerPage() {
|
||||
subs={subs}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
danmakuArea={danmakuArea}
|
||||
danmakuSearch={danmakuSearch}
|
||||
danmakuEpisodeId={danmakuEpisodeId}
|
||||
danmakuOpen={danmakuOpen}
|
||||
onToggleDanmaku={() => setDanmakuOpen((v) => !v)}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
danmakuPanel={
|
||||
<PlayerDanmakuPanel
|
||||
open={danmakuOpen}
|
||||
onClose={() => setDanmakuOpen(false)}
|
||||
enabled={danmakuEnabled}
|
||||
onToggleEnabled={setDanmakuEnabled}
|
||||
search={danmakuSearch ?? ''}
|
||||
onSearch={searchDanmaku}
|
||||
searching={danmakuSearching}
|
||||
area={danmakuArea}
|
||||
onAreaChange={setDanmakuArea}
|
||||
opacity={danmakuOpacity}
|
||||
onOpacityChange={setDanmakuOpacity}
|
||||
fontSize={danmakuFontSize}
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
candidates={danmakuCandidates}
|
||||
selectedSource={danmakuSelectedSource}
|
||||
onSelectEpisode={danmakuSelectEpisode}
|
||||
onResetAuto={danmakuResetAuto}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,12 @@ type PlayerTopBarProps = {
|
||||
onToggleMode: () => void
|
||||
}
|
||||
|
||||
export function PlayerTopBar({ directOnly, mode, onBack, onToggleMode }: PlayerTopBarProps) {
|
||||
export function PlayerTopBar({
|
||||
directOnly,
|
||||
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
|
||||
@@ -19,30 +24,32 @@ export function PlayerTopBar({ directOnly, mode, onBack, onToggleMode }: PlayerT
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
|
||||
{directOnly ? (
|
||||
<span
|
||||
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"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
>
|
||||
<Sparkles size={14} /> 客户端直连解码
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleMode}
|
||||
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"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
@@ -9,6 +12,18 @@ type PlayerVideoStageProps = {
|
||||
subs: SubtitleTrack[]
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
danmakuOpacity: number
|
||||
danmakuFontSize: number
|
||||
danmakuArea: number
|
||||
danmakuSearch: string | null
|
||||
danmakuEpisodeId: number | string | null
|
||||
danmakuOpen: boolean
|
||||
onToggleDanmaku: () => void
|
||||
onDanmakuLoaded: () => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
@@ -17,29 +32,78 @@ export function PlayerVideoStage({
|
||||
subs,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
danmakuOpacity,
|
||||
danmakuFontSize,
|
||||
danmakuArea,
|
||||
danmakuSearch,
|
||||
danmakuEpisodeId,
|
||||
danmakuOpen,
|
||||
onToggleDanmaku,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
danmakuPanel,
|
||||
}: PlayerVideoStageProps) {
|
||||
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) void video.play()?.catch(() => undefined)
|
||||
else video.pause()
|
||||
}
|
||||
const toggleFullscreen = () => {
|
||||
const stage = videoRef.current?.parentElement
|
||||
if (!stage) return
|
||||
if (document.fullscreenElement) void document.exitFullscreen()
|
||||
else void stage.requestFullscreen?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div
|
||||
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
|
||||
onClick={togglePlay}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
>
|
||||
{media ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track, index) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track, index) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
<DanmakuStage
|
||||
media={media}
|
||||
videoRef={videoRef}
|
||||
enabled={danmakuEnabled}
|
||||
opacity={danmakuOpacity}
|
||||
fontSize={danmakuFontSize}
|
||||
area={danmakuArea}
|
||||
search={danmakuSearch}
|
||||
episodeId={danmakuEpisodeId}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
/>
|
||||
<PlayerControls
|
||||
videoRef={videoRef}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
/>
|
||||
{danmakuPanel}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
@@ -50,4 +114,4 @@ export function PlayerVideoStage({
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
// 弹幕配置:对接 dandanplay(弹弹play)协议弹幕源 + 渲染通用功能。
|
||||
// 来源地址留空时使用官方 https://api.dandanplay.net;填写任何符合
|
||||
// dandanplay 协议(/api/v2/search/episodes + /api/v2/comment/:id)的服务地址均可。
|
||||
export const danmakuSettingsGroup: SettingGroup = {
|
||||
key: 'danmaku',
|
||||
label: '弹幕',
|
||||
description: '弹幕来源(dandanplay 协议)与渲染参数(透明度 / 字号 / 显示区域)',
|
||||
items: [
|
||||
{
|
||||
key: 'danmaku.enabled',
|
||||
label: '启用弹幕',
|
||||
type: 'toggle',
|
||||
hint: '关闭后播放页不拉取、不渲染弹幕',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.source',
|
||||
label: '弹幕服务地址(dandanplay 协议)',
|
||||
type: 'text',
|
||||
hint: '留空使用官方 https://api.dandanplay.net。可填写自建或第三方符合 dandanplay 协议的服务地址(含 /api/v2/search/episodes 搜索与 /api/v2/comment/:id 弹幕接口)。播放时按视频名称搜索番剧并拉取 Bilibili 格式 XML 弹幕。',
|
||||
placeholder: 'https://api.dandanplay.net',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.opacity',
|
||||
label: '弹幕透明度',
|
||||
type: 'number',
|
||||
hint: '0.1 ~ 1,数值越小越透明(1 为完全不透明)',
|
||||
defaultValue: '1',
|
||||
placeholder: '1',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.font_size',
|
||||
label: '弹幕字号',
|
||||
type: 'number',
|
||||
hint: '弹幕渲染字号(px),默认 24',
|
||||
defaultValue: '24',
|
||||
placeholder: '24',
|
||||
},
|
||||
{
|
||||
key: 'danmaku.area',
|
||||
label: '弹幕显示区域',
|
||||
type: 'number',
|
||||
hint: '0 ~ 1,弹幕占视频高度比例(1 为全屏,0.5 为上半屏)',
|
||||
defaultValue: '1',
|
||||
placeholder: '1',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
|
||||
import { danmakuSettingsGroup } from './settingsGroupDanmaku'
|
||||
import { generalSettingsGroup } from './settingsGroupGeneral'
|
||||
import { librarySettingsGroup } from './settingsGroupLibrary'
|
||||
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
||||
@@ -12,6 +13,7 @@ export const GROUPS: SettingGroup[] = [
|
||||
librarySettingsGroup,
|
||||
apiConfigsSettingsGroup,
|
||||
recognitionWordsSettingsGroup,
|
||||
danmakuSettingsGroup,
|
||||
adultSettingsGroup,
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Danmaku parsing utilities. Normalizes Bilibili XML and custom JSON danmaku
|
||||
// sources into a single Comment[] shape that the player renders.
|
||||
//
|
||||
// The Bilibili <d> element carries a `p` attribute:
|
||||
// p="time, mode, fontSize, color, timestamp, pool, senderHash, dmid"
|
||||
// where mode: 1=scroll, 4=bottom, 5=top.
|
||||
//
|
||||
// Custom JSON sources may look like either `{ comments: [...] }` or a bare
|
||||
// array; each comment may be an object `{ time, text, mode, color, size }` or
|
||||
// the classic CCL tuple `[time, mode, size, color, text]`. The dandanplay
|
||||
// protocol's own JSON shape `{ comments: [{ p: "time,mode,color,user", m:
|
||||
// text, t: seconds }] }` is detected and parsed too.
|
||||
|
||||
export type DanmakuMode = 'scroll' | 'top' | 'bottom'
|
||||
|
||||
export interface Comment {
|
||||
/** Offset in seconds relative to the clip start. */
|
||||
time: number
|
||||
text: string
|
||||
mode: DanmakuMode
|
||||
/** CSS color, e.g. "#ffffff" or a hex int string. */
|
||||
color: string
|
||||
/** Font size ratio relative to the default (1 = default). */
|
||||
size: number
|
||||
}
|
||||
|
||||
const BILI_MODES: Record<string, DanmakuMode> = { '1': 'scroll', '4': 'bottom', '5': 'top' }
|
||||
|
||||
function biliColorToCSS(color: string): string {
|
||||
if (color && color !== '16777215') {
|
||||
return `#${Number(color).toString(16).padStart(6, '0')}`
|
||||
}
|
||||
return '#ffffff'
|
||||
}
|
||||
|
||||
/** Parse a Bilibili XML danmaku document into normalized comments. */
|
||||
//
|
||||
// Two flavors of `p` attribute exist:
|
||||
// Bilibili: time, mode, fontSize, color, timestamp, pool, senderHash, dmid
|
||||
// dandanplay: time, mode, color, userId (4 fields only)
|
||||
// Field count disambiguates: >=5 fields means Bilibili (color at index 3),
|
||||
// 4 fields means dandanplay (color at index 2).
|
||||
function parseBilibiliXml(xml: string): Comment[] {
|
||||
const comments: Comment[] = []
|
||||
const pattern = /<d\s+p="([^"]*)"[^>]*>([\s\S]*?)<\/d>/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(xml)) !== null) {
|
||||
const fields = match[1].split(',')
|
||||
const text = match[2].trim()
|
||||
const t = Number(fields[0])
|
||||
if (!text || !Number.isFinite(t) || t < 0) continue
|
||||
const modeStr = fields[1]
|
||||
const isDandanplay = fields.length >= 4 && fields.length < 5
|
||||
const sizeStr = isDandanplay ? '' : fields[2]
|
||||
const colorStr = isDandanplay ? fields[2] : fields[3]
|
||||
comments.push({
|
||||
time: t,
|
||||
text,
|
||||
mode: BILI_MODES[modeStr] ?? 'scroll',
|
||||
color: biliColorToCSS(colorStr),
|
||||
size: clampSize(Number(sizeStr)),
|
||||
})
|
||||
}
|
||||
return comments
|
||||
}
|
||||
|
||||
interface JsonComment {
|
||||
time?: number
|
||||
text?: string
|
||||
mode?: number | string
|
||||
color?: string | number
|
||||
size?: number
|
||||
/** dandanplay JSON 弹幕(弹弹play 新接口 / 自建源):p="time,mode,color,user" */
|
||||
p?: string
|
||||
m?: string
|
||||
t?: number
|
||||
}
|
||||
|
||||
/** dandanplay JSON items look like { p, m, t, cid, like } — no time/text keys. */
|
||||
function isDandanplayJson(list: JsonComment[]): boolean {
|
||||
const first = list[0]
|
||||
return (
|
||||
!!first &&
|
||||
typeof first === 'object' &&
|
||||
!Array.isArray(first) &&
|
||||
typeof first.p === 'string' &&
|
||||
typeof first.m === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/** Parse the dandanplay JSON shape: { comments: [{ p, m, t, ... }] }. */
|
||||
function parseDandanplayJson(list: JsonComment[]): Comment[] {
|
||||
const comments: Comment[] = []
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||
const text = String(item.m ?? '').trim()
|
||||
if (!text) continue
|
||||
const fields = String(item.p ?? '').split(',')
|
||||
const t = Number(fields[0])
|
||||
if (!Number.isFinite(t) || t < 0) continue
|
||||
comments.push({
|
||||
time: t,
|
||||
text,
|
||||
mode: BILI_MODES[fields[1]] ?? 'scroll',
|
||||
color: biliColorToCSS(fields[2]),
|
||||
size: 1,
|
||||
})
|
||||
}
|
||||
return comments
|
||||
}
|
||||
|
||||
function clampSize(size: number): number {
|
||||
if (!Number.isFinite(size) || size <= 0) return 1
|
||||
// Bilibili font sizes are absolute 12-64; fold them onto a ~0.75-1.5 ratio.
|
||||
return Math.min(1.6, Math.max(0.6, size / 25))
|
||||
}
|
||||
|
||||
/** Parse custom JSON danmaku data into normalized comments. */
|
||||
function parseJson(raw: string): Comment[] {
|
||||
const data = JSON.parse(raw) as JsonComment[] | { comments?: JsonComment[] } | unknown
|
||||
const list = Array.isArray(data) ? data : (data as { comments?: JsonComment[] })?.comments
|
||||
if (!Array.isArray(list)) return []
|
||||
if (isDandanplayJson(list)) return parseDandanplayJson(list)
|
||||
const comments: Comment[] = []
|
||||
for (const rawComment of list) {
|
||||
let keyed: JsonComment
|
||||
if (Array.isArray(rawComment)) {
|
||||
// Classic CCL tuple: [time, mode, size, color, text]
|
||||
keyed = {
|
||||
time: Number(rawComment[0]),
|
||||
mode: rawComment[1],
|
||||
size: Number(rawComment[2]),
|
||||
color: rawComment[3],
|
||||
text: rawComment[4],
|
||||
}
|
||||
} else {
|
||||
keyed = rawComment as JsonComment
|
||||
}
|
||||
const t = Number(keyed.time)
|
||||
const text = String(keyed.text ?? '').trim()
|
||||
if (!text || !Number.isFinite(t) || t < 0) continue
|
||||
// Accept both Bilibili mode ints and explicit "scroll"/"top"/"bottom".
|
||||
let mode: DanmakuMode
|
||||
if (typeof keyed.mode === 'string') {
|
||||
mode = (['scroll', 'top', 'bottom'] as const).includes(keyed.mode as DanmakuMode)
|
||||
? (keyed.mode as DanmakuMode)
|
||||
: 'scroll'
|
||||
} else {
|
||||
mode = BILI_MODES[String(keyed.mode)] ?? 'scroll'
|
||||
}
|
||||
let color = '#ffffff'
|
||||
if (typeof keyed.color === 'string') {
|
||||
if (/^#/.test(keyed.color)) {
|
||||
color = keyed.color
|
||||
} else {
|
||||
const n = Number(keyed.color)
|
||||
if (Number.isFinite(n)) color = `#${n.toString(16).padStart(6, '0')}`
|
||||
}
|
||||
} else if (typeof keyed.color === 'number' && Number.isFinite(keyed.color)) {
|
||||
color = `#${keyed.color.toString(16).padStart(6, '0')}`
|
||||
}
|
||||
comments.push({ time: t, text, mode, color, size: clampSize(Number(keyed.size)) })
|
||||
}
|
||||
return comments
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse danmaku payload into normalized comments. Auto-detects the format when
|
||||
* `sourceType` is "auto" (tries JSON first, falls back to XML).
|
||||
*/
|
||||
export function parseDanmaku(raw: string, sourceType: 'auto' | 'xml' | 'json'): Comment[] {
|
||||
if (sourceType === 'json' || (sourceType === 'auto' && looksLikeJson(raw))) {
|
||||
try {
|
||||
return parseJson(raw)
|
||||
} catch {
|
||||
if (sourceType === 'json') return []
|
||||
}
|
||||
}
|
||||
if (sourceType === 'xml') return parseBilibiliXml(raw)
|
||||
// auto: JSON parsing failed or didn't look like JSON — try XML.
|
||||
return parseBilibiliXml(raw)
|
||||
}
|
||||
|
||||
function looksLikeJson(raw: string): boolean {
|
||||
const trimmed = raw.trimStart()
|
||||
return trimmed.startsWith('[') || trimmed.startsWith('{')
|
||||
}
|
||||
Reference in New Issue
Block a user