mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge aa89f0c9327cfecba4e537b38d26d512a2d2f296 into dbd26ba2e71d0a5b79dba873a2beeff59f1cd8dd
This commit is contained in:
commit
247c8b996d
@ -87,6 +87,7 @@ func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions
|
||||
}
|
||||
|
||||
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
|
||||
var pluginErr error
|
||||
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
@ -101,6 +102,9 @@ func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles [
|
||||
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
if pattern != "embedded" && !strings.HasPrefix(pattern, ".") {
|
||||
pluginErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@ -110,7 +114,7 @@ func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles [
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
return nil, pluginErr
|
||||
}
|
||||
|
||||
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
|
||||
|
||||
@ -21,6 +21,17 @@ var _ = Describe("Lyrics", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
expectLyricsWithSource := func(actual, expected model.LyricList, source model.LyricsSource) {
|
||||
GinkgoHelper()
|
||||
withoutSource := make(model.LyricList, len(actual))
|
||||
copy(withoutSource, actual)
|
||||
for i := range withoutSource {
|
||||
Expect(withoutSource[i].Source).To(Equal(&source))
|
||||
withoutSource[i].Source = nil
|
||||
}
|
||||
Expect(withoutSource).To(Equal(expected))
|
||||
}
|
||||
|
||||
embeddedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
@ -162,19 +173,19 @@ var _ = Describe("Lyrics", func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList, source model.LyricsSource) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
expectLyricsWithSource(list, expected, source)
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics, model.LyricsSource{Type: model.LyricsSourceEmbedded}),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "lrc"}),
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "elrc"}),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "srt"}),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "txt"}),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "ttml"}))
|
||||
|
||||
It("resolves source priority across duplicate media files", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
@ -196,7 +207,10 @@ var _ = Describe("Lyrics", func() {
|
||||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(ttmlLyrics))
|
||||
expectLyricsWithSource(list, ttmlLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceSidecar,
|
||||
Format: "ttml",
|
||||
})
|
||||
})
|
||||
|
||||
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
|
||||
@ -289,7 +303,9 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
@ -316,7 +332,10 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "test-lyrics-plugin",
|
||||
})
|
||||
})
|
||||
|
||||
It("should try plugin after embedded returns nothing", func() {
|
||||
@ -326,7 +345,10 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "test-lyrics-plugin",
|
||||
})
|
||||
})
|
||||
|
||||
It("should skip plugin if embedded has lyrics", func() {
|
||||
@ -335,7 +357,9 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should skip unknown plugin names gracefully", func() {
|
||||
@ -344,7 +368,9 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should preserve plugin name case from config", func() {
|
||||
@ -354,7 +380,10 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "MyLyricsPlugin",
|
||||
})
|
||||
})
|
||||
|
||||
It("should handle plugin error gracefully", func() {
|
||||
@ -363,7 +392,21 @@ var _ = Describe("Lyrics", func() {
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should return a plugin error when no later source has lyrics", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||
mf.Lyrics = ""
|
||||
mockLoader.err = fmt.Errorf("remote lyrics cooldown active for 30s")
|
||||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
|
||||
Expect(list).To(BeEmpty())
|
||||
Expect(err).To(MatchError("remote lyrics cooldown active for 30s"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -17,7 +18,8 @@ import (
|
||||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
if mf.Lyrics != "" {
|
||||
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
|
||||
return mf.StructuredLyrics()
|
||||
list, err := mf.StructuredLyrics()
|
||||
return withLyricsSource(list, model.LyricsSource{Type: model.LyricsSourceEmbedded}), err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
|
||||
@ -65,7 +67,10 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file")
|
||||
return list, nil
|
||||
return withLyricsSource(list, model.LyricsSource{
|
||||
Type: model.LyricsSourceSidecar,
|
||||
Format: strings.ToLower(strings.TrimPrefix(suffix, ".")),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
@ -89,5 +94,39 @@ func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, plu
|
||||
if len(lyricsList) > 0 {
|
||||
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
|
||||
}
|
||||
return lyricsList, nil
|
||||
return withLyricsSource(lyricsList, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: pluginName,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// withLyricsSource copies the lyric entries before adding defaults so resolver
|
||||
// metadata does not mutate cached embedded lyrics or a provider-owned result.
|
||||
func withLyricsSource(list model.LyricList, defaults model.LyricsSource) model.LyricList {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
|
||||
result := make(model.LyricList, len(list))
|
||||
copy(result, list)
|
||||
for i := range result {
|
||||
source := defaults
|
||||
if result[i].Source != nil {
|
||||
source = *result[i].Source
|
||||
if source.Type == "" {
|
||||
source.Type = defaults.Type
|
||||
}
|
||||
if source.Name == "" {
|
||||
source.Name = defaults.Name
|
||||
}
|
||||
if source.Provider == "" {
|
||||
source.Provider = defaults.Provider
|
||||
}
|
||||
if source.Format == "" {
|
||||
source.Format = defaults.Format
|
||||
}
|
||||
}
|
||||
result[i].Source = &source
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -47,6 +47,13 @@ var _ = Describe("sources", func() {
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(len(expectedList)))
|
||||
for i := range lyrics {
|
||||
Expect(lyrics[i].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
}))
|
||||
lyrics[i].Source = nil
|
||||
}
|
||||
Expect(lyrics).To(Equal(expectedList))
|
||||
})
|
||||
|
||||
@ -91,6 +98,10 @@ var _ = Describe("sources", func() {
|
||||
Expect(lyrics).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Line).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Synced).To(Equal(expectSynced))
|
||||
Expect(lyrics[0].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourceSidecar,
|
||||
Format: suffix[1:],
|
||||
}))
|
||||
},
|
||||
Entry(".lrc synced", "test.mp3", ".lrc", true),
|
||||
Entry(".elrc enhanced", "test.mp3", ".elrc", true),
|
||||
|
||||
@ -20,6 +20,23 @@ type Agent struct {
|
||||
Name string `structs:"name,omitempty" json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type LyricsSourceType string
|
||||
|
||||
const (
|
||||
LyricsSourceEmbedded LyricsSourceType = "embedded"
|
||||
LyricsSourceSidecar LyricsSourceType = "sidecar"
|
||||
LyricsSourcePlugin LyricsSourceType = "plugin"
|
||||
)
|
||||
|
||||
// LyricsSource describes where Navidrome resolved a lyric. Name identifies a
|
||||
// plugin, while Provider identifies an optional upstream used by that plugin.
|
||||
type LyricsSource struct {
|
||||
Type LyricsSourceType `structs:"type" json:"type"`
|
||||
Name string `structs:"name,omitempty" json:"name,omitempty"`
|
||||
Provider string `structs:"provider,omitempty" json:"provider,omitempty"`
|
||||
Format string `structs:"format,omitempty" json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
|
||||
End *int64 `structs:"end,omitempty" json:"end,omitempty"`
|
||||
@ -28,14 +45,15 @@ type Line struct {
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `structs:"kind,omitempty" json:"kind,omitempty"`
|
||||
Lang string `structs:"lang" json:"lang"`
|
||||
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
|
||||
Line []Line `structs:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `structs:"synced" json:"synced"`
|
||||
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `structs:"kind,omitempty" json:"kind,omitempty"`
|
||||
Lang string `structs:"lang" json:"lang"`
|
||||
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
|
||||
Line []Line `structs:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
|
||||
Source *LyricsSource `structs:"source,omitempty" json:"source,omitempty"`
|
||||
Synced bool `structs:"synced" json:"synced"`
|
||||
}
|
||||
|
||||
// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are
|
||||
|
||||
@ -15,12 +15,20 @@ type GetLyricsRequest struct {
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Source *LyricsSource `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
// Navidrome content-sniffs and parses the returned text.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// LyricsSource identifies the optional upstream provider and format selected
|
||||
// by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
type LyricsSource struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
@ -23,12 +23,24 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LyricsText'
|
||||
source:
|
||||
$ref: '#/components/schemas/LyricsSource'
|
||||
nullable: true
|
||||
required:
|
||||
- lyrics
|
||||
LyricsSource:
|
||||
description: |-
|
||||
LyricsSource identifies the optional upstream provider and format selected
|
||||
by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
format:
|
||||
type: string
|
||||
LyricsText:
|
||||
description: |-
|
||||
LyricsText represents a single set of lyrics in raw text format.
|
||||
Text can be plain text or LRC format — Navidrome will parse it.
|
||||
Navidrome content-sniffs and parses the returned text.
|
||||
properties:
|
||||
lang:
|
||||
type: string
|
||||
|
||||
@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -26,13 +27,18 @@ func init() {
|
||||
}
|
||||
|
||||
func newLyricsPlugin(p *plugin) *LyricsPlugin {
|
||||
return &LyricsPlugin{name: p.name, plugin: p}
|
||||
displayName := p.name
|
||||
if p.manifest != nil && strings.TrimSpace(p.manifest.Name) != "" {
|
||||
displayName = strings.TrimSpace(p.manifest.Name)
|
||||
}
|
||||
return &LyricsPlugin{name: p.name, displayName: displayName, plugin: p}
|
||||
}
|
||||
|
||||
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
|
||||
type LyricsPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
name string
|
||||
displayName string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response
|
||||
@ -57,6 +63,14 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode
|
||||
// The lyric text comes from the plugin, not the media file's own tags, so
|
||||
// attribute logs to both the plugin and the track it was fetched for.
|
||||
ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path)
|
||||
source := &model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: l.displayName,
|
||||
}
|
||||
if resp.Source != nil {
|
||||
source.Provider = strings.TrimSpace(resp.Source.Provider)
|
||||
source.Format = strings.ToLower(strings.TrimSpace(resp.Source.Format))
|
||||
}
|
||||
|
||||
var result model.LyricList
|
||||
for _, lt := range resp.Lyrics {
|
||||
@ -71,6 +85,7 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode
|
||||
}
|
||||
for _, lyric := range parsed {
|
||||
if !lyric.IsEmpty() {
|
||||
lyric.Source = source
|
||||
result = append(result, lyric)
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,6 +56,10 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Line).ToNot(BeEmpty())
|
||||
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
|
||||
Expect(result[0].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "Test Lyrics",
|
||||
}))
|
||||
})
|
||||
|
||||
It("defaults language to 'xxx' when plugin does not provide one", func() {
|
||||
@ -73,6 +77,30 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
|
||||
Expect(result[0].Lang).To(Equal("xxx"))
|
||||
})
|
||||
|
||||
It("preserves optional provider provenance from the plugin", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-lyrics": {
|
||||
"source_provider": "unison",
|
||||
"source_format": "ttml",
|
||||
},
|
||||
}, "test-lyrics"+PackageExtension)
|
||||
|
||||
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
result, err := p.GetLyrics(GinkgoT().Context(), &model.MediaFile{
|
||||
ID: "track-1", Title: "Test Song", Artist: "Test Artist",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "Test Lyrics",
|
||||
Provider: "unison",
|
||||
Format: "ttml",
|
||||
}))
|
||||
})
|
||||
|
||||
It("blocks new calls while the per-plugin concurrency cap is saturated", func() {
|
||||
sem := provider.plugin.lyricsSem
|
||||
for range cap(sem) {
|
||||
|
||||
@ -22,11 +22,19 @@ type GetLyricsRequest struct {
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Source *LyricsSource `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsSource identifies the optional upstream provider and format selected
|
||||
// by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
type LyricsSource struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
// Navidrome content-sniffs and parses the returned text.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
|
||||
@ -20,11 +20,19 @@ type GetLyricsRequest struct {
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Source *LyricsSource `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsSource identifies the optional upstream provider and format selected
|
||||
// by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
type LyricsSource struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
// Navidrome content-sniffs and parses the returned text.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
|
||||
@ -34,9 +34,21 @@ pub struct GetLyricsRequest {
|
||||
pub struct GetLyricsResponse {
|
||||
#[serde(default)]
|
||||
pub lyrics: Vec<LyricsText>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<LyricsSource>,
|
||||
}
|
||||
/// LyricsSource identifies the optional upstream provider and format selected
|
||||
/// by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LyricsSource {
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub provider: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub format: String,
|
||||
}
|
||||
/// LyricsText represents a single set of lyrics in raw text format.
|
||||
/// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
/// Navidrome content-sniffs and parses the returned text.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LyricsText {
|
||||
|
||||
9
plugins/testdata/test-lyrics/main.go
vendored
9
plugins/testdata/test-lyrics/main.go
vendored
@ -20,6 +20,13 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR
|
||||
return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
var source *lyrics.LyricsSource
|
||||
provider, hasProvider := pdk.GetConfig("source_provider")
|
||||
sourceFormat, hasSourceFormat := pdk.GetConfig("source_format")
|
||||
if hasProvider || hasSourceFormat {
|
||||
source = &lyrics.LyricsSource{Provider: provider, Format: sourceFormat}
|
||||
}
|
||||
|
||||
// Config-selected format lets tests exercise the adapter's content-sniffing per format.
|
||||
format, hasFormat := pdk.GetConfig("format")
|
||||
if hasFormat {
|
||||
@ -52,6 +59,7 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR
|
||||
if text != "" {
|
||||
return lyrics.GetLyricsResponse{
|
||||
Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}},
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@ -65,6 +73,7 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR
|
||||
|
||||
// Return test lyrics based on track info
|
||||
return lyrics.GetLyricsResponse{
|
||||
Source: source,
|
||||
Lyrics: []lyrics.LyricsText{
|
||||
{
|
||||
Lang: lang,
|
||||
|
||||
@ -56,6 +56,14 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo
|
||||
|
||||
if enhanced {
|
||||
structured.Kind = lyrics.EffectiveKind()
|
||||
if lyrics.Source != nil && lyrics.Source.Type != "" {
|
||||
structured.Source = &responses.LyricsSource{
|
||||
Type: string(lyrics.Source.Type),
|
||||
Name: lyrics.Source.Name,
|
||||
Provider: lyrics.Source.Provider,
|
||||
Format: lyrics.Source.Format,
|
||||
}
|
||||
}
|
||||
if len(cueLines) > 0 && len(agents.response) > 0 {
|
||||
structured.Agents = agents.response
|
||||
}
|
||||
|
||||
@ -218,6 +218,9 @@ var _ = Describe("GetLyricsBySongId", func() {
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, lyric := range response.LyricsList.StructuredLyrics {
|
||||
Expect(lyric.Source).To(BeNil())
|
||||
}
|
||||
|
||||
porTime := int64(18800)
|
||||
ttmlTime := int64(22800)
|
||||
@ -274,6 +277,12 @@ var _ = Describe("GetLyricsBySongId", func() {
|
||||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, lyric := range response.LyricsList.StructuredLyrics {
|
||||
Expect(lyric.Source).To(Equal(&responses.LyricsSource{
|
||||
Type: "sidecar",
|
||||
Format: "ttml",
|
||||
}))
|
||||
}
|
||||
|
||||
mainStartA := int64(1000)
|
||||
mainStartB := int64(2000)
|
||||
|
||||
@ -572,16 +572,24 @@ type CueLine struct {
|
||||
Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"`
|
||||
}
|
||||
|
||||
type LyricsSource struct {
|
||||
Type string `xml:"type,attr" json:"type"`
|
||||
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
|
||||
Provider string `xml:"provider,attr,omitempty" json:"provider,omitempty"`
|
||||
Format string `xml:"format,attr,omitempty" json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type StructuredLyric struct {
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
|
||||
Lang string `xml:"lang,attr" json:"lang"`
|
||||
Line []Line `xml:"line" json:"line"`
|
||||
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
|
||||
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
|
||||
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `xml:"synced,attr" json:"synced"`
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
|
||||
Lang string `xml:"lang,attr" json:"lang"`
|
||||
Line []Line `xml:"line" json:"line"`
|
||||
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
|
||||
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
|
||||
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Source *LyricsSource `xml:"source,omitempty" json:"source,omitempty"`
|
||||
Synced bool `xml:"synced,attr" json:"synced"`
|
||||
}
|
||||
|
||||
type StructuredLyrics []StructuredLyric
|
||||
|
||||
@ -23,6 +23,7 @@ import share from './share'
|
||||
import library from './library'
|
||||
import plugin from './plugin'
|
||||
import { Player } from './audioplayer'
|
||||
import { LyricsLayoutProvider } from './audioplayer/LyricsLayoutContext'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
libraryReducer,
|
||||
@ -85,7 +86,9 @@ const adminStore = createAdminStore({
|
||||
|
||||
const App = () => (
|
||||
<Provider store={adminStore}>
|
||||
<Admin />
|
||||
<LyricsLayoutProvider>
|
||||
<Admin />
|
||||
</LyricsLayoutProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
|
||||
@ -7,11 +7,12 @@ import { decisionService } from '../transcode'
|
||||
import useStyle from './styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints'
|
||||
|
||||
const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
||||
const classes = useStyle()
|
||||
const className = classes.audioTitle
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
|
||||
const song = audioInfo.song
|
||||
const [, dragSongRef] = useDrag(
|
||||
|
||||
305
ui/src/audioplayer/LyricsLayerControls.jsx
Normal file
305
ui/src/audioplayer/LyricsLayerControls.jsx
Normal file
@ -0,0 +1,305 @@
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import Popover from '@material-ui/core/Popover'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import { alpha, makeStyles } from '@material-ui/core/styles'
|
||||
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'
|
||||
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
|
||||
import TranslateIcon from '@material-ui/icons/Translate'
|
||||
import clsx from 'clsx'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
const UPPERCASE_SOURCE_VALUES = new Set([
|
||||
'elrc',
|
||||
'lrc',
|
||||
'srt',
|
||||
'ttml',
|
||||
'txt',
|
||||
'yaml',
|
||||
])
|
||||
|
||||
const formatSourceValue = (value) => {
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
if (!normalized) return ''
|
||||
if (UPPERCASE_SOURCE_VALUES.has(normalized.toLowerCase())) {
|
||||
return normalized.toUpperCase()
|
||||
}
|
||||
if (normalized.toLowerCase() === 'plain') return 'Plain text'
|
||||
return normalized
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
}
|
||||
|
||||
const getSourceName = (source, labels) => {
|
||||
switch (source?.type) {
|
||||
case 'embedded':
|
||||
return labels.embeddedSource || 'Embedded lyrics'
|
||||
case 'sidecar':
|
||||
return labels.sidecarSource || 'Sidecar file'
|
||||
case 'plugin':
|
||||
return source.name || labels.pluginSource || 'Lyrics plugin'
|
||||
default:
|
||||
return source?.name || formatSourceValue(source?.type) || 'Lyrics'
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(1),
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.25),
|
||||
padding: theme.spacing(0.25),
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
sidebarPlacement: {
|
||||
right: theme.spacing(0.75),
|
||||
},
|
||||
mobilePlacement: {
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
},
|
||||
controlButton: {
|
||||
padding: theme.spacing(0.75),
|
||||
color: alpha(theme.palette.text.primary, 0.58),
|
||||
backgroundColor: 'transparent',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
transition:
|
||||
'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover': {
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
||||
},
|
||||
'&$controlActive:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
'&:focus-visible': {
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
},
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.97)',
|
||||
},
|
||||
'&$controlActive': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&$controlActive:focus-visible': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&:disabled': {
|
||||
color: alpha(theme.palette.text.primary, 0.28),
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
controlActive: {},
|
||||
sourcePopover: {
|
||||
minWidth: 200,
|
||||
maxWidth: 280,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
},
|
||||
sourceHeading: {
|
||||
display: 'block',
|
||||
color: alpha(theme.palette.text.primary, 0.58),
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
sourceName: {
|
||||
marginTop: theme.spacing(0.25),
|
||||
color: theme.palette.text.primary,
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
sourceDetails: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto minmax(0, 1fr)',
|
||||
columnGap: theme.spacing(1.5),
|
||||
rowGap: theme.spacing(0.5),
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
sourceDetailLabel: {
|
||||
color: alpha(theme.palette.text.primary, 0.58),
|
||||
},
|
||||
sourceDetailValue: {
|
||||
color: theme.palette.text.primary,
|
||||
textAlign: 'right',
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
}),
|
||||
{ name: 'NDLyricsLayerControls' },
|
||||
)
|
||||
|
||||
const LayerButton = ({
|
||||
active,
|
||||
classes,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
data-testid={testId}
|
||||
className={clsx(classes.controlButton, {
|
||||
[classes.controlActive]: active && !disabled,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const LyricsLayerControls = ({
|
||||
placement = 'sidebar',
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
translationEnabled,
|
||||
pronunciationEnabled,
|
||||
onToggleTranslation,
|
||||
onTogglePronunciation,
|
||||
source,
|
||||
active = true,
|
||||
labels = {},
|
||||
testId = 'lyrics-layer-controls',
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
const [sourceAnchor, setSourceAnchor] = useState(null)
|
||||
const sourcePopoverId = `${testId}-source-popover`
|
||||
const sourceOpen = Boolean(active && source && sourceAnchor)
|
||||
const sourceDetails = [
|
||||
{
|
||||
label: labels.provider || 'Provider',
|
||||
value: formatSourceValue(source?.provider),
|
||||
},
|
||||
{
|
||||
label: labels.format || 'Format',
|
||||
value: formatSourceValue(source?.format),
|
||||
},
|
||||
].filter((detail) => detail.value)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !source) setSourceAnchor(null)
|
||||
}, [active, source])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.controls, {
|
||||
[classes.mobilePlacement]: placement === 'mobile',
|
||||
[classes.sidebarPlacement]: placement !== 'mobile',
|
||||
})}
|
||||
data-testid={testId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{source && (
|
||||
<>
|
||||
<Tooltip title={labels.viewSource || 'View lyrics source'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => setSourceAnchor(event.currentTarget)}
|
||||
aria-label={labels.viewSource || 'View lyrics source'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={sourceOpen}
|
||||
aria-controls={sourceOpen ? sourcePopoverId : undefined}
|
||||
data-testid="lyrics-source-button"
|
||||
className={clsx(classes.controlButton, {
|
||||
[classes.controlActive]: sourceOpen,
|
||||
})}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
id={sourcePopoverId}
|
||||
open={sourceOpen}
|
||||
anchorEl={sourceAnchor}
|
||||
onClose={() => setSourceAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
PaperProps={{ className: classes.sourcePopover }}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={labels.sourceTitle || 'Lyrics source'}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Typography variant="overline" className={classes.sourceHeading}>
|
||||
{labels.sourceTitle || 'Lyrics source'}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" className={classes.sourceName}>
|
||||
{getSourceName(source, labels)}
|
||||
</Typography>
|
||||
{sourceDetails.length > 0 && (
|
||||
<div className={classes.sourceDetails}>
|
||||
{sourceDetails.map((detail) => (
|
||||
<React.Fragment key={detail.label}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={classes.sourceDetailLabel}
|
||||
>
|
||||
{detail.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={classes.sourceDetailValue}
|
||||
>
|
||||
{detail.value}
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
<LayerButton
|
||||
active={showPronunciation}
|
||||
classes={classes}
|
||||
disabled={!pronunciationEnabled}
|
||||
label={
|
||||
showPronunciation
|
||||
? labels.hidePronunciation || 'Hide pronunciation'
|
||||
: labels.showPronunciation || 'Show pronunciation'
|
||||
}
|
||||
onClick={onTogglePronunciation}
|
||||
testId="toggle-pronunciation-button"
|
||||
>
|
||||
<RecordVoiceOverIcon fontSize="small" />
|
||||
</LayerButton>
|
||||
<LayerButton
|
||||
active={showTranslation}
|
||||
classes={classes}
|
||||
disabled={!translationEnabled}
|
||||
label={
|
||||
showTranslation
|
||||
? labels.hideTranslation || 'Hide translation'
|
||||
: labels.showTranslation || 'Show translation'
|
||||
}
|
||||
onClick={onToggleTranslation}
|
||||
testId="toggle-translation-button"
|
||||
>
|
||||
<TranslateIcon fontSize="small" />
|
||||
</LayerButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsLayerControls
|
||||
89
ui/src/audioplayer/LyricsLayerControls.test.jsx
Normal file
89
ui/src/audioplayer/LyricsLayerControls.test.jsx
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
|
||||
const labels = {
|
||||
viewSource: 'View lyrics source',
|
||||
sourceTitle: 'Lyrics source',
|
||||
embeddedSource: 'Embedded lyrics',
|
||||
sidecarSource: 'Sidecar file',
|
||||
pluginSource: 'Lyrics plugin',
|
||||
provider: 'Provider',
|
||||
format: 'Format',
|
||||
}
|
||||
|
||||
const renderControls = (source) =>
|
||||
render(
|
||||
<LyricsLayerControls
|
||||
showTranslation={false}
|
||||
showPronunciation={false}
|
||||
translationEnabled={false}
|
||||
pronunciationEnabled={false}
|
||||
onToggleTranslation={() => {}}
|
||||
onTogglePronunciation={() => {}}
|
||||
labels={labels}
|
||||
source={source}
|
||||
/>,
|
||||
)
|
||||
|
||||
describe('<LyricsLayerControls /> source details', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('omits the source control when provenance is unavailable', () => {
|
||||
renderControls(null)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'View lyrics source' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ type: 'embedded' }, 'Embedded lyrics', null],
|
||||
[{ type: 'sidecar', format: 'lrc' }, 'Sidecar file', 'LRC'],
|
||||
])('shows built-in source details', (source, name, format) => {
|
||||
renderControls(source)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View lyrics source' }))
|
||||
|
||||
expect(screen.getByText(name)).toBeVisible()
|
||||
if (format) expect(screen.getByText(format)).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows plugin, provider, and format without exposing extra metadata', async () => {
|
||||
renderControls({
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'unison',
|
||||
format: 'ttml',
|
||||
path: '/music/private/song.ttml',
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button', { name: 'View lyrics source' })
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true')
|
||||
const dialog = screen.getByRole('dialog', { name: 'Lyrics source' })
|
||||
expect(dialog).toBeVisible()
|
||||
expect(screen.getByText('Better Lyrics')).toBeVisible()
|
||||
expect(screen.getByText('Unison')).toBeVisible()
|
||||
expect(screen.getByText('TTML')).toBeVisible()
|
||||
expect(
|
||||
screen.queryByText('/music/private/song.ttml'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(dialog.closest('[role="presentation"]'), {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false'),
|
||||
)
|
||||
})
|
||||
})
|
||||
41
ui/src/audioplayer/LyricsLayoutContext.jsx
Normal file
41
ui/src/audioplayer/LyricsLayoutContext.jsx
Normal file
@ -0,0 +1,41 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const fallbackContext = {
|
||||
desktopLyricsProps: null,
|
||||
setDesktopLyricsProps: noop,
|
||||
}
|
||||
|
||||
const LyricsLayoutContext = createContext(fallbackContext)
|
||||
|
||||
export const LyricsLayoutProvider = ({ children }) => {
|
||||
const [desktopLyricsProps, setDesktopLyricsPropsState] = useState(null)
|
||||
|
||||
const setDesktopLyricsProps = useCallback((nextProps) => {
|
||||
setDesktopLyricsPropsState(nextProps || null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
desktopLyricsProps,
|
||||
setDesktopLyricsProps,
|
||||
}),
|
||||
[desktopLyricsProps, setDesktopLyricsProps],
|
||||
)
|
||||
|
||||
return (
|
||||
<LyricsLayoutContext.Provider value={value}>
|
||||
{children}
|
||||
</LyricsLayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLyricsLayout = () => useContext(LyricsLayoutContext)
|
||||
621
ui/src/audioplayer/LyricsLineRows.jsx
Normal file
621
ui/src/audioplayer/LyricsLineRows.jsx
Normal file
@ -0,0 +1,621 @@
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import clsx from 'clsx'
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { buildSegmentsFromLine } from './lyricsSegments'
|
||||
import {
|
||||
TOKEN_ACTIVE_ALPHA,
|
||||
TOKEN_FUTURE_ALPHA,
|
||||
TOKEN_WIPE_EDGE_PCT,
|
||||
TOKEN_WIPE_SOFT_SPREAD_PCT,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import {
|
||||
buildEmphasisStyle,
|
||||
isEmphasisRole,
|
||||
parseColorRGB,
|
||||
} from './lyricsKaraokeStyles'
|
||||
import { resolveKaraokeTokenWindows } from './lyricsTimeline'
|
||||
|
||||
const EMPHASIS_TONE = 0.7
|
||||
const EMPHASIS_PAINT_OVERHANG = '0.18em'
|
||||
const DEFAULT_RGB = [255, 255, 255]
|
||||
|
||||
const emphasisPaintOverhangStyle = {
|
||||
marginInlineEnd: `-${EMPHASIS_PAINT_OVERHANG}`,
|
||||
paddingInlineEnd: EMPHASIS_PAINT_OVERHANG,
|
||||
}
|
||||
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: null
|
||||
|
||||
const splitGraphemes = (value) => {
|
||||
const text = String(value || '')
|
||||
return graphemeSegmenter
|
||||
? Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment)
|
||||
: Array.from(text)
|
||||
}
|
||||
|
||||
const waveTextRootStyle = {
|
||||
display: 'inline-block',
|
||||
position: 'relative',
|
||||
verticalAlign: 'baseline',
|
||||
}
|
||||
|
||||
const waveTextMeasureStyle = { visibility: 'hidden' }
|
||||
|
||||
const waveTextOverlayStyle = {
|
||||
display: 'block',
|
||||
inset: 0,
|
||||
position: 'absolute',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const renderWaveText = (text, eligible, enabled, emphasized, className) => {
|
||||
if (!eligible) return text
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-lyrics-wave-text="true"
|
||||
style={waveTextRootStyle}
|
||||
>
|
||||
{enabled ? (
|
||||
<>
|
||||
<span data-lyrics-wave-measure="true" style={waveTextMeasureStyle}>
|
||||
{text}
|
||||
</span>
|
||||
<span data-lyrics-wave-overlay="true" style={waveTextOverlayStyle}>
|
||||
{splitGraphemes(text).map((character, index) => {
|
||||
if (/^\s+$/.test(character)) return character
|
||||
return (
|
||||
<span
|
||||
key={`${index}-${character}`}
|
||||
className={className}
|
||||
data-lyrics-character="true"
|
||||
style={emphasized ? emphasisPaintOverhangStyle : undefined}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const tokenColor = (rgb, alpha) => {
|
||||
const [r, g, b] = rgb || [255, 255, 255]
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
const toneEmphasisRGB = (rgb) =>
|
||||
rgb ? rgb.map((channel) => Math.round(channel * EMPHASIS_TONE)) : rgb
|
||||
|
||||
const getTokenRGB = (token, rgb) =>
|
||||
isEmphasisRole(token) ? toneEmphasisRGB(rgb) : rgb
|
||||
|
||||
const stripLayerColors = (style) => {
|
||||
const result = { ...(style || {}) }
|
||||
delete result.opacity
|
||||
delete result.color
|
||||
delete result.WebkitTextFillColor
|
||||
delete result['--lyrics-active-color']
|
||||
return result
|
||||
}
|
||||
|
||||
const buildLineStyle = (line, style) => {
|
||||
const emphasisStyle = buildEmphasisStyle(line)
|
||||
return {
|
||||
...stripLayerColors(style),
|
||||
...emphasisStyle,
|
||||
...(emphasisStyle ? { filter: `brightness(${EMPHASIS_TONE})` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const buildStaticEmphasisStyle = (token) => {
|
||||
const emphasisStyle = buildEmphasisStyle(token)
|
||||
if (!emphasisStyle) return undefined
|
||||
return {
|
||||
...emphasisStyle,
|
||||
filter: `brightness(${EMPHASIS_TONE})`,
|
||||
}
|
||||
}
|
||||
|
||||
const buildTokenData = (token, rgb, line) => {
|
||||
const emphasized = isEmphasisRole(token) || isEmphasisRole(line)
|
||||
const tonedRGB = getTokenRGB(token, rgb)
|
||||
const futureColor = tokenColor(tonedRGB, TOKEN_FUTURE_ALPHA)
|
||||
const gradientDoneColor = tokenColor(
|
||||
tonedRGB,
|
||||
'var(--lyrics-token-active-alpha, 1)',
|
||||
)
|
||||
const softColor = tokenColor(
|
||||
tonedRGB,
|
||||
TOKEN_FUTURE_ALPHA + (TOKEN_ACTIVE_ALPHA - TOKEN_FUTURE_ALPHA) * 0.58,
|
||||
)
|
||||
const sweepRange = 100 + TOKEN_WIPE_SOFT_SPREAD_PCT
|
||||
const activeStop = `calc(var(--lyrics-progress) * ${sweepRange}% - ${TOKEN_WIPE_SOFT_SPREAD_PCT}%)`
|
||||
const softStop = `calc(var(--lyrics-progress) * ${sweepRange}% - ${TOKEN_WIPE_EDGE_PCT}%)`
|
||||
const futureStop = `calc(var(--lyrics-progress) * ${sweepRange}%)`
|
||||
const gradient = `linear-gradient(90deg, ${gradientDoneColor} 0%, ${gradientDoneColor} ${activeStop}, ${softColor} ${softStop}, ${futureColor} ${futureStop}, ${futureColor} 100%)`
|
||||
|
||||
return {
|
||||
style: {
|
||||
'--lyrics-progress': 0,
|
||||
'--lyrics-token-active-alpha': TOKEN_ACTIVE_ALPHA,
|
||||
color: 'transparent',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundImage: gradient,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
...buildEmphasisStyle(token),
|
||||
...(emphasized ? emphasisPaintOverhangStyle : {}),
|
||||
},
|
||||
presentation: {
|
||||
futureAlpha: TOKEN_FUTURE_ALPHA,
|
||||
activeAlpha: TOKEN_ACTIVE_ALPHA,
|
||||
gradient,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const isTimedWindow = (window) =>
|
||||
Number.isFinite(window?.start) && Number.isFinite(window?.end)
|
||||
|
||||
const useStableTokenRef = (registerToken) => {
|
||||
const registerTokenRef = useRef(registerToken)
|
||||
const callbacksRef = useRef(new Map())
|
||||
registerTokenRef.current = registerToken
|
||||
|
||||
return useCallback(
|
||||
({ key, lineIndex, window, presentation, waveEnabled }) => {
|
||||
const signature = [
|
||||
lineIndex,
|
||||
window?.start ?? '',
|
||||
window?.end ?? '',
|
||||
presentation?.futureAlpha ?? '',
|
||||
presentation?.activeAlpha ?? '',
|
||||
presentation?.gradient ?? '',
|
||||
waveEnabled ? 1 : 0,
|
||||
].join('\u0000')
|
||||
const cached = callbacksRef.current.get(key)
|
||||
if (cached?.signature === signature) return cached.callback
|
||||
|
||||
const descriptor = { lineIndex, window, presentation }
|
||||
const callback = (node) => {
|
||||
registerTokenRef.current?.(key, descriptor, node)
|
||||
}
|
||||
callbacksRef.current.set(key, { signature, callback })
|
||||
return callback
|
||||
},
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export const KaraokeLineRow = memo(
|
||||
({
|
||||
lineIndex,
|
||||
line,
|
||||
nextLineStart,
|
||||
className,
|
||||
style,
|
||||
tokenClassName,
|
||||
waveCharacterClassName,
|
||||
registerToken,
|
||||
renderCharacterWave = true,
|
||||
rowKey = 'main',
|
||||
testId,
|
||||
}) => {
|
||||
const getTokenRef = useStableTokenRef(registerToken)
|
||||
const segments = useMemo(() => buildSegmentsFromLine(line), [line])
|
||||
const windows = useMemo(
|
||||
() => resolveKaraokeTokenWindows(line, nextLineStart),
|
||||
[line, nextLineStart],
|
||||
)
|
||||
const tokenRGB = useMemo(
|
||||
() => (style?.color ? parseColorRGB(style.color) : DEFAULT_RGB),
|
||||
[style?.color],
|
||||
)
|
||||
const hasTimedTokens = windows.some(
|
||||
(window) => window?.start != null && window?.end != null,
|
||||
)
|
||||
const lineStyle = useMemo(() => buildLineStyle(line, style), [line, style])
|
||||
|
||||
return (
|
||||
<Typography
|
||||
className={className}
|
||||
component="div"
|
||||
data-testid={testId}
|
||||
data-tokenized={hasTimedTokens ? 'true' : 'false'}
|
||||
style={lineStyle}
|
||||
>
|
||||
{segments.map((segment, idx) => {
|
||||
if (!segment.token)
|
||||
return <span key={`text-${idx}`}>{segment.text}</span>
|
||||
|
||||
const window = windows[segment.tokenIndex]
|
||||
const timedWindow = isTimedWindow(window) ? window : null
|
||||
const key = `${lineIndex}:${rowKey}:${segment.tokenIndex}:main`
|
||||
const emphasized =
|
||||
isEmphasisRole(segment.token) || isEmphasisRole(line)
|
||||
const tokenData = buildTokenData(segment.token, tokenRGB, line)
|
||||
return (
|
||||
<span
|
||||
key={`token-${idx}-${window?.start ?? 'na'}`}
|
||||
className={tokenClassName}
|
||||
data-testid="lyrics-token"
|
||||
data-lyrics-state="future"
|
||||
ref={
|
||||
timedWindow
|
||||
? getTokenRef({
|
||||
key,
|
||||
lineIndex,
|
||||
window: timedWindow,
|
||||
presentation: tokenData.presentation,
|
||||
waveEnabled: renderCharacterWave,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
style={tokenData.style}
|
||||
aria-label={segment.text}
|
||||
>
|
||||
{renderWaveText(
|
||||
segment.text,
|
||||
Boolean(timedWindow),
|
||||
Boolean(renderCharacterWave && timedWindow),
|
||||
emphasized,
|
||||
waveCharacterClassName,
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Typography>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
KaraokeLineRow.displayName = 'KaraokeLineRow'
|
||||
|
||||
const splitTextSegments = (value) =>
|
||||
(value || '')
|
||||
.split(/(\s+)/)
|
||||
.filter((text) => text.length > 0)
|
||||
.map((text) => ({
|
||||
text,
|
||||
token: null,
|
||||
tokenIndex: -1,
|
||||
isWhitespace: /^\s+$/.test(text),
|
||||
}))
|
||||
|
||||
const buildPronunciationParts = (line) => {
|
||||
if (!line?.value) return []
|
||||
|
||||
const segments = buildSegmentsFromLine(line)
|
||||
const tokenParts = segments
|
||||
.filter((segment) => segment.token && segment.text.trim())
|
||||
.map((segment) => ({
|
||||
text: segment.text.trim(),
|
||||
segment,
|
||||
}))
|
||||
|
||||
if (tokenParts.length > 0) return tokenParts
|
||||
|
||||
return splitTextSegments(line.value)
|
||||
.filter((segment) => !segment.isWhitespace)
|
||||
.map((segment) => ({
|
||||
text: segment.text.trim(),
|
||||
segment: null,
|
||||
}))
|
||||
}
|
||||
|
||||
const canPairPronunciationSegment = (segment, hasTokenSegments) =>
|
||||
Boolean(
|
||||
segment.token ||
|
||||
(!hasTokenSegments && !segment.isWhitespace && segment.text.trim()),
|
||||
)
|
||||
|
||||
const buildStackedPronunciationSegments = (line, pronunciationLine) => {
|
||||
const lineSegments = buildSegmentsFromLine(line)
|
||||
const hasTokenSegments = lineSegments.some((segment) => segment.token)
|
||||
const mainSegments = hasTokenSegments
|
||||
? lineSegments
|
||||
: splitTextSegments(line?.value || '')
|
||||
const pronunciationParts = buildPronunciationParts(pronunciationLine)
|
||||
const pairableSegments = mainSegments.filter((segment) =>
|
||||
canPairPronunciationSegment(segment, hasTokenSegments),
|
||||
)
|
||||
|
||||
if (
|
||||
!hasTokenSegments &&
|
||||
pronunciationParts.length > 0 &&
|
||||
pairableSegments.length > 0 &&
|
||||
pronunciationParts.length !== pairableSegments.length
|
||||
) {
|
||||
return [
|
||||
{
|
||||
text: line?.value || '',
|
||||
token: null,
|
||||
tokenIndex: -1,
|
||||
isWhitespace: false,
|
||||
pronunciation: pronunciationLine?.value?.trim() || '',
|
||||
pronunciationSegment: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const plainPronunciationParts = []
|
||||
const emphasisPronunciationParts = []
|
||||
for (const part of pronunciationParts) {
|
||||
if (hasTokenSegments && isEmphasisRole(part.segment?.token)) {
|
||||
emphasisPronunciationParts.push(part)
|
||||
} else {
|
||||
plainPronunciationParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
let pronunciationIndex = 0
|
||||
let plainPronunciationIndex = 0
|
||||
let emphasisPronunciationIndex = 0
|
||||
|
||||
const getPronunciationPart = (segment) => {
|
||||
if (!hasTokenSegments) {
|
||||
const part = pronunciationParts[pronunciationIndex] || null
|
||||
pronunciationIndex += 1
|
||||
return part
|
||||
}
|
||||
|
||||
if (isEmphasisRole(segment.token)) {
|
||||
const part =
|
||||
emphasisPronunciationParts[emphasisPronunciationIndex] || null
|
||||
emphasisPronunciationIndex += 1
|
||||
return part
|
||||
}
|
||||
|
||||
const part = plainPronunciationParts[plainPronunciationIndex] || null
|
||||
plainPronunciationIndex += 1
|
||||
return part
|
||||
}
|
||||
|
||||
return mainSegments.map((segment) => {
|
||||
if (!canPairPronunciationSegment(segment, hasTokenSegments)) {
|
||||
return { ...segment, pronunciation: '' }
|
||||
}
|
||||
const pronunciationPart = getPronunciationPart(segment)
|
||||
return {
|
||||
...segment,
|
||||
pronunciation: pronunciationPart?.text || '',
|
||||
pronunciationSegment: pronunciationPart?.segment || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const KaraokeStackedLineRow = memo(
|
||||
({
|
||||
lineIndex,
|
||||
line,
|
||||
pronunciationLine,
|
||||
pronunciationStyle,
|
||||
nextLineStart,
|
||||
className,
|
||||
style,
|
||||
tokenClassName,
|
||||
waveCharacterClassName,
|
||||
classes,
|
||||
registerToken,
|
||||
renderCharacterWave = true,
|
||||
rowKey = 'main',
|
||||
testId,
|
||||
}) => {
|
||||
const rowRef = useRef(null)
|
||||
const getTokenRef = useStableTokenRef(registerToken)
|
||||
const [isWrapped, setIsWrapped] = useState(false)
|
||||
const segments = useMemo(
|
||||
() => buildStackedPronunciationSegments(line, pronunciationLine),
|
||||
[line, pronunciationLine],
|
||||
)
|
||||
const mainWindows = useMemo(
|
||||
() => resolveKaraokeTokenWindows(line, nextLineStart),
|
||||
[line, nextLineStart],
|
||||
)
|
||||
const pronunciationWindows = useMemo(
|
||||
() => resolveKaraokeTokenWindows(pronunciationLine, null),
|
||||
[pronunciationLine],
|
||||
)
|
||||
const tokenRGB = useMemo(
|
||||
() => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]),
|
||||
[style?.color],
|
||||
)
|
||||
const pronunciationRGB = useMemo(
|
||||
() =>
|
||||
pronunciationStyle?.color
|
||||
? parseColorRGB(pronunciationStyle.color)
|
||||
: DEFAULT_RGB,
|
||||
[pronunciationStyle?.color],
|
||||
)
|
||||
const hasTimedTokens = mainWindows.some(
|
||||
(window) => window?.start != null && window?.end != null,
|
||||
)
|
||||
const lineStyle = useMemo(() => buildLineStyle(line, style), [line, style])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current
|
||||
if (!row) return undefined
|
||||
|
||||
const updateWrappedState = () => {
|
||||
const tokenRows = new Set(
|
||||
Array.from(row.querySelectorAll('[data-stacked-token="true"]')).map(
|
||||
(node) => node.offsetTop,
|
||||
),
|
||||
)
|
||||
const wrapped = tokenRows.size > 1
|
||||
setIsWrapped((current) => (current === wrapped ? current : wrapped))
|
||||
}
|
||||
|
||||
updateWrappedState()
|
||||
const ResizeObserverConstructor =
|
||||
typeof window !== 'undefined' ? window.ResizeObserver : null
|
||||
if (!ResizeObserverConstructor) return undefined
|
||||
const resizeObserver = new ResizeObserverConstructor(updateWrappedState)
|
||||
resizeObserver.observe(row)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [segments])
|
||||
|
||||
return (
|
||||
<Typography
|
||||
className={clsx(className, {
|
||||
[classes.wrappedStackedLine]: isWrapped,
|
||||
})}
|
||||
component="div"
|
||||
data-wrapped={isWrapped ? 'true' : 'false'}
|
||||
data-testid={testId}
|
||||
data-tokenized={hasTimedTokens ? 'true' : 'false'}
|
||||
ref={rowRef}
|
||||
style={lineStyle}
|
||||
>
|
||||
{segments.map((segment, idx) => {
|
||||
const resolvedMainWindow = segment.token
|
||||
? mainWindows[segment.tokenIndex]
|
||||
: null
|
||||
const mainWindow = isTimedWindow(resolvedMainWindow)
|
||||
? resolvedMainWindow
|
||||
: null
|
||||
const mainKey = `${lineIndex}:${rowKey}:${segment.tokenIndex}:main`
|
||||
const mainTokenData = segment.token
|
||||
? buildTokenData(segment.token, tokenRGB, line)
|
||||
: null
|
||||
const mainEmphasized =
|
||||
isEmphasisRole(segment.token) || isEmphasisRole(line)
|
||||
const mainText = segment.token ? (
|
||||
<span
|
||||
key={`main-${idx}`}
|
||||
className={clsx(tokenClassName, classes.stackedMainText)}
|
||||
data-testid="lyrics-token"
|
||||
data-lyrics-state="future"
|
||||
ref={
|
||||
mainWindow
|
||||
? getTokenRef({
|
||||
key: mainKey,
|
||||
lineIndex,
|
||||
window: mainWindow,
|
||||
presentation: mainTokenData.presentation,
|
||||
waveEnabled: renderCharacterWave,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
style={mainTokenData.style}
|
||||
aria-label={segment.text}
|
||||
>
|
||||
{renderWaveText(
|
||||
segment.text,
|
||||
Boolean(mainWindow?.start != null && mainWindow?.end != null),
|
||||
Boolean(
|
||||
renderCharacterWave &&
|
||||
mainWindow?.start != null &&
|
||||
mainWindow?.end != null,
|
||||
),
|
||||
mainEmphasized,
|
||||
waveCharacterClassName,
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`main-${idx}`} className={classes.stackedSpacer}>
|
||||
{segment.text}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!segment.pronunciation) return mainText
|
||||
|
||||
const pronunciationToken = segment.pronunciationSegment?.token
|
||||
const resolvedPronunciationWindow = pronunciationToken
|
||||
? pronunciationWindows[segment.pronunciationSegment.tokenIndex]
|
||||
: null
|
||||
const pronunciationWindow =
|
||||
mainWindow ||
|
||||
(isTimedWindow(resolvedPronunciationWindow)
|
||||
? resolvedPronunciationWindow
|
||||
: null)
|
||||
const pronunciationTokenIndex =
|
||||
segment.pronunciationSegment?.tokenIndex >= 0
|
||||
? segment.pronunciationSegment.tokenIndex
|
||||
: segment.tokenIndex >= 0
|
||||
? segment.tokenIndex
|
||||
: idx
|
||||
const pronunciationKey = `${lineIndex}:${rowKey}:${pronunciationTokenIndex}:pronunciation`
|
||||
const pronunciationTokenData = pronunciationWindow
|
||||
? buildTokenData(
|
||||
pronunciationToken || segment.token,
|
||||
pronunciationRGB,
|
||||
pronunciationLine || line,
|
||||
)
|
||||
: null
|
||||
const pronunciationEmphasized = Boolean(
|
||||
isEmphasisRole(pronunciationToken) ||
|
||||
isEmphasisRole(segment.token) ||
|
||||
isEmphasisRole(pronunciationLine) ||
|
||||
isEmphasisRole(line),
|
||||
)
|
||||
return (
|
||||
<span
|
||||
key={`stacked-${idx}-${mainWindow?.start ?? segment.text}`}
|
||||
className={classes.stackedToken}
|
||||
data-stacked-token="true"
|
||||
>
|
||||
{mainText}
|
||||
<span
|
||||
className={classes.stackedPronunciation}
|
||||
data-testid="lyrics-pronunciation-token"
|
||||
data-lyrics-state="future"
|
||||
data-timed={pronunciationWindow ? 'true' : 'false'}
|
||||
aria-label={
|
||||
pronunciationWindow ? segment.pronunciation : undefined
|
||||
}
|
||||
ref={
|
||||
pronunciationWindow
|
||||
? getTokenRef({
|
||||
key: pronunciationKey,
|
||||
lineIndex,
|
||||
window: pronunciationWindow,
|
||||
presentation: pronunciationTokenData.presentation,
|
||||
waveEnabled: renderCharacterWave,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
pronunciationTokenData?.style || {
|
||||
backgroundImage: 'none',
|
||||
...buildStaticEmphasisStyle(
|
||||
pronunciationToken || segment.token,
|
||||
),
|
||||
}
|
||||
}
|
||||
>
|
||||
{renderWaveText(
|
||||
segment.pronunciation,
|
||||
Boolean(pronunciationWindow),
|
||||
Boolean(renderCharacterWave && pronunciationWindow),
|
||||
pronunciationEmphasized,
|
||||
waveCharacterClassName,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Typography>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
KaraokeStackedLineRow.displayName = 'KaraokeStackedLineRow'
|
||||
942
ui/src/audioplayer/LyricsPanel.jsx
Normal file
942
ui/src/audioplayer/LyricsPanel.jsx
Normal file
@ -0,0 +1,942 @@
|
||||
import { alpha, makeStyles, useTheme } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
buildKaraokeLines,
|
||||
hasStructuredLyricContent,
|
||||
hasUsableKaraokeTiming,
|
||||
} from './lyrics'
|
||||
import { KaraokeLineRow, KaraokeStackedLineRow } from './LyricsLineRows'
|
||||
import {
|
||||
KARAOKE_LINE_OPACITY_MS,
|
||||
KARAOKE_AUX_LINE_HEIGHT,
|
||||
KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO,
|
||||
KARAOKE_EASING,
|
||||
KARAOKE_IDLE_LAYER_OPACITY,
|
||||
KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO,
|
||||
KARAOKE_LINE_LIFT_PX,
|
||||
KARAOKE_LINE_MOTION_EASING,
|
||||
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
|
||||
KARAOKE_SCROLLBAR_VISIBLE_MS,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import {
|
||||
animateScrollTop,
|
||||
cancelScrollAnimation,
|
||||
getAnchoredScrollTop,
|
||||
getScrollEndPadding,
|
||||
} from './lyricsScroll'
|
||||
import { finiteTime } from './lyricsTimeline'
|
||||
import useLyricsTimeline from './useLyricsTimeline'
|
||||
|
||||
const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
|
||||
const KARAOKE_LAYER_COLOR_TRANSITION = `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}, -webkit-text-fill-color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
inlineRoot: {
|
||||
borderRadius: 'inherit',
|
||||
background: 'transparent',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
padding: theme.spacing(4, 2.25, 3.25),
|
||||
overscrollBehavior: 'contain',
|
||||
scrollbarGutter: 'stable',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'transparent transparent',
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
// Player themes use two-class scrollbar selectors.
|
||||
'&&&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 999,
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
bodyTopFade: {
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
|
||||
},
|
||||
bodyUserScrolling: {
|
||||
scrollbarColor: `${theme.palette.action.disabled} transparent`,
|
||||
'&&&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.action.disabled,
|
||||
},
|
||||
},
|
||||
inlineBody: {
|
||||
padding: theme.spacing(0.5, 1.25, 1.5),
|
||||
textAlign: 'center',
|
||||
},
|
||||
lines: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: theme.spacing(3),
|
||||
},
|
||||
lineGroup: {
|
||||
width: '100%',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
'--lyrics-main-current-color':
|
||||
'var(--lyrics-main-idle-color, currentColor)',
|
||||
'--lyrics-pronunciation-current-color':
|
||||
'var(--lyrics-pronunciation-idle-color, currentColor)',
|
||||
'--lyrics-translation-current-color':
|
||||
'var(--lyrics-translation-idle-color, currentColor)',
|
||||
'--lyrics-layer-opacity': KARAOKE_IDLE_LAYER_OPACITY,
|
||||
transform: 'translateY(0)',
|
||||
transition: `background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
'&[role="button"]:focus-visible': {
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.055),
|
||||
},
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&[role="button"]:hover': {
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.055),
|
||||
},
|
||||
},
|
||||
'&[data-raised="true"][data-line-motion="character"][data-character-wave="false"] $line':
|
||||
{
|
||||
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
transition: KARAOKE_LAYER_OPACITY_TRANSITION,
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
'--lyrics-main-current-color':
|
||||
'var(--lyrics-main-active-color, var(--lyrics-main-idle-color, currentColor))',
|
||||
'--lyrics-pronunciation-current-color':
|
||||
'var(--lyrics-pronunciation-active-color, var(--lyrics-pronunciation-idle-color, currentColor))',
|
||||
'--lyrics-translation-current-color':
|
||||
'var(--lyrics-translation-active-color, var(--lyrics-translation-idle-color, currentColor))',
|
||||
'--lyrics-layer-opacity': 1,
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
'& $line': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
waveCharacter: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'baseline',
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
transformOrigin: 'center bottom',
|
||||
willChange: 'transform',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transform: 'none !important',
|
||||
willChange: 'auto',
|
||||
},
|
||||
},
|
||||
line: {
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
lineHeight: 1.18,
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
letterSpacing: 0,
|
||||
color: 'var(--lyrics-main-current-color, currentColor)',
|
||||
WebkitTextFillColor: 'var(--lyrics-main-current-color, currentColor)',
|
||||
'&[data-tokenized="false"]': {
|
||||
opacity: 'var(--lyrics-layer-opacity)',
|
||||
color: 'var(--lyrics-main-active-color, currentColor)',
|
||||
WebkitTextFillColor: 'var(--lyrics-main-active-color, currentColor)',
|
||||
transition: KARAOKE_LAYER_OPACITY_TRANSITION,
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
},
|
||||
},
|
||||
translationLine: {
|
||||
display: 'block',
|
||||
opacity: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
lineHeight: KARAOKE_AUX_LINE_HEIGHT,
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
letterSpacing: 0,
|
||||
color: 'var(--lyrics-translation-current-color, currentColor)',
|
||||
WebkitTextFillColor:
|
||||
'var(--lyrics-translation-current-color, currentColor)',
|
||||
transition: KARAOKE_LAYER_COLOR_TRANSITION,
|
||||
'&&': {
|
||||
marginTop: theme.spacing(0.6),
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
},
|
||||
},
|
||||
stackedToken: {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
verticalAlign: 'top',
|
||||
width: 'max-content',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
paddingRight: theme.spacing(0.5),
|
||||
marginBottom: theme.spacing(0.25),
|
||||
},
|
||||
wrappedStackedLine: {
|
||||
'& $stackedToken': {
|
||||
marginBottom: theme.spacing(0.95),
|
||||
},
|
||||
},
|
||||
stackedSpacer: {
|
||||
display: 'inline',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
stackedMainText: {
|
||||
display: 'block',
|
||||
lineHeight: 1.05,
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
stackedPronunciation: {
|
||||
display: 'block',
|
||||
marginTop: theme.spacing(0.15),
|
||||
fontSize: 15,
|
||||
lineHeight: 1.05,
|
||||
fontWeight: 700,
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--lyrics-pronunciation-current-color, currentColor)',
|
||||
WebkitTextFillColor:
|
||||
'var(--lyrics-pronunciation-current-color, currentColor)',
|
||||
'&[data-timed="false"]': {
|
||||
color: 'var(--lyrics-pronunciation-active-color, currentColor)',
|
||||
WebkitTextFillColor:
|
||||
'var(--lyrics-pronunciation-active-color, currentColor)',
|
||||
},
|
||||
'&[data-timed="true"]': {
|
||||
transition: 'none',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
},
|
||||
},
|
||||
token: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
fontKerning: 'none',
|
||||
fontVariantLigatures: 'none',
|
||||
},
|
||||
voiceLanes: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: theme.spacing(0.7),
|
||||
},
|
||||
secondaryVoiceLane: {
|
||||
fontSize: 22,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(3),
|
||||
color: alpha(theme.palette.text.primary, 0.68),
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
},
|
||||
}))
|
||||
|
||||
const normalizeLineText = (value) =>
|
||||
String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[\p{P}\p{S}\s]+/gu, '')
|
||||
|
||||
const shouldShowAuxLine = (mainLine, auxLine) =>
|
||||
Boolean(
|
||||
auxLine?.value &&
|
||||
normalizeLineText(auxLine.value) !== normalizeLineText(mainLine?.value),
|
||||
)
|
||||
|
||||
const getLayerMatchWindow = (lines, index) => {
|
||||
const line = lines[index]
|
||||
if (!line) return { start: null, end: null }
|
||||
return {
|
||||
start: finiteTime(line.start),
|
||||
end: finiteTime(line.end) ?? finiteTime(lines[index + 1]?.start),
|
||||
}
|
||||
}
|
||||
|
||||
const buildUniqueLayerMap = (mainLines, layerLines) => {
|
||||
if (!mainLines.length || !layerLines.length) return {}
|
||||
|
||||
const candidates = []
|
||||
for (let layerIndex = 0; layerIndex < layerLines.length; layerIndex += 1) {
|
||||
if (layerLines[layerIndex]?.renderable === false) continue
|
||||
const layerWindow = getLayerMatchWindow(layerLines, layerIndex)
|
||||
for (let mainIndex = 0; mainIndex < mainLines.length; mainIndex += 1) {
|
||||
if (mainLines[mainIndex]?.renderable === false) continue
|
||||
const mainWindow = getLayerMatchWindow(mainLines, mainIndex)
|
||||
let score = null
|
||||
|
||||
if (layerWindow.start != null && mainWindow.start != null) {
|
||||
const mainEnd = mainWindow.end ?? mainWindow.start
|
||||
const layerEnd = layerWindow.end ?? layerWindow.start
|
||||
const overlap =
|
||||
Math.min(mainEnd, layerEnd) -
|
||||
Math.max(mainWindow.start, layerWindow.start)
|
||||
const mainDuration = Math.max(0, mainEnd - mainWindow.start)
|
||||
const maxDelta = Math.max(550, Math.min(1400, mainDuration + 420))
|
||||
const startDelta = Math.abs(layerWindow.start - mainWindow.start)
|
||||
if (overlap < 0 && startDelta > maxDelta) continue
|
||||
score =
|
||||
startDelta +
|
||||
Math.abs(layerIndex - mainIndex) * 30 +
|
||||
(overlap < 0 ? 200 : 0)
|
||||
} else if (layerIndex === mainIndex) {
|
||||
score = 2000
|
||||
}
|
||||
|
||||
if (score != null) {
|
||||
candidates.push({ layerIndex, mainIndex, score })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
left.score - right.score ||
|
||||
left.layerIndex - right.layerIndex ||
|
||||
left.mainIndex - right.mainIndex,
|
||||
)
|
||||
|
||||
const usedLayers = new Set()
|
||||
const usedMainLines = new Set()
|
||||
const result = {}
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
usedLayers.has(candidate.layerIndex) ||
|
||||
usedMainLines.has(candidate.mainIndex)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
usedLayers.add(candidate.layerIndex)
|
||||
usedMainLines.add(candidate.mainIndex)
|
||||
result[candidate.mainIndex] = layerLines[candidate.layerIndex]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const getLineLanes = (line) =>
|
||||
Array.isArray(line?.lanes) && line.lanes.length > 0 ? line.lanes : [line]
|
||||
|
||||
const buildStaticTranslationLine = (translationLine) => {
|
||||
if (!translationLine) return null
|
||||
return {
|
||||
...translationLine,
|
||||
tokens: [],
|
||||
lanes: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const buildLineGroupStyle = (canSeekLine, layerStyles) => ({
|
||||
cursor: canSeekLine ? 'pointer' : undefined,
|
||||
'--lyrics-main-idle-color': layerStyles.main.color,
|
||||
'--lyrics-main-active-color':
|
||||
layerStyles.main['--lyrics-active-color'] || layerStyles.main.color,
|
||||
'--lyrics-pronunciation-idle-color': layerStyles.pronunciation.color,
|
||||
'--lyrics-pronunciation-active-color':
|
||||
layerStyles.pronunciation['--lyrics-active-color'] ||
|
||||
layerStyles.pronunciation.color,
|
||||
'--lyrics-translation-idle-color': layerStyles.translation.color,
|
||||
'--lyrics-translation-active-color':
|
||||
layerStyles.translation['--lyrics-active-color'],
|
||||
})
|
||||
|
||||
const usePrefersReducedMotion = () => {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return undefined
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const updatePreference = () =>
|
||||
setPrefersReducedMotion(Boolean(mediaQuery.matches))
|
||||
updatePreference()
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', updatePreference)
|
||||
return () => mediaQuery.removeEventListener('change', updatePreference)
|
||||
}
|
||||
mediaQuery.addListener(updatePreference)
|
||||
return () => mediaQuery.removeListener(updatePreference)
|
||||
}, [])
|
||||
|
||||
return prefersReducedMotion
|
||||
}
|
||||
|
||||
const LyricsPanel = ({
|
||||
visible = true,
|
||||
obscured = false,
|
||||
mainLyric,
|
||||
translationLyric,
|
||||
pronunciationLyric,
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
audioInstance,
|
||||
inline = false,
|
||||
loading = false,
|
||||
error = null,
|
||||
errorMessage = null,
|
||||
labels = {},
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
const theme = useTheme()
|
||||
const bodyRef = useRef(null)
|
||||
const scrollAnimationRef = useRef(null)
|
||||
const scrollbarTimerRef = useRef(null)
|
||||
const manualScrollTimerRef = useRef(null)
|
||||
const manualScrollUntilRef = useRef(0)
|
||||
const [showScrollbar, setShowScrollbar] = useState(false)
|
||||
const [autoScrollResumeKey, setAutoScrollResumeKey] = useState(0)
|
||||
const [layoutVersion, setLayoutVersion] = useState(0)
|
||||
const [hasTopFade, setHasTopFade] = useState(false)
|
||||
const [scrollEndPadding, setScrollEndPadding] = useState(0)
|
||||
const [scrollStartPadding, setScrollStartPadding] = useState(0)
|
||||
const [focusedLineIndex, setFocusedLineIndex] = useState(null)
|
||||
const prefersReducedMotion = usePrefersReducedMotion()
|
||||
const timelineVisible = visible && !obscured
|
||||
const activeLineAnchorRatio = inline
|
||||
? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO
|
||||
: KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO
|
||||
|
||||
const mainLines = useMemo(() => buildKaraokeLines(mainLyric), [mainLyric])
|
||||
const translationLines = useMemo(
|
||||
() => buildKaraokeLines(translationLyric),
|
||||
[translationLyric],
|
||||
)
|
||||
const pronunciationLines = useMemo(
|
||||
() => buildKaraokeLines(pronunciationLyric),
|
||||
[pronunciationLyric],
|
||||
)
|
||||
const hasTimedMainLines = useMemo(
|
||||
() => hasUsableKaraokeTiming(mainLines),
|
||||
[mainLines],
|
||||
)
|
||||
const {
|
||||
activeIndexes,
|
||||
primaryIndex: activeIndex,
|
||||
scrollTargetIndex,
|
||||
registerLine,
|
||||
registerToken,
|
||||
getLineNode,
|
||||
syncNow,
|
||||
timeline,
|
||||
} = useLyricsTimeline({
|
||||
lines: mainLines,
|
||||
audioInstance,
|
||||
visible: timelineVisible && hasTimedMainLines,
|
||||
reducedMotion: prefersReducedMotion,
|
||||
})
|
||||
const activeIndexSet = useMemo(() => new Set(activeIndexes), [activeIndexes])
|
||||
const seekableLineIndexes = useMemo(() => {
|
||||
if (!audioInstance) return []
|
||||
return mainLines.reduce((indexes, line, index) => {
|
||||
if (line.renderable !== false && line.start != null) indexes.push(index)
|
||||
return indexes
|
||||
}, [])
|
||||
}, [audioInstance, mainLines])
|
||||
const tabbableLineIndex = seekableLineIndexes.includes(focusedLineIndex)
|
||||
? focusedLineIndex
|
||||
: seekableLineIndexes.includes(activeIndex)
|
||||
? activeIndex
|
||||
: seekableLineIndexes[0]
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedLineIndex(null)
|
||||
}, [mainLyric])
|
||||
|
||||
const trByMainIndex = useMemo(() => {
|
||||
if (!showTranslation || translationLines.length === 0) return {}
|
||||
return buildUniqueLayerMap(mainLines, translationLines)
|
||||
}, [mainLines, translationLines, showTranslation])
|
||||
|
||||
const prByMainIndex = useMemo(() => {
|
||||
if (!showPronunciation || pronunciationLines.length === 0) return {}
|
||||
return buildUniqueLayerMap(mainLines, pronunciationLines)
|
||||
}, [mainLines, pronunciationLines, showPronunciation])
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
main: theme.palette.text.primary,
|
||||
pronunciation: theme.palette.primary.main,
|
||||
}),
|
||||
[theme],
|
||||
)
|
||||
|
||||
const layerStyles = useMemo(() => {
|
||||
const styleFor = (layer) => {
|
||||
const sourceColor = colors[layer]
|
||||
if (!hasTimedMainLines) {
|
||||
return {
|
||||
opacity: 1,
|
||||
color: alpha(sourceColor, layer === 'main' ? 0.98 : 0.86),
|
||||
}
|
||||
}
|
||||
const activeAlpha = layer === 'main' ? 0.98 : 0.78
|
||||
const pronunciationFadeRatio = 0.38 / 0.78
|
||||
const idleAlpha = activeAlpha * pronunciationFadeRatio
|
||||
return {
|
||||
opacity: 1,
|
||||
color: alpha(sourceColor, idleAlpha),
|
||||
'--lyrics-active-color': alpha(sourceColor, activeAlpha),
|
||||
}
|
||||
}
|
||||
return {
|
||||
main: styleFor('main'),
|
||||
pronunciation: styleFor('pronunciation'),
|
||||
translation: {
|
||||
color: theme.palette.text.disabled,
|
||||
'--lyrics-active-color': theme.palette.text.secondary,
|
||||
},
|
||||
}
|
||||
}, [colors, hasTimedMainLines, theme])
|
||||
|
||||
const showScrollbarForManualScroll = useCallback(() => {
|
||||
if (scrollbarTimerRef.current) {
|
||||
window.clearTimeout(scrollbarTimerRef.current)
|
||||
}
|
||||
setShowScrollbar(true)
|
||||
scrollbarTimerRef.current = window.setTimeout(() => {
|
||||
setShowScrollbar(false)
|
||||
scrollbarTimerRef.current = null
|
||||
}, KARAOKE_SCROLLBAR_VISIBLE_MS)
|
||||
}, [])
|
||||
|
||||
const clearManualScrollTimer = useCallback(() => {
|
||||
if (manualScrollTimerRef.current) {
|
||||
window.clearTimeout(manualScrollTimerRef.current)
|
||||
manualScrollTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resumeAutoScroll = useCallback(() => {
|
||||
clearManualScrollTimer()
|
||||
manualScrollUntilRef.current = 0
|
||||
setAutoScrollResumeKey((current) => current + 1)
|
||||
}, [clearManualScrollTimer])
|
||||
|
||||
const markManualScrollIntent = useCallback(() => {
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
clearManualScrollTimer()
|
||||
manualScrollUntilRef.current =
|
||||
performance.now() + KARAOKE_MANUAL_SCROLL_PAUSE_MS
|
||||
manualScrollTimerRef.current = window.setTimeout(() => {
|
||||
manualScrollTimerRef.current = null
|
||||
resumeAutoScroll()
|
||||
}, KARAOKE_MANUAL_SCROLL_PAUSE_MS)
|
||||
showScrollbarForManualScroll()
|
||||
}, [clearManualScrollTimer, resumeAutoScroll, showScrollbarForManualScroll])
|
||||
|
||||
const updateTopFade = useCallback(() => {
|
||||
const body = bodyRef.current
|
||||
setHasTopFade((current) => {
|
||||
const next = Boolean(body && body.scrollTop > 1)
|
||||
return current === next ? current : next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (scrollbarTimerRef.current) {
|
||||
window.clearTimeout(scrollbarTimerRef.current)
|
||||
}
|
||||
if (manualScrollTimerRef.current) {
|
||||
window.clearTimeout(manualScrollTimerRef.current)
|
||||
}
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (!visible || !body) return undefined
|
||||
const ResizeObserverConstructor =
|
||||
typeof window !== 'undefined' ? window.ResizeObserver : null
|
||||
if (!ResizeObserverConstructor) return undefined
|
||||
const resizeObserver = new ResizeObserverConstructor(() => {
|
||||
setLayoutVersion((current) => current + 1)
|
||||
})
|
||||
resizeObserver.observe(body)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [visible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (!visible || !body) {
|
||||
setScrollEndPadding(0)
|
||||
setScrollStartPadding(0)
|
||||
return
|
||||
}
|
||||
const nextEndPadding = getScrollEndPadding(body, activeLineAnchorRatio)
|
||||
setScrollEndPadding((current) =>
|
||||
current === nextEndPadding ? current : nextEndPadding,
|
||||
)
|
||||
if (inline) {
|
||||
const nextStartPadding = Math.round(
|
||||
body.clientHeight * activeLineAnchorRatio,
|
||||
)
|
||||
setScrollStartPadding((current) =>
|
||||
current === nextStartPadding ? current : nextStartPadding,
|
||||
)
|
||||
} else {
|
||||
setScrollStartPadding(0)
|
||||
}
|
||||
}, [activeLineAnchorRatio, inline, layoutVersion, mainLines.length, visible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (!visible || !body) return
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
clearManualScrollTimer()
|
||||
manualScrollUntilRef.current = 0
|
||||
body.scrollTop = 0
|
||||
setHasTopFade(false)
|
||||
}, [clearManualScrollTimer, mainLyric, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!timelineVisible || !hasTimedMainLines || scrollTargetIndex < 0) {
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const animFrameId = window.requestAnimationFrame(() => {
|
||||
if (manualScrollUntilRef.current > performance.now()) return
|
||||
const body = bodyRef.current
|
||||
const targetNode = getLineNode(scrollTargetIndex)
|
||||
if (!body || !targetNode) return
|
||||
animateScrollTop({
|
||||
body,
|
||||
targetTop: getAnchoredScrollTop(
|
||||
body,
|
||||
targetNode,
|
||||
activeLineAnchorRatio,
|
||||
),
|
||||
reducedMotion: prefersReducedMotion,
|
||||
scrollAnimationRef,
|
||||
})
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(animFrameId)
|
||||
}, [
|
||||
activeLineAnchorRatio,
|
||||
autoScrollResumeKey,
|
||||
getLineNode,
|
||||
hasTimedMainLines,
|
||||
layoutVersion,
|
||||
prefersReducedMotion,
|
||||
scrollTargetIndex,
|
||||
timelineVisible,
|
||||
])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
if (!hasStructuredLyricContent(mainLyric) || mainLines.length === 0) {
|
||||
const message = loading
|
||||
? labels.loading || 'Loading lyrics'
|
||||
: error
|
||||
? errorMessage || labels.unavailable || 'Lyrics unavailable'
|
||||
: labels.empty || 'No lyrics available'
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.root, { [classes.inlineRoot]: inline })}
|
||||
data-testid="karaoke-lyrics-panel"
|
||||
onClick={inline ? (event) => event.stopPropagation() : undefined}
|
||||
>
|
||||
<div
|
||||
className={classes.emptyState}
|
||||
data-testid="lyrics-empty-state"
|
||||
aria-live="polite"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const seekToLine = (line) => {
|
||||
if (!audioInstance || line.start == null) return
|
||||
audioInstance.currentTime = line.start / 1000
|
||||
syncNow(line.start, true)
|
||||
resumeAutoScroll()
|
||||
}
|
||||
|
||||
const focusSeekableLine = (index) => {
|
||||
setFocusedLineIndex(index)
|
||||
getLineNode(index)?.focus()
|
||||
}
|
||||
|
||||
const handleLineKeyDown = (event, index, line) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
seekToLine(line)
|
||||
return
|
||||
}
|
||||
|
||||
const currentPosition = seekableLineIndexes.indexOf(index)
|
||||
if (currentPosition < 0) return
|
||||
let nextPosition = null
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
nextPosition = Math.min(
|
||||
currentPosition + 1,
|
||||
seekableLineIndexes.length - 1,
|
||||
)
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
nextPosition = Math.max(currentPosition - 1, 0)
|
||||
} else if (event.key === 'Home') {
|
||||
nextPosition = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextPosition = seekableLineIndexes.length - 1
|
||||
}
|
||||
if (nextPosition == null) return
|
||||
event.preventDefault()
|
||||
focusSeekableLine(seekableLineIndexes[nextPosition])
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.root, { [classes.inlineRoot]: inline })}
|
||||
data-testid="karaoke-lyrics-panel"
|
||||
onClick={inline ? (event) => event.stopPropagation() : undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx(classes.body, {
|
||||
[classes.inlineBody]: inline,
|
||||
[classes.bodyTopFade]: hasTopFade,
|
||||
[classes.bodyUserScrolling]: showScrollbar,
|
||||
})}
|
||||
ref={bodyRef}
|
||||
data-testid="lyrics-scroll-body"
|
||||
data-reduced-motion={prefersReducedMotion ? 'true' : 'false'}
|
||||
data-scrollbar-visible={showScrollbar ? 'true' : 'false'}
|
||||
data-top-fade-enabled={hasTopFade ? 'true' : 'false'}
|
||||
onScroll={updateTopFade}
|
||||
onWheel={markManualScrollIntent}
|
||||
onTouchMove={markManualScrollIntent}
|
||||
>
|
||||
<div
|
||||
className={classes.lines}
|
||||
data-scroll-end-padding={scrollEndPadding}
|
||||
style={
|
||||
scrollEndPadding > 0 || scrollStartPadding > 0
|
||||
? {
|
||||
paddingTop: scrollStartPadding || undefined,
|
||||
paddingBottom: scrollEndPadding || undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{mainLines.map((line, idx) => {
|
||||
if (line.renderable === false) return null
|
||||
const trLine = trByMainIndex[idx]
|
||||
const prLine = prByMainIndex[idx]
|
||||
const mainNextLineStart =
|
||||
timeline.windows[idx]?.nextTimedStart ?? null
|
||||
const showTr = shouldShowAuxLine(line, trLine)
|
||||
const showPr = shouldShowAuxLine(line, prLine)
|
||||
const lineLanes = getLineLanes(line)
|
||||
const usesCharacterRise = lineLanes.some(
|
||||
(lane) => Array.isArray(lane?.tokens) && lane.tokens.length > 0,
|
||||
)
|
||||
const canSeekLine = Boolean(audioInstance && line.start != null)
|
||||
const isActiveLine = activeIndexSet.has(idx)
|
||||
const renderCharacterWave = Boolean(
|
||||
!prefersReducedMotion &&
|
||||
hasTimedMainLines &&
|
||||
usesCharacterRise &&
|
||||
isActiveLine,
|
||||
)
|
||||
const isStaticLine = !hasTimedMainLines
|
||||
return (
|
||||
<div
|
||||
key={`line-${line.index}-${line.start ?? idx}`}
|
||||
ref={
|
||||
hasTimedMainLines
|
||||
? (node) => registerLine(idx, node)
|
||||
: undefined
|
||||
}
|
||||
className={classes.lineGroup}
|
||||
data-line-motion={usesCharacterRise ? 'character' : 'line'}
|
||||
data-character-wave={renderCharacterWave ? 'true' : 'false'}
|
||||
data-active={isStaticLine || isActiveLine ? 'true' : 'false'}
|
||||
{...(isStaticLine
|
||||
? {
|
||||
'data-lifecycle': 'active',
|
||||
'data-highlight-active': 'true',
|
||||
'data-raised': 'false',
|
||||
}
|
||||
: {})}
|
||||
aria-current={idx === activeIndex ? 'true' : undefined}
|
||||
data-scroll-target={
|
||||
idx === scrollTargetIndex ? 'true' : 'false'
|
||||
}
|
||||
data-testid="lyrics-line-group"
|
||||
style={buildLineGroupStyle(canSeekLine, layerStyles)}
|
||||
role={canSeekLine ? 'button' : undefined}
|
||||
tabIndex={
|
||||
canSeekLine ? (idx === tabbableLineIndex ? 0 : -1) : undefined
|
||||
}
|
||||
onClick={() => seekToLine(line)}
|
||||
onFocus={
|
||||
canSeekLine ? () => setFocusedLineIndex(idx) : undefined
|
||||
}
|
||||
onBlur={
|
||||
canSeekLine
|
||||
? (event) => {
|
||||
if (!bodyRef.current?.contains(event.relatedTarget)) {
|
||||
setFocusedLineIndex(null)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={
|
||||
canSeekLine
|
||||
? (event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
canSeekLine
|
||||
? (event) => handleLineKeyDown(event, idx, line)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{lineLanes.length > 1 ? (
|
||||
<div
|
||||
className={classes.voiceLanes}
|
||||
data-testid="lyrics-voice-lanes"
|
||||
>
|
||||
{lineLanes.map((lane, laneIdx) => {
|
||||
const laneClassName = clsx(classes.line, {
|
||||
[classes.secondaryVoiceLane]: laneIdx > 0,
|
||||
})
|
||||
const rowKey = lane.key || `lane-${laneIdx}`
|
||||
|
||||
return showPr && laneIdx === 0 ? (
|
||||
<KaraokeStackedLineRow
|
||||
key={rowKey}
|
||||
lineIndex={idx}
|
||||
line={lane}
|
||||
pronunciationLine={prLine}
|
||||
pronunciationStyle={layerStyles.pronunciation}
|
||||
nextLineStart={mainNextLineStart}
|
||||
className={laneClassName}
|
||||
style={layerStyles.main}
|
||||
tokenClassName={classes.token}
|
||||
waveCharacterClassName={classes.waveCharacter}
|
||||
classes={classes}
|
||||
registerToken={registerToken}
|
||||
renderCharacterWave={renderCharacterWave}
|
||||
rowKey={rowKey}
|
||||
testId="lyrics-voice-lane"
|
||||
/>
|
||||
) : (
|
||||
<KaraokeLineRow
|
||||
key={rowKey}
|
||||
lineIndex={idx}
|
||||
line={lane}
|
||||
nextLineStart={mainNextLineStart}
|
||||
className={laneClassName}
|
||||
style={layerStyles.main}
|
||||
tokenClassName={classes.token}
|
||||
waveCharacterClassName={classes.waveCharacter}
|
||||
registerToken={registerToken}
|
||||
renderCharacterWave={renderCharacterWave}
|
||||
rowKey={rowKey}
|
||||
testId="lyrics-voice-lane"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : showPr ? (
|
||||
<KaraokeStackedLineRow
|
||||
lineIndex={idx}
|
||||
line={line}
|
||||
pronunciationLine={prLine}
|
||||
pronunciationStyle={layerStyles.pronunciation}
|
||||
nextLineStart={mainNextLineStart}
|
||||
className={classes.line}
|
||||
style={layerStyles.main}
|
||||
tokenClassName={classes.token}
|
||||
waveCharacterClassName={classes.waveCharacter}
|
||||
classes={classes}
|
||||
registerToken={registerToken}
|
||||
renderCharacterWave={renderCharacterWave}
|
||||
rowKey="main"
|
||||
/>
|
||||
) : (
|
||||
<KaraokeLineRow
|
||||
lineIndex={idx}
|
||||
line={line}
|
||||
nextLineStart={mainNextLineStart}
|
||||
className={classes.line}
|
||||
style={layerStyles.main}
|
||||
tokenClassName={classes.token}
|
||||
waveCharacterClassName={classes.waveCharacter}
|
||||
registerToken={registerToken}
|
||||
renderCharacterWave={renderCharacterWave}
|
||||
rowKey="main"
|
||||
/>
|
||||
)}
|
||||
{showTr && (
|
||||
<KaraokeLineRow
|
||||
lineIndex={idx}
|
||||
line={buildStaticTranslationLine(trLine)}
|
||||
nextLineStart={null}
|
||||
className={classes.translationLine}
|
||||
tokenClassName={classes.token}
|
||||
rowKey="translation"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsPanel
|
||||
1009
ui/src/audioplayer/LyricsPanel.test.jsx
Normal file
1009
ui/src/audioplayer/LyricsPanel.test.jsx
Normal file
File diff suppressed because it is too large
Load Diff
306
ui/src/audioplayer/LyricsSidebar.jsx
Normal file
306
ui/src/audioplayer/LyricsSidebar.jsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { alpha, makeStyles } from '@material-ui/core/styles'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import {
|
||||
LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
LYRICS_SIDEBAR_WIDTH_STEP,
|
||||
clampSidebarMaxWidth,
|
||||
clampSidebarWidth,
|
||||
} from './lyricsSidebarWidth'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
sidebar: {
|
||||
position: 'fixed',
|
||||
top: 48,
|
||||
right: 0,
|
||||
bottom: 80,
|
||||
minHeight: 0,
|
||||
minWidth: LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
maxWidth: LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
backgroundImage: 'none',
|
||||
borderLeft: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||
boxShadow: 'none',
|
||||
zIndex: theme.zIndex.appBar - 1,
|
||||
transition: `transform ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`,
|
||||
willChange: 'transform, opacity',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
},
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover $resizer::after': {
|
||||
background: alpha(theme.palette.primary.main, 0.32),
|
||||
},
|
||||
},
|
||||
},
|
||||
resizer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: -6,
|
||||
width: 12,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
cursor: 'ew-resize',
|
||||
zIndex: 3,
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 5,
|
||||
width: 2,
|
||||
background: 'transparent',
|
||||
transition: 'background 160ms ease',
|
||||
},
|
||||
'&:focus::after': {
|
||||
background: alpha(theme.palette.primary.main, 0.48),
|
||||
},
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover::after': {
|
||||
background: alpha(theme.palette.primary.main, 0.48),
|
||||
},
|
||||
},
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
},
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const LyricsSidebar = ({
|
||||
visible,
|
||||
obscuredByQueue = false,
|
||||
width = LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
onWidthChange = noop,
|
||||
mainLyric,
|
||||
translationLyric,
|
||||
pronunciationLyric,
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
translationEnabled,
|
||||
pronunciationEnabled,
|
||||
source,
|
||||
onToggleTranslation,
|
||||
onTogglePronunciation,
|
||||
audioInstance,
|
||||
loading = false,
|
||||
error = null,
|
||||
errorMessage = null,
|
||||
labels = {},
|
||||
returnFocusRef,
|
||||
}) => {
|
||||
const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
|
||||
const resolvedWidth = clampSidebarWidth(width, resolvedMaxWidth)
|
||||
const sidebarRef = useRef(null)
|
||||
const resizerRef = useRef(null)
|
||||
const widthRef = useRef(resolvedWidth)
|
||||
const resizeCleanupRef = useRef(null)
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
visible,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
)
|
||||
const interactive = entered && !obscuredByQueue
|
||||
const classes = useStyles()
|
||||
|
||||
useRestoreFocusOnExit({
|
||||
surfaceRef: sidebarRef,
|
||||
entered: interactive,
|
||||
returnFocusRef,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
widthRef.current = resolvedWidth
|
||||
}, [resolvedWidth])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
resizeCleanupRef.current?.()
|
||||
resizeCleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const applyWidth = useCallback(
|
||||
(next, { persist = false } = {}) => {
|
||||
const nextWidth = clampSidebarWidth(
|
||||
typeof next === 'function' ? next(widthRef.current) : next,
|
||||
resolvedMaxWidth,
|
||||
)
|
||||
widthRef.current = nextWidth
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.width = `${nextWidth}px`
|
||||
}
|
||||
resizerRef.current?.setAttribute('aria-valuenow', String(nextWidth))
|
||||
onWidthChange(nextWidth, { persist })
|
||||
return nextWidth
|
||||
},
|
||||
[onWidthChange, resolvedMaxWidth],
|
||||
)
|
||||
|
||||
const handleResizePointerDown = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
resizeCleanupRef.current?.()
|
||||
|
||||
const target = event.currentTarget
|
||||
const pointerId = event.pointerId
|
||||
const startX = event.clientX
|
||||
const startWidth = widthRef.current
|
||||
let latestWidth = startWidth
|
||||
let cleanedUp = false
|
||||
const canCapture =
|
||||
pointerId != null && typeof target.setPointerCapture === 'function'
|
||||
const listenerTarget = window
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
latestWidth = applyWidth(startWidth + startX - moveEvent.clientX)
|
||||
}
|
||||
|
||||
const cleanupResize = ({ persist = false } = {}) => {
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
listenerTarget.removeEventListener('pointermove', handlePointerMove)
|
||||
listenerTarget.removeEventListener('pointerup', handlePointerUp)
|
||||
listenerTarget.removeEventListener('pointercancel', handlePointerCancel)
|
||||
target.removeEventListener('lostpointercapture', handlePointerCancel)
|
||||
if (
|
||||
canCapture &&
|
||||
typeof target.releasePointerCapture === 'function' &&
|
||||
(!target.hasPointerCapture || target.hasPointerCapture(pointerId))
|
||||
) {
|
||||
try {
|
||||
target.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// Ignore stale pointer capture state.
|
||||
}
|
||||
}
|
||||
if (persist) onWidthChange(latestWidth, { persist: true })
|
||||
if (resizeCleanupRef.current === cleanupResize) {
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = () => cleanupResize({ persist: true })
|
||||
const handlePointerCancel = () => cleanupResize()
|
||||
|
||||
resizeCleanupRef.current = cleanupResize
|
||||
listenerTarget.addEventListener('pointermove', handlePointerMove)
|
||||
listenerTarget.addEventListener('pointerup', handlePointerUp)
|
||||
listenerTarget.addEventListener('pointercancel', handlePointerCancel)
|
||||
target.addEventListener('lostpointercapture', handlePointerCancel)
|
||||
if (canCapture) {
|
||||
try {
|
||||
target.setPointerCapture(pointerId)
|
||||
} catch {
|
||||
// Window-level listeners keep resize working when capture is unavailable.
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyWidth, onWidthChange],
|
||||
)
|
||||
|
||||
const handleResizeKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
applyWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
persist: true,
|
||||
})
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
applyWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
persist: true,
|
||||
})
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
applyWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
applyWidth(resolvedMaxWidth, { persist: true })
|
||||
}
|
||||
},
|
||||
[applyWidth, resolvedMaxWidth],
|
||||
)
|
||||
|
||||
if (!rendered) return null
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={classes.sidebar}
|
||||
data-testid="lyrics-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{
|
||||
width: resolvedWidth,
|
||||
transform: entered ? 'translateX(0)' : 'translateX(100%)',
|
||||
opacity: entered ? 1 : 0,
|
||||
pointerEvents: interactive ? 'auto' : 'none',
|
||||
}}
|
||||
aria-label={labels.title || 'Lyrics'}
|
||||
aria-hidden={!interactive}
|
||||
inert={interactive ? undefined : ''}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={resizerRef}
|
||||
className={classes.resizer}
|
||||
data-testid="lyrics-sidebar-resizer"
|
||||
aria-label={labels.resize || 'Resize lyrics sidebar'}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={LYRICS_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={resolvedMaxWidth}
|
||||
aria-valuenow={resolvedWidth}
|
||||
role="separator"
|
||||
onPointerDown={handleResizePointerDown}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
/>
|
||||
<div className={classes.panel}>
|
||||
<LyricsLayerControls
|
||||
showPronunciation={showPronunciation}
|
||||
showTranslation={showTranslation}
|
||||
pronunciationEnabled={pronunciationEnabled}
|
||||
translationEnabled={translationEnabled}
|
||||
onTogglePronunciation={onTogglePronunciation}
|
||||
onToggleTranslation={onToggleTranslation}
|
||||
source={source}
|
||||
active={interactive}
|
||||
labels={labels}
|
||||
testId="lyrics-sidebar-floating-controls"
|
||||
/>
|
||||
<LyricsPanel
|
||||
visible={visible}
|
||||
obscured={obscuredByQueue}
|
||||
mainLyric={mainLyric}
|
||||
translationLyric={translationLyric}
|
||||
pronunciationLyric={pronunciationLyric}
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
audioInstance={audioInstance}
|
||||
loading={loading}
|
||||
error={error}
|
||||
errorMessage={errorMessage}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsSidebar
|
||||
400
ui/src/audioplayer/LyricsSidebar.test.jsx
Normal file
400
ui/src/audioplayer/LyricsSidebar.test.jsx
Normal file
@ -0,0 +1,400 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import LyricsSidebar from './LyricsSidebar'
|
||||
import {
|
||||
LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
} from './lyricsSidebarWidth'
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#35aa66',
|
||||
},
|
||||
background: {
|
||||
default: '#101820',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
},
|
||||
})
|
||||
const lyric = {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Main line' }],
|
||||
}
|
||||
const source = {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'ttml',
|
||||
format: 'ttml',
|
||||
}
|
||||
const originalPointerEvent = window.PointerEvent
|
||||
|
||||
const ControlledLyricsSidebar = ({
|
||||
initialWidth = LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
onWidthChange,
|
||||
...props
|
||||
}) => {
|
||||
const [width, setWidth] = useState(initialWidth)
|
||||
const handleWidthChange = useCallback(
|
||||
(nextWidth, options) => {
|
||||
setWidth(nextWidth)
|
||||
onWidthChange?.(nextWidth, options)
|
||||
},
|
||||
[onWidthChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<LyricsSidebar
|
||||
width={width}
|
||||
maxWidth={maxWidth}
|
||||
onWidthChange={handleWidthChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarView = (props = {}) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<ControlledLyricsSidebar
|
||||
visible
|
||||
mainLyric={lyric}
|
||||
showTranslation
|
||||
showPronunciation
|
||||
translationEnabled
|
||||
pronunciationEnabled
|
||||
onToggleTranslation={vi.fn()}
|
||||
onTogglePronunciation={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
const renderSidebar = (props) => render(sidebarView(props))
|
||||
|
||||
describe('<LyricsSidebar />', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
document.body.className = ''
|
||||
document
|
||||
.querySelectorAll('[data-lyrics-return-target]')
|
||||
.forEach((node) => node.remove())
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalPointerEvent,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders as an embedded sidebar without global layout side effects', () => {
|
||||
renderSidebar()
|
||||
|
||||
expect(document.body.className).toBe('')
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
expect(sidebar).toHaveStyle({
|
||||
transform: 'translateX(0)',
|
||||
opacity: '1',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the sidebar mounted while sliding closed', () => {
|
||||
vi.useFakeTimers()
|
||||
const returnTarget = document.createElement('button')
|
||||
returnTarget.dataset.lyricsReturnTarget = 'true'
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { rerender } = renderSidebar({ returnFocusRef })
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
expect(sidebar).not.toHaveAttribute('inert')
|
||||
resizer.focus()
|
||||
|
||||
rerender(sidebarView({ visible: false, returnFocusRef }))
|
||||
|
||||
expect(sidebar).toHaveStyle({
|
||||
transform: 'translateX(100%)',
|
||||
opacity: '0',
|
||||
})
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
expect(document.body.className).toBe('')
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
|
||||
vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
|
||||
|
||||
expect(screen.queryByTestId('lyrics-sidebar')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('restores focus if a breakpoint unmounts the open sidebar', () => {
|
||||
const returnTarget = document.createElement('button')
|
||||
returnTarget.dataset.lyricsReturnTarget = 'true'
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { unmount } = renderSidebar({ returnFocusRef })
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
resizer.focus()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
})
|
||||
|
||||
it('keeps lyrics mounted but noninteractive while the queue obscures them', () => {
|
||||
const returnTarget = document.createElement('button')
|
||||
returnTarget.dataset.lyricsReturnTarget = 'true'
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { rerender } = renderSidebar({ returnFocusRef })
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const panel = screen.getByTestId('karaoke-lyrics-panel')
|
||||
screen.getByTestId('lyrics-sidebar-resizer').focus()
|
||||
|
||||
rerender(
|
||||
sidebarView({
|
||||
obscuredByQueue: true,
|
||||
returnFocusRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
expect(sidebar).toHaveStyle({
|
||||
transform: 'translateX(0)',
|
||||
opacity: '1',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
|
||||
rerender(sidebarView({ returnFocusRef }))
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
expect(sidebar).toHaveStyle({ pointerEvents: 'auto' })
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'false')
|
||||
expect(sidebar).not.toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('closes the source popover when the queue obscures the sidebar', async () => {
|
||||
const { rerender } = renderSidebar({ source })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View lyrics source' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Lyrics source' })).toBeVisible()
|
||||
|
||||
rerender(sidebarView({ source, obscuredByQueue: true }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Lyrics source' }),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps controlled keyboard resizing to the available width', () => {
|
||||
const onWidthChange = vi.fn()
|
||||
renderSidebar({ initialWidth: 999, maxWidth: 435, onWidthChange })
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: '435px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '435')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'Home' })
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` })
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
|
||||
persist: true,
|
||||
})
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'End' })
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: '435px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: true })
|
||||
})
|
||||
|
||||
it('clamps pointer resizing from the left separator', async () => {
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
const onWidthChange = vi.fn()
|
||||
renderSidebar({ maxWidth: 435, onWidthChange })
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
resizer.setPointerCapture = vi.fn(() => {
|
||||
throw new Error('pointer capture unavailable')
|
||||
})
|
||||
resizer.releasePointerCapture = vi.fn()
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
|
||||
await waitFor(() => expect(sidebar).toHaveStyle({ width: '435px' }))
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '435')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: false })
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointerup'))
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
|
||||
persist: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the live pointer width through an unrelated rerender', async () => {
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
const { rerender } = renderSidebar()
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 450 }))
|
||||
await waitFor(() => expect(sidebar).toHaveStyle({ width: '410px' }))
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '410')
|
||||
|
||||
rerender(sidebarView({ labels: { title: 'Updated lyrics' } }))
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ width: '410px' })
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
'410',
|
||||
)
|
||||
window.dispatchEvent(new MouseEvent('pointercancel'))
|
||||
})
|
||||
|
||||
it('cleans pointer resizing on cancellation and unmount', async () => {
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
const onWidthChange = vi.fn()
|
||||
const { unmount } = renderSidebar({ onWidthChange })
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointercancel'))
|
||||
const callsAfterCancel = onWidthChange.mock.calls.length
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(onWidthChange).toHaveBeenCalledTimes(callsAfterCancel)
|
||||
expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
|
||||
persist: true,
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }),
|
||||
)
|
||||
|
||||
unmount()
|
||||
const callsBeforePointerUp = onWidthChange.mock.calls.length
|
||||
window.dispatchEvent(new MouseEvent('pointerup'))
|
||||
|
||||
expect(onWidthChange).toHaveBeenCalledTimes(callsBeforePointerUp)
|
||||
expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
|
||||
persist: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses icon toggle buttons with pressed and disabled states', () => {
|
||||
const onTogglePronunciation = vi.fn()
|
||||
const onToggleTranslation = vi.fn()
|
||||
renderSidebar({
|
||||
showPronunciation: true,
|
||||
showTranslation: false,
|
||||
pronunciationEnabled: true,
|
||||
translationEnabled: false,
|
||||
onTogglePronunciation,
|
||||
onToggleTranslation,
|
||||
labels: {
|
||||
title: 'Song words',
|
||||
resize: 'Resize song words',
|
||||
showTranslation: 'Reveal translation',
|
||||
hidePronunciation: 'Conceal pronunciation',
|
||||
},
|
||||
})
|
||||
|
||||
const pronunciation = screen.getByTestId('toggle-pronunciation-button')
|
||||
const translation = screen.getByTestId('toggle-translation-button')
|
||||
expect(screen.getByLabelText('Song words')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Resize song words')).toBeInTheDocument()
|
||||
expect(pronunciation).toHaveAttribute('aria-label', 'Conceal pronunciation')
|
||||
expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(pronunciation.className).toContain('controlActive')
|
||||
fireEvent.click(pronunciation)
|
||||
expect(onTogglePronunciation).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(translation).toHaveAttribute('aria-label', 'Reveal translation')
|
||||
expect(translation).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(translation).toBeDisabled()
|
||||
expect(translation.className).not.toContain('controlActive')
|
||||
fireEvent.click(translation)
|
||||
expect(onToggleTranslation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays mounted without re-entering when an open sidebar receives no lyrics', () => {
|
||||
const { rerender } = renderSidebar()
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
expect(sidebar).toHaveStyle({ transform: 'translateX(0)' })
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
sidebarView({
|
||||
mainLyric: null,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
|
||||
transform: 'translateX(0)',
|
||||
})
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent(
|
||||
'No lyrics available',
|
||||
)
|
||||
expect(screen.queryByTestId('lyrics-line-group')).toBeNull()
|
||||
expect(screen.getByTestId('toggle-pronunciation-button')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-translation-button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
90
ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
Normal file
90
ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
|
||||
|
||||
export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
|
||||
'.react-jinke-music-player-mobile-cover'
|
||||
export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active'
|
||||
export const MOBILE_KARAOKE_LYRICS_ENTERED_CLASS = 'nd-mobile-lyrics-entered'
|
||||
export const MOBILE_KARAOKE_LYRICS_LAYER_CLASS = 'nd-mobile-lyrics-layer'
|
||||
|
||||
const resolveMobileLyricsHost = () => {
|
||||
if (typeof document === 'undefined') return null
|
||||
return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
|
||||
}
|
||||
|
||||
const MobileKaraokeLyricsPortal = ({
|
||||
active,
|
||||
children,
|
||||
obscured = false,
|
||||
returnFocusRef,
|
||||
}) => {
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
active,
|
||||
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
|
||||
)
|
||||
const [host, setHost] = useState(() =>
|
||||
active ? resolveMobileLyricsHost() : null,
|
||||
)
|
||||
const layerRef = useRef(null)
|
||||
const interactive = entered && !obscured
|
||||
|
||||
useRestoreFocusOnExit({
|
||||
surfaceRef: layerRef,
|
||||
entered: interactive,
|
||||
returnFocusRef,
|
||||
surfaceKey: host,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
setHost(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!rendered) {
|
||||
setHost(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
setHost(resolveMobileLyricsHost())
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const nextHost = resolveMobileLyricsHost()
|
||||
setHost((current) => (current === nextHost ? current : nextHost))
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [rendered])
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) return undefined
|
||||
|
||||
host.classList.toggle(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, rendered)
|
||||
host.classList.toggle(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS, entered)
|
||||
return () => {
|
||||
host.classList.remove(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
host.classList.remove(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
}
|
||||
}, [entered, host, rendered])
|
||||
|
||||
if (!rendered || !host) return null
|
||||
return createPortal(
|
||||
<div
|
||||
className={MOBILE_KARAOKE_LYRICS_LAYER_CLASS}
|
||||
ref={layerRef}
|
||||
data-entered={entered ? 'true' : 'false'}
|
||||
aria-hidden={!interactive}
|
||||
inert={interactive ? undefined : ''}
|
||||
style={{ pointerEvents: interactive ? 'auto' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
host,
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileKaraokeLyricsPortal
|
||||
380
ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
Normal file
380
ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
Normal file
@ -0,0 +1,380 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import MobileKaraokeLyricsPortal, {
|
||||
MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_ENTERED_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_HOST_SELECTOR,
|
||||
MOBILE_KARAOKE_LYRICS_LAYER_CLASS,
|
||||
} from './MobileKaraokeLyricsPortal'
|
||||
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
|
||||
const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
|
||||
const defaultLyricsResponse = {
|
||||
layers: {
|
||||
main: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Persistent panel lyrics' }],
|
||||
},
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
}
|
||||
return {
|
||||
defaultLyricsResponse,
|
||||
useEnhancedLyricsMock: vi.fn(() => defaultLyricsResponse),
|
||||
}
|
||||
})
|
||||
|
||||
const sourcedLyricsResponse = {
|
||||
...defaultLyricsResponse,
|
||||
layers: {
|
||||
...defaultLyricsResponse.layers,
|
||||
main: {
|
||||
...defaultLyricsResponse.layers.main,
|
||||
source: {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'ttml',
|
||||
format: 'ttml',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('./useEnhancedLyrics', () => ({
|
||||
default: useEnhancedLyricsMock,
|
||||
}))
|
||||
|
||||
const createHost = () => {
|
||||
const host = document.createElement('div')
|
||||
host.className = MOBILE_KARAOKE_LYRICS_HOST_SELECTOR.slice(1)
|
||||
document.body.appendChild(host)
|
||||
return host
|
||||
}
|
||||
|
||||
const portal = (text, active = true, returnFocusRef, obscured = false) => (
|
||||
<MobileKaraokeLyricsPortal
|
||||
active={active}
|
||||
obscured={obscured}
|
||||
returnFocusRef={returnFocusRef}
|
||||
>
|
||||
<span>{text}</span>
|
||||
</MobileKaraokeLyricsPortal>
|
||||
)
|
||||
|
||||
const MobileLyricsHarness = ({
|
||||
trackId = 'track-1',
|
||||
isRadio = false,
|
||||
obscuredByQueue = false,
|
||||
}) => {
|
||||
const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({
|
||||
trackId,
|
||||
trackUpdatedAt: 'now',
|
||||
isRadio,
|
||||
audioInstance: null,
|
||||
isDesktop: false,
|
||||
obscuredByQueue,
|
||||
translate: (key) => key,
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toolbarLyricsProps.onToggleLyrics}
|
||||
disabled={toolbarLyricsProps.lyricsDisabled}
|
||||
>
|
||||
Toggle lyrics
|
||||
</button>
|
||||
{mobileLyricsSurface}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<MobileKaraokeLyricsPortal />', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
useEnhancedLyricsMock.mockReset()
|
||||
useEnhancedLyricsMock.mockImplementation(() => defaultLyricsResponse)
|
||||
cleanup()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('mounts active lyrics into the mobile cover host and cleans up the class', () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const { rerender } = render(portal('Inline lyrics'))
|
||||
|
||||
expect(within(host).getByText('Inline lyrics')).toBeInTheDocument()
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveAttribute('data-entered', 'true')
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'auto' })
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).not.toHaveAttribute('inert')
|
||||
|
||||
rerender(portal('Inline lyrics', false))
|
||||
|
||||
expect(within(host).getByText('Inline lyrics')).toBeInTheDocument()
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveAttribute('data-entered', 'false')
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'none' })
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveAttribute('inert')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Inline lyrics')).not.toBeInTheDocument()
|
||||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
})
|
||||
|
||||
it('makes the exiting layer inert and returns focus to the lyrics toggle', () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const returnTarget = document.createElement('button')
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { rerender } = render(
|
||||
<MobileKaraokeLyricsPortal active returnFocusRef={returnFocusRef}>
|
||||
<button type="button">Layer control</button>
|
||||
</MobileKaraokeLyricsPortal>,
|
||||
)
|
||||
const layerControl = within(host).getByRole('button', {
|
||||
name: 'Layer control',
|
||||
})
|
||||
layerControl.focus()
|
||||
|
||||
rerender(
|
||||
<MobileKaraokeLyricsPortal active={false} returnFocusRef={returnFocusRef}>
|
||||
<button type="button">Layer control</button>
|
||||
</MobileKaraokeLyricsPortal>,
|
||||
)
|
||||
|
||||
const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
|
||||
expect(layer).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(layer).toHaveAttribute('inert')
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
})
|
||||
|
||||
it('keeps open lyrics mounted and inert while the queue obscures them', async () => {
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
|
||||
const panel = within(host).getByTestId('karaoke-lyrics-panel')
|
||||
await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
|
||||
|
||||
rerender(<MobileLyricsHarness obscuredByQueue />)
|
||||
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
expect(host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)).toBe(
|
||||
layer,
|
||||
)
|
||||
expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
expect(layer).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(layer).toHaveAttribute('inert')
|
||||
expect(layer).toHaveStyle({ pointerEvents: 'none' })
|
||||
expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ requested: true }),
|
||||
)
|
||||
|
||||
rerender(<MobileLyricsHarness />)
|
||||
|
||||
expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
|
||||
expect(layer).not.toHaveAttribute('inert')
|
||||
expect(layer).toHaveStyle({ pointerEvents: 'auto' })
|
||||
})
|
||||
|
||||
it('closes the source popover when the queue obscures mobile lyrics', async () => {
|
||||
useEnhancedLyricsMock.mockImplementation(() => sourcedLyricsResponse)
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
const sourceButton = await within(host).findByTestId('lyrics-source-button')
|
||||
fireEvent.click(sourceButton)
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
|
||||
rerender(<MobileLyricsHarness obscuredByQueue />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches when the mobile cover host appears after activation', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(portal('Late lyrics'))
|
||||
|
||||
expect(screen.queryByText('Late lyrics')).not.toBeInTheDocument()
|
||||
|
||||
const host = createHost()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(host).getByText('Late lyrics')).toBeInTheDocument(),
|
||||
)
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
})
|
||||
|
||||
it('moves active lyrics when the mobile cover host is replaced', async () => {
|
||||
const firstHost = createHost()
|
||||
|
||||
render(portal('Persistent lyrics'))
|
||||
expect(within(firstHost).getByText('Persistent lyrics')).toBeInTheDocument()
|
||||
|
||||
const secondHost = createHost()
|
||||
firstHost.replaceWith(secondHost)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(secondHost).getByText('Persistent lyrics'),
|
||||
).toBeInTheDocument(),
|
||||
)
|
||||
expect(firstHost).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(secondHost).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
})
|
||||
|
||||
it('keeps panel contents rendered for the complete mobile exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
expect(
|
||||
within(host).getByText('Persistent panel lyrics'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
|
||||
expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ requested: false }),
|
||||
)
|
||||
rerender(<MobileLyricsHarness trackId="track-2" />)
|
||||
const trackTwoRequests = useEnhancedLyricsMock.mock.calls
|
||||
.filter(([options]) => options.trackId === 'track-2')
|
||||
.map(([options]) => options.requested)
|
||||
expect(trackTwoRequests.length).toBeGreaterThan(0)
|
||||
expect(trackTwoRequests).not.toContain(true)
|
||||
expect(
|
||||
within(host).getByText('Persistent panel lyrics'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'none' })
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByText('Persistent panel lyrics'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('centers working pronunciation and translation toggles below mobile lyrics', () => {
|
||||
useEnhancedLyricsMock.mockImplementation(() => ({
|
||||
...defaultLyricsResponse,
|
||||
layers: {
|
||||
...defaultLyricsResponse.layers,
|
||||
pronunciation: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Spoken lyrics' }],
|
||||
},
|
||||
translation: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Translated lyrics' }],
|
||||
},
|
||||
},
|
||||
}))
|
||||
const host = createHost()
|
||||
render(<MobileLyricsHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
|
||||
const controls = within(host).getByTestId('lyrics-mobile-layer-controls')
|
||||
expect(controls).toHaveStyle({
|
||||
bottom: '8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
})
|
||||
|
||||
const pronunciation = within(host).getByTestId(
|
||||
'toggle-pronunciation-button',
|
||||
)
|
||||
const translation = within(host).getByTestId('toggle-translation-button')
|
||||
expect(pronunciation).toHaveAttribute(
|
||||
'aria-label',
|
||||
'player.hideLyricsPronunciationText',
|
||||
)
|
||||
expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(translation).toHaveAttribute(
|
||||
'aria-label',
|
||||
'player.hideLyricsTranslationText',
|
||||
)
|
||||
expect(translation).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(pronunciation)
|
||||
fireEvent.click(translation)
|
||||
|
||||
expect(pronunciation).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(translation).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(within(host).queryByText('Spoken lyrics')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(host).queryByText('Translated lyrics'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps open lyrics closable after playback switches to radio', () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
const toggle = screen.getByRole('button', { name: 'Toggle lyrics' })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(
|
||||
within(host).getByText('Persistent panel lyrics'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
rerender(<MobileLyricsHarness isRadio />)
|
||||
expect(toggle).toBeEnabled()
|
||||
fireEvent.click(toggle)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
})
|
||||
expect(
|
||||
screen.queryByText('Persistent panel lyrics'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(toggle).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -34,6 +34,12 @@ import { keyMap } from '../hotkeys'
|
||||
import keyHandlers from './keyHandlers'
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
import { detectBrowserProfile, decisionService } from '../transcode'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
import { useLyricsLayout } from './LyricsLayoutContext'
|
||||
import {
|
||||
PLAYER_DESKTOP_MEDIA_QUERY,
|
||||
PLAYER_MOBILE_MATCH_MEDIA_QUERY,
|
||||
} from './playerBreakpoints'
|
||||
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
@ -48,7 +54,8 @@ const Player = () => {
|
||||
const currentTrackIdRef = useRef(null)
|
||||
const stoppedRef = useRef(false)
|
||||
const [audioInstance, setAudioInstance] = useState(null)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const [audioListsPanelVisible, setAudioListsPanelVisible] = useState(false)
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
const isMobilePlayer =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
@ -138,6 +145,33 @@ const Player = () => {
|
||||
const gainInfo = useSelector((state) => state.replayGain)
|
||||
const [context, setContext] = useState(null)
|
||||
const [gainNode, setGainNode] = useState(null)
|
||||
const { setDesktopLyricsProps } = useLyricsLayout()
|
||||
const {
|
||||
toolbarLyricsProps,
|
||||
desktopLyricsProps,
|
||||
mobileLyricsSurface,
|
||||
useInlineMobileLyrics,
|
||||
closeLyrics,
|
||||
} = usePlayerLyrics({
|
||||
trackId: playerState.current?.trackId || currentTrackId,
|
||||
trackUpdatedAt:
|
||||
playerState.current?.song?.updatedAt || playerState.current?.updatedAt,
|
||||
isRadio: playerState.current?.isRadio || false,
|
||||
audioInstance,
|
||||
isDesktop,
|
||||
obscuredByQueue: audioListsPanelVisible,
|
||||
translate,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
setDesktopLyricsProps(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
setDesktopLyricsProps(desktopLyricsProps)
|
||||
return () => setDesktopLyricsProps(null)
|
||||
}, [desktopLyricsProps, isDesktop, setDesktopLyricsProps])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -213,12 +247,13 @@ const Player = () => {
|
||||
clearPriorAudioLists: false,
|
||||
showDestroy: true,
|
||||
showDownload: false,
|
||||
showLyric: true,
|
||||
showLyric: false,
|
||||
showReload: false,
|
||||
toggleMode: !isDesktop,
|
||||
glassBg: false,
|
||||
showThemeSwitch: false,
|
||||
showMediaSession: true,
|
||||
mobileMediaQuery: PLAYER_MOBILE_MATCH_MEDIA_QUERY,
|
||||
restartCurrentOnPrev: true,
|
||||
quietUpdate: true,
|
||||
defaultPosition: {
|
||||
@ -251,12 +286,16 @@ const Player = () => {
|
||||
(playerState.clear || playerState.playIndex === 0),
|
||||
clearPriorAudioLists: playerState.clear,
|
||||
extendsContent: (
|
||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
||||
<PlayerToolbar
|
||||
id={current.trackId}
|
||||
isRadio={current.isRadio}
|
||||
{...toolbarLyricsProps}
|
||||
/>
|
||||
),
|
||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
||||
showMediaSession: !current.isRadio,
|
||||
}
|
||||
}, [playerState, defaultOptions, isMobilePlayer])
|
||||
}, [playerState, defaultOptions, isMobilePlayer, toolbarLyricsProps])
|
||||
|
||||
const onAudioListsChange = useCallback(
|
||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
||||
@ -365,11 +404,15 @@ const Player = () => {
|
||||
[dispatch, dataProvider, currentTrackId],
|
||||
)
|
||||
|
||||
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
||||
if (mode === 'full' && audioInfo?.song?.albumId) {
|
||||
window.location.href = `#/album/${audioInfo.song.albumId}/show`
|
||||
}
|
||||
}, [])
|
||||
const onCoverClick = useCallback(
|
||||
(mode, audioLists, audioInfo) => {
|
||||
if (useInlineMobileLyrics) return
|
||||
if (mode === 'full' && audioInfo?.song?.albumId) {
|
||||
window.location.href = `#/album/${audioInfo.song.albumId}/show`
|
||||
}
|
||||
},
|
||||
[useInlineMobileLyrics],
|
||||
)
|
||||
|
||||
const onAudioError = useCallback(
|
||||
(error, currentPlayId, audioLists, audioInfo) => {
|
||||
@ -404,10 +447,11 @@ const Player = () => {
|
||||
}
|
||||
setHeartbeatTrackId(null)
|
||||
setCurrentTrackId(null)
|
||||
closeLyrics()
|
||||
dispatch(clearQueue())
|
||||
reject()
|
||||
})
|
||||
}, [dispatch, currentTrackId])
|
||||
}, [closeLyrics, dispatch, currentTrackId])
|
||||
|
||||
if (!visible) {
|
||||
document.title = 'Navidrome'
|
||||
@ -459,6 +503,7 @@ const Player = () => {
|
||||
{...options}
|
||||
className={classes.player}
|
||||
onAudioListsChange={onAudioListsChange}
|
||||
onAudioListsPanelChange={setAudioListsPanelVisible}
|
||||
onAudioVolumeChange={onAudioVolumeChange}
|
||||
onAudioProgress={onAudioProgress}
|
||||
onAudioPlay={onAudioPlay}
|
||||
@ -471,6 +516,7 @@ const Player = () => {
|
||||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
/>
|
||||
{mobileLyricsSurface}
|
||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
@ -4,11 +4,14 @@ import { useGetOne } from 'react-admin'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import { RiFileMusicLine } from 'react-icons/ri'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
@ -55,11 +58,21 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const PlayerToolbar = ({
|
||||
id,
|
||||
isRadio,
|
||||
onToggleLyrics,
|
||||
lyricsActive = false,
|
||||
lyricsDisabled = false,
|
||||
lyricsLoading = false,
|
||||
lyricsLabel = 'Toggle lyrics',
|
||||
lyricsLoadingLabel = 'Loading lyrics',
|
||||
lyricsToggleRef,
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
const classes = useStyles()
|
||||
|
||||
const handlers = {
|
||||
@ -76,6 +89,7 @@ const PlayerToolbar = ({ id, isRadio }) => {
|
||||
|
||||
const buttonClass = isDesktop ? classes.button : classes.mobileButton
|
||||
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
|
||||
const lyricsUnavailable = lyricsDisabled || (lyricsLoading && !lyricsActive)
|
||||
|
||||
const saveQueueButton = (
|
||||
<IconButton
|
||||
@ -99,6 +113,30 @@ const PlayerToolbar = ({ id, isRadio }) => {
|
||||
/>
|
||||
)
|
||||
|
||||
const currentLyricsLabel = lyricsLoading ? lyricsLoadingLabel : lyricsLabel
|
||||
const lyricsButton = (
|
||||
<Tooltip title={currentLyricsLabel}>
|
||||
<span>
|
||||
<IconButton
|
||||
ref={lyricsToggleRef}
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!onToggleLyrics || lyricsUnavailable}
|
||||
data-testid="toggle-lyrics-button"
|
||||
className={buttonClass}
|
||||
color={lyricsActive ? 'primary' : 'default'}
|
||||
aria-label={currentLyricsLabel}
|
||||
aria-busy={lyricsLoading}
|
||||
aria-pressed={lyricsActive}
|
||||
>
|
||||
<RiFileMusicLine
|
||||
className={!isDesktop ? classes.mobileIcon : undefined}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||
@ -106,11 +144,13 @@ const PlayerToolbar = ({ id, isRadio }) => {
|
||||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
{lyricsButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
<li className={`${listItemClass} item`}>{lyricsButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -61,37 +61,22 @@ describe('<PlayerToolbar />', () => {
|
||||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
it('renders desktop toolbar with all buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
// All buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
// Verify all buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens save queue dialog when save button is clicked', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
@ -102,6 +87,32 @@ describe('<PlayerToolbar />', () => {
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles lyrics from an accessible toolbar button', () => {
|
||||
const onToggleLyrics = vi.fn()
|
||||
const lyricsToggleRef = React.createRef()
|
||||
render(
|
||||
<PlayerToolbar
|
||||
id="song-1"
|
||||
onToggleLyrics={onToggleLyrics}
|
||||
lyricsActive
|
||||
lyricsToggleRef={lyricsToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
const lyricsButton = screen.getByLabelText('Toggle lyrics')
|
||||
expect(lyricsButton).toHaveAttribute(
|
||||
'data-testid',
|
||||
'toggle-lyrics-button',
|
||||
)
|
||||
expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary')
|
||||
expect(lyricsButton).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(lyricsToggleRef.current).toBe(lyricsButton)
|
||||
|
||||
fireEvent.click(lyricsButton)
|
||||
|
||||
expect(onToggleLyrics).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
@ -114,35 +125,57 @@ describe('<PlayerToolbar />', () => {
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
expect(listItems).toHaveLength(3)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
// Verify all buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
expect(listItems[2].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
it('disables lyrics toggle when lyrics are unavailable', () => {
|
||||
render(
|
||||
<PlayerToolbar id="song-1" onToggleLyrics={vi.fn()} lyricsDisabled />,
|
||||
)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
it('shows loading state on the lyrics toggle before lyrics are available', () => {
|
||||
render(
|
||||
<PlayerToolbar id="song-1" onToggleLyrics={vi.fn()} lyricsLoading />,
|
||||
)
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
const lyricsButton = screen.getByTestId('toggle-lyrics-button')
|
||||
expect(lyricsButton).toBeDisabled()
|
||||
expect(lyricsButton).toHaveAttribute('aria-label', 'Loading lyrics')
|
||||
expect(lyricsButton).toHaveAttribute('aria-busy', 'true')
|
||||
expect(lyricsButton).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
expect(screen.getByTestId('save-queue-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
expect(screen.getByTestId('love-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders global hotkeys in both layouts', () => {
|
||||
// Test desktop layout
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
|
||||
508
ui/src/audioplayer/lyrics.js
Normal file
508
ui/src/audioplayer/lyrics.js
Normal file
@ -0,0 +1,508 @@
|
||||
const normalizeLanguageTag = (language) => {
|
||||
const normalized = (language || '').trim().replace(/_/g, '-')
|
||||
if (!normalized) return ''
|
||||
|
||||
try {
|
||||
return Intl.getCanonicalLocales(normalized)[0].toLowerCase()
|
||||
} catch {
|
||||
return normalized.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const LYRIC_KIND_MAIN = 'main'
|
||||
const LYRIC_KIND_TRANSLATION = 'translation'
|
||||
const LYRIC_KIND_PRONUNCIATION = 'pronunciation'
|
||||
|
||||
const toTime = (value) => {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
const applyTimeOffset = (value, offset = 0) => {
|
||||
const time = toTime(value)
|
||||
return time == null ? null : time + offset
|
||||
}
|
||||
|
||||
const toByteOffset = (value) => {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const numeric = Number(value)
|
||||
if (!Number.isInteger(numeric) || numeric < 0) {
|
||||
return null
|
||||
}
|
||||
return numeric
|
||||
}
|
||||
|
||||
const compareNullableTime = (a, b) => {
|
||||
if (a == null && b == null) return 0
|
||||
if (a == null) return 1
|
||||
if (b == null) return -1
|
||||
return a - b
|
||||
}
|
||||
|
||||
const sortTokensByStart = (tokens) =>
|
||||
tokens
|
||||
.map((token, order) => ({ ...token, order }))
|
||||
.sort((a, b) => {
|
||||
const byStart = compareNullableTime(a.start, b.start)
|
||||
if (byStart !== 0) return byStart
|
||||
const byEnd = compareNullableTime(a.end, b.end)
|
||||
if (byEnd !== 0) return byEnd
|
||||
return a.order - b.order
|
||||
})
|
||||
.map(({ order, ...token }) => token)
|
||||
|
||||
const languageMatch = (candidate, preferred) => {
|
||||
if (!candidate || !preferred) return false
|
||||
return (
|
||||
candidate === preferred ||
|
||||
candidate.startsWith(`${preferred}-`) ||
|
||||
preferred.startsWith(`${candidate}-`)
|
||||
)
|
||||
}
|
||||
|
||||
const hasTimedCueLines = (lyric) =>
|
||||
Array.isArray(lyric?.cueLine) &&
|
||||
lyric.cueLine.some(
|
||||
(cueLine) =>
|
||||
toTime(cueLine?.start) != null ||
|
||||
(Array.isArray(cueLine?.cue) &&
|
||||
cueLine.cue.some((cue) => toTime(cue?.start) != null)),
|
||||
)
|
||||
|
||||
const hasTimedLines = (lyric) =>
|
||||
Boolean(
|
||||
lyric &&
|
||||
lyric.synced &&
|
||||
((Array.isArray(lyric.line) &&
|
||||
lyric.line.some((line) => toTime(line.start) != null)) ||
|
||||
hasTimedCueLines(lyric)),
|
||||
)
|
||||
|
||||
const preferTimedLyrics = (lyrics) => {
|
||||
const timed = lyrics.filter(hasTimedLines)
|
||||
return timed.length > 0 ? timed : lyrics
|
||||
}
|
||||
|
||||
const normalizeToken = (token, offset = 0) => {
|
||||
if (!token) return null
|
||||
const value = typeof token.value === 'string' ? token.value : ''
|
||||
if (value.length === 0) return null
|
||||
const byteStart = toByteOffset(token.byteStart)
|
||||
const byteEnd = toByteOffset(token.byteEnd)
|
||||
return {
|
||||
start: applyTimeOffset(token.start, offset),
|
||||
end: applyTimeOffset(token.end, offset),
|
||||
value,
|
||||
...(byteStart != null ? { byteStart } : {}),
|
||||
...(byteEnd != null ? { byteEnd } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const utf8BytesForCodePoint = (codePoint) => {
|
||||
if (codePoint <= 0x7f) return 1
|
||||
if (codePoint <= 0x7ff) return 2
|
||||
if (codePoint <= 0xffff) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export const utf8ByteOffsetToCodeUnitIndex = (text, targetByteOffset) => {
|
||||
if (typeof text !== 'string' || text.length === 0) return 0
|
||||
|
||||
const target = toByteOffset(targetByteOffset)
|
||||
if (target == null || target <= 0) return 0
|
||||
|
||||
let byteOffset = 0
|
||||
let index = 0
|
||||
while (index < text.length) {
|
||||
if (byteOffset >= target) return index
|
||||
const codePoint = text.codePointAt(index)
|
||||
byteOffset += utf8BytesForCodePoint(codePoint)
|
||||
index += codePoint > 0xffff ? 2 : 1
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
export const utf8ByteRangeToCodeUnitRange = (text, byteStart, byteEnd) => {
|
||||
if (typeof text !== 'string') return null
|
||||
|
||||
const start = toByteOffset(byteStart)
|
||||
const end = toByteOffset(byteEnd)
|
||||
if (start == null || end == null || end < start) return null
|
||||
|
||||
const startIndex = utf8ByteOffsetToCodeUnitIndex(text, start)
|
||||
const endIndex = utf8ByteOffsetToCodeUnitIndex(text, end + 1)
|
||||
if (
|
||||
startIndex >= endIndex ||
|
||||
startIndex > text.length ||
|
||||
endIndex > text.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
start: startIndex,
|
||||
end: endIndex,
|
||||
text: text.slice(startIndex, endIndex),
|
||||
}
|
||||
}
|
||||
|
||||
const buildAgentLookup = (structuredLyric) => {
|
||||
const lookup = new Map()
|
||||
const agents = Array.isArray(structuredLyric?.agents)
|
||||
? structuredLyric.agents
|
||||
: []
|
||||
for (const agent of agents) {
|
||||
const id = typeof agent?.id === 'string' ? agent.id : ''
|
||||
if (!id || lookup.has(id)) continue
|
||||
lookup.set(id, {
|
||||
id,
|
||||
role: typeof agent?.role === 'string' ? agent.role : '',
|
||||
name: typeof agent?.name === 'string' ? agent.name : '',
|
||||
})
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
const deriveUiRole = (agent) => {
|
||||
if (!agent?.role || agent.role === 'main') return ''
|
||||
return agent.role
|
||||
}
|
||||
|
||||
const normalizeCueLine = (cueLine, fallbackIndex, agentLookup, offset = 0) => {
|
||||
const index = Number.isFinite(Number(cueLine?.index))
|
||||
? Number(cueLine.index)
|
||||
: fallbackIndex
|
||||
const agentId = typeof cueLine?.agentId === 'string' ? cueLine.agentId : ''
|
||||
const agent = agentId ? agentLookup.get(agentId) || null : null
|
||||
const fallbackRole = typeof cueLine?.role === 'string' ? cueLine.role : ''
|
||||
const tokens = sortTokensByStart(
|
||||
Array.isArray(cueLine?.cue)
|
||||
? cueLine.cue.map((cue) => normalizeToken(cue, offset)).filter(Boolean)
|
||||
: [],
|
||||
)
|
||||
|
||||
return {
|
||||
index,
|
||||
start: applyTimeOffset(cueLine?.start, offset),
|
||||
end: applyTimeOffset(cueLine?.end, offset),
|
||||
value: typeof cueLine?.value === 'string' ? cueLine.value : '',
|
||||
role: agent ? deriveUiRole(agent) : fallbackRole,
|
||||
agentId,
|
||||
agentRole: agent?.role || fallbackRole,
|
||||
agentName: agent?.name || '',
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeLyricKind = (kind) => {
|
||||
const normalized = (kind || '').toLowerCase().trim()
|
||||
switch (normalized) {
|
||||
case LYRIC_KIND_TRANSLATION:
|
||||
return LYRIC_KIND_TRANSLATION
|
||||
case LYRIC_KIND_PRONUNCIATION:
|
||||
return LYRIC_KIND_PRONUNCIATION
|
||||
default:
|
||||
return LYRIC_KIND_MAIN
|
||||
}
|
||||
}
|
||||
|
||||
const lyricsInPreferredLanguage = (lyrics, preferredLanguage) => {
|
||||
if (!Array.isArray(lyrics) || lyrics.length === 0) return null
|
||||
|
||||
const preferred = normalizeLanguageTag(preferredLanguage)
|
||||
const preferredBase = preferred.split('-')[0]
|
||||
|
||||
const preferredMatches = lyrics.filter((lyric) =>
|
||||
languageMatch(normalizeLanguageTag(lyric.lang), preferred),
|
||||
)
|
||||
if (preferredMatches.length > 0) return preferredMatches
|
||||
|
||||
const baseMatches = lyrics.filter((lyric) =>
|
||||
languageMatch(normalizeLanguageTag(lyric.lang), preferredBase),
|
||||
)
|
||||
if (baseMatches.length > 0) return baseMatches
|
||||
|
||||
const englishMatches = lyrics.filter((lyric) =>
|
||||
languageMatch(normalizeLanguageTag(lyric.lang), 'en'),
|
||||
)
|
||||
return englishMatches.length > 0 ? englishMatches : lyrics
|
||||
}
|
||||
|
||||
const pickPreferredLyric = (lyrics, preferredLanguage) => {
|
||||
const preferred = lyricsInPreferredLanguage(lyrics, preferredLanguage)
|
||||
return preferred ? preferTimedLyrics(preferred)[0] || null : null
|
||||
}
|
||||
|
||||
export const hasCueTiming = (structuredLyric) =>
|
||||
hasTimedCueLines(structuredLyric)
|
||||
|
||||
export const hasStructuredLyricContent = (structuredLyric) =>
|
||||
Boolean(
|
||||
structuredLyric &&
|
||||
((Array.isArray(structuredLyric.line) &&
|
||||
structuredLyric.line.some(
|
||||
(line) => typeof line?.value === 'string' && line.value.trim() !== '',
|
||||
)) ||
|
||||
hasCueTiming(structuredLyric)),
|
||||
)
|
||||
|
||||
export const getPreferredLyricLanguage = () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
const stored = window.localStorage.getItem('locale')
|
||||
if (stored) return stored
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the browser language when storage access is restricted.
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||
return navigator.language
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export const selectLyricLayers = (structuredLyrics, preferredLanguage) => {
|
||||
if (!Array.isArray(structuredLyrics)) {
|
||||
return { main: null, translation: null, pronunciation: null }
|
||||
}
|
||||
|
||||
const available = structuredLyrics.filter(hasStructuredLyricContent)
|
||||
if (available.length === 0) {
|
||||
return { main: null, translation: null, pronunciation: null }
|
||||
}
|
||||
|
||||
const grouped = {
|
||||
[LYRIC_KIND_MAIN]: [],
|
||||
[LYRIC_KIND_TRANSLATION]: [],
|
||||
[LYRIC_KIND_PRONUNCIATION]: [],
|
||||
}
|
||||
|
||||
for (const lyric of available) {
|
||||
grouped[normalizeLyricKind(lyric?.kind)].push(lyric)
|
||||
}
|
||||
|
||||
const mainCandidates = grouped[LYRIC_KIND_MAIN].length
|
||||
? grouped[LYRIC_KIND_MAIN]
|
||||
: available
|
||||
|
||||
return {
|
||||
main: pickPreferredLyric(mainCandidates, preferredLanguage),
|
||||
translation: pickPreferredLyric(
|
||||
grouped[LYRIC_KIND_TRANSLATION],
|
||||
preferredLanguage,
|
||||
),
|
||||
pronunciation: pickPreferredLyric(
|
||||
grouped[LYRIC_KIND_PRONUNCIATION],
|
||||
preferredLanguage,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const buildBaseKaraokeLine = (line, index, offset = 0) => ({
|
||||
index,
|
||||
start: applyTimeOffset(line?.start, offset),
|
||||
end: applyTimeOffset(line?.end, offset),
|
||||
value: typeof line?.value === 'string' ? line.value : '',
|
||||
tokens: [],
|
||||
lanes: [],
|
||||
})
|
||||
|
||||
const buildBaseKaraokeLines = (baseLines, offset = 0) =>
|
||||
baseLines.map((line, index) => buildBaseKaraokeLine(line, index, offset))
|
||||
|
||||
const minNullableTime = (...values) => {
|
||||
const times = values.filter((value) => value != null)
|
||||
return times.length > 0 ? Math.min(...times) : null
|
||||
}
|
||||
|
||||
const maxNullableTime = (...values) => {
|
||||
const times = values.filter((value) => value != null)
|
||||
return times.length > 0 ? Math.max(...times) : null
|
||||
}
|
||||
|
||||
const laneRoleRank = (line) => {
|
||||
const role = (line?.agentRole || line?.role || '').toLowerCase()
|
||||
switch (role) {
|
||||
case '':
|
||||
case 'main':
|
||||
return 0
|
||||
case 'voice':
|
||||
return 1
|
||||
case 'group':
|
||||
case 'chorus':
|
||||
case 'choir':
|
||||
return 2
|
||||
case 'bg':
|
||||
case 'background':
|
||||
case 'background vocals':
|
||||
case 'background-vocals':
|
||||
case 'backing':
|
||||
case 'backing vocals':
|
||||
case 'backing-vocals':
|
||||
return 3
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const sortCueLineLanes = (lanes) =>
|
||||
[...lanes].sort((a, b) => {
|
||||
const byRole = laneRoleRank(a) - laneRoleRank(b)
|
||||
if (byRole !== 0) return byRole
|
||||
return a.order - b.order
|
||||
})
|
||||
|
||||
const buildLaneFromCueLine = (cueLine, laneIndex) => ({
|
||||
key: `${cueLine.index}-${cueLine.agentId || 'default'}-${laneIndex}`,
|
||||
index: cueLine.index,
|
||||
start: cueLine.start,
|
||||
end: cueLine.end,
|
||||
value: cueLine.value,
|
||||
role: cueLine.role,
|
||||
agentId: cueLine.agentId,
|
||||
agentName: cueLine.agentName,
|
||||
agentRole: cueLine.agentRole,
|
||||
tokens: cueLine.tokens,
|
||||
})
|
||||
|
||||
const buildLineFromCueLineGroup = (index, group, baseLines, offset = 0) => {
|
||||
const baseLine = buildBaseKaraokeLine(baseLines[index] || {}, index, offset)
|
||||
const orderedGroup = sortCueLineLanes(group)
|
||||
const lanes = orderedGroup.map(buildLaneFromCueLine)
|
||||
const first = lanes[0] || {}
|
||||
const tokens = sortTokensByStart(lanes.flatMap((lane) => lane.tokens || []))
|
||||
const fallbackStart =
|
||||
tokens.find((token) => token.start != null)?.start ?? null
|
||||
const fallbackEnd =
|
||||
[...tokens].reverse().find((token) => token.end != null)?.end ?? null
|
||||
const laneStart = minNullableTime(
|
||||
...lanes.flatMap((lane) => [lane.start, lane.tokens?.[0]?.start]),
|
||||
)
|
||||
const laneEnd = maxNullableTime(
|
||||
...lanes.flatMap((lane) => [
|
||||
lane.end,
|
||||
[...(lane.tokens || [])].reverse().find((token) => token.end != null)
|
||||
?.end,
|
||||
]),
|
||||
)
|
||||
const fallbackValue = tokens
|
||||
.map((token) => token.value)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const value = baseLine.value || first.value || fallbackValue
|
||||
|
||||
return {
|
||||
...baseLine,
|
||||
index,
|
||||
start: minNullableTime(
|
||||
baseLine.start,
|
||||
first.start,
|
||||
laneStart,
|
||||
fallbackStart,
|
||||
),
|
||||
end: maxNullableTime(baseLine.end, first.end, laneEnd, fallbackEnd),
|
||||
value,
|
||||
agentId: first.agentId,
|
||||
agentName: first.agentName,
|
||||
agentRole: first.agentRole,
|
||||
tokens,
|
||||
lanes,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildKaraokeLinesFromCueLines = (
|
||||
rawCueLines,
|
||||
baseLines,
|
||||
agentLookup,
|
||||
offset = 0,
|
||||
) => {
|
||||
const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => {
|
||||
const normalized = normalizeCueLine(
|
||||
cueLine,
|
||||
fallbackIndex,
|
||||
agentLookup,
|
||||
offset,
|
||||
)
|
||||
return {
|
||||
...normalized,
|
||||
order: fallbackIndex,
|
||||
tokens: normalized.tokens.map((token) => ({
|
||||
...token,
|
||||
role: normalized.role,
|
||||
agentId: normalized.agentId,
|
||||
agentName: normalized.agentName,
|
||||
agentRole: normalized.agentRole,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const byIndex = new Map()
|
||||
for (const cueLine of normalizedCueLines) {
|
||||
if (!byIndex.has(cueLine.index)) byIndex.set(cueLine.index, [])
|
||||
byIndex.get(cueLine.index).push(cueLine)
|
||||
}
|
||||
|
||||
const indexes = new Set(baseLines.map((_line, index) => index))
|
||||
byIndex.forEach((_group, index) => indexes.add(index))
|
||||
|
||||
return Array.from(indexes)
|
||||
.sort((a, b) => a - b)
|
||||
.map((index) => {
|
||||
const group = byIndex.get(index) || []
|
||||
if (group.length === 0) {
|
||||
return buildBaseKaraokeLine(baseLines[index] || {}, index, offset)
|
||||
}
|
||||
return buildLineFromCueLineGroup(index, group, baseLines, offset)
|
||||
})
|
||||
}
|
||||
|
||||
export const buildKaraokeLines = (structuredLyric) => {
|
||||
if (!structuredLyric) return []
|
||||
|
||||
const offset = toTime(structuredLyric.offset) ?? 0
|
||||
const agentLookup = buildAgentLookup(structuredLyric)
|
||||
const baseLines = Array.isArray(structuredLyric.line)
|
||||
? structuredLyric.line
|
||||
: []
|
||||
const rawCueLines = Array.isArray(structuredLyric.cueLine)
|
||||
? structuredLyric.cueLine
|
||||
: []
|
||||
|
||||
const lines =
|
||||
rawCueLines.length > 0
|
||||
? buildKaraokeLinesFromCueLines(
|
||||
rawCueLines,
|
||||
baseLines,
|
||||
agentLookup,
|
||||
offset,
|
||||
)
|
||||
: buildBaseKaraokeLines(baseLines, offset)
|
||||
|
||||
return lines
|
||||
.map((line) => ({
|
||||
...line,
|
||||
renderable: Boolean(line.value?.trim() || line.tokens.length > 0),
|
||||
}))
|
||||
.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export const hasUsableKaraokeTiming = (lines) =>
|
||||
Array.isArray(lines) &&
|
||||
lines.some(
|
||||
(line) =>
|
||||
line?.renderable !== false &&
|
||||
(toTime(line?.start) != null ||
|
||||
(Array.isArray(line?.tokens) &&
|
||||
line.tokens.some(
|
||||
(token) =>
|
||||
toTime(token?.start) != null || toTime(token?.end) != null,
|
||||
))),
|
||||
)
|
||||
362
ui/src/audioplayer/lyrics.test.js
Normal file
362
ui/src/audioplayer/lyrics.test.js
Normal file
@ -0,0 +1,362 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildKaraokeLines,
|
||||
getPreferredLyricLanguage,
|
||||
hasStructuredLyricContent,
|
||||
selectLyricLayers,
|
||||
utf8ByteRangeToCodeUnitRange,
|
||||
} from './lyrics'
|
||||
|
||||
const timed = (value, start, end, extra = {}) => ({
|
||||
...extra,
|
||||
start,
|
||||
...(end == null ? {} : { end }),
|
||||
value,
|
||||
})
|
||||
|
||||
const structured = (kind, lang, value) => ({
|
||||
kind,
|
||||
lang,
|
||||
synced: true,
|
||||
line: [timed(value, 0)],
|
||||
})
|
||||
|
||||
const cueLine = (index, value, start, end, cue, extra = {}) => ({
|
||||
index,
|
||||
...timed(value, start, end, extra),
|
||||
cue,
|
||||
})
|
||||
|
||||
describe('lyrics helpers', () => {
|
||||
it('selects main, pronunciation, and translation layers by kind and language', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
structured('translation', 'es', 'Hola'),
|
||||
structured('main', 'en-US', 'Hello'),
|
||||
structured('pronunciation', 'en', 'heh-low'),
|
||||
],
|
||||
'en',
|
||||
)
|
||||
|
||||
expect(layers.main.value || layers.main.line[0].value).toBe('Hello')
|
||||
expect(layers.pronunciation.line[0].value).toBe('heh-low')
|
||||
expect(layers.translation.line[0].value).toBe('Hola')
|
||||
})
|
||||
|
||||
it('prefers cue-timed lyrics when line starts are omitted', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
{
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: false,
|
||||
line: [{ value: 'Plain lyrics' }],
|
||||
},
|
||||
{
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: true,
|
||||
line: [{ value: 'Cue-timed lyrics' }],
|
||||
cueLine: [
|
||||
{
|
||||
index: 0,
|
||||
value: 'Cue-timed lyrics',
|
||||
cue: [{ start: 1000, value: 'Cue-timed lyrics' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'en',
|
||||
)
|
||||
|
||||
expect(layers.main.line[0].value).toBe('Cue-timed lyrics')
|
||||
})
|
||||
|
||||
it('prefers the requested language before timed variants', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
{
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: false,
|
||||
line: [{ value: 'Preferred plain lyrics' }],
|
||||
},
|
||||
structured('main', 'ja', '別の言語'),
|
||||
],
|
||||
'en-US',
|
||||
)
|
||||
|
||||
expect(layers.main.line[0].value).toBe('Preferred plain lyrics')
|
||||
})
|
||||
|
||||
it('matches language tags with multiple underscore separators', () => {
|
||||
const layers = selectLyricLayers(
|
||||
[
|
||||
structured('main', 'zh-Hans', '简体'),
|
||||
structured('main', 'zh-Hant-TW', '繁體'),
|
||||
],
|
||||
'zh_Hant_TW',
|
||||
)
|
||||
|
||||
expect(layers.main.line[0].value).toBe('繁體')
|
||||
})
|
||||
|
||||
it('matches ISO 639-2 lyric languages to regional browser locales', () => {
|
||||
const lyrics = [
|
||||
structured('main', 'spa', 'Español'),
|
||||
structured('main', 'eng', 'English'),
|
||||
structured('main', 'por', 'Português'),
|
||||
]
|
||||
|
||||
expect(selectLyricLayers(lyrics, 'en-US').main.line[0].value).toBe(
|
||||
'English',
|
||||
)
|
||||
expect(selectLyricLayers(lyrics, 'pt-BR').main.line[0].value).toBe(
|
||||
'Português',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves UTF-8 byte ranges without confusing repeated words', () => {
|
||||
const text = 'caf\u00e9 caf\u00e9'
|
||||
|
||||
expect(utf8ByteRangeToCodeUnitRange(text, 0, 4)).toMatchObject({
|
||||
text: 'caf\u00e9',
|
||||
})
|
||||
expect(utf8ByteRangeToCodeUnitRange(text, 6, 10)).toMatchObject({
|
||||
text: 'caf\u00e9',
|
||||
start: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('applies structured lyric offsets to line and cue timing', () => {
|
||||
const delayed = buildKaraokeLines({
|
||||
synced: true,
|
||||
offset: 500,
|
||||
line: [timed('Delayed line', 1000, 3000)],
|
||||
cueLine: [
|
||||
cueLine(0, 'Delayed line', 1000, 3000, [
|
||||
timed('Delayed ', 1000, 2000, { byteStart: 0, byteEnd: 7 }),
|
||||
timed('line', 2000, 3000, { byteStart: 8, byteEnd: 11 }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const advanced = buildKaraokeLines({
|
||||
synced: true,
|
||||
offset: -250,
|
||||
line: [timed('Advanced line', 1000)],
|
||||
})
|
||||
|
||||
expect(delayed[0].start).toBe(1500)
|
||||
expect(delayed[0].end).toBe(3500)
|
||||
expect(delayed[0].tokens[0].start).toBe(1500)
|
||||
expect(delayed[0].tokens[0].end).toBe(2500)
|
||||
expect(advanced[0].start).toBe(750)
|
||||
})
|
||||
|
||||
it('keeps base lyric lines that do not have word-level cue lines', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [
|
||||
timed('Word timed', 1000),
|
||||
timed('Plain timed line', 2000),
|
||||
timed('More words', 3000),
|
||||
],
|
||||
cueLine: [
|
||||
cueLine(0, 'Word timed', 1000, null, [
|
||||
timed('Word ', 1000, null, { byteStart: 0, byteEnd: 4 }),
|
||||
timed('timed', 1500, null, { byteStart: 5, byteEnd: 9 }),
|
||||
]),
|
||||
cueLine(2, 'More words', 3000, null, [
|
||||
timed('More ', 3000, null, { byteStart: 0, byteEnd: 4 }),
|
||||
timed('words', 3500, null, { byteStart: 5, byteEnd: 9 }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines.map((line) => line.value)).toEqual([
|
||||
'Word timed',
|
||||
'Plain timed line',
|
||||
'More words',
|
||||
])
|
||||
expect(lines[1].tokens).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves repeated ELRC-style cue timing and trailing cue ends', () => {
|
||||
const repeatedLine = (index, start) =>
|
||||
cueLine(index, 'Hello world', start, start + 900, [
|
||||
timed('Hello ', start + 100, start + 500, {
|
||||
byteStart: 0,
|
||||
byteEnd: 5,
|
||||
}),
|
||||
timed('world', start + 500, start + 900, {
|
||||
byteStart: 6,
|
||||
byteEnd: 10,
|
||||
}),
|
||||
])
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [
|
||||
timed('Hello world', 10000, 10900),
|
||||
timed('Hello world', 30000, 30900),
|
||||
],
|
||||
cueLine: [repeatedLine(0, 10000), repeatedLine(1, 30000)],
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0].tokens[1].end).toBe(10900)
|
||||
expect(lines[1].tokens[0].start).toBe(30100)
|
||||
})
|
||||
|
||||
it('keeps multiline SRT, TTML, and plain text values intact', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [{ start: 1000, value: 'first line\nsecond line' }],
|
||||
})
|
||||
|
||||
expect(lines[0].value).toBe('first line\nsecond line')
|
||||
})
|
||||
|
||||
it('keeps adjacent TTML syllable cue tokens in order', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [timed('konni', 2000)],
|
||||
cueLine: [
|
||||
cueLine(0, 'konni', 2000, 2600, [
|
||||
timed('ko', 2000, 2300, { byteStart: 0, byteEnd: 1 }),
|
||||
timed('nni', 2300, 2600, { byteStart: 2, byteEnd: 4 }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines[0].tokens.map((token) => token.value)).toEqual(['ko', 'nni'])
|
||||
})
|
||||
|
||||
it('keeps same-index agent cue lines as ordered voice lanes', () => {
|
||||
const agentCue = (value, agentId, start, end) =>
|
||||
cueLine(
|
||||
0,
|
||||
value,
|
||||
start,
|
||||
end,
|
||||
[timed(value, start, end, { byteStart: 0, byteEnd: value.length - 1 })],
|
||||
{ agentId },
|
||||
)
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
agents: [
|
||||
{ id: 'lead', role: 'main' },
|
||||
{ id: 'all', role: 'group' },
|
||||
{ id: 'lead-bg', role: 'bg' },
|
||||
],
|
||||
line: [timed('Lead all echo', 1000, 4000)],
|
||||
cueLine: [
|
||||
agentCue('echo', 'lead-bg', 2000, 3000),
|
||||
agentCue('all', 'all', 1500, 2500),
|
||||
agentCue('Lead', 'lead', 1000, 2000),
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].lanes).toHaveLength(3)
|
||||
expect(lines[0].lanes.map((lane) => lane.agentRole)).toEqual([
|
||||
'main',
|
||||
'group',
|
||||
'bg',
|
||||
])
|
||||
expect(lines[0].tokens.map((token) => token.value)).toEqual([
|
||||
'Lead',
|
||||
'all',
|
||||
'echo',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses whitespace-aware text when cue-only fallback values are needed', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
cueLine: [
|
||||
cueLine(0, '', 1000, 3000, [
|
||||
timed('Lead', 1000, 2000),
|
||||
timed('all', 2000, 3000),
|
||||
timed('echo', 3000, 3500),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines[0].value).toBe('Lead all echo')
|
||||
})
|
||||
|
||||
it('preserves untimed lyric rows between surrounding timed rows', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 1000, value: 'First verse' },
|
||||
{ value: '[instrumental]' },
|
||||
{ start: 2000, value: 'Second verse' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines.map((line) => line.value)).toEqual([
|
||||
'First verse',
|
||||
'[instrumental]',
|
||||
'Second verse',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves source display order independently from timestamps', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 3000, value: 'Displayed first' },
|
||||
{ start: 1000, value: 'Displayed second' },
|
||||
{ start: 2000, value: 'Displayed third' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines.map((line) => line.value)).toEqual([
|
||||
'Displayed first',
|
||||
'Displayed second',
|
||||
'Displayed third',
|
||||
])
|
||||
expect(lines.map((line) => line.start)).toEqual([3000, 1000, 2000])
|
||||
})
|
||||
|
||||
it('preserves timed blank rows as non-renderable timing markers', () => {
|
||||
const lines = buildKaraokeLines({
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 1000, value: 'Before pause' },
|
||||
{ start: 2000, value: '' },
|
||||
{ start: 4000, value: 'After pause' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[1]).toMatchObject({
|
||||
start: 2000,
|
||||
value: '',
|
||||
renderable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the browser language when locale storage is unavailable', () => {
|
||||
const storage = vi
|
||||
.spyOn(Storage.prototype, 'getItem')
|
||||
.mockImplementation(() => {
|
||||
throw new DOMException('Access denied', 'SecurityError')
|
||||
})
|
||||
|
||||
expect(getPreferredLyricLanguage()).toBe(navigator.language)
|
||||
storage.mockRestore()
|
||||
})
|
||||
|
||||
it('treats instrumental empty lyrics as no renderable content', () => {
|
||||
expect(
|
||||
hasStructuredLyricContent({
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: false,
|
||||
line: [],
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
113
ui/src/audioplayer/lyricsKaraokeConstants.js
Normal file
113
ui/src/audioplayer/lyricsKaraokeConstants.js
Normal file
@ -0,0 +1,113 @@
|
||||
export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140
|
||||
export const KARAOKE_HIGHLIGHT_LEAD_MS = 120
|
||||
export const KARAOKE_ANIMATION_MS = 500
|
||||
export const KARAOKE_LINE_OPACITY_MS = 300
|
||||
export const KARAOKE_SCROLLBAR_VISIBLE_MS = 1400
|
||||
export const KARAOKE_MANUAL_SCROLL_PAUSE_MS = 2200
|
||||
export const KARAOKE_SCROLL_ANIMATION_MS = 300
|
||||
export const KARAOKE_LINE_LIFT_PX = 1.5
|
||||
const KARAOKE_EASE_IN_OUT_X1 = 0.77
|
||||
const KARAOKE_EASE_IN_OUT_Y1 = 0
|
||||
const KARAOKE_EASE_IN_OUT_X2 = 0.175
|
||||
const KARAOKE_EASE_IN_OUT_Y2 = 1
|
||||
const KARAOKE_OPACITY_EASING_X1 = 0.22
|
||||
const KARAOKE_OPACITY_EASING_Y1 = 1
|
||||
const KARAOKE_OPACITY_EASING_X2 = 0.36
|
||||
const KARAOKE_OPACITY_EASING_Y2 = 1
|
||||
export const KARAOKE_LINE_MOTION_EASING = `cubic-bezier(${KARAOKE_EASE_IN_OUT_X1}, ${KARAOKE_EASE_IN_OUT_Y1}, ${KARAOKE_EASE_IN_OUT_X2}, ${KARAOKE_EASE_IN_OUT_Y2})`
|
||||
export const KARAOKE_CHARACTER_LIFT_PX = KARAOKE_LINE_LIFT_PX
|
||||
export const KARAOKE_CHARACTER_RISE_MS = KARAOKE_ANIMATION_MS
|
||||
export const KARAOKE_CHARACTER_WAVE_SPAN_RATIO = 0.36
|
||||
export const KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS = 140
|
||||
export const KARAOKE_IDLE_LAYER_OPACITY = 0.49
|
||||
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS
|
||||
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
|
||||
export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
|
||||
export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1
|
||||
export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42
|
||||
export const KARAOKE_SCROLL_SETTLE_PX = 2
|
||||
export const KARAOKE_AUX_LINE_HEIGHT = 1.18
|
||||
export const KARAOKE_EASING = `cubic-bezier(${KARAOKE_OPACITY_EASING_X1}, ${KARAOKE_OPACITY_EASING_Y1}, ${KARAOKE_OPACITY_EASING_X2}, ${KARAOKE_OPACITY_EASING_Y2})`
|
||||
export const TOKEN_FUTURE_ALPHA = 0.56
|
||||
export const TOKEN_ACTIVE_ALPHA = 1
|
||||
export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34
|
||||
export const TOKEN_WIPE_EDGE_PCT = 12
|
||||
|
||||
export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const cubicBezierCoordinate = (time, firstControl, secondControl) => {
|
||||
const inverse = 1 - time
|
||||
return (
|
||||
3 * inverse * inverse * time * firstControl +
|
||||
3 * inverse * time * time * secondControl +
|
||||
time * time * time
|
||||
)
|
||||
}
|
||||
|
||||
const cubicBezierSlope = (time, firstControl, secondControl) => {
|
||||
const inverse = 1 - time
|
||||
return (
|
||||
3 * inverse * inverse * firstControl +
|
||||
6 * inverse * time * (secondControl - firstControl) +
|
||||
3 * time * time * (1 - secondControl)
|
||||
)
|
||||
}
|
||||
|
||||
const solveCubicBezierTime = (progress, firstControl, secondControl) => {
|
||||
let time = progress
|
||||
for (let iteration = 0; iteration < 6; iteration += 1) {
|
||||
const error =
|
||||
cubicBezierCoordinate(time, firstControl, secondControl) - progress
|
||||
if (Math.abs(error) < 0.000001) return time
|
||||
const slope = cubicBezierSlope(time, firstControl, secondControl)
|
||||
if (Math.abs(slope) < 0.000001) break
|
||||
time = clamp(time - error / slope, 0, 1)
|
||||
}
|
||||
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
for (let iteration = 0; iteration < 12; iteration += 1) {
|
||||
time = (lower + upper) / 2
|
||||
if (cubicBezierCoordinate(time, firstControl, secondControl) < progress) {
|
||||
lower = time
|
||||
} else {
|
||||
upper = time
|
||||
}
|
||||
}
|
||||
return time
|
||||
}
|
||||
|
||||
export const easeKaraokeMotion = (value) => {
|
||||
const progress = clamp(value, 0, 1)
|
||||
if (progress === 0 || progress === 1) return progress
|
||||
return cubicBezierCoordinate(
|
||||
solveCubicBezierTime(
|
||||
progress,
|
||||
KARAOKE_EASE_IN_OUT_X1,
|
||||
KARAOKE_EASE_IN_OUT_X2,
|
||||
),
|
||||
KARAOKE_EASE_IN_OUT_Y1,
|
||||
KARAOKE_EASE_IN_OUT_Y2,
|
||||
)
|
||||
}
|
||||
|
||||
export const easeKaraokeOpacity = (value) => {
|
||||
const progress = clamp(value, 0, 1)
|
||||
if (progress === 0 || progress === 1) return progress
|
||||
return cubicBezierCoordinate(
|
||||
solveCubicBezierTime(
|
||||
progress,
|
||||
KARAOKE_OPACITY_EASING_X1,
|
||||
KARAOKE_OPACITY_EASING_X2,
|
||||
),
|
||||
KARAOKE_OPACITY_EASING_Y1,
|
||||
KARAOKE_OPACITY_EASING_Y2,
|
||||
)
|
||||
}
|
||||
|
||||
export const easeInOut = (value) => {
|
||||
const progress = clamp(value, 0, 1)
|
||||
return progress < 0.5
|
||||
? 2 * progress * progress
|
||||
: 1 - (-2 * progress + 2) ** 2 / 2
|
||||
}
|
||||
45
ui/src/audioplayer/lyricsKaraokeStyles.js
Normal file
45
ui/src/audioplayer/lyricsKaraokeStyles.js
Normal file
@ -0,0 +1,45 @@
|
||||
const EMPHASIS_ROLES = new Set([
|
||||
'adlib',
|
||||
'backing',
|
||||
'backing vocals',
|
||||
'backing-vocals',
|
||||
'background',
|
||||
'background vocals',
|
||||
'background-vocals',
|
||||
'bg',
|
||||
'choir',
|
||||
'chorus',
|
||||
'group',
|
||||
'harmony',
|
||||
])
|
||||
|
||||
const normalizeRole = (role) =>
|
||||
String(role || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
export const isEmphasisRole = (token) =>
|
||||
EMPHASIS_ROLES.has(normalizeRole(token?.role)) ||
|
||||
EMPHASIS_ROLES.has(normalizeRole(token?.agentRole))
|
||||
|
||||
export const buildEmphasisStyle = (token) =>
|
||||
isEmphasisRole(token) ? { fontStyle: 'italic' } : undefined
|
||||
|
||||
export const parseColorRGB = (color) => {
|
||||
const value = String(color || '').trim()
|
||||
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
|
||||
if (hex) {
|
||||
const raw = hex[1]
|
||||
if (raw.length === 3) {
|
||||
return raw.split('').map((part) => parseInt(part + part, 16))
|
||||
}
|
||||
return [
|
||||
parseInt(raw.slice(0, 2), 16),
|
||||
parseInt(raw.slice(2, 4), 16),
|
||||
parseInt(raw.slice(4, 6), 16),
|
||||
]
|
||||
}
|
||||
|
||||
const rgb = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
||||
return rgb ? [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])] : null
|
||||
}
|
||||
73
ui/src/audioplayer/lyricsScroll.js
Normal file
73
ui/src/audioplayer/lyricsScroll.js
Normal file
@ -0,0 +1,73 @@
|
||||
import {
|
||||
KARAOKE_SCROLL_ANIMATION_MS,
|
||||
KARAOKE_SCROLL_SETTLE_PX,
|
||||
clamp,
|
||||
easeInOut,
|
||||
} from './lyricsKaraokeConstants'
|
||||
|
||||
export const cancelScrollAnimation = (scrollAnimationRef) => {
|
||||
const animation = scrollAnimationRef.current
|
||||
if (animation?.frameId != null) {
|
||||
window.cancelAnimationFrame(animation.frameId)
|
||||
}
|
||||
scrollAnimationRef.current = null
|
||||
}
|
||||
|
||||
export const getScrollEndPadding = (body, anchorRatio) => {
|
||||
const height = Number(body?.clientHeight)
|
||||
if (!Number.isFinite(height) || height <= 0) return 0
|
||||
return Math.round(height * (1 - anchorRatio))
|
||||
}
|
||||
|
||||
export const getAnchoredScrollTop = (body, targetNode, anchorRatio) => {
|
||||
const bodyRect = body.getBoundingClientRect()
|
||||
const targetRect = targetNode.getBoundingClientRect()
|
||||
const activeAnchorTop = body.clientHeight * anchorRatio
|
||||
const deltaWithinBody = targetRect.top - bodyRect.top - activeAnchorTop
|
||||
const maxTop = Math.max(0, body.scrollHeight - body.clientHeight)
|
||||
return clamp(body.scrollTop + deltaWithinBody, 0, maxTop)
|
||||
}
|
||||
|
||||
export const animateScrollTop = ({
|
||||
body,
|
||||
targetTop,
|
||||
reducedMotion,
|
||||
scrollAnimationRef,
|
||||
}) => {
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
|
||||
const startTop = body.scrollTop
|
||||
const maxTop = Math.max(0, body.scrollHeight - body.clientHeight)
|
||||
const nextTargetTop = clamp(targetTop, 0, maxTop)
|
||||
const distance = nextTargetTop - startTop
|
||||
if (Math.abs(distance) < KARAOKE_SCROLL_SETTLE_PX) return
|
||||
|
||||
if (reducedMotion) {
|
||||
body.scrollTop = nextTargetTop
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const animation = { frameId: null }
|
||||
const step = () => {
|
||||
if (scrollAnimationRef.current !== animation) return
|
||||
|
||||
const progress = clamp(
|
||||
(performance.now() - startedAt) / KARAOKE_SCROLL_ANIMATION_MS,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
body.scrollTop =
|
||||
progress >= 1 ? nextTargetTop : startTop + distance * easeInOut(progress)
|
||||
|
||||
if (progress < 1) {
|
||||
animation.frameId = window.requestAnimationFrame(step)
|
||||
return
|
||||
}
|
||||
|
||||
scrollAnimationRef.current = null
|
||||
}
|
||||
|
||||
animation.frameId = window.requestAnimationFrame(step)
|
||||
scrollAnimationRef.current = animation
|
||||
}
|
||||
157
ui/src/audioplayer/lyricsScroll.test.js
Normal file
157
ui/src/audioplayer/lyricsScroll.test.js
Normal file
@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { KARAOKE_SCROLL_ANIMATION_MS } from './lyricsKaraokeConstants'
|
||||
import {
|
||||
animateScrollTop,
|
||||
cancelScrollAnimation,
|
||||
getAnchoredScrollTop,
|
||||
getScrollEndPadding,
|
||||
} from './lyricsScroll'
|
||||
|
||||
const createScrollableBody = (scrollTop = 0) => ({
|
||||
clientHeight: 200,
|
||||
scrollHeight: 1000,
|
||||
scrollTop,
|
||||
})
|
||||
|
||||
const animate = (body, scrollAnimationRef, targetTop, reducedMotion = false) =>
|
||||
animateScrollTop({
|
||||
body,
|
||||
targetTop,
|
||||
reducedMotion,
|
||||
scrollAnimationRef,
|
||||
})
|
||||
|
||||
describe('lyrics scroll helpers', () => {
|
||||
let animationFrames
|
||||
let now
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
now = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('calculates end padding and clamps anchored targets', () => {
|
||||
expect(getScrollEndPadding({ clientHeight: 500 }, 0.42)).toBe(290)
|
||||
expect(getScrollEndPadding({ clientHeight: 200 }, 0.5)).toBe(100)
|
||||
expect(getScrollEndPadding({ clientHeight: 0 }, 0.5)).toBe(0)
|
||||
expect(getScrollEndPadding(null, 0.5)).toBe(0)
|
||||
const body = {
|
||||
...createScrollableBody(120),
|
||||
getBoundingClientRect: () => ({ top: 50 }),
|
||||
}
|
||||
const target = {
|
||||
getBoundingClientRect: () => ({ top: 310 }),
|
||||
}
|
||||
|
||||
// 120 + (310 - 50 - 200 * 0.4) = 300
|
||||
expect(getAnchoredScrollTop(body, target, 0.4)).toBe(300)
|
||||
const clampedBody = {
|
||||
...createScrollableBody(20),
|
||||
getBoundingClientRect: () => ({ top: 100 }),
|
||||
}
|
||||
const above = {
|
||||
getBoundingClientRect: () => ({ top: -500 }),
|
||||
}
|
||||
const below = {
|
||||
getBoundingClientRect: () => ({ top: 5000 }),
|
||||
}
|
||||
|
||||
expect(getAnchoredScrollTop(clampedBody, above, 0.4)).toBe(0)
|
||||
expect(getAnchoredScrollTop(clampedBody, below, 0.4)).toBe(800)
|
||||
})
|
||||
|
||||
it('stores a cancellable frame while animating scroll position', () => {
|
||||
const body = createScrollableBody()
|
||||
const scrollAnimationRef = { current: null }
|
||||
window.requestAnimationFrame.mockImplementationOnce((callback) => {
|
||||
animationFrames.push(callback)
|
||||
return 0
|
||||
})
|
||||
|
||||
animate(body, scrollAnimationRef, 400)
|
||||
|
||||
expect(scrollAnimationRef.current?.frameId).toBe(0)
|
||||
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(0)
|
||||
expect(scrollAnimationRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('updates scroll position immediately when reduced motion is enabled', () => {
|
||||
const body = createScrollableBody()
|
||||
const scrollAnimationRef = { current: null }
|
||||
|
||||
animate(body, scrollAnimationRef, 400, true)
|
||||
|
||||
expect(body.scrollTop).toBe(400)
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(scrollAnimationRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('skips tiny scroll adjustments within the settle distance', () => {
|
||||
const body = createScrollableBody()
|
||||
const scrollAnimationRef = { current: null }
|
||||
|
||||
animate(body, scrollAnimationRef, 1)
|
||||
|
||||
expect(body.scrollTop).toBe(0)
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(scrollAnimationRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the current animation after natural completion', () => {
|
||||
const body = createScrollableBody()
|
||||
const scrollAnimationRef = { current: null }
|
||||
|
||||
animate(body, scrollAnimationRef, 400)
|
||||
|
||||
now = 1000
|
||||
animationFrames[0]()
|
||||
|
||||
expect(body.scrollTop).toBe(400)
|
||||
expect(scrollAnimationRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores stale frames after a newer scroll animation starts', () => {
|
||||
const body = createScrollableBody()
|
||||
const scrollAnimationRef = { current: null }
|
||||
|
||||
animate(body, scrollAnimationRef, 400)
|
||||
const firstAnimation = scrollAnimationRef.current
|
||||
|
||||
animate(body, scrollAnimationRef, 500)
|
||||
const secondAnimation = scrollAnimationRef.current
|
||||
|
||||
expect(secondAnimation).not.toBe(firstAnimation)
|
||||
|
||||
now = KARAOKE_SCROLL_ANIMATION_MS / 2
|
||||
animationFrames[0]()
|
||||
|
||||
expect(body.scrollTop).toBe(0)
|
||||
expect(scrollAnimationRef.current).toBe(secondAnimation)
|
||||
expect(animationFrames).toHaveLength(2)
|
||||
|
||||
animationFrames[1]()
|
||||
|
||||
expect(body.scrollTop).toBe(250)
|
||||
expect(scrollAnimationRef.current).toBe(secondAnimation)
|
||||
expect(animationFrames).toHaveLength(3)
|
||||
|
||||
now = KARAOKE_SCROLL_ANIMATION_MS
|
||||
animationFrames[2]()
|
||||
|
||||
expect(body.scrollTop).toBe(500)
|
||||
expect(scrollAnimationRef.current).toBeNull()
|
||||
})
|
||||
})
|
||||
146
ui/src/audioplayer/lyricsSegments.js
Normal file
146
ui/src/audioplayer/lyricsSegments.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { utf8ByteRangeToCodeUnitRange } from './lyrics'
|
||||
|
||||
export const buildSegmentsFromLine = (line) => {
|
||||
if (!line || !Array.isArray(line.tokens) || line.tokens.length === 0) {
|
||||
return [{ text: line?.value || '', token: null, tokenIndex: -1 }]
|
||||
}
|
||||
|
||||
const text = line.value || ''
|
||||
const exactSegments = (() => {
|
||||
if (!text) return null
|
||||
|
||||
const rangedTokens = line.tokens
|
||||
.map((token, tokenIndex) => ({
|
||||
token,
|
||||
tokenIndex,
|
||||
range: utf8ByteRangeToCodeUnitRange(
|
||||
text,
|
||||
token?.byteStart,
|
||||
token?.byteEnd,
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.range != null)
|
||||
|
||||
if (
|
||||
rangedTokens.length !== line.tokens.length ||
|
||||
rangedTokens.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
rangedTokens.sort(
|
||||
(a, b) =>
|
||||
a.range.start - b.range.start ||
|
||||
a.range.end - b.range.end ||
|
||||
a.tokenIndex - b.tokenIndex,
|
||||
)
|
||||
|
||||
const segments = []
|
||||
let cursor = 0
|
||||
for (const entry of rangedTokens) {
|
||||
if (entry.range.start < cursor) return null
|
||||
if (entry.range.start > cursor) {
|
||||
segments.push({
|
||||
text: text.slice(cursor, entry.range.start),
|
||||
token: null,
|
||||
tokenIndex: -1,
|
||||
})
|
||||
}
|
||||
segments.push({
|
||||
text: entry.range.text,
|
||||
token: entry.token,
|
||||
tokenIndex: entry.tokenIndex,
|
||||
})
|
||||
cursor = entry.range.end
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ text: text.slice(cursor), token: null, tokenIndex: -1 })
|
||||
}
|
||||
|
||||
return segments
|
||||
})()
|
||||
|
||||
if (exactSegments) return exactSegments
|
||||
|
||||
const matchedSegments = []
|
||||
const fallbackSegments = []
|
||||
let cursor = 0
|
||||
let allMatched = text.length > 0
|
||||
let anyMatched = false
|
||||
|
||||
const pushFallbackSeparatorIfNeeded = (nextTokenText) => {
|
||||
if (fallbackSegments.length === 0) return
|
||||
const prevText = fallbackSegments[fallbackSegments.length - 1].text || ''
|
||||
if (!prevText || !nextTokenText) return
|
||||
if (/\s$/.test(prevText) || /^\s/.test(nextTokenText)) return
|
||||
if (/[A-Za-z0-9]$/.test(prevText) && /^[A-Za-z0-9]/.test(nextTokenText)) {
|
||||
fallbackSegments.push({ text: ' ', token: null, tokenIndex: -1 })
|
||||
}
|
||||
}
|
||||
|
||||
for (let tokenIndex = 0; tokenIndex < line.tokens.length; tokenIndex += 1) {
|
||||
const token = line.tokens[tokenIndex]
|
||||
const tokenText = token.value || ''
|
||||
if (!tokenText) continue
|
||||
|
||||
pushFallbackSeparatorIfNeeded(tokenText)
|
||||
fallbackSegments.push({ text: tokenText, token, tokenIndex })
|
||||
|
||||
if (!text) {
|
||||
allMatched = false
|
||||
continue
|
||||
}
|
||||
|
||||
const foundAt = text.indexOf(tokenText, cursor)
|
||||
const lowerText = foundAt < 0 ? text.toLowerCase() : text
|
||||
const lowerTokenText = foundAt < 0 ? tokenText.toLowerCase() : tokenText
|
||||
const canReuseLowercaseOffsets =
|
||||
lowerText.length === text.length &&
|
||||
lowerTokenText.length === tokenText.length
|
||||
const normalizedFoundAt =
|
||||
foundAt >= 0
|
||||
? foundAt
|
||||
: canReuseLowercaseOffsets
|
||||
? lowerText.indexOf(lowerTokenText, cursor)
|
||||
: -1
|
||||
|
||||
if (normalizedFoundAt >= 0) {
|
||||
anyMatched = true
|
||||
if (normalizedFoundAt > cursor) {
|
||||
matchedSegments.push({
|
||||
text: text.slice(cursor, normalizedFoundAt),
|
||||
token: null,
|
||||
tokenIndex: -1,
|
||||
})
|
||||
}
|
||||
const matchedTokenText = text.slice(
|
||||
normalizedFoundAt,
|
||||
normalizedFoundAt + tokenText.length,
|
||||
)
|
||||
matchedSegments.push({
|
||||
text: matchedTokenText || tokenText,
|
||||
token,
|
||||
tokenIndex,
|
||||
})
|
||||
cursor = normalizedFoundAt + tokenText.length
|
||||
} else {
|
||||
allMatched = false
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatched && anyMatched) {
|
||||
if (cursor < text.length) {
|
||||
matchedSegments.push({
|
||||
text: text.slice(cursor),
|
||||
token: null,
|
||||
tokenIndex: -1,
|
||||
})
|
||||
}
|
||||
return matchedSegments
|
||||
}
|
||||
|
||||
if (fallbackSegments.length > 0) return fallbackSegments
|
||||
|
||||
return [{ text, token: null, tokenIndex: -1 }]
|
||||
}
|
||||
15
ui/src/audioplayer/lyricsSegments.test.js
Normal file
15
ui/src/audioplayer/lyricsSegments.test.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSegmentsFromLine } from './lyricsSegments'
|
||||
|
||||
describe('buildSegmentsFromLine', () => {
|
||||
it('does not reuse lowercase offsets when Unicode case folding expands text', () => {
|
||||
const segments = buildSegmentsFromLine({
|
||||
value: '\u0130A',
|
||||
tokens: [{ value: 'a' }],
|
||||
})
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ text: 'a', token: { value: 'a' }, tokenIndex: 0 },
|
||||
])
|
||||
})
|
||||
})
|
||||
24
ui/src/audioplayer/lyricsSidebarState.js
Normal file
24
ui/src/audioplayer/lyricsSidebarState.js
Normal file
@ -0,0 +1,24 @@
|
||||
export const resolveLyricsSidebarState = ({
|
||||
lyricsVisiblePreference,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasTranslationLyric,
|
||||
hasPronunciationLyric,
|
||||
}) => ({
|
||||
lyricsVisible: Boolean(lyricsVisiblePreference),
|
||||
showTranslation: Boolean(
|
||||
(translationPreference ?? true) && hasTranslationLyric,
|
||||
),
|
||||
showPronunciation: Boolean(
|
||||
(pronunciationPreference ?? true) && hasPronunciationLyric,
|
||||
),
|
||||
})
|
||||
|
||||
export const toggleLayerPreference = (
|
||||
previousPreference,
|
||||
hasLayer,
|
||||
defaultEnabled = true,
|
||||
) => {
|
||||
if (!hasLayer) return false
|
||||
return !(previousPreference ?? defaultEnabled)
|
||||
}
|
||||
41
ui/src/audioplayer/lyricsSidebarState.test.js
Normal file
41
ui/src/audioplayer/lyricsSidebarState.test.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveLyricsSidebarState,
|
||||
toggleLayerPreference,
|
||||
} from './lyricsSidebarState'
|
||||
|
||||
describe('lyricsSidebarState', () => {
|
||||
it('defaults available layers on and keeps preferences local', () => {
|
||||
const resolve = (availability, preferences = {}) =>
|
||||
resolveLyricsSidebarState({
|
||||
lyricsVisiblePreference: true,
|
||||
translationPreference: null,
|
||||
pronunciationPreference: null,
|
||||
...availability,
|
||||
...preferences,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolve({ hasTranslationLyric: true, hasPronunciationLyric: true }),
|
||||
).toEqual({
|
||||
lyricsVisible: true,
|
||||
showTranslation: true,
|
||||
showPronunciation: true,
|
||||
})
|
||||
expect(
|
||||
resolve(
|
||||
{ hasTranslationLyric: true, hasPronunciationLyric: false },
|
||||
{ translationPreference: false, pronunciationPreference: true },
|
||||
),
|
||||
).toEqual({
|
||||
lyricsVisible: true,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
})
|
||||
|
||||
expect(toggleLayerPreference(null, true)).toBe(false)
|
||||
expect(toggleLayerPreference(null, true, false)).toBe(true)
|
||||
expect(toggleLayerPreference(false, true)).toBe(true)
|
||||
expect(toggleLayerPreference(true, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
73
ui/src/audioplayer/lyricsSidebarWidth.js
Normal file
73
ui/src/audioplayer/lyricsSidebarWidth.js
Normal file
@ -0,0 +1,73 @@
|
||||
export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1'
|
||||
export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360
|
||||
export const LYRICS_SIDEBAR_MIN_WIDTH = 300
|
||||
export const LYRICS_SIDEBAR_MAX_WIDTH = 520
|
||||
export const LYRICS_ROUTE_MIN_WIDTH = 320
|
||||
export const LYRICS_SIDEBAR_WIDTH_STEP = 20
|
||||
export const LYRICS_SIDEBAR_TRANSITION_MS = 260
|
||||
|
||||
export const clampSidebarMaxWidth = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_MAX_WIDTH
|
||||
return Math.round(
|
||||
Math.min(
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const clampSidebarWidth = (
|
||||
value,
|
||||
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
) => {
|
||||
const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return Math.min(LYRICS_SIDEBAR_DEFAULT_WIDTH, resolvedMaxWidth)
|
||||
}
|
||||
return Math.round(
|
||||
Math.min(resolvedMaxWidth, Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric)),
|
||||
)
|
||||
}
|
||||
|
||||
export const getSidebarMaxWidth = (contentWidth) => {
|
||||
const numeric = Number(contentWidth)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return LYRICS_SIDEBAR_MAX_WIDTH
|
||||
}
|
||||
return clampSidebarMaxWidth(numeric - LYRICS_ROUTE_MIN_WIDTH)
|
||||
}
|
||||
|
||||
const hasLocalStorage = () => {
|
||||
try {
|
||||
return typeof window !== 'undefined' && Boolean(window.localStorage)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const loadSidebarWidth = () => {
|
||||
if (!hasLocalStorage()) return LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)
|
||||
return stored == null
|
||||
? LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
: clampSidebarWidth(stored)
|
||||
} catch {
|
||||
return LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
}
|
||||
}
|
||||
|
||||
export const saveSidebarWidth = (width) => {
|
||||
const resolvedWidth = clampSidebarWidth(width)
|
||||
if (!hasLocalStorage()) return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
LYRICS_SIDEBAR_STORAGE_KEY,
|
||||
String(resolvedWidth),
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
312
ui/src/audioplayer/lyricsTimeline.js
Normal file
312
ui/src/audioplayer/lyricsTimeline.js
Normal file
@ -0,0 +1,312 @@
|
||||
import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants'
|
||||
|
||||
export const finiteTime = (value) => {
|
||||
if (value == null || value === '') return null
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : null
|
||||
}
|
||||
|
||||
const upperBound = (items, time, getTime) => {
|
||||
let low = 0
|
||||
let high = items.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
if (getTime(items[middle]) <= time) low = middle + 1
|
||||
else high = middle
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
export const sameIndexes = (left, right) =>
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
|
||||
const getPrimaryActiveIndex = (timeline, indexes) =>
|
||||
indexes.reduce((primaryIndex, lineIndex) => {
|
||||
if (primaryIndex < 0) return lineIndex
|
||||
const primary = timeline?.windows?.[primaryIndex]
|
||||
const candidate = timeline?.windows?.[lineIndex]
|
||||
if ((candidate?.start ?? -Infinity) !== (primary?.start ?? -Infinity)) {
|
||||
return (candidate?.start ?? -Infinity) > (primary?.start ?? -Infinity)
|
||||
? lineIndex
|
||||
: primaryIndex
|
||||
}
|
||||
return lineIndex > primaryIndex ? lineIndex : primaryIndex
|
||||
}, -1)
|
||||
|
||||
const earliestTokenStart = (line) => {
|
||||
const tokens = Array.isArray(line?.tokens) ? line.tokens : []
|
||||
let earliest = null
|
||||
for (const token of tokens) {
|
||||
const start = finiteTime(token?.start)
|
||||
if (start != null && (earliest == null || start < earliest))
|
||||
earliest = start
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
|
||||
const latestExplicitTokenEnd = (line) => {
|
||||
const tokens = Array.isArray(line?.tokens) ? line.tokens : []
|
||||
let latest = null
|
||||
for (const token of tokens) {
|
||||
const end = finiteTime(token?.end)
|
||||
if (end != null && (latest == null || end > latest)) latest = end
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
export const buildLyricsTimeline = (
|
||||
lines,
|
||||
{ durationMs = null, fallbackLineDurationMs = 8000 } = {},
|
||||
) => {
|
||||
const sourceLines = Array.isArray(lines) ? lines : []
|
||||
const starts = sourceLines.map(
|
||||
(line) => finiteTime(line?.start) ?? earliestTokenStart(line),
|
||||
)
|
||||
const nextTimedStarts = new Array(sourceLines.length).fill(null)
|
||||
const timedEntries = starts
|
||||
.map((start, lineIndex) => ({ start, lineIndex }))
|
||||
.filter((entry) => entry.start != null)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.start - right.start || left.lineIndex - right.lineIndex,
|
||||
)
|
||||
let nextDistinctStart = null
|
||||
for (let index = timedEntries.length - 1; index >= 0; ) {
|
||||
const groupStart = timedEntries[index].start
|
||||
let groupIndex = index
|
||||
while (groupIndex >= 0 && timedEntries[groupIndex].start === groupStart) {
|
||||
nextTimedStarts[timedEntries[groupIndex].lineIndex] = nextDistinctStart
|
||||
groupIndex -= 1
|
||||
}
|
||||
nextDistinctStart = groupStart
|
||||
index = groupIndex
|
||||
}
|
||||
|
||||
const trackEnd = finiteTime(durationMs)
|
||||
const windows = sourceLines.map((line, lineIndex) => {
|
||||
const start = starts[lineIndex]
|
||||
let end = finiteTime(line?.end) ?? latestExplicitTokenEnd(line)
|
||||
if (end == null) end = nextTimedStarts[lineIndex]
|
||||
if (end == null && start != null) {
|
||||
end = start + fallbackLineDurationMs
|
||||
if (trackEnd != null && trackEnd > start) {
|
||||
end = Math.min(end, trackEnd)
|
||||
}
|
||||
}
|
||||
const renderable = line?.renderable !== false
|
||||
const intervalValid = start != null && end != null && end > start
|
||||
const valid = renderable && intervalValid
|
||||
return {
|
||||
lineIndex,
|
||||
start,
|
||||
end,
|
||||
renderable,
|
||||
intervalValid,
|
||||
valid,
|
||||
preRollStart:
|
||||
start == null ? null : Math.max(0, start - KARAOKE_SCROLL_PRE_ROLL_MS),
|
||||
nextTimedStart: nextTimedStarts[lineIndex],
|
||||
}
|
||||
})
|
||||
|
||||
const events = windows
|
||||
.filter((window) => window.valid)
|
||||
.flatMap((window) => [
|
||||
{ time: window.start, type: 'start', lineIndex: window.lineIndex },
|
||||
{ time: window.end, type: 'end', lineIndex: window.lineIndex },
|
||||
])
|
||||
.sort((left, right) => {
|
||||
if (left.time !== right.time) return left.time - right.time
|
||||
if (left.type !== right.type) return left.type === 'end' ? -1 : 1
|
||||
return left.lineIndex - right.lineIndex
|
||||
})
|
||||
|
||||
const checkpoints = []
|
||||
const active = new Set()
|
||||
events.forEach((event, index) => {
|
||||
if (event.type === 'start') active.add(event.lineIndex)
|
||||
else active.delete(event.lineIndex)
|
||||
if ((index + 1) % 64 === 0) {
|
||||
checkpoints.push({
|
||||
eventIndex: index + 1,
|
||||
time: event.time,
|
||||
active: Array.from(active),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const scrollOrder = windows
|
||||
.filter((window) => window.valid && window.preRollStart != null)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.preRollStart - right.preRollStart ||
|
||||
left.start - right.start ||
|
||||
left.lineIndex - right.lineIndex,
|
||||
)
|
||||
|
||||
return { windows, events, checkpoints, scrollOrder }
|
||||
}
|
||||
|
||||
export const getTimelineScrollTarget = (timeline, time) => {
|
||||
const current = finiteTime(time) ?? 0
|
||||
const order = timeline?.scrollOrder || []
|
||||
const index = upperBound(order, current, (item) => item.preRollStart) - 1
|
||||
return index >= 0 ? order[index].lineIndex : -1
|
||||
}
|
||||
|
||||
export class LyricTimelineCursor {
|
||||
constructor(timeline) {
|
||||
this.timeline = timeline
|
||||
this.eventIndex = 0
|
||||
this.active = new Set()
|
||||
this.lastTime = -Infinity
|
||||
this.lastIndexes = []
|
||||
this.result = {
|
||||
indexes: this.lastIndexes,
|
||||
changed: false,
|
||||
primaryIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
reset(timeline = this.timeline) {
|
||||
this.timeline = timeline
|
||||
this.eventIndex = 0
|
||||
this.active.clear()
|
||||
this.lastTime = -Infinity
|
||||
this.lastIndexes = []
|
||||
this.result.indexes = this.lastIndexes
|
||||
this.result.changed = false
|
||||
this.result.primaryIndex = -1
|
||||
}
|
||||
|
||||
applyEvent(event) {
|
||||
if (event.type === 'start') this.active.add(event.lineIndex)
|
||||
else this.active.delete(event.lineIndex)
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
const events = this.timeline?.events || []
|
||||
const checkpoints = this.timeline?.checkpoints || []
|
||||
const checkpointIndex =
|
||||
upperBound(checkpoints, time, (item) => item.time) - 1
|
||||
const checkpoint =
|
||||
checkpointIndex >= 0 ? checkpoints[checkpointIndex] : null
|
||||
this.active = new Set(checkpoint?.active || [])
|
||||
this.eventIndex = checkpoint?.eventIndex || 0
|
||||
while (
|
||||
this.eventIndex < events.length &&
|
||||
events[this.eventIndex].time <= time
|
||||
) {
|
||||
this.applyEvent(events[this.eventIndex])
|
||||
this.eventIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
update(time, forceSeek = false) {
|
||||
const current = finiteTime(time) ?? 0
|
||||
const events = this.timeline?.events || []
|
||||
const previousEventIndex = this.eventIndex
|
||||
if (forceSeek || current < this.lastTime) {
|
||||
this.seek(current)
|
||||
} else {
|
||||
while (
|
||||
this.eventIndex < events.length &&
|
||||
events[this.eventIndex].time <= current
|
||||
) {
|
||||
this.applyEvent(events[this.eventIndex])
|
||||
this.eventIndex += 1
|
||||
}
|
||||
}
|
||||
this.lastTime = current
|
||||
if (!forceSeek && previousEventIndex === this.eventIndex) {
|
||||
this.result.changed = false
|
||||
return this.result
|
||||
}
|
||||
const indexes = Array.from(this.active).sort((a, b) => a - b)
|
||||
const changed = !sameIndexes(indexes, this.lastIndexes)
|
||||
this.lastIndexes = indexes
|
||||
this.result.indexes = indexes
|
||||
this.result.changed = changed
|
||||
this.result.primaryIndex = getPrimaryActiveIndex(this.timeline, indexes)
|
||||
return this.result
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveKaraokeTokenWindows = (line, lineEndFallback = null) => {
|
||||
const tokens = Array.isArray(line?.tokens) ? line.tokens : []
|
||||
if (tokens.length === 0) return []
|
||||
|
||||
const lineStart = finiteTime(line?.start)
|
||||
const lineEnd = finiteTime(line?.end) ?? finiteTime(lineEndFallback)
|
||||
const hasLineWindow =
|
||||
lineStart != null && lineEnd != null && lineEnd > lineStart
|
||||
const tokenCount = tokens.length
|
||||
const windows = new Array(tokenCount)
|
||||
|
||||
for (let tokenIndex = 0; tokenIndex < tokenCount; tokenIndex += 1) {
|
||||
const token = tokens[tokenIndex]
|
||||
const previousToken = tokenIndex > 0 ? tokens[tokenIndex - 1] : null
|
||||
const nextToken =
|
||||
tokenIndex + 1 < tokenCount ? tokens[tokenIndex + 1] : null
|
||||
const estimatedStart = hasLineWindow
|
||||
? lineStart + ((lineEnd - lineStart) * tokenIndex) / tokenCount
|
||||
: null
|
||||
const estimatedEnd = hasLineWindow
|
||||
? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
|
||||
: null
|
||||
const previousEnd =
|
||||
finiteTime(previousToken?.end) ?? finiteTime(previousToken?.start)
|
||||
|
||||
let start = finiteTime(token?.start)
|
||||
if (start == null) start = previousEnd ?? estimatedStart ?? lineStart
|
||||
|
||||
let end = finiteTime(token?.end)
|
||||
if (end == null) {
|
||||
const nextStart = finiteTime(nextToken?.start)
|
||||
const nextEstimatedStart =
|
||||
hasLineWindow && tokenIndex + 1 < tokenCount
|
||||
? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
|
||||
: null
|
||||
end = nextStart ?? nextEstimatedStart ?? estimatedEnd ?? lineEnd
|
||||
}
|
||||
|
||||
if (
|
||||
tokenCount === 1 &&
|
||||
hasLineWindow &&
|
||||
(start == null || end == null || end <= start + 1)
|
||||
) {
|
||||
start = lineStart
|
||||
end = lineEnd
|
||||
}
|
||||
if (start != null && end != null && end < start) end = start
|
||||
|
||||
const previousWindow = tokenIndex > 0 ? windows[tokenIndex - 1] : null
|
||||
const visualStart =
|
||||
start != null && previousWindow?.end != null
|
||||
? Math.max(start, previousWindow.end)
|
||||
: start
|
||||
const visualEnd =
|
||||
end != null && visualStart != null && end <= visualStart
|
||||
? visualStart + 1
|
||||
: end
|
||||
windows[tokenIndex] = {
|
||||
start: visualStart,
|
||||
end: visualEnd,
|
||||
sourceStart: start,
|
||||
sourceEnd: end,
|
||||
}
|
||||
}
|
||||
|
||||
return windows
|
||||
}
|
||||
|
||||
export const tokenProgressAt = (window, time) => {
|
||||
const current = finiteTime(time) ?? 0
|
||||
if (!window || window.start == null || window.end == null) return 0
|
||||
if (window.end <= window.start) return current >= window.start ? 1 : 0
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(1, (current - window.start) / (window.end - window.start)),
|
||||
)
|
||||
}
|
||||
196
ui/src/audioplayer/lyricsTimeline.test.js
Normal file
196
ui/src/audioplayer/lyricsTimeline.test.js
Normal file
@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildLyricsTimeline,
|
||||
getTimelineScrollTarget,
|
||||
LyricTimelineCursor,
|
||||
resolveKaraokeTokenWindows,
|
||||
tokenProgressAt,
|
||||
} from './lyricsTimeline'
|
||||
import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants'
|
||||
|
||||
describe('lyricsTimeline', () => {
|
||||
it('supports overlapping active lines and backward seeks', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{ start: 1000, end: 3000, tokens: [] },
|
||||
{ start: 2000, end: 4000, tokens: [] },
|
||||
])
|
||||
const cursor = new LyricTimelineCursor(timeline)
|
||||
|
||||
expect(cursor.update(1500, true).indexes).toEqual([0])
|
||||
expect(cursor.update(2500).indexes).toEqual([0, 1])
|
||||
expect(cursor.update(3500).indexes).toEqual([1])
|
||||
expect(cursor.update(1500).indexes).toEqual([0])
|
||||
})
|
||||
|
||||
it('excludes zero and negative duration intervals', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{ start: 1000, end: 1000, tokens: [] },
|
||||
{ start: 2000, end: 1500, tokens: [] },
|
||||
{ start: 3000, end: 4000, tokens: [] },
|
||||
])
|
||||
|
||||
expect(timeline.events).toEqual([
|
||||
{ time: 3000, type: 'start', lineIndex: 2 },
|
||||
{ time: 4000, type: 'end', lineIndex: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the next timed line across untimed display rows', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{ start: 1000, tokens: [] },
|
||||
{ value: 'untimed annotation', tokens: [] },
|
||||
{ start: 5000, end: 6000, tokens: [] },
|
||||
])
|
||||
|
||||
expect(timeline.windows[0].end).toBe(5000)
|
||||
expect(timeline.windows[0].nextTimedStart).toBe(5000)
|
||||
expect(timeline.windows[1].valid).toBe(false)
|
||||
})
|
||||
|
||||
it('uses chronological timing independently from display order', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{ start: 3000, end: 5000, value: 'Displayed first', tokens: [] },
|
||||
{ start: 1000, end: 4000, value: 'Displayed second', tokens: [] },
|
||||
{ start: 2000, value: 'Displayed third', tokens: [] },
|
||||
])
|
||||
const cursor = new LyricTimelineCursor(timeline)
|
||||
|
||||
expect(timeline.windows[2].end).toBe(3000)
|
||||
expect(cursor.update(2500, true)).toMatchObject({
|
||||
indexes: [1, 2],
|
||||
primaryIndex: 2,
|
||||
})
|
||||
expect(cursor.update(3500)).toMatchObject({
|
||||
indexes: [0, 1],
|
||||
primaryIndex: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses timed blank markers as boundaries without activating or scrolling to them', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{
|
||||
start: 1000,
|
||||
value: 'Before pause',
|
||||
tokens: [],
|
||||
renderable: true,
|
||||
},
|
||||
{ start: 2000, value: '', tokens: [], renderable: false },
|
||||
{
|
||||
start: 4000,
|
||||
end: 5000,
|
||||
value: 'After pause',
|
||||
tokens: [],
|
||||
renderable: true,
|
||||
},
|
||||
])
|
||||
const cursor = new LyricTimelineCursor(timeline)
|
||||
|
||||
expect(timeline.windows[0].end).toBe(2000)
|
||||
expect(timeline.windows[1]).toMatchObject({
|
||||
renderable: false,
|
||||
intervalValid: true,
|
||||
valid: false,
|
||||
})
|
||||
expect(cursor.update(2500, true).indexes).toEqual([])
|
||||
expect(timeline.scrollOrder.map((window) => window.lineIndex)).toEqual([
|
||||
0, 2,
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds a final open line when media duration is unavailable', () => {
|
||||
const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], {
|
||||
fallbackLineDurationMs: 8000,
|
||||
})
|
||||
|
||||
expect(timeline.windows[0]).toMatchObject({
|
||||
start: 7000,
|
||||
end: 15000,
|
||||
valid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a start-only line active until the next timestamp', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{ start: 1000, value: 'A lyric line', tokens: [] },
|
||||
{ start: 5000, end: 6000, tokens: [] },
|
||||
])
|
||||
const cursor = new LyricTimelineCursor(timeline)
|
||||
|
||||
expect(timeline.windows[0].end).toBe(5000)
|
||||
expect(cursor.update(4999, true).indexes).toEqual([0])
|
||||
expect(cursor.update(5000).indexes).toEqual([1])
|
||||
})
|
||||
|
||||
it('caps a final open line with track duration', () => {
|
||||
const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], {
|
||||
durationMs: 10000,
|
||||
})
|
||||
|
||||
expect(timeline.windows[0]).toMatchObject({
|
||||
start: 7000,
|
||||
end: 10000,
|
||||
valid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a stale track duration that precedes the final line', () => {
|
||||
const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], {
|
||||
durationMs: 5000,
|
||||
fallbackLineDurationMs: 8000,
|
||||
})
|
||||
|
||||
expect(timeline.windows[0]).toMatchObject({
|
||||
start: 7000,
|
||||
end: 15000,
|
||||
valid: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('seeks through checkpoints in timelines longer than 32 lines', () => {
|
||||
const timeline = buildLyricsTimeline(
|
||||
Array.from({ length: 40 }, (_, index) => ({
|
||||
start: index * 1000,
|
||||
end: (index + 1) * 1000,
|
||||
tokens: [],
|
||||
})),
|
||||
)
|
||||
const cursor = new LyricTimelineCursor(timeline)
|
||||
|
||||
expect(timeline.checkpoints.length).toBeGreaterThan(0)
|
||||
expect(cursor.update(35500, true)).toMatchObject({
|
||||
indexes: [35],
|
||||
primaryIndex: 35,
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the scroll target at pre-roll without changing active state', () => {
|
||||
const timeline = buildLyricsTimeline([
|
||||
{ start: 1000, end: 1500, tokens: [] },
|
||||
{ start: 3000, end: 3500, tokens: [] },
|
||||
])
|
||||
|
||||
expect(getTimelineScrollTarget(timeline, 0)).toBe(-1)
|
||||
expect(
|
||||
getTimelineScrollTarget(timeline, 3000 - KARAOKE_SCROLL_PRE_ROLL_MS),
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('precomputes token windows once and keeps progress deterministic', () => {
|
||||
const line = {
|
||||
start: 1000,
|
||||
end: 3000,
|
||||
tokens: [
|
||||
{ start: 1000, value: 'one' },
|
||||
{ start: 2000, end: 3000, value: 'two' },
|
||||
],
|
||||
}
|
||||
const windows = resolveKaraokeTokenWindows(line)
|
||||
|
||||
expect(windows).toEqual([
|
||||
{ start: 1000, end: 2000, sourceStart: 1000, sourceEnd: 2000 },
|
||||
{ start: 2000, end: 3000, sourceStart: 2000, sourceEnd: 3000 },
|
||||
])
|
||||
expect(tokenProgressAt(windows[0], 1500)).toBe(0.5)
|
||||
expect(tokenProgressAt(windows[0], 2500)).toBe(1)
|
||||
})
|
||||
})
|
||||
8
ui/src/audioplayer/playerBreakpoints.js
Normal file
8
ui/src/audioplayer/playerBreakpoints.js
Normal file
@ -0,0 +1,8 @@
|
||||
export const PLAYER_DESKTOP_MIN_WIDTH = 810
|
||||
export const PLAYER_MEDIA_QUERY_STEP = 0.05
|
||||
export const PLAYER_MOBILE_MAX_WIDTH =
|
||||
PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MEDIA_QUERY_STEP
|
||||
|
||||
export const PLAYER_DESKTOP_MEDIA_QUERY = `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`
|
||||
export const PLAYER_MOBILE_MATCH_MEDIA_QUERY = `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`
|
||||
export const PLAYER_MOBILE_MEDIA_QUERY = `@media screen and ${PLAYER_MOBILE_MATCH_MEDIA_QUERY}`
|
||||
28
ui/src/audioplayer/playerBreakpoints.test.js
Normal file
28
ui/src/audioplayer/playerBreakpoints.test.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PLAYER_DESKTOP_MEDIA_QUERY,
|
||||
PLAYER_DESKTOP_MIN_WIDTH,
|
||||
PLAYER_MOBILE_MATCH_MEDIA_QUERY,
|
||||
PLAYER_MOBILE_MAX_WIDTH,
|
||||
PLAYER_MOBILE_MEDIA_QUERY,
|
||||
PLAYER_MEDIA_QUERY_STEP,
|
||||
} from './playerBreakpoints'
|
||||
|
||||
describe('player breakpoints', () => {
|
||||
it('keeps compact and desktop player ranges from overlapping', () => {
|
||||
expect(PLAYER_DESKTOP_MIN_WIDTH).toBe(810)
|
||||
expect(PLAYER_MOBILE_MAX_WIDTH).toBe(809.95)
|
||||
expect(PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MOBILE_MAX_WIDTH).toBeCloseTo(
|
||||
PLAYER_MEDIA_QUERY_STEP,
|
||||
)
|
||||
expect(PLAYER_DESKTOP_MEDIA_QUERY).toBe(
|
||||
`(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`,
|
||||
)
|
||||
expect(PLAYER_MOBILE_MEDIA_QUERY).toBe(
|
||||
`@media screen and (max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`,
|
||||
)
|
||||
expect(PLAYER_MOBILE_MATCH_MEDIA_QUERY).toBe(
|
||||
`(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,8 @@
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
|
||||
import { PLAYER_MOBILE_MEDIA_QUERY } from './playerBreakpoints'
|
||||
|
||||
const MOBILE_LYRICS_TRANSITION = `opacity ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), transform ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
|
||||
const useStyle = makeStyles(
|
||||
(theme) => ({
|
||||
@ -27,12 +31,12 @@ const useStyle = makeStyles(
|
||||
},
|
||||
player: {
|
||||
display: (props) => (props.visible ? 'block' : 'none'),
|
||||
'@media screen and (max-width:810px)': {
|
||||
[PLAYER_MOBILE_MEDIA_QUERY]: {
|
||||
'& .sound-operation': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
'@media (prefers-reduced-motion)': {
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
animation: 'none',
|
||||
},
|
||||
@ -42,7 +46,10 @@ const useStyle = makeStyles(
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .play-mode-title': {
|
||||
'pointer-events': 'none',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'& .audio-lists-panel': {
|
||||
zIndex: theme.zIndex.appBar,
|
||||
},
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
// Customize desktop player when cover animation is disabled
|
||||
@ -62,11 +69,56 @@ const useStyle = makeStyles(
|
||||
// Fix cover display when image is not square
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active':
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '100%',
|
||||
aspectRatio: 'auto',
|
||||
borderRadius: 0,
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'stretch',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer':
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
transform: 'scale(0.985)',
|
||||
transition: MOBILE_LYRICS_TRANSITION,
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'opacity 120ms linear',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer[data-entered="true"]':
|
||||
{
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover':
|
||||
{
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
objectFit: 'contain', // Fix cover display when image is not square
|
||||
transition: MOBILE_LYRICS_TRANSITION,
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'opacity 120ms linear',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active img.cover':
|
||||
{
|
||||
opacity: 0,
|
||||
transform: 'scale(0.96)',
|
||||
pointerEvents: 'none',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
// Hide old singer display
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer':
|
||||
|
||||
266
ui/src/audioplayer/useEnhancedLyrics.js
Normal file
266
ui/src/audioplayer/useEnhancedLyrics.js
Normal file
@ -0,0 +1,266 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import subsonic from '../subsonic'
|
||||
import { getPreferredLyricLanguage, selectLyricLayers } from './lyrics'
|
||||
|
||||
export const emptyLyricLayers = Object.freeze({
|
||||
main: null,
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
})
|
||||
|
||||
const MAX_LYRIC_CACHE_ENTRIES = 75
|
||||
const NEGATIVE_CACHE_TTL_MS = 30_000
|
||||
const COOLDOWN_PATTERN = /(?:cooldown active for|retry after)\s+(\d+)s/gi
|
||||
|
||||
const cache = new Map()
|
||||
const inFlight = new Map()
|
||||
|
||||
const normalizeLyricLayers = (layers) => ({
|
||||
main: layers?.main || null,
|
||||
translation: layers?.translation || null,
|
||||
pronunciation: layers?.pronunciation || null,
|
||||
})
|
||||
|
||||
const readStructuredLyrics = (response) =>
|
||||
response?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || []
|
||||
|
||||
const retryDelayFromError = (error) => {
|
||||
const serverMessage =
|
||||
error?.body?.['subsonic-response']?.error?.message || error?.message || ''
|
||||
const matches = [...String(serverMessage).matchAll(COOLDOWN_PATTERN)]
|
||||
if (matches.length === 0) return null
|
||||
const seconds = Math.max(...matches.map((match) => Number(match[1])))
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
|
||||
}
|
||||
|
||||
const buildCacheKey = ({ trackId, preferredLanguage, updatedAt }) =>
|
||||
[trackId || '', updatedAt || '', preferredLanguage || ''].join('\u0000')
|
||||
|
||||
const rememberLyrics = (cacheKey, layers, expiresAt = null) => {
|
||||
cache.delete(cacheKey)
|
||||
cache.set(cacheKey, { layers, expiresAt })
|
||||
while (cache.size > MAX_LYRIC_CACHE_ENTRIES) {
|
||||
const oldestCacheKey = cache.keys().next().value
|
||||
cache.delete(oldestCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
const readCachedLyrics = (cacheKey) => {
|
||||
const cached = cache.get(cacheKey)
|
||||
if (!cached) return null
|
||||
if (cached.expiresAt != null && cached.expiresAt <= Date.now()) {
|
||||
cache.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
cache.delete(cacheKey)
|
||||
cache.set(cacheKey, cached)
|
||||
return cached.layers
|
||||
}
|
||||
|
||||
const createLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => {
|
||||
const controller = new AbortController()
|
||||
const entry = {
|
||||
controller,
|
||||
consumers: 0,
|
||||
settled: false,
|
||||
promise: null,
|
||||
}
|
||||
entry.promise = subsonic
|
||||
.getLyricsBySongId(trackId, { signal: controller.signal })
|
||||
.then((response) => {
|
||||
const selected = normalizeLyricLayers(
|
||||
selectLyricLayers(readStructuredLyrics(response), preferredLanguage),
|
||||
)
|
||||
const hasAnyLayer = Boolean(
|
||||
selected.main || selected.translation || selected.pronunciation,
|
||||
)
|
||||
rememberLyrics(
|
||||
cacheKey,
|
||||
selected,
|
||||
hasAnyLayer ? null : Date.now() + NEGATIVE_CACHE_TTL_MS,
|
||||
)
|
||||
return selected
|
||||
})
|
||||
.finally(() => {
|
||||
entry.settled = true
|
||||
if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey)
|
||||
})
|
||||
inFlight.set(cacheKey, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
const acquireLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => {
|
||||
const existing = inFlight.get(cacheKey)
|
||||
const entry =
|
||||
existing && !existing.controller.signal.aborted
|
||||
? existing
|
||||
: createLyricsRequest({ trackId, preferredLanguage, cacheKey })
|
||||
entry.consumers += 1
|
||||
let released = false
|
||||
return {
|
||||
promise: entry.promise,
|
||||
release: () => {
|
||||
if (released) return
|
||||
released = true
|
||||
entry.consumers = Math.max(0, entry.consumers - 1)
|
||||
if (!entry.settled && entry.consumers === 0) {
|
||||
if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey)
|
||||
entry.controller.abort()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const useEnhancedLyrics = ({
|
||||
trackId,
|
||||
updatedAt,
|
||||
disabled = false,
|
||||
requested = true,
|
||||
}) => {
|
||||
const preferredLanguage = getPreferredLyricLanguage()
|
||||
const cacheKey = useMemo(
|
||||
() => buildCacheKey({ trackId, preferredLanguage, updatedAt }),
|
||||
[preferredLanguage, trackId, updatedAt],
|
||||
)
|
||||
const [state, setState] = useState(() => ({
|
||||
cacheKey,
|
||||
layers: emptyLyricLayers,
|
||||
loading: false,
|
||||
error: null,
|
||||
retryAt: null,
|
||||
}))
|
||||
const [requestGeneration, setRequestGeneration] = useState(0)
|
||||
const [, setCountdownTick] = useState(0)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
cache.delete(cacheKey)
|
||||
setRequestGeneration((current) => current + 1)
|
||||
}, [cacheKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trackId || disabled || !requested) {
|
||||
setState({
|
||||
cacheKey,
|
||||
layers: emptyLyricLayers,
|
||||
loading: false,
|
||||
error: null,
|
||||
retryAt: null,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cached = readCachedLyrics(cacheKey)
|
||||
if (cached) {
|
||||
setState({
|
||||
cacheKey,
|
||||
layers: cached,
|
||||
loading: false,
|
||||
error: null,
|
||||
retryAt: null,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
let active = true
|
||||
const request = acquireLyricsRequest({
|
||||
trackId,
|
||||
preferredLanguage,
|
||||
cacheKey,
|
||||
})
|
||||
setState({
|
||||
cacheKey,
|
||||
layers: emptyLyricLayers,
|
||||
loading: true,
|
||||
error: null,
|
||||
retryAt: null,
|
||||
})
|
||||
|
||||
request.promise
|
||||
.then((layers) => {
|
||||
if (!active) return
|
||||
setState({
|
||||
cacheKey,
|
||||
layers,
|
||||
loading: false,
|
||||
error: null,
|
||||
retryAt: null,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active || error?.name === 'AbortError') return
|
||||
cache.delete(cacheKey)
|
||||
setState({
|
||||
cacheKey,
|
||||
layers: emptyLyricLayers,
|
||||
loading: false,
|
||||
error,
|
||||
retryAt: (() => {
|
||||
const delay = retryDelayFromError(error)
|
||||
return delay == null ? null : Date.now() + delay
|
||||
})(),
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
request.release()
|
||||
}
|
||||
}, [
|
||||
cacheKey,
|
||||
disabled,
|
||||
preferredLanguage,
|
||||
requestGeneration,
|
||||
requested,
|
||||
trackId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.cacheKey !== cacheKey ||
|
||||
state.retryAt == null ||
|
||||
disabled ||
|
||||
!requested
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const retryDelay = Math.max(0, state.retryAt - Date.now())
|
||||
const retryTimer = window.setTimeout(retry, retryDelay)
|
||||
const countdownTimer = window.setInterval(
|
||||
() => setCountdownTick((current) => current + 1),
|
||||
1_000,
|
||||
)
|
||||
return () => {
|
||||
window.clearTimeout(retryTimer)
|
||||
window.clearInterval(countdownTimer)
|
||||
}
|
||||
}, [cacheKey, disabled, requested, retry, state.cacheKey, state.retryAt])
|
||||
|
||||
if (state.cacheKey !== cacheKey) {
|
||||
return {
|
||||
layers: emptyLyricLayers,
|
||||
loading: Boolean(trackId && !disabled && requested),
|
||||
error: null,
|
||||
retryAfterSeconds: null,
|
||||
retry,
|
||||
}
|
||||
}
|
||||
return {
|
||||
layers: state.layers,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
retryAfterSeconds:
|
||||
state.retryAt == null
|
||||
? null
|
||||
: Math.max(1, Math.ceil((state.retryAt - Date.now()) / 1_000)),
|
||||
retry,
|
||||
}
|
||||
}
|
||||
|
||||
export const clearEnhancedLyricsCache = () => {
|
||||
cache.clear()
|
||||
inFlight.forEach((entry) => entry.controller.abort())
|
||||
inFlight.clear()
|
||||
}
|
||||
|
||||
export default useEnhancedLyrics
|
||||
251
ui/src/audioplayer/useEnhancedLyrics.test.js
Normal file
251
ui/src/audioplayer/useEnhancedLyrics.test.js
Normal file
@ -0,0 +1,251 @@
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import subsonic from '../subsonic'
|
||||
import useEnhancedLyrics, {
|
||||
clearEnhancedLyricsCache,
|
||||
emptyLyricLayers,
|
||||
} from './useEnhancedLyrics'
|
||||
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: {
|
||||
getLyricsBySongId: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const responseFor = (value, lang = 'en', source) => ({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
lyricsList: {
|
||||
structuredLyrics: [
|
||||
{
|
||||
kind: 'main',
|
||||
lang,
|
||||
synced: true,
|
||||
line: [{ start: 0, value }],
|
||||
...(source ? { source } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const renderLyrics = (initialProps) =>
|
||||
renderHook((props) => useEnhancedLyrics(props), { initialProps })
|
||||
|
||||
const expectLyric = (result, value) =>
|
||||
waitFor(() => expect(result.current.layers.main?.line[0].value).toBe(value))
|
||||
|
||||
describe('useEnhancedLyrics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('locale', 'en')
|
||||
clearEnhancedLyricsCache()
|
||||
subsonic.getLyricsBySongId.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
clearEnhancedLyricsCache()
|
||||
})
|
||||
|
||||
it('fetches enhanced structured lyrics and caches them by track id', async () => {
|
||||
subsonic.getLyricsBySongId
|
||||
.mockResolvedValueOnce(responseFor('Track one'))
|
||||
.mockResolvedValueOnce(responseFor('Track two'))
|
||||
|
||||
const { result, rerender } = renderLyrics({ trackId: 'song-1' })
|
||||
|
||||
await expectLyric(result, 'Track one')
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledWith(
|
||||
'song-1',
|
||||
expect.objectContaining({ signal: expect.any(Object) }),
|
||||
)
|
||||
|
||||
rerender({ trackId: 'song-2' })
|
||||
await expectLyric(result, 'Track two')
|
||||
|
||||
rerender({ trackId: 'song-1' })
|
||||
await expectLyric(result, 'Track one')
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves source provenance on the selected lyric layer', async () => {
|
||||
const source = {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'kugou',
|
||||
format: 'lrc',
|
||||
}
|
||||
subsonic.getLyricsBySongId.mockResolvedValueOnce(
|
||||
responseFor('Sourced lyrics', 'en', source),
|
||||
)
|
||||
|
||||
const { result } = renderLyrics({ trackId: 'song-source' })
|
||||
|
||||
await expectLyric(result, 'Sourced lyrics')
|
||||
expect(result.current.layers.main.source).toEqual(source)
|
||||
})
|
||||
|
||||
it('clears previous lyrics while loading an uncached track', async () => {
|
||||
const nextRequest = createDeferred()
|
||||
subsonic.getLyricsBySongId
|
||||
.mockResolvedValueOnce(responseFor('Track one'))
|
||||
.mockReturnValueOnce(nextRequest.promise)
|
||||
|
||||
const { result, rerender } = renderLyrics({ trackId: 'song-1' })
|
||||
|
||||
await expectLyric(result, 'Track one')
|
||||
|
||||
rerender({ trackId: 'song-2' })
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.layers).toBe(emptyLyricLayers)
|
||||
|
||||
nextRequest.resolve(responseFor('Track two'))
|
||||
await expectLyric(result, 'Track two')
|
||||
})
|
||||
|
||||
it('stays empty when disabled or not requested', () => {
|
||||
const { result } = renderLyrics({
|
||||
trackId: 'song-1',
|
||||
disabled: true,
|
||||
requested: false,
|
||||
})
|
||||
|
||||
expect(result.current.layers).toBe(emptyLyricLayers)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(subsonic.getLyricsBySongId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets layers and retries the same lyrics identity after an error', async () => {
|
||||
const error = new Error('lyrics failed')
|
||||
subsonic.getLyricsBySongId
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValueOnce(responseFor('Recovered lyrics'))
|
||||
|
||||
const { result } = renderLyrics({ trackId: 'song-error' })
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe(error))
|
||||
expect(result.current.layers).toBe(emptyLyricLayers)
|
||||
expect(result.current.loading).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.retry()
|
||||
})
|
||||
|
||||
await expectLyric(result, 'Recovered lyrics')
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a cooldown countdown and retries the current song when it expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-23T06:00:00Z'))
|
||||
const cooldownError = new Error('better lyrics API cooldown active for 2s')
|
||||
subsonic.getLyricsBySongId
|
||||
.mockRejectedValueOnce(cooldownError)
|
||||
.mockResolvedValueOnce(responseFor('Recovered after cooldown'))
|
||||
|
||||
const { result } = renderLyrics({ trackId: 'rate-limited-song' })
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe(cooldownError)
|
||||
expect(result.current.retryAfterSeconds).toBe(2)
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.layers.main?.line[0].value).toBe(
|
||||
'Recovered after cooldown',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cached lyrics separate by preferred language', async () => {
|
||||
subsonic.getLyricsBySongId
|
||||
.mockResolvedValueOnce(responseFor('English lyrics', 'en'))
|
||||
.mockResolvedValueOnce(responseFor('Japanese lyrics', 'ja'))
|
||||
|
||||
const { result, rerender } = renderLyrics({ trackId: 'song-1' })
|
||||
|
||||
await expectLyric(result, 'English lyrics')
|
||||
|
||||
localStorage.setItem('locale', 'ja')
|
||||
rerender({ trackId: 'song-1' })
|
||||
|
||||
await expectLyric(result, 'Japanese lyrics')
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidates cache entries when the track update identity changes', async () => {
|
||||
subsonic.getLyricsBySongId
|
||||
.mockResolvedValueOnce(responseFor('Old lyrics'))
|
||||
.mockResolvedValueOnce(responseFor('Updated lyrics'))
|
||||
|
||||
const { result, rerender } = renderLyrics({
|
||||
trackId: 'song-1',
|
||||
updatedAt: 'old',
|
||||
})
|
||||
|
||||
await expectLyric(result, 'Old lyrics')
|
||||
rerender({ trackId: 'song-1', updatedAt: 'new' })
|
||||
await expectLyric(result, 'Updated lyrics')
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent consumers and aborts only after the last one leaves', () => {
|
||||
let signal
|
||||
subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => {
|
||||
signal = options.signal
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const first = renderLyrics({ trackId: 'shared-song' })
|
||||
const second = renderLyrics({ trackId: 'shared-song' })
|
||||
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(1)
|
||||
first.unmount()
|
||||
expect(signal.aborted).toBe(false)
|
||||
second.unmount()
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('starts a fresh request after an abandoned request is aborted', () => {
|
||||
const signals = []
|
||||
subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => {
|
||||
signals.push(options.signal)
|
||||
return new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const first = renderLyrics({ trackId: 'retry-song' })
|
||||
first.unmount()
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
|
||||
const second = renderLyrics({ trackId: 'retry-song' })
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
second.unmount()
|
||||
})
|
||||
})
|
||||
22
ui/src/audioplayer/useEnterExitTransition.js
Normal file
22
ui/src/audioplayer/useEnterExitTransition.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const useEnterExitTransition = (active, transitionMs) => {
|
||||
const [rendered, setRendered] = useState(active)
|
||||
const [entered, setEntered] = useState(active)
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setRendered(true)
|
||||
const frameId = window.requestAnimationFrame(() => setEntered(true))
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
|
||||
setEntered(false)
|
||||
const timerId = window.setTimeout(() => setRendered(false), transitionMs)
|
||||
return () => window.clearTimeout(timerId)
|
||||
}, [active, transitionMs])
|
||||
|
||||
return { rendered, entered }
|
||||
}
|
||||
|
||||
export default useEnterExitTransition
|
||||
776
ui/src/audioplayer/useLyricsTimeline.js
Normal file
776
ui/src/audioplayer/useLyricsTimeline.js
Normal file
@ -0,0 +1,776 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_RISE_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_CLOCK_DRIFT_RESET_MS,
|
||||
KARAOKE_HIGHLIGHT_LEAD_MS,
|
||||
KARAOKE_LINE_RELEASE_MS,
|
||||
easeKaraokeMotion,
|
||||
easeKaraokeOpacity,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import {
|
||||
buildLyricsTimeline,
|
||||
getTimelineScrollTarget,
|
||||
LyricTimelineCursor,
|
||||
sameIndexes,
|
||||
tokenProgressAt,
|
||||
} from './lyricsTimeline'
|
||||
|
||||
const mediaTimeMs = (audio) => {
|
||||
const seconds = Number(audio?.currentTime)
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 0
|
||||
}
|
||||
|
||||
const mediaDurationMs = (audio) => {
|
||||
const seconds = Number(audio?.duration)
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
|
||||
}
|
||||
|
||||
const setProgress = (record, value) => {
|
||||
const next = Math.max(0, Math.min(1, value))
|
||||
if (record.progress != null && Math.abs(record.progress - next) < 0.001)
|
||||
return
|
||||
record.progress = next
|
||||
record.node.style.setProperty('--lyrics-progress', String(next))
|
||||
}
|
||||
|
||||
const setCharacterTransform = (node, localProgress) => {
|
||||
const lift = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_LIFT_PX * easeKaraokeMotion(localProgress),
|
||||
),
|
||||
)
|
||||
if (lift < 0.00005) {
|
||||
if (node.style.transform) node.style.removeProperty('transform')
|
||||
return
|
||||
}
|
||||
const nextTransform = `translate3d(0, -${lift.toFixed(4)}px, 0)`
|
||||
if (node.style.transform !== nextTransform) {
|
||||
node.style.transform = nextTransform
|
||||
}
|
||||
}
|
||||
|
||||
const resetCharacterLift = (record) => {
|
||||
const characters = record.characters || []
|
||||
if (!characters.length) return
|
||||
if (record.characterMotionState === 'future') return
|
||||
|
||||
characters.forEach(({ node }) => setCharacterTransform(node, 0))
|
||||
record.characterMotionState = 'future'
|
||||
}
|
||||
|
||||
const settleCharacterLift = (record) => {
|
||||
const characters = record.characters || []
|
||||
if (!characters.length) return
|
||||
if (record.characterMotionState === 'settled') return
|
||||
|
||||
characters.forEach(({ node }) => setCharacterTransform(node, 1))
|
||||
record.characterMotionState = 'settled'
|
||||
}
|
||||
|
||||
const setCharacterLiftAtTime = (record, time) => {
|
||||
const characters = record.characters || []
|
||||
if (!characters.length) return
|
||||
|
||||
const current = Number(time)
|
||||
if (!Number.isFinite(current) || current <= record.characterMotionStart) {
|
||||
resetCharacterLift(record)
|
||||
return
|
||||
}
|
||||
if (current >= record.characterMotionEnd) {
|
||||
settleCharacterLift(record)
|
||||
return
|
||||
}
|
||||
|
||||
characters.forEach(({ node, start }) => {
|
||||
const localProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (current - start) / KARAOKE_CHARACTER_RISE_MS),
|
||||
)
|
||||
setCharacterTransform(node, localProgress)
|
||||
})
|
||||
record.characterMotionState = 'moving'
|
||||
}
|
||||
|
||||
const getCharacterTiming = (window, characterCount) => {
|
||||
const start = Number(window?.start)
|
||||
const duration = Number(window?.end) - Number(window?.start)
|
||||
const safeStart = Number.isFinite(start) ? start : 0
|
||||
const safeDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0
|
||||
const waveSpan =
|
||||
characterCount <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
safeDuration * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
)
|
||||
return {
|
||||
start: safeStart,
|
||||
waveSpan,
|
||||
end: safeStart + waveSpan + KARAOKE_CHARACTER_RISE_MS,
|
||||
}
|
||||
}
|
||||
|
||||
const setCharacterGradient = (record) => {
|
||||
const tokenRect = record.node.getBoundingClientRect()
|
||||
const tokenWidth = tokenRect.width || record.node.offsetWidth || 1
|
||||
if (!record.presentation?.gradient) return
|
||||
|
||||
const measurements = record.characters.map((character) => ({
|
||||
character,
|
||||
offset: character.node.getBoundingClientRect().left - tokenRect.left,
|
||||
}))
|
||||
|
||||
measurements.forEach(({ character, offset }) => {
|
||||
const { node } = character
|
||||
if (
|
||||
character.gradientWidth === tokenWidth &&
|
||||
character.gradientOffset === offset
|
||||
) {
|
||||
return
|
||||
}
|
||||
character.gradientWidth = tokenWidth
|
||||
character.gradientOffset = offset
|
||||
node.style.color = 'transparent'
|
||||
node.style.webkitTextFillColor = 'transparent'
|
||||
node.style.backgroundImage = record.presentation.gradient
|
||||
node.style.backgroundSize = `${tokenWidth}px 100%`
|
||||
node.style.backgroundPosition = `${-offset}px 0`
|
||||
node.style.backgroundRepeat = 'no-repeat'
|
||||
node.style.backgroundClip = 'text'
|
||||
node.style.webkitBackgroundClip = 'text'
|
||||
})
|
||||
}
|
||||
|
||||
const setTokenOpacity = (record, value) => {
|
||||
const next = Math.max(0, Math.min(1, value))
|
||||
if (record.opacity != null && Math.abs(record.opacity - next) < 0.001) return
|
||||
record.opacity = next
|
||||
record.node.style.opacity = String(next)
|
||||
}
|
||||
|
||||
const setTokenActiveAlpha = (record, value) => {
|
||||
const next = Math.max(0, Math.min(1, value))
|
||||
if (record.activeAlpha != null && Math.abs(record.activeAlpha - next) < 0.001)
|
||||
return
|
||||
record.activeAlpha = next
|
||||
record.node.style.setProperty('--lyrics-token-active-alpha', String(next))
|
||||
}
|
||||
|
||||
const setGradientTokenColor = (record) => {
|
||||
const paintNode = record.paintNode || record.node
|
||||
const paintsOnChild = paintNode !== record.node
|
||||
|
||||
record.node.style.color = 'transparent'
|
||||
record.node.style.webkitTextFillColor = 'transparent'
|
||||
record.node.style.backgroundImage = paintsOnChild
|
||||
? 'none'
|
||||
: record.presentation.gradient
|
||||
record.node.style.backgroundSize = paintsOnChild ? 'auto' : '100% 100%'
|
||||
record.node.style.backgroundClip = paintsOnChild ? 'border-box' : 'text'
|
||||
record.node.style.webkitBackgroundClip = paintsOnChild ? 'border-box' : 'text'
|
||||
|
||||
if (!paintsOnChild) return
|
||||
|
||||
paintNode.style.color = 'transparent'
|
||||
paintNode.style.webkitTextFillColor = 'transparent'
|
||||
paintNode.style.backgroundImage = record.characters.length
|
||||
? 'none'
|
||||
: record.presentation.gradient
|
||||
paintNode.style.backgroundSize = '100% 100%'
|
||||
paintNode.style.backgroundPosition = '0 0'
|
||||
paintNode.style.backgroundRepeat = 'no-repeat'
|
||||
paintNode.style.backgroundClip = 'text'
|
||||
paintNode.style.webkitBackgroundClip = 'text'
|
||||
}
|
||||
|
||||
const getInactiveTokenAlpha = (presentation = {}) =>
|
||||
Math.min(1, Math.max(0, presentation.futureAlpha ?? 0.34))
|
||||
|
||||
const getActiveTokenAlpha = (presentation = {}) =>
|
||||
Math.min(1, Math.max(0, presentation.activeAlpha ?? 1))
|
||||
|
||||
const isGradientTokenState = (state) =>
|
||||
state === 'future' ||
|
||||
state === 'active' ||
|
||||
state === 'completed' ||
|
||||
state === 'release' ||
|
||||
state === 'inactive-past'
|
||||
|
||||
const applyTokenState = (record, state, progress = 0) => {
|
||||
const previousState = record.state
|
||||
record.state = state
|
||||
record.node.dataset.lyricsState = state
|
||||
const presentation = record.presentation || {}
|
||||
|
||||
if (!isGradientTokenState(previousState)) {
|
||||
setGradientTokenColor(record)
|
||||
}
|
||||
|
||||
const isFuture = state === 'future'
|
||||
const isPast = state === 'inactive-past'
|
||||
const nextProgress = isFuture ? 0 : state === 'active' ? progress : 1
|
||||
setProgress(record, nextProgress)
|
||||
setTokenOpacity(record, 1)
|
||||
setTokenActiveAlpha(
|
||||
record,
|
||||
isPast
|
||||
? getInactiveTokenAlpha(presentation)
|
||||
: getActiveTokenAlpha(presentation),
|
||||
)
|
||||
}
|
||||
|
||||
const setTokenReleasePresentation = (record, progress) => {
|
||||
const presentation = record.presentation || {}
|
||||
const nextProgress = Math.max(0, Math.min(1, progress))
|
||||
const easedProgress = easeKaraokeOpacity(nextProgress)
|
||||
if (record.state !== 'release') {
|
||||
applyTokenState(record, 'completed', 1)
|
||||
record.state = 'release'
|
||||
record.node.dataset.lyricsState = 'release'
|
||||
}
|
||||
const activeAlpha = getActiveTokenAlpha(presentation)
|
||||
const targetAlpha = getInactiveTokenAlpha(presentation)
|
||||
setTokenOpacity(record, 1)
|
||||
setTokenActiveAlpha(
|
||||
record,
|
||||
activeAlpha + (targetAlpha - activeAlpha) * easedProgress,
|
||||
)
|
||||
settleCharacterLift(record)
|
||||
}
|
||||
|
||||
const resetToken = (record, state = 'future') => {
|
||||
applyTokenState(record, state, 0)
|
||||
if (state === 'future') resetCharacterLift(record)
|
||||
else settleCharacterLift(record)
|
||||
}
|
||||
|
||||
const setTokenPresentation = (record, time) => {
|
||||
const progress = tokenProgressAt(record.window, time)
|
||||
const state =
|
||||
progress <= 0 ? 'future' : progress >= 1 ? 'completed' : 'active'
|
||||
if (record.state !== state || state === 'active') {
|
||||
applyTokenState(record, state, progress)
|
||||
}
|
||||
setCharacterLiftAtTime(record, time)
|
||||
}
|
||||
|
||||
const canListenToMedia = (audio) =>
|
||||
Boolean(
|
||||
audio &&
|
||||
typeof audio.addEventListener === 'function' &&
|
||||
typeof audio.removeEventListener === 'function',
|
||||
)
|
||||
|
||||
const useLyricsTimeline = ({
|
||||
lines,
|
||||
audioInstance,
|
||||
visible,
|
||||
reducedMotion,
|
||||
}) => {
|
||||
const [durationMs, setDurationMs] = useState(() =>
|
||||
mediaDurationMs(audioInstance),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateDuration = () => setDurationMs(mediaDurationMs(audioInstance))
|
||||
updateDuration()
|
||||
if (!canListenToMedia(audioInstance)) return undefined
|
||||
audioInstance.addEventListener('loadedmetadata', updateDuration)
|
||||
audioInstance.addEventListener('durationchange', updateDuration)
|
||||
return () => {
|
||||
audioInstance.removeEventListener('loadedmetadata', updateDuration)
|
||||
audioInstance.removeEventListener('durationchange', updateDuration)
|
||||
}
|
||||
}, [audioInstance])
|
||||
|
||||
const timeline = useMemo(
|
||||
() => buildLyricsTimeline(lines, { durationMs }),
|
||||
[durationMs, lines],
|
||||
)
|
||||
const cursorRef = useRef(new LyricTimelineCursor(timeline))
|
||||
const lineNodesRef = useRef(new Map())
|
||||
const tokenRecordsRef = useRef(new Map())
|
||||
const tokenRecordsByNodeRef = useRef(new WeakMap())
|
||||
const tokenResizeObserverRef = useRef(null)
|
||||
const lineTokenKeysRef = useRef(new Map())
|
||||
const releaseIndexesRef = useRef(new Set())
|
||||
const frameRef = useRef(0)
|
||||
const resetInterpolationRef = useRef(null)
|
||||
const lastPublishedIndexesRef = useRef([])
|
||||
const lastPublishedPrimaryIndexRef = useRef(-1)
|
||||
const lastScrollTargetRef = useRef(-1)
|
||||
const lastAppliedTimeRef = useRef(0)
|
||||
const [activeState, setActiveState] = useState(() => ({
|
||||
indexes: [],
|
||||
primaryIndex: -1,
|
||||
}))
|
||||
const [scrollTargetIndex, setScrollTargetIndex] = useState(-1)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const ResizeObserverConstructor = window.ResizeObserver
|
||||
if (!ResizeObserverConstructor || reducedMotion) return undefined
|
||||
|
||||
const observer = new ResizeObserverConstructor((entries) => {
|
||||
entries.forEach(({ target }) => {
|
||||
const record = tokenRecordsByNodeRef.current.get(target)
|
||||
if (record) setCharacterGradient(record)
|
||||
})
|
||||
})
|
||||
tokenResizeObserverRef.current = observer
|
||||
tokenRecordsRef.current.forEach((record) => {
|
||||
if (record.characters.length) observer.observe(record.node)
|
||||
})
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
tokenResizeObserverRef.current = null
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
const setLineState = useCallback((lineIndex, phase) => {
|
||||
const node = lineNodesRef.current.get(lineIndex)
|
||||
if (!node) return
|
||||
node.dataset.active = phase === 'active' ? 'true' : 'false'
|
||||
node.dataset.lifecycle = phase
|
||||
node.dataset.highlightActive = phase === 'active' ? 'true' : 'false'
|
||||
node.dataset.raised = phase === 'idle' ? 'false' : 'true'
|
||||
}, [])
|
||||
|
||||
const resetLineTokens = useCallback((lineIndex, state = 'future') => {
|
||||
lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => {
|
||||
const record = tokenRecordsRef.current.get(key)
|
||||
if (record) resetToken(record, state)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateLineTokens = useCallback((lineIndex, time) => {
|
||||
lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => {
|
||||
const record = tokenRecordsRef.current.get(key)
|
||||
if (record) setTokenPresentation(record, time)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateLineReleaseTokens = useCallback((lineIndex, progress) => {
|
||||
lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => {
|
||||
const record = tokenRecordsRef.current.get(key)
|
||||
if (record) setTokenReleasePresentation(record, progress)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const publishActiveState = useCallback((indexes, nextPrimaryIndex) => {
|
||||
if (
|
||||
sameIndexes(lastPublishedIndexesRef.current, indexes) &&
|
||||
lastPublishedPrimaryIndexRef.current === nextPrimaryIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastPublishedIndexesRef.current = indexes
|
||||
lastPublishedPrimaryIndexRef.current = nextPrimaryIndex
|
||||
setActiveState({ indexes, primaryIndex: nextPrimaryIndex })
|
||||
}, [])
|
||||
|
||||
const publishScrollTarget = useCallback(
|
||||
(time) => {
|
||||
const nextIndex = getTimelineScrollTarget(timeline, time)
|
||||
if (nextIndex === lastScrollTargetRef.current) return
|
||||
lastScrollTargetRef.current = nextIndex
|
||||
setScrollTargetIndex(nextIndex)
|
||||
},
|
||||
[timeline],
|
||||
)
|
||||
|
||||
const apply = useCallback(
|
||||
(time, forceSeek = false) => {
|
||||
const current = Number.isFinite(Number(time))
|
||||
? Math.max(0, Number(time))
|
||||
: 0
|
||||
const lead = reducedMotion ? 0 : KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
lastAppliedTimeRef.current = current
|
||||
const cursor = cursorRef.current
|
||||
const previousIndexes = cursor.lastIndexes
|
||||
const result = cursor.update(current, forceSeek)
|
||||
|
||||
if (forceSeek) {
|
||||
releaseIndexesRef.current.clear()
|
||||
timeline.windows.forEach((window) => {
|
||||
if (!window.valid || current < window.start) {
|
||||
setLineState(window.lineIndex, 'idle')
|
||||
resetLineTokens(window.lineIndex, 'future')
|
||||
return
|
||||
}
|
||||
if (current >= window.end) {
|
||||
if (current < window.end + KARAOKE_LINE_RELEASE_MS) {
|
||||
releaseIndexesRef.current.add(window.lineIndex)
|
||||
setLineState(window.lineIndex, 'release')
|
||||
updateLineReleaseTokens(
|
||||
window.lineIndex,
|
||||
(current - window.end) / KARAOKE_LINE_RELEASE_MS,
|
||||
)
|
||||
} else {
|
||||
setLineState(window.lineIndex, 'past')
|
||||
resetLineTokens(window.lineIndex, 'inactive-past')
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (result.changed) {
|
||||
previousIndexes.forEach((lineIndex) => {
|
||||
if (cursor.active.has(lineIndex)) return
|
||||
const window = timeline.windows[lineIndex]
|
||||
if (
|
||||
window?.valid &&
|
||||
current >= window.end &&
|
||||
current < window.end + KARAOKE_LINE_RELEASE_MS
|
||||
) {
|
||||
releaseIndexesRef.current.add(lineIndex)
|
||||
setLineState(lineIndex, 'release')
|
||||
updateLineReleaseTokens(lineIndex, 0)
|
||||
} else {
|
||||
setLineState(lineIndex, 'past')
|
||||
resetLineTokens(lineIndex, 'inactive-past')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
result.indexes.forEach((lineIndex) => {
|
||||
releaseIndexesRef.current.delete(lineIndex)
|
||||
setLineState(lineIndex, 'active')
|
||||
updateLineTokens(lineIndex, current + lead)
|
||||
})
|
||||
|
||||
releaseIndexesRef.current.forEach((lineIndex) => {
|
||||
const window = timeline.windows[lineIndex]
|
||||
if (
|
||||
!window?.valid ||
|
||||
current >= window.end + KARAOKE_LINE_RELEASE_MS ||
|
||||
current < window.end
|
||||
) {
|
||||
releaseIndexesRef.current.delete(lineIndex)
|
||||
const isPast = current >= (window?.end ?? Infinity)
|
||||
setLineState(lineIndex, isPast ? 'past' : 'idle')
|
||||
resetLineTokens(lineIndex, isPast ? 'inactive-past' : 'future')
|
||||
return
|
||||
}
|
||||
updateLineReleaseTokens(
|
||||
lineIndex,
|
||||
(current - window.end) / KARAOKE_LINE_RELEASE_MS,
|
||||
)
|
||||
})
|
||||
|
||||
if (result.changed || forceSeek) {
|
||||
publishActiveState(result.indexes, result.primaryIndex)
|
||||
}
|
||||
publishScrollTarget(current)
|
||||
return result
|
||||
},
|
||||
[
|
||||
publishActiveState,
|
||||
publishScrollTarget,
|
||||
reducedMotion,
|
||||
resetLineTokens,
|
||||
setLineState,
|
||||
timeline.windows,
|
||||
updateLineReleaseTokens,
|
||||
updateLineTokens,
|
||||
],
|
||||
)
|
||||
|
||||
const registerLine = useCallback(
|
||||
(lineIndex, node) => {
|
||||
if (!node) {
|
||||
lineNodesRef.current.delete(lineIndex)
|
||||
return
|
||||
}
|
||||
lineNodesRef.current.set(lineIndex, node)
|
||||
const window = timeline.windows[lineIndex]
|
||||
const time = lastAppliedTimeRef.current
|
||||
if (cursorRef.current.active.has(lineIndex)) {
|
||||
setLineState(lineIndex, 'active')
|
||||
} else if (
|
||||
window?.valid &&
|
||||
time >= window.end &&
|
||||
time < window.end + KARAOKE_LINE_RELEASE_MS
|
||||
) {
|
||||
setLineState(lineIndex, 'release')
|
||||
} else if (window?.valid && time >= window.end) {
|
||||
setLineState(lineIndex, 'past')
|
||||
} else {
|
||||
setLineState(lineIndex, 'idle')
|
||||
}
|
||||
},
|
||||
[setLineState, timeline.windows],
|
||||
)
|
||||
|
||||
const registerToken = useCallback(
|
||||
(key, descriptor, node) => {
|
||||
const existing = tokenRecordsRef.current.get(key)
|
||||
if (!node) {
|
||||
if (existing) {
|
||||
tokenResizeObserverRef.current?.unobserve(existing.node)
|
||||
tokenRecordsByNodeRef.current.delete(existing.node)
|
||||
tokenRecordsRef.current.delete(key)
|
||||
const keys = lineTokenKeysRef.current.get(existing.lineIndex)
|
||||
keys?.delete(key)
|
||||
if (keys?.size === 0) {
|
||||
lineTokenKeysRef.current.delete(existing.lineIndex)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const characterNodes = reducedMotion
|
||||
? []
|
||||
: Array.from(node.querySelectorAll('[data-lyrics-character="true"]'))
|
||||
const characterCount = characterNodes.length
|
||||
const characterTiming = getCharacterTiming(
|
||||
descriptor.window,
|
||||
characterCount,
|
||||
)
|
||||
const characters = characterNodes.map((characterNode, index) => ({
|
||||
node: characterNode,
|
||||
start:
|
||||
characterCount <= 1
|
||||
? characterTiming.start
|
||||
: characterTiming.start +
|
||||
(index / (characterCount - 1)) * characterTiming.waveSpan,
|
||||
}))
|
||||
const record = {
|
||||
key,
|
||||
node,
|
||||
paintNode: node.querySelector('[data-lyrics-wave-text="true"]'),
|
||||
lineIndex: descriptor.lineIndex,
|
||||
window: descriptor.window,
|
||||
presentation: descriptor.presentation,
|
||||
characters,
|
||||
characterMotionStart: characterTiming.start,
|
||||
characterMotionEnd: characterTiming.end,
|
||||
characterMotionState: null,
|
||||
progress: null,
|
||||
opacity: null,
|
||||
activeAlpha: null,
|
||||
state: null,
|
||||
}
|
||||
setCharacterGradient(record)
|
||||
tokenRecordsByNodeRef.current.set(node, record)
|
||||
if (characters.length) tokenResizeObserverRef.current?.observe(node)
|
||||
tokenRecordsRef.current.set(key, record)
|
||||
if (!lineTokenKeysRef.current.has(record.lineIndex)) {
|
||||
lineTokenKeysRef.current.set(record.lineIndex, new Set())
|
||||
}
|
||||
lineTokenKeysRef.current.get(record.lineIndex).add(key)
|
||||
|
||||
if (cursorRef.current.active.has(record.lineIndex)) {
|
||||
setTokenPresentation(
|
||||
record,
|
||||
lastAppliedTimeRef.current +
|
||||
(reducedMotion ? 0 : KARAOKE_HIGHLIGHT_LEAD_MS),
|
||||
)
|
||||
} else {
|
||||
const lineWindow = timeline.windows[record.lineIndex]
|
||||
if (
|
||||
lineWindow?.valid &&
|
||||
lastAppliedTimeRef.current >= lineWindow.end &&
|
||||
lastAppliedTimeRef.current < lineWindow.end + KARAOKE_LINE_RELEASE_MS
|
||||
) {
|
||||
setTokenReleasePresentation(
|
||||
record,
|
||||
(lastAppliedTimeRef.current - lineWindow.end) /
|
||||
KARAOKE_LINE_RELEASE_MS,
|
||||
)
|
||||
} else {
|
||||
resetToken(
|
||||
record,
|
||||
lineWindow?.valid && lastAppliedTimeRef.current >= lineWindow.end
|
||||
? 'inactive-past'
|
||||
: 'future',
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[reducedMotion, timeline.windows],
|
||||
)
|
||||
|
||||
const getLineNode = useCallback(
|
||||
(lineIndex) => lineNodesRef.current.get(lineIndex) || null,
|
||||
[],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
lineNodesRef.current.forEach((node) => {
|
||||
node.dataset.active = 'false'
|
||||
node.dataset.lifecycle = 'idle'
|
||||
node.dataset.highlightActive = 'false'
|
||||
node.dataset.raised = 'false'
|
||||
})
|
||||
tokenRecordsRef.current.forEach((record) => resetToken(record))
|
||||
cursorRef.current = new LyricTimelineCursor(timeline)
|
||||
releaseIndexesRef.current.clear()
|
||||
lastPublishedIndexesRef.current = []
|
||||
lastPublishedPrimaryIndexRef.current = -1
|
||||
lastScrollTargetRef.current = -1
|
||||
setActiveState({ indexes: [], primaryIndex: -1 })
|
||||
setScrollTargetIndex(-1)
|
||||
apply(mediaTimeMs(audioInstance), true)
|
||||
}, [apply, audioInstance, timeline])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!audioInstance ||
|
||||
!timeline.events.length ||
|
||||
!canListenToMedia(audioInstance)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const seek = () => {
|
||||
resetInterpolationRef.current?.()
|
||||
apply(mediaTimeMs(audioInstance), true)
|
||||
}
|
||||
audioInstance.addEventListener('seeking', seek)
|
||||
audioInstance.addEventListener('seeked', seek)
|
||||
return () => {
|
||||
audioInstance.removeEventListener('seeking', seek)
|
||||
audioInstance.removeEventListener('seeked', seek)
|
||||
}
|
||||
}, [apply, audioInstance, timeline.events.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioInstance || !timeline.events.length || !visible) return undefined
|
||||
if (!canListenToMedia(audioInstance)) {
|
||||
apply(mediaTimeMs(audioInstance), true)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let anchorAudioMs = mediaTimeMs(audioInstance)
|
||||
let anchorPerfMs = performance.now()
|
||||
let lastFrameTime = anchorAudioMs
|
||||
|
||||
const stop = () => {
|
||||
if (frameRef.current) window.cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = 0
|
||||
}
|
||||
|
||||
const resetAnchor = () => {
|
||||
anchorAudioMs = mediaTimeMs(audioInstance)
|
||||
anchorPerfMs = performance.now()
|
||||
lastFrameTime = anchorAudioMs
|
||||
}
|
||||
resetInterpolationRef.current = resetAnchor
|
||||
|
||||
const readInterpolatedTime = () => {
|
||||
const observed = mediaTimeMs(audioInstance)
|
||||
const now = performance.now()
|
||||
const rate = Number(audioInstance.playbackRate)
|
||||
const canInterpolate =
|
||||
!audioInstance.paused &&
|
||||
!audioInstance.seeking &&
|
||||
Number.isFinite(rate) &&
|
||||
rate > 0
|
||||
if (!canInterpolate) {
|
||||
anchorAudioMs = observed
|
||||
anchorPerfMs = now
|
||||
lastFrameTime = observed
|
||||
return observed
|
||||
}
|
||||
const predicted = anchorAudioMs + (now - anchorPerfMs) * rate
|
||||
const drift = observed - predicted
|
||||
let current =
|
||||
Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS ? observed : predicted
|
||||
if (Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS) {
|
||||
anchorAudioMs = observed
|
||||
anchorPerfMs = now
|
||||
}
|
||||
const backwards = lastFrameTime - current
|
||||
if (backwards > 0) {
|
||||
current = lastFrameTime
|
||||
}
|
||||
lastFrameTime = Math.max(0, current)
|
||||
return lastFrameTime
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (
|
||||
cancelled ||
|
||||
audioInstance.paused ||
|
||||
window.document.visibilityState !== 'visible'
|
||||
) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
apply(readInterpolatedTime())
|
||||
frameRef.current = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (
|
||||
frameRef.current ||
|
||||
audioInstance.paused ||
|
||||
window.document.visibilityState !== 'visible'
|
||||
) {
|
||||
return
|
||||
}
|
||||
resetAnchor()
|
||||
frameRef.current = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const syncAndStop = () => {
|
||||
stop()
|
||||
apply(mediaTimeMs(audioInstance), true)
|
||||
}
|
||||
|
||||
const visibility = () => {
|
||||
if (window.document.visibilityState !== 'visible') {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
apply(mediaTimeMs(audioInstance), true)
|
||||
start()
|
||||
}
|
||||
|
||||
audioInstance.addEventListener('play', start)
|
||||
audioInstance.addEventListener('pause', syncAndStop)
|
||||
window.document.addEventListener('visibilitychange', visibility)
|
||||
apply(mediaTimeMs(audioInstance), true)
|
||||
start()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
stop()
|
||||
if (resetInterpolationRef.current === resetAnchor) {
|
||||
resetInterpolationRef.current = null
|
||||
}
|
||||
audioInstance.removeEventListener('play', start)
|
||||
audioInstance.removeEventListener('pause', syncAndStop)
|
||||
window.document.removeEventListener('visibilitychange', visibility)
|
||||
}
|
||||
}, [apply, audioInstance, timeline.events.length, visible])
|
||||
|
||||
const syncNow = useCallback(
|
||||
(time = mediaTimeMs(audioInstance), forceSeek = true) =>
|
||||
apply(time, forceSeek),
|
||||
[apply, audioInstance],
|
||||
)
|
||||
|
||||
return {
|
||||
activeIndexes: activeState.indexes,
|
||||
primaryIndex: activeState.primaryIndex,
|
||||
scrollTargetIndex,
|
||||
registerLine,
|
||||
registerToken,
|
||||
getLineNode,
|
||||
syncNow,
|
||||
timeline,
|
||||
}
|
||||
}
|
||||
|
||||
export default useLyricsTimeline
|
||||
646
ui/src/audioplayer/useLyricsTimeline.test.js
Normal file
646
ui/src/audioplayer/useLyricsTimeline.test.js
Normal file
@ -0,0 +1,646 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
KARAOKE_CHARACTER_LIFT_PX,
|
||||
KARAOKE_CHARACTER_RISE_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_CLOCK_DRIFT_RESET_MS,
|
||||
KARAOKE_HIGHLIGHT_LEAD_MS,
|
||||
KARAOKE_LINE_RELEASE_MS,
|
||||
easeKaraokeOpacity,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import useLyricsTimeline from './useLyricsTimeline'
|
||||
|
||||
const createAudio = ({
|
||||
currentTime = 0,
|
||||
duration = 10,
|
||||
paused = true,
|
||||
playbackRate = 1,
|
||||
} = {}) => {
|
||||
const target = new EventTarget()
|
||||
target.currentTime = currentTime
|
||||
target.duration = duration
|
||||
target.paused = paused
|
||||
target.playbackRate = playbackRate
|
||||
target.seeking = false
|
||||
return target
|
||||
}
|
||||
|
||||
const lines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 1000,
|
||||
tokens: [
|
||||
{ start: 0, end: 500, value: 'first' },
|
||||
{ start: 500, end: 1000, value: 'second' },
|
||||
],
|
||||
},
|
||||
{ start: 1000, end: 2000, tokens: [] },
|
||||
]
|
||||
|
||||
const presentation = {
|
||||
rgb: [255, 255, 255],
|
||||
futureAlpha: 0.34,
|
||||
activeAlpha: 1,
|
||||
futureColor: 'rgba(255, 255, 255, 0.34)',
|
||||
doneColor: 'rgba(255, 255, 255, 1)',
|
||||
gradient: 'linear-gradient(90deg, white, transparent)',
|
||||
useCrossfade: false,
|
||||
}
|
||||
|
||||
const fullCharacterLift = `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(
|
||||
4,
|
||||
)}px, 0)`
|
||||
|
||||
const createTokenNode = (text = '') => {
|
||||
const tokenNode = document.createElement('span')
|
||||
Array.from(text).forEach((character) => {
|
||||
const node = document.createElement('span')
|
||||
node.dataset.lyricsCharacter = 'true'
|
||||
node.textContent = character
|
||||
tokenNode.appendChild(node)
|
||||
})
|
||||
return tokenNode
|
||||
}
|
||||
|
||||
const registerToken = (result, key, window, text = '') => {
|
||||
const tokenNode = createTokenNode(text)
|
||||
act(() => {
|
||||
result.current.registerToken(
|
||||
key,
|
||||
{ lineIndex: 0, window, presentation },
|
||||
tokenNode,
|
||||
)
|
||||
})
|
||||
return tokenNode
|
||||
}
|
||||
|
||||
const syncNow = (result, time) => {
|
||||
act(() => {
|
||||
result.current.syncNow(time, true)
|
||||
})
|
||||
}
|
||||
|
||||
const renderTimeline = ({
|
||||
audio = createAudio(),
|
||||
sourceLines = lines,
|
||||
reducedMotion = false,
|
||||
} = {}) =>
|
||||
renderHook(() =>
|
||||
useLyricsTimeline({
|
||||
lines: sourceLines,
|
||||
audioInstance: audio,
|
||||
visible: true,
|
||||
reducedMotion,
|
||||
}),
|
||||
)
|
||||
|
||||
describe('useLyricsTimeline', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('synchronizes paused media without starting an animation loop', () => {
|
||||
const audio = createAudio({ currentTime: 0.25, paused: true })
|
||||
const { result } = renderTimeline({ audio })
|
||||
const lineNode = document.createElement('div')
|
||||
const tokenNode = createTokenNode()
|
||||
|
||||
act(() => {
|
||||
result.current.registerLine(0, lineNode)
|
||||
result.current.registerToken(
|
||||
'0:main:0',
|
||||
{
|
||||
lineIndex: 0,
|
||||
window: { start: 0, end: 500 },
|
||||
presentation,
|
||||
},
|
||||
tokenNode,
|
||||
)
|
||||
})
|
||||
|
||||
expect(result.current.activeIndexes).toEqual([0])
|
||||
expect(lineNode.dataset.active).toBe('true')
|
||||
expect(tokenNode.dataset.lyricsState).toBe('active')
|
||||
expect(
|
||||
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
|
||||
).toBeCloseTo(0.74, 2)
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recomputes token state immediately after seeking backward', () => {
|
||||
const audio = createAudio({ currentTime: 0.75, paused: true })
|
||||
const { result } = renderTimeline({ audio, reducedMotion: true })
|
||||
const first = document.createElement('span')
|
||||
const second = document.createElement('span')
|
||||
|
||||
act(() => {
|
||||
result.current.registerToken(
|
||||
'0:first',
|
||||
{
|
||||
lineIndex: 0,
|
||||
window: { start: 0, end: 500 },
|
||||
presentation,
|
||||
},
|
||||
first,
|
||||
)
|
||||
result.current.registerToken(
|
||||
'0:second',
|
||||
{
|
||||
lineIndex: 0,
|
||||
window: { start: 500, end: 1000 },
|
||||
presentation,
|
||||
},
|
||||
second,
|
||||
)
|
||||
})
|
||||
|
||||
expect(first.dataset.lyricsState).toBe('completed')
|
||||
expect(second.dataset.lyricsState).toBe('active')
|
||||
|
||||
act(() => {
|
||||
audio.currentTime = 0.1
|
||||
audio.dispatchEvent(new Event('seeking'))
|
||||
})
|
||||
|
||||
expect(first.dataset.lyricsState).toBe('active')
|
||||
expect(second.dataset.lyricsState).toBe('future')
|
||||
})
|
||||
|
||||
it('tracks overlapping line intervals as an active set', () => {
|
||||
const audio = createAudio({ currentTime: 2.5, paused: true })
|
||||
const overlappingLines = [
|
||||
{ start: 1000, end: 4000, tokens: [] },
|
||||
{ start: 2000, end: 3000, tokens: [] },
|
||||
{ start: 5000, end: 6000, tokens: [] },
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines: overlappingLines })
|
||||
|
||||
expect(result.current.activeIndexes).toEqual([0, 1])
|
||||
expect(result.current.primaryIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('publishes the most recently started line when display order differs', () => {
|
||||
const audio = createAudio({ currentTime: 2.5, paused: true })
|
||||
const displayOrderLines = [
|
||||
{ start: 2000, end: 4000, tokens: [] },
|
||||
{ start: 1000, end: 4000, tokens: [] },
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines: displayOrderLines })
|
||||
|
||||
expect(result.current.activeIndexes).toEqual([0, 1])
|
||||
expect(result.current.primaryIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the same gradient paint when an active word completes', () => {
|
||||
const audio = createAudio({ currentTime: 0.25, paused: true })
|
||||
const { result } = renderTimeline({ audio })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:stable-completion',
|
||||
{ start: 0, end: 500 },
|
||||
'first',
|
||||
)
|
||||
|
||||
const activeBackground = tokenNode.style.backgroundImage
|
||||
expect(tokenNode.dataset.lyricsState).toBe('active')
|
||||
expect(tokenNode.style.color).toBe('transparent')
|
||||
|
||||
syncNow(result, 600)
|
||||
|
||||
expect(tokenNode.dataset.lyricsState).toBe('completed')
|
||||
expect(tokenNode.style.backgroundImage).toBe(activeBackground)
|
||||
expect(tokenNode.style.color).toBe('transparent')
|
||||
expect(tokenNode.style.webkitTextFillColor).toBe('transparent')
|
||||
tokenNode
|
||||
.querySelectorAll('[data-lyrics-character="true"]')
|
||||
.forEach((character) =>
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps gradient paint and opacity continuous when release becomes past', () => {
|
||||
const audio = createAudio({ currentTime: 0.25, paused: true })
|
||||
const { result } = renderTimeline({ audio })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:no-release-blink',
|
||||
{ start: 0, end: 500 },
|
||||
'first',
|
||||
)
|
||||
|
||||
const releaseEnd = 1000 + KARAOKE_LINE_RELEASE_MS
|
||||
syncNow(result, releaseEnd - 1)
|
||||
const gradient = tokenNode.style.backgroundImage
|
||||
const releaseAlpha = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
|
||||
)
|
||||
expect(tokenNode.dataset.lyricsState).toBe('release')
|
||||
expect(tokenNode.style.color).toBe('transparent')
|
||||
expect(tokenNode.style.opacity).toBe('1')
|
||||
|
||||
syncNow(result, releaseEnd)
|
||||
|
||||
const pastAlpha = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
|
||||
)
|
||||
expect(tokenNode.dataset.lyricsState).toBe('inactive-past')
|
||||
expect(tokenNode.style.backgroundImage).toBe(gradient)
|
||||
expect(tokenNode.style.color).toBe('transparent')
|
||||
expect(tokenNode.style.webkitTextFillColor).toBe('transparent')
|
||||
expect(tokenNode.style.opacity).toBe('1')
|
||||
expect(Math.abs(pastAlpha - releaseAlpha)).toBeLessThan(0.01)
|
||||
expect(pastAlpha).toBeCloseTo(presentation.futureAlpha, 5)
|
||||
expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
|
||||
})
|
||||
|
||||
it('uses the shared opacity easing throughout line release', () => {
|
||||
const audio = createAudio({ currentTime: 0.25, paused: true })
|
||||
const { result } = renderTimeline({ audio })
|
||||
const lineNode = document.createElement('div')
|
||||
act(() => result.current.registerLine(0, lineNode))
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:eased-release',
|
||||
{ start: 0, end: 500 },
|
||||
'first',
|
||||
)
|
||||
|
||||
syncNow(result, 1000 + KARAOKE_LINE_RELEASE_MS / 2)
|
||||
|
||||
expect(lineNode).toHaveAttribute('data-lifecycle', 'release')
|
||||
expect(lineNode).toHaveAttribute('data-active', 'false')
|
||||
const expectedAlpha =
|
||||
presentation.activeAlpha +
|
||||
(presentation.futureAlpha - presentation.activeAlpha) *
|
||||
easeKaraokeOpacity(0.5)
|
||||
expect(
|
||||
Number(tokenNode.style.getPropertyValue('--lyrics-token-active-alpha')),
|
||||
).toBeCloseTo(expectedAlpha, 5)
|
||||
})
|
||||
|
||||
it('completes long-token character waves while highlighting continues', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 6, paused: true })
|
||||
const longLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 5000,
|
||||
tokens: [{ start: 500, end: 4500, value: 'super' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines: longLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:long-word',
|
||||
{ start: 500, end: 4500 },
|
||||
'super',
|
||||
)
|
||||
|
||||
const characters = Array.from(
|
||||
tokenNode.querySelectorAll('[data-lyrics-character="true"]'),
|
||||
)
|
||||
expect(characters[0].style.backgroundImage).toBe(
|
||||
tokenNode.style.backgroundImage,
|
||||
)
|
||||
const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
syncNow(result, tokenStart)
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(''),
|
||||
)
|
||||
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS / 2)
|
||||
expect(characters[0].style.transform).not.toBe(fullCharacterLift)
|
||||
expect(characters[0].style.transform).not.toBe('')
|
||||
expect(characters[4].style.transform).toBe('')
|
||||
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
|
||||
expect(characters[0].style.transform).toBe(fullCharacterLift)
|
||||
expect(characters[4].style.transform).toMatch(
|
||||
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
|
||||
)
|
||||
expect(characters[4].style.transform).not.toBe(fullCharacterLift)
|
||||
|
||||
const completeWaveAt =
|
||||
tokenStart +
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS +
|
||||
KARAOKE_CHARACTER_RISE_MS
|
||||
syncNow(result, completeWaveAt)
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
expect(
|
||||
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
|
||||
).toBeCloseTo(
|
||||
(KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS + KARAOKE_CHARACTER_RISE_MS) / 4000,
|
||||
5,
|
||||
)
|
||||
})
|
||||
|
||||
it('finishes short-token motion after highlighting without dropping the lift', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
|
||||
const shortLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 1200,
|
||||
tokens: [{ start: 500, end: 680, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines: shortLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:short-word',
|
||||
{ start: 500, end: 680 },
|
||||
'go',
|
||||
)
|
||||
|
||||
const characters = tokenNode.querySelectorAll(
|
||||
'[data-lyrics-character="true"]',
|
||||
)
|
||||
const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
const waveSpan = 180 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO
|
||||
syncNow(result, tokenStart)
|
||||
expect(characters[0].style.transform).toBe('')
|
||||
expect(characters[1].style.transform).toBe('')
|
||||
|
||||
syncNow(result, 680 - KARAOKE_HIGHLIGHT_LEAD_MS)
|
||||
expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
|
||||
expect(characters[0].style.transform).not.toBe('')
|
||||
expect(characters[1].style.transform).not.toBe(fullCharacterLift)
|
||||
|
||||
syncNow(result, tokenStart + waveSpan + KARAOKE_CHARACTER_RISE_MS)
|
||||
expect(characters[0].style.transform).toBe(fullCharacterLift)
|
||||
expect(characters[1].style.transform).toBe(fullCharacterLift)
|
||||
})
|
||||
|
||||
it('gives a single character the same fixed rise duration', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 5, paused: true })
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 5000,
|
||||
tokens: [{ start: 120, end: 4120, value: 'I' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:single-character',
|
||||
{ start: 120, end: 4120 },
|
||||
'I',
|
||||
)
|
||||
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
|
||||
const tokenStart = 120 - KARAOKE_HIGHLIGHT_LEAD_MS
|
||||
|
||||
syncNow(result, tokenStart)
|
||||
expect(character.style.transform).toBe('')
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
|
||||
expect(character.style.transform).toMatch(
|
||||
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
|
||||
)
|
||||
expect(character.style.transform).not.toBe(fullCharacterLift)
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS - 20)
|
||||
expect(character.style.transform).not.toBe(fullCharacterLift)
|
||||
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
|
||||
expect(character.style.transform).toBe(fullCharacterLift)
|
||||
expect(
|
||||
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
|
||||
).toBeCloseTo(KARAOKE_CHARACTER_RISE_MS / 4000, 5)
|
||||
})
|
||||
|
||||
it('forces an unfinished character wave to its raised release state', () => {
|
||||
const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 600,
|
||||
tokens: [{ start: 500, end: 550, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:release-wave',
|
||||
{ start: 500, end: 550 },
|
||||
'go',
|
||||
)
|
||||
const characters = tokenNode.querySelectorAll(
|
||||
'[data-lyrics-character="true"]',
|
||||
)
|
||||
|
||||
syncNow(result, 500 - KARAOKE_HIGHLIGHT_LEAD_MS)
|
||||
expect(characters[1].style.transform).toBe('')
|
||||
|
||||
syncNow(result, 600)
|
||||
expect(tokenNode.dataset.lyricsState).toBe('release')
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps interpolated playback time monotonic between coarse media updates', () => {
|
||||
let frameCallback = null
|
||||
let now = 0
|
||||
window.requestAnimationFrame.mockImplementation((callback) => {
|
||||
frameCallback = callback
|
||||
return 1
|
||||
})
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
|
||||
const audio = createAudio({ currentTime: 0.2, paused: false })
|
||||
const { result } = renderTimeline({ audio })
|
||||
const tokenNode = document.createElement('span')
|
||||
act(() => {
|
||||
result.current.registerToken(
|
||||
'0:monotonic',
|
||||
{
|
||||
lineIndex: 0,
|
||||
window: { start: 0, end: 1000 },
|
||||
presentation,
|
||||
},
|
||||
tokenNode,
|
||||
)
|
||||
})
|
||||
|
||||
now = 100
|
||||
act(() => frameCallback())
|
||||
const firstProgress = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-progress'),
|
||||
)
|
||||
now = 180
|
||||
act(() => frameCallback())
|
||||
const secondProgress = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-progress'),
|
||||
)
|
||||
|
||||
expect(secondProgress).toBeGreaterThanOrEqual(firstProgress)
|
||||
})
|
||||
|
||||
it('drives character motion from playback-rate-adjusted media time', () => {
|
||||
let frameCallback = null
|
||||
let now = 0
|
||||
window.requestAnimationFrame.mockImplementation((callback) => {
|
||||
frameCallback = callback
|
||||
return 1
|
||||
})
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
|
||||
const audio = createAudio({
|
||||
currentTime: 0.38,
|
||||
duration: 2,
|
||||
paused: false,
|
||||
playbackRate: 2,
|
||||
})
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 1200,
|
||||
tokens: [{ start: 500, end: 1000, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:playback-rate-wave',
|
||||
{ start: 500, end: 1000 },
|
||||
'go',
|
||||
)
|
||||
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
|
||||
|
||||
expect(character.style.transform).toBe('')
|
||||
now = KARAOKE_CLOCK_DRIFT_RESET_MS / 4
|
||||
act(() => frameCallback())
|
||||
expect(character.style.transform).toMatch(
|
||||
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
|
||||
)
|
||||
expect(character.style.transform).not.toBe(fullCharacterLift)
|
||||
})
|
||||
|
||||
it('reconstructs settled character motion after visibility recovery', () => {
|
||||
let visibility = 'visible'
|
||||
vi.spyOn(window.document, 'visibilityState', 'get').mockImplementation(
|
||||
() => visibility,
|
||||
)
|
||||
const audio = createAudio({
|
||||
currentTime: 0.38,
|
||||
duration: 2,
|
||||
paused: false,
|
||||
})
|
||||
const sourceLines = [
|
||||
{
|
||||
start: 0,
|
||||
end: 1200,
|
||||
tokens: [{ start: 500, end: 1000, value: 'go' }],
|
||||
},
|
||||
]
|
||||
const { result } = renderTimeline({ audio, sourceLines })
|
||||
const tokenNode = registerToken(
|
||||
result,
|
||||
'0:visibility-wave',
|
||||
{ start: 500, end: 1000 },
|
||||
'go',
|
||||
)
|
||||
const characters = tokenNode.querySelectorAll(
|
||||
'[data-lyrics-character="true"]',
|
||||
)
|
||||
|
||||
visibility = 'hidden'
|
||||
act(() => {
|
||||
window.document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
const characterMotionEnd =
|
||||
500 +
|
||||
Math.min(
|
||||
500 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
|
||||
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
|
||||
) +
|
||||
KARAOKE_CHARACTER_RISE_MS
|
||||
audio.currentTime = (characterMotionEnd - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000
|
||||
visibility = 'visible'
|
||||
act(() => {
|
||||
window.document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
|
||||
characters.forEach((character) =>
|
||||
expect(character.style.transform).toBe(fullCharacterLift),
|
||||
)
|
||||
})
|
||||
|
||||
it('resets monotonic interpolation after an explicit backward seek', () => {
|
||||
let frameCallback = null
|
||||
let now = 0
|
||||
window.requestAnimationFrame.mockImplementation((callback) => {
|
||||
frameCallback = callback
|
||||
return 1
|
||||
})
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
|
||||
const audio = createAudio({ currentTime: 0.8, paused: false })
|
||||
const { result } = renderTimeline({ audio })
|
||||
const tokenNode = registerToken(result, '0:backward-seek', {
|
||||
start: 0,
|
||||
end: 1000,
|
||||
})
|
||||
|
||||
now = 100
|
||||
act(() => frameCallback())
|
||||
const beforeSeek = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-progress'),
|
||||
)
|
||||
|
||||
audio.currentTime = 0.1
|
||||
act(() => {
|
||||
audio.dispatchEvent(new Event('seeked'))
|
||||
})
|
||||
now = 120
|
||||
act(() => frameCallback())
|
||||
|
||||
const afterSeek = Number(
|
||||
tokenNode.style.getPropertyValue('--lyrics-progress'),
|
||||
)
|
||||
expect(afterSeek).toBeLessThan(beforeSeek)
|
||||
expect(afterSeek).toBeCloseTo(0.24, 2)
|
||||
})
|
||||
|
||||
it('starts and stops requestAnimationFrame with playback visibility', () => {
|
||||
const audio = createAudio({ currentTime: 0.25, paused: true })
|
||||
renderTimeline({ audio })
|
||||
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
audio.paused = false
|
||||
audio.dispatchEvent(new Event('play'))
|
||||
})
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
audio.paused = true
|
||||
audio.dispatchEvent(new Event('pause'))
|
||||
})
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rebuilds open-line timing when media duration changes', () => {
|
||||
const audio = createAudio({ duration: 10, paused: true })
|
||||
const { result } = renderTimeline({
|
||||
audio,
|
||||
sourceLines: [{ start: 7000, tokens: [] }],
|
||||
})
|
||||
|
||||
expect(result.current.timeline.windows[0].end).toBe(10000)
|
||||
|
||||
act(() => {
|
||||
audio.duration = 20
|
||||
audio.dispatchEvent(new Event('durationchange'))
|
||||
})
|
||||
|
||||
expect(result.current.timeline.windows[0].end).toBe(15000)
|
||||
})
|
||||
})
|
||||
233
ui/src/audioplayer/usePlayerLyrics.jsx
Normal file
233
ui/src/audioplayer/usePlayerLyrics.jsx
Normal file
@ -0,0 +1,233 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
|
||||
import { hasStructuredLyricContent } from './lyrics'
|
||||
import {
|
||||
resolveLyricsSidebarState,
|
||||
toggleLayerPreference,
|
||||
} from './lyricsSidebarState'
|
||||
import useEnhancedLyrics from './useEnhancedLyrics'
|
||||
|
||||
const usePlayerLyrics = ({
|
||||
trackId,
|
||||
trackUpdatedAt,
|
||||
isRadio,
|
||||
audioInstance,
|
||||
isDesktop,
|
||||
obscuredByQueue = false,
|
||||
translate,
|
||||
}) => {
|
||||
const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false)
|
||||
const [translationPreference, setTranslationPreference] = useState(null)
|
||||
const [pronunciationPreference, setPronunciationPreference] = useState(null)
|
||||
const lyricsToggleRef = useRef(null)
|
||||
|
||||
const {
|
||||
layers: lyricLayers,
|
||||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
retryAfterSeconds: lyricsRetryAfterSeconds,
|
||||
retry: retryLyrics,
|
||||
} = useEnhancedLyrics({
|
||||
trackId,
|
||||
updatedAt: trackUpdatedAt,
|
||||
disabled: isRadio,
|
||||
requested: lyricsVisiblePreference,
|
||||
})
|
||||
|
||||
const hasTranslationLyric = hasStructuredLyricContent(lyricLayers.translation)
|
||||
const hasPronunciationLyric = hasStructuredLyricContent(
|
||||
lyricLayers.pronunciation,
|
||||
)
|
||||
const lyricsSource =
|
||||
lyricLayers.main?.source ||
|
||||
lyricLayers.translation?.source ||
|
||||
lyricLayers.pronunciation?.source ||
|
||||
null
|
||||
const { lyricsVisible, showTranslation, showPronunciation } =
|
||||
resolveLyricsSidebarState({
|
||||
lyricsVisiblePreference,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasTranslationLyric,
|
||||
hasPronunciationLyric,
|
||||
})
|
||||
const lyricsToggleDisabled = !lyricsVisiblePreference && (isRadio || !trackId)
|
||||
const useInlineMobileLyrics = lyricsVisible && !isDesktop
|
||||
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
title: translate('player.lyricsTitleText'),
|
||||
toggle: translate('player.toggleLyricText'),
|
||||
loading: translate('player.lyricsLoadingText'),
|
||||
unavailable: translate('player.lyricsUnavailableText'),
|
||||
rateLimited:
|
||||
lyricsRetryAfterSeconds == null
|
||||
? null
|
||||
: translate('player.lyricsRateLimitedText', {
|
||||
smart_count: lyricsRetryAfterSeconds,
|
||||
}),
|
||||
empty: translate('player.emptyLyricText'),
|
||||
resize: translate('player.resizeLyricsSidebarText'),
|
||||
showTranslation: translate('player.showLyricsTranslationText'),
|
||||
hideTranslation: translate('player.hideLyricsTranslationText'),
|
||||
showPronunciation: translate('player.showLyricsPronunciationText'),
|
||||
hidePronunciation: translate('player.hideLyricsPronunciationText'),
|
||||
viewSource: translate('player.viewLyricsSourceText'),
|
||||
sourceTitle: translate('player.lyricsSourceText'),
|
||||
embeddedSource: translate('player.embeddedLyricsSourceText'),
|
||||
sidecarSource: translate('player.sidecarLyricsSourceText'),
|
||||
pluginSource: translate('player.pluginLyricsSourceText'),
|
||||
provider: translate('player.lyricsProviderText'),
|
||||
format: translate('player.lyricsFormatText'),
|
||||
}),
|
||||
[lyricsRetryAfterSeconds, translate],
|
||||
)
|
||||
|
||||
const toggleLyrics = useCallback(() => {
|
||||
const next = !lyricsVisiblePreference
|
||||
setLyricsVisiblePreference(next)
|
||||
if (next) {
|
||||
if (lyricsError) retryLyrics()
|
||||
}
|
||||
}, [lyricsError, lyricsVisiblePreference, retryLyrics])
|
||||
|
||||
const closeLyrics = useCallback(() => {
|
||||
setLyricsVisiblePreference(false)
|
||||
}, [])
|
||||
|
||||
const toggleTranslation = useCallback(() => {
|
||||
setTranslationPreference((current) =>
|
||||
toggleLayerPreference(current, hasTranslationLyric),
|
||||
)
|
||||
}, [hasTranslationLyric])
|
||||
|
||||
const togglePronunciation = useCallback(() => {
|
||||
setPronunciationPreference((current) =>
|
||||
toggleLayerPreference(current, hasPronunciationLyric, true),
|
||||
)
|
||||
}, [hasPronunciationLyric])
|
||||
|
||||
const toolbarLyricsProps = useMemo(
|
||||
() => ({
|
||||
onToggleLyrics: toggleLyrics,
|
||||
lyricsActive: lyricsVisible,
|
||||
lyricsDisabled: lyricsToggleDisabled,
|
||||
lyricsLoading,
|
||||
lyricsLabel: labels.toggle,
|
||||
lyricsLoadingLabel: labels.loading,
|
||||
lyricsToggleRef,
|
||||
}),
|
||||
[labels, lyricsLoading, lyricsToggleDisabled, lyricsVisible, toggleLyrics],
|
||||
)
|
||||
|
||||
const desktopLyricsProps = useMemo(
|
||||
() => ({
|
||||
visible: isDesktop && lyricsVisible,
|
||||
mainLyric: lyricLayers.main,
|
||||
translationLyric: lyricLayers.translation,
|
||||
pronunciationLyric: lyricLayers.pronunciation,
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
translationEnabled: hasTranslationLyric,
|
||||
pronunciationEnabled: hasPronunciationLyric,
|
||||
source: lyricsSource,
|
||||
onToggleTranslation: toggleTranslation,
|
||||
onTogglePronunciation: togglePronunciation,
|
||||
audioInstance,
|
||||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
errorMessage: labels.rateLimited,
|
||||
labels,
|
||||
obscuredByQueue,
|
||||
returnFocusRef: lyricsToggleRef,
|
||||
}),
|
||||
[
|
||||
audioInstance,
|
||||
hasPronunciationLyric,
|
||||
hasTranslationLyric,
|
||||
isDesktop,
|
||||
labels,
|
||||
lyricLayers.main,
|
||||
lyricLayers.pronunciation,
|
||||
lyricLayers.translation,
|
||||
lyricsError,
|
||||
lyricsLoading,
|
||||
lyricsSource,
|
||||
lyricsVisible,
|
||||
obscuredByQueue,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
toggleTranslation,
|
||||
],
|
||||
)
|
||||
|
||||
const mobileLyricsSurface = useMemo(
|
||||
() => (
|
||||
<MobileKaraokeLyricsPortal
|
||||
active={useInlineMobileLyrics}
|
||||
obscured={obscuredByQueue}
|
||||
returnFocusRef={lyricsToggleRef}
|
||||
>
|
||||
<LyricsPanel
|
||||
visible
|
||||
obscured={obscuredByQueue}
|
||||
mainLyric={lyricLayers.main}
|
||||
translationLyric={lyricLayers.translation}
|
||||
pronunciationLyric={lyricLayers.pronunciation}
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
audioInstance={audioInstance}
|
||||
loading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
errorMessage={labels.rateLimited}
|
||||
labels={labels}
|
||||
inline
|
||||
/>
|
||||
<LyricsLayerControls
|
||||
placement="mobile"
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
translationEnabled={hasTranslationLyric}
|
||||
pronunciationEnabled={hasPronunciationLyric}
|
||||
onToggleTranslation={toggleTranslation}
|
||||
onTogglePronunciation={togglePronunciation}
|
||||
source={lyricsSource}
|
||||
active={useInlineMobileLyrics && !obscuredByQueue}
|
||||
labels={labels}
|
||||
testId="lyrics-mobile-layer-controls"
|
||||
/>
|
||||
</MobileKaraokeLyricsPortal>
|
||||
),
|
||||
[
|
||||
audioInstance,
|
||||
hasPronunciationLyric,
|
||||
hasTranslationLyric,
|
||||
lyricLayers.main,
|
||||
lyricLayers.pronunciation,
|
||||
lyricLayers.translation,
|
||||
lyricsError,
|
||||
lyricsLoading,
|
||||
lyricsSource,
|
||||
labels,
|
||||
obscuredByQueue,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
toggleTranslation,
|
||||
useInlineMobileLyrics,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
toolbarLyricsProps,
|
||||
desktopLyricsProps,
|
||||
mobileLyricsSurface,
|
||||
useInlineMobileLyrics,
|
||||
closeLyrics,
|
||||
}
|
||||
}
|
||||
|
||||
export default usePlayerLyrics
|
||||
38
ui/src/audioplayer/useRestoreFocusOnExit.js
Normal file
38
ui/src/audioplayer/useRestoreFocusOnExit.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect } from 'react'
|
||||
|
||||
const restoreFocusFromSurface = (surface, returnFocusRef) => {
|
||||
if (!surface || typeof document === 'undefined') return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
if (!activeElement || !surface.contains(activeElement)) return
|
||||
|
||||
const returnTarget = returnFocusRef?.current
|
||||
const canReturnFocus =
|
||||
returnTarget?.isConnected &&
|
||||
!returnTarget.disabled &&
|
||||
returnTarget.getAttribute?.('aria-disabled') !== 'true' &&
|
||||
typeof returnTarget.focus === 'function'
|
||||
|
||||
if (canReturnFocus) {
|
||||
returnTarget.focus()
|
||||
return
|
||||
}
|
||||
|
||||
activeElement.blur?.()
|
||||
}
|
||||
|
||||
const useRestoreFocusOnExit = ({
|
||||
surfaceRef,
|
||||
entered,
|
||||
returnFocusRef,
|
||||
surfaceKey,
|
||||
}) => {
|
||||
useLayoutEffect(() => {
|
||||
const surface = surfaceRef.current
|
||||
if (!entered) restoreFocusFromSurface(surface, returnFocusRef)
|
||||
|
||||
return () => restoreFocusFromSurface(surface, returnFocusRef)
|
||||
}, [entered, returnFocusRef, surfaceKey, surfaceRef])
|
||||
}
|
||||
|
||||
export default useRestoreFocusOnExit
|
||||
@ -658,6 +658,22 @@
|
||||
"reloadText": "Reload",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "Toggle lyrics",
|
||||
"lyricsTitleText": "Lyrics",
|
||||
"lyricsLoadingText": "Loading lyrics",
|
||||
"lyricsUnavailableText": "Lyrics unavailable",
|
||||
"lyricsRateLimitedText": "Remote lyrics are rate-limited. Retrying in %{smart_count} second. |||| Remote lyrics are rate-limited. Retrying in %{smart_count} seconds.",
|
||||
"resizeLyricsSidebarText": "Resize lyrics sidebar",
|
||||
"showLyricsTranslationText": "Show translation",
|
||||
"hideLyricsTranslationText": "Hide translation",
|
||||
"showLyricsPronunciationText": "Show pronunciation",
|
||||
"hideLyricsPronunciationText": "Hide pronunciation",
|
||||
"viewLyricsSourceText": "View lyrics source",
|
||||
"lyricsSourceText": "Lyrics source",
|
||||
"embeddedLyricsSourceText": "Embedded lyrics",
|
||||
"sidecarLyricsSourceText": "Sidecar file",
|
||||
"pluginLyricsSourceText": "Lyrics plugin",
|
||||
"lyricsProviderText": "Provider",
|
||||
"lyricsFormatText": "Format",
|
||||
"toggleMiniModeText": "Minimize",
|
||||
"destroyText": "Destroy",
|
||||
"downloadText": "Download",
|
||||
|
||||
@ -8,6 +8,7 @@ import AppBar from './AppBar'
|
||||
import Notification from './Notification'
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import { useSearchRefocus } from '../common'
|
||||
import LyricsLayoutFrame from './LyricsLayoutFrame'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
|
||||
@ -33,7 +34,9 @@ const Layout = (props) => {
|
||||
appBar={AppBar}
|
||||
theme={theme}
|
||||
notification={Notification}
|
||||
/>
|
||||
>
|
||||
<LyricsLayoutFrame>{props.children}</LyricsLayoutFrame>
|
||||
</RALayout>
|
||||
</HotKeys>
|
||||
)
|
||||
}
|
||||
|
||||
171
ui/src/layout/LyricsLayoutFrame.jsx
Normal file
171
ui/src/layout/LyricsLayoutFrame.jsx
Normal file
@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { setSidebarVisibility } from 'react-admin'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import LyricsSidebar from '../audioplayer/LyricsSidebar'
|
||||
import { useLyricsLayout } from '../audioplayer/LyricsLayoutContext'
|
||||
import {
|
||||
LYRICS_ROUTE_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
clampSidebarWidth,
|
||||
getSidebarMaxWidth,
|
||||
loadSidebarWidth,
|
||||
saveSidebarWidth,
|
||||
} from '../audioplayer/lyricsSidebarWidth'
|
||||
|
||||
const MIN_CONTENT_WITH_LYRICS =
|
||||
LYRICS_ROUTE_MIN_WIDTH + LYRICS_SIDEBAR_MIN_WIDTH
|
||||
|
||||
const useStyles = makeStyles(
|
||||
() => ({
|
||||
frame: {
|
||||
minWidth: 0,
|
||||
},
|
||||
}),
|
||||
{ name: 'NDLyricsLayoutFrame' },
|
||||
)
|
||||
|
||||
const LyricsLayoutFrame = ({ children }) => {
|
||||
const { desktopLyricsProps } = useLyricsLayout()
|
||||
const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible)
|
||||
const [preferredSidebarWidth, setPreferredSidebarWidth] =
|
||||
useState(loadSidebarWidth)
|
||||
const [contentWidth, setContentWidth] = useState(null)
|
||||
const frameRef = useRef(null)
|
||||
const autoCollapsedRef = useRef(false)
|
||||
const manualSidebarOverrideRef = useRef(false)
|
||||
const pendingSidebarStateRef = useRef(null)
|
||||
const previousSidebarOpenRef = useRef(null)
|
||||
const dispatch = useDispatch()
|
||||
const sidebarOpen = useSelector(
|
||||
(state) => state.admin?.ui?.sidebarOpen ?? false,
|
||||
)
|
||||
const classes = useStyles()
|
||||
const sidebarMaxWidth = getSidebarMaxWidth(contentWidth)
|
||||
const sidebarWidth = clampSidebarWidth(preferredSidebarWidth, sidebarMaxWidth)
|
||||
|
||||
useEffect(() => {
|
||||
const parent = frameRef.current?.parentElement
|
||||
if (!parent) return undefined
|
||||
|
||||
const updateContentWidth = (nextWidth) => {
|
||||
const numeric = Number(nextWidth)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return
|
||||
const rounded = Math.round(numeric)
|
||||
setContentWidth((current) => (current === rounded ? current : rounded))
|
||||
}
|
||||
|
||||
const measureParent = () => {
|
||||
updateContentWidth(parent.clientWidth)
|
||||
}
|
||||
|
||||
measureParent()
|
||||
const ResizeObserverConstructor = window.ResizeObserver
|
||||
if (!ResizeObserverConstructor) {
|
||||
window.addEventListener('resize', measureParent)
|
||||
return () => window.removeEventListener('resize', measureParent)
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserverConstructor((entries) => {
|
||||
const entry = entries.find((candidate) => candidate.target === parent)
|
||||
const borderBoxSize = Array.isArray(entry?.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: entry?.borderBoxSize
|
||||
updateContentWidth(
|
||||
borderBoxSize?.inlineSize ||
|
||||
entry?.target?.clientWidth ||
|
||||
entry?.contentRect?.width,
|
||||
)
|
||||
})
|
||||
resizeObserver.observe(parent)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasVisibleLyrics) {
|
||||
setPreferredSidebarWidth(loadSidebarWidth())
|
||||
}
|
||||
}, [hasVisibleLyrics])
|
||||
|
||||
useEffect(() => {
|
||||
const previousSidebarOpen = previousSidebarOpenRef.current
|
||||
previousSidebarOpenRef.current = sidebarOpen
|
||||
if (previousSidebarOpen == null || previousSidebarOpen === sidebarOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingSidebarStateRef.current === sidebarOpen) {
|
||||
pendingSidebarStateRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (hasVisibleLyrics) {
|
||||
manualSidebarOverrideRef.current = true
|
||||
autoCollapsedRef.current = false
|
||||
}
|
||||
}, [hasVisibleLyrics, sidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasVisibleLyrics) {
|
||||
if (
|
||||
autoCollapsedRef.current &&
|
||||
!manualSidebarOverrideRef.current &&
|
||||
!sidebarOpen
|
||||
) {
|
||||
autoCollapsedRef.current = false
|
||||
pendingSidebarStateRef.current = true
|
||||
dispatch(setSidebarVisibility(true))
|
||||
}
|
||||
manualSidebarOverrideRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
manualSidebarOverrideRef.current ||
|
||||
!Number.isFinite(contentWidth) ||
|
||||
contentWidth <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sidebarOpen && contentWidth < MIN_CONTENT_WITH_LYRICS) {
|
||||
autoCollapsedRef.current = true
|
||||
pendingSidebarStateRef.current = false
|
||||
dispatch(setSidebarVisibility(false))
|
||||
return
|
||||
}
|
||||
}, [contentWidth, dispatch, hasVisibleLyrics, sidebarOpen])
|
||||
|
||||
const handleSidebarWidthChange = useCallback(
|
||||
(nextWidth, { persist = false } = {}) => {
|
||||
const resolvedWidth = clampSidebarWidth(nextWidth, sidebarMaxWidth)
|
||||
setPreferredSidebarWidth(resolvedWidth)
|
||||
if (persist) saveSidebarWidth(resolvedWidth)
|
||||
},
|
||||
[sidebarMaxWidth],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frameRef}
|
||||
className={classes.frame}
|
||||
data-testid="lyrics-layout-frame"
|
||||
data-lyrics-sidebar-visible={hasVisibleLyrics ? 'true' : 'false'}
|
||||
style={{
|
||||
marginRight: hasVisibleLyrics ? sidebarWidth : 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{desktopLyricsProps && (
|
||||
<LyricsSidebar
|
||||
{...desktopLyricsProps}
|
||||
width={sidebarWidth}
|
||||
maxWidth={sidebarMaxWidth}
|
||||
onWidthChange={handleSidebarWidthChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsLayoutFrame
|
||||
370
ui/src/layout/LyricsLayoutFrame.test.jsx
Normal file
370
ui/src/layout/LyricsLayoutFrame.test.jsx
Normal file
@ -0,0 +1,370 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||
import { setSidebarVisibility } from 'react-admin'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createStore } from 'redux'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LyricsLayoutProvider,
|
||||
useLyricsLayout,
|
||||
} from '../audioplayer/LyricsLayoutContext'
|
||||
import {
|
||||
LYRICS_SIDEBAR_STORAGE_KEY,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
} from '../audioplayer/lyricsSidebarWidth'
|
||||
import LyricsLayoutFrame from './LyricsLayoutFrame'
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: { main: '#35aa66' },
|
||||
background: { default: '#101820' },
|
||||
},
|
||||
})
|
||||
|
||||
const lyric = {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Main line' }],
|
||||
}
|
||||
|
||||
const lyricsProps = {
|
||||
visible: true,
|
||||
mainLyric: lyric,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
onToggleTranslation: vi.fn(),
|
||||
onTogglePronunciation: vi.fn(),
|
||||
}
|
||||
|
||||
const originalResizeObserver = window.ResizeObserver
|
||||
const resizeObservers = []
|
||||
|
||||
class TestResizeObserver {
|
||||
constructor(callback) {
|
||||
this.callback = callback
|
||||
this.target = null
|
||||
this.disconnected = false
|
||||
resizeObservers.push(this)
|
||||
}
|
||||
|
||||
observe(target) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.disconnected = true
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContentWidth = (width) => {
|
||||
act(() => {
|
||||
resizeObservers.forEach((observer) => {
|
||||
if (observer.disconnected || !observer.target) return
|
||||
observer.callback([
|
||||
{
|
||||
target: observer.target,
|
||||
contentRect: { width },
|
||||
borderBoxSize: [{ inlineSize: width }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sidebarVisibilityActionType = setSidebarVisibility(true).type
|
||||
|
||||
const createLayoutStore = (sidebarOpen = false) =>
|
||||
createStore(
|
||||
(state, action) => {
|
||||
if (action.type !== sidebarVisibilityActionType) return state
|
||||
return {
|
||||
...state,
|
||||
admin: {
|
||||
...state.admin,
|
||||
ui: {
|
||||
...state.admin.ui,
|
||||
sidebarOpen: action.payload,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
{ admin: { ui: { sidebarOpen } } },
|
||||
)
|
||||
|
||||
const PublishLyricsProps = ({ props }) => {
|
||||
const { setDesktopLyricsProps } = useLyricsLayout()
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopLyricsProps(props)
|
||||
return () => setDesktopLyricsProps(null)
|
||||
}, [props, setDesktopLyricsProps])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const frame = (props, store) => (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<LyricsLayoutProvider>
|
||||
<PublishLyricsProps props={props} />
|
||||
<LyricsLayoutFrame>
|
||||
<div data-testid="route-content">Albums grid</div>
|
||||
</LyricsLayoutFrame>
|
||||
</LyricsLayoutProvider>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
const renderFrame = (props, { sidebarOpen = false, store } = {}) => {
|
||||
window.ResizeObserver = TestResizeObserver
|
||||
const layoutStore = store || createLayoutStore(sidebarOpen)
|
||||
const view = render(frame(props, layoutStore))
|
||||
return {
|
||||
...view,
|
||||
store: layoutStore,
|
||||
rerenderFrame: (nextProps) => view.rerender(frame(nextProps, layoutStore)),
|
||||
}
|
||||
}
|
||||
|
||||
describe('<LyricsLayoutFrame />', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
resizeObservers.splice(0)
|
||||
window.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('reserves a native right-hand lyrics pane beside route content', async () => {
|
||||
renderFrame(lyricsProps)
|
||||
|
||||
const sidebar = await screen.findByTestId('lyrics-sidebar')
|
||||
const frame = screen.getByTestId('lyrics-layout-frame')
|
||||
const content = screen.getByTestId('route-content')
|
||||
|
||||
expect(frame).toHaveAttribute('data-lyrics-sidebar-visible', 'true')
|
||||
expect(frame).toHaveStyle({ marginRight: '360px' })
|
||||
expect(frame).toContainElement(content)
|
||||
expect(frame).toContainElement(sidebar)
|
||||
expect(window.getComputedStyle(sidebar).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('caps a persisted width against the measured route reserve without overwriting it', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
renderFrame(lyricsProps)
|
||||
const sidebar = await screen.findByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
notifyContentWidth(755)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(sidebar).toHaveStyle({ width: '435px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '435')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
|
||||
|
||||
notifyContentWidth(840)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '520px',
|
||||
})
|
||||
expect(sidebar).toHaveStyle({ width: '520px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '520')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '520')
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
|
||||
})
|
||||
|
||||
it('keeps the layout width reserved while the queue obscures lyrics', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
renderFrame({ ...lyricsProps, obscuredByQueue: true })
|
||||
const sidebar = await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
notifyContentWidth(755)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(sidebar).toHaveStyle({
|
||||
width: '435px',
|
||||
transform: 'translateX(0)',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses an open app menu only when the minimum pane cannot fit', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
const { rerenderFrame, store } = renderFrame(lyricsProps, {
|
||||
sidebarOpen: true,
|
||||
})
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
notifyContentWidth(570)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '300px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'300',
|
||||
)
|
||||
|
||||
notifyContentWidth(755)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the app menu open at the 960px and 1280px layout caps', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
const { store } = renderFrame(lyricsProps, { sidebarOpen: true })
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
notifyContentWidth(720)
|
||||
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true)
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '400px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'400',
|
||||
)
|
||||
|
||||
notifyContentWidth(1040)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '520px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'520',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an automatic collapse until lyrics close when space returns', async () => {
|
||||
const { rerenderFrame, store } = renderFrame(lyricsProps, {
|
||||
sidebarOpen: true,
|
||||
})
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
notifyContentWidth(570)
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
|
||||
notifyContentWidth(1040)
|
||||
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore the menu after a manual override', async () => {
|
||||
const { rerenderFrame, store } = renderFrame(lyricsProps, {
|
||||
sidebarOpen: true,
|
||||
})
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
notifyContentWidth(570)
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
store.dispatch(setSidebarVisibility(true))
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
|
||||
)
|
||||
act(() => {
|
||||
store.dispatch(setSidebarVisibility(false))
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('persists deliberate keyboard resizing at the measured cap', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
renderFrame(lyricsProps)
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
notifyContentWidth(755)
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'Home' })
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '300px',
|
||||
})
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('300')
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'End' })
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('435')
|
||||
})
|
||||
|
||||
it('releases the layout frame after the sidebar exit transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerenderFrame } = renderFrame(lyricsProps)
|
||||
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute(
|
||||
'data-lyrics-sidebar-visible',
|
||||
'false',
|
||||
)
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '0px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
|
||||
transform: 'translateX(100%)',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute(
|
||||
'data-lyrics-sidebar-visible',
|
||||
'false',
|
||||
)
|
||||
expect(screen.queryByTestId('lyrics-sidebar')).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -11,6 +11,10 @@ import albumLists from '../album/albumLists'
|
||||
import PlaylistsSubMenu from './PlaylistsSubMenu'
|
||||
import LibrarySelector from '../common/LibrarySelector'
|
||||
import config from '../config'
|
||||
import {
|
||||
APP_SIDEBAR_CLOSED_WIDTH,
|
||||
APP_SIDEBAR_OPEN_WIDTH,
|
||||
} from './layoutWidths'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
@ -23,10 +27,10 @@ const useStyles = makeStyles((theme) => ({
|
||||
paddingBottom: (props) => (props.addPadding ? '80px' : '20px'),
|
||||
},
|
||||
open: {
|
||||
width: 240,
|
||||
width: APP_SIDEBAR_OPEN_WIDTH,
|
||||
},
|
||||
closed: {
|
||||
width: 55,
|
||||
width: APP_SIDEBAR_CLOSED_WIDTH,
|
||||
},
|
||||
active: {
|
||||
color: theme.palette.text.primary,
|
||||
|
||||
2
ui/src/layout/layoutWidths.js
Normal file
2
ui/src/layout/layoutWidths.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const APP_SIDEBAR_OPEN_WIDTH = 240
|
||||
export const APP_SIDEBAR_CLOSED_WIDTH = 55
|
||||
@ -131,6 +131,26 @@ const getTopSongs = (artist, count = 50) => {
|
||||
return httpClient(url('getTopSongs', null, { artist, count }))
|
||||
}
|
||||
|
||||
const requireSuccessfulLyricsResponse = (response) => {
|
||||
const subsonicResponse = response?.json?.['subsonic-response']
|
||||
if (subsonicResponse?.status !== 'failed') return response
|
||||
|
||||
const error = new Error(
|
||||
subsonicResponse.error?.message || 'Lyrics request failed',
|
||||
)
|
||||
error.body = response.json
|
||||
error.code = subsonicResponse.error?.code
|
||||
throw error
|
||||
}
|
||||
|
||||
const getLyricsBySongId = (id, requestOptions) => {
|
||||
const requestUrl = url('getLyricsBySongId', id, { enhanced: true })
|
||||
const request = requestOptions
|
||||
? httpClient(requestUrl, requestOptions)
|
||||
: httpClient(requestUrl)
|
||||
return request.then(requireSuccessfulLyricsResponse)
|
||||
}
|
||||
|
||||
const streamUrl = (id, options) => {
|
||||
return baseUrl(
|
||||
url('stream', id, {
|
||||
@ -160,4 +180,5 @@ export default {
|
||||
getArtistInfo,
|
||||
getTopSongs,
|
||||
getSimilarSongs2,
|
||||
getLyricsBySongId,
|
||||
}
|
||||
|
||||
@ -1,27 +1,56 @@
|
||||
import { vi } from 'vitest'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
import config from '../config'
|
||||
import { httpClient } from '../dataProvider'
|
||||
import subsonic from './index'
|
||||
|
||||
vi.mock('../dataProvider', () => ({
|
||||
httpClient: vi.fn((target) => Promise.resolve({ target })),
|
||||
clientUniqueId: 'test-client-id',
|
||||
clientUniqueIdHeader: 'x-test-client-id',
|
||||
}))
|
||||
|
||||
const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
|
||||
window,
|
||||
'localStorage',
|
||||
)
|
||||
|
||||
const mockSubsonicLocalStorage = () => {
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: localStorageMock,
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLocalStorageDescriptor) {
|
||||
Object.defineProperty(
|
||||
window,
|
||||
'localStorage',
|
||||
originalLocalStorageDescriptor,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('getCoverArtUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.location
|
||||
delete window.location
|
||||
window.location = { href: 'http://localhost:3000/app' }
|
||||
|
||||
// Mock localStorage values required by subsonic
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
mockSubsonicLocalStorage()
|
||||
})
|
||||
|
||||
it('should return playlist cover art URL for records with sync property', () => {
|
||||
@ -163,19 +192,45 @@ describe('getCoverArtUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLyricsBySongId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSubsonicLocalStorage()
|
||||
})
|
||||
|
||||
it('requests enhanced structured lyrics for the song id', async () => {
|
||||
await subsonic.getLyricsBySongId('song-123')
|
||||
|
||||
expect(httpClient).toHaveBeenCalledTimes(1)
|
||||
const requestedUrl = httpClient.mock.calls[0][0]
|
||||
expect(requestedUrl).toContain('/rest/getLyricsBySongId?')
|
||||
expect(requestedUrl).toContain('id=song-123')
|
||||
expect(requestedUrl).toContain('enhanced=true')
|
||||
})
|
||||
|
||||
it('rejects a failed Subsonic lyrics response with its server message', async () => {
|
||||
httpClient.mockResolvedValueOnce({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 0,
|
||||
message:
|
||||
'Internal Server Error: better lyrics API cooldown active for 30s',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(subsonic.getLyricsBySongId('song-123')).rejects.toThrow(
|
||||
'better lyrics API cooldown active for 30s',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDiscCoverArtUrl', () => {
|
||||
beforeEach(() => {
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
mockSubsonicLocalStorage()
|
||||
})
|
||||
|
||||
it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => {
|
||||
@ -215,18 +270,7 @@ describe('getDiscCoverArtUrl', () => {
|
||||
|
||||
describe('getAvatarUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage values required by subsonic
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
mockSubsonicLocalStorage()
|
||||
})
|
||||
|
||||
it('should include username parameter', () => {
|
||||
@ -238,17 +282,7 @@ describe('getAvatarUrl', () => {
|
||||
|
||||
describe('reportPlayback', () => {
|
||||
beforeEach(() => {
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
mockSubsonicLocalStorage()
|
||||
})
|
||||
|
||||
it('should construct reportPlayback URL with correct parameters', () => {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { PLAYER_MOBILE_MAX_WIDTH } from '../audioplayer/playerBreakpoints'
|
||||
|
||||
const stylesheet = `
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
@ -136,7 +138,7 @@ button[data-testid="save-queue-button"] {
|
||||
stroke: #fff;
|
||||
height: .8em;
|
||||
}
|
||||
@media screen and (max-width: 810px) {
|
||||
@media screen and (max-width: ${PLAYER_MOBILE_MAX_WIDTH}px) {
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon {
|
||||
margin-left: 5px;
|
||||
margin-right: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user