diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index b9fb8cb74..8dd570b78 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -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) { diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index b00bcd576..e76cc5469 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -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")) }) }) diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 23c20122d..23535cbf5 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -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 } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 7c7922bfd..de128d8b5 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -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), diff --git a/model/lyrics.go b/model/lyrics.go index 111b1c2a9..24c810059 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -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 diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go index 6f6d19177..8352c4fc3 100644 --- a/plugins/capabilities/lyrics.go +++ b/plugins/capabilities/lyrics.go @@ -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"` +} diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 7336124fc..1b8d9da97 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -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 diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 9e02115e7..f5578bcf3 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -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) } } diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..b77480c73 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -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) { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index b3c7f29ac..cd43a07df 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -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"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 76c54d2e5..59eb15852 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -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"` diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index a8449a5ec..15438d60e 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -34,9 +34,21 @@ pub struct GetLyricsRequest { pub struct GetLyricsResponse { #[serde(default)] pub lyrics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} +/// 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 { diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 2ee2dabbf..10cc06a55 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -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, diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go index bfe1f1899..ce437095e 100644 --- a/server/subsonic/lyrics.go +++ b/server/subsonic/lyrics.go @@ -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 } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index 8713b7a3b..8d97aa5f3 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -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) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..d7004505d 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -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 diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d10aa5a33..a86470ea4 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -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 = () => ( - + + + ) diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index df37edfbb..340b8d730 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -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( diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx new file mode 100644 index 000000000..449a704d9 --- /dev/null +++ b/ui/src/audioplayer/LyricsLayerControls.jsx @@ -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 ( + + + + {children} + + + + ) +} + +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 ( +
event.stopPropagation()} + > + {source && ( + <> + + 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, + })} + > + + + + setSourceAnchor(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'right' }} + PaperProps={{ className: classes.sourcePopover }} + > +
event.stopPropagation()} + > + + {labels.sourceTitle || 'Lyrics source'} + + + {getSourceName(source, labels)} + + {sourceDetails.length > 0 && ( +
+ {sourceDetails.map((detail) => ( + + + {detail.label} + + + {detail.value} + + + ))} +
+ )} +
+
+ + )} + + + + + + +
+ ) +} + +export default LyricsLayerControls diff --git a/ui/src/audioplayer/LyricsLayerControls.test.jsx b/ui/src/audioplayer/LyricsLayerControls.test.jsx new file mode 100644 index 000000000..2e4b03569 --- /dev/null +++ b/ui/src/audioplayer/LyricsLayerControls.test.jsx @@ -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( + {}} + onTogglePronunciation={() => {}} + labels={labels} + source={source} + />, + ) + +describe(' 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'), + ) + }) +}) diff --git a/ui/src/audioplayer/LyricsLayoutContext.jsx b/ui/src/audioplayer/LyricsLayoutContext.jsx new file mode 100644 index 000000000..70aa0b06c --- /dev/null +++ b/ui/src/audioplayer/LyricsLayoutContext.jsx @@ -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 ( + + {children} + + ) +} + +export const useLyricsLayout = () => useContext(LyricsLayoutContext) diff --git a/ui/src/audioplayer/LyricsLineRows.jsx b/ui/src/audioplayer/LyricsLineRows.jsx new file mode 100644 index 000000000..27894020b --- /dev/null +++ b/ui/src/audioplayer/LyricsLineRows.jsx @@ -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 ( + + ) +} + +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 ( + + {segments.map((segment, idx) => { + if (!segment.token) + return {segment.text} + + 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 ( + + {renderWaveText( + segment.text, + Boolean(timedWindow), + Boolean(renderCharacterWave && timedWindow), + emphasized, + waveCharacterClassName, + )} + + ) + })} + + ) + }, +) + +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 ( + + {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 ? ( + + {renderWaveText( + segment.text, + Boolean(mainWindow?.start != null && mainWindow?.end != null), + Boolean( + renderCharacterWave && + mainWindow?.start != null && + mainWindow?.end != null, + ), + mainEmphasized, + waveCharacterClassName, + )} + + ) : ( + + {segment.text} + + ) + + 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 ( + + {mainText} + + {renderWaveText( + segment.pronunciation, + Boolean(pronunciationWindow), + Boolean(renderCharacterWave && pronunciationWindow), + pronunciationEmphasized, + waveCharacterClassName, + )} + + + ) + })} + + ) + }, +) + +KaraokeStackedLineRow.displayName = 'KaraokeStackedLineRow' diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx new file mode 100644 index 000000000..838645ac2 --- /dev/null +++ b/ui/src/audioplayer/LyricsPanel.jsx @@ -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 ( +
event.stopPropagation() : undefined} + > +
+ {message} +
+
+ ) + } + + 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 ( +
event.stopPropagation() : undefined} + > +
+
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 ( +
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 ? ( +
+ {lineLanes.map((lane, laneIdx) => { + const laneClassName = clsx(classes.line, { + [classes.secondaryVoiceLane]: laneIdx > 0, + }) + const rowKey = lane.key || `lane-${laneIdx}` + + return showPr && laneIdx === 0 ? ( + + ) : ( + + ) + })} +
+ ) : showPr ? ( + + ) : ( + + )} + {showTr && ( + + )} +
+ ) + })} +
+
+
+ ) +} + +export default LyricsPanel diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx new file mode 100644 index 000000000..bbc632178 --- /dev/null +++ b/ui/src/audioplayer/LyricsPanel.test.jsx @@ -0,0 +1,1009 @@ +import React from 'react' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LyricsPanel from './LyricsPanel' +import { KaraokeLineRow } from './LyricsLineRows' +import { + KARAOKE_EASING, + KARAOKE_LINE_OPACITY_MS, + KARAOKE_CHARACTER_LIFT_PX, + KARAOKE_CHARACTER_RISE_MS, + KARAOKE_HIGHLIGHT_LEAD_MS, + KARAOKE_LINE_LIFT_PX, + KARAOKE_MANUAL_SCROLL_PAUSE_MS, + KARAOKE_SCROLLBAR_VISIBLE_MS, + TOKEN_FUTURE_ALPHA, +} from './lyricsKaraokeConstants' + +const theme = createTheme({ + palette: { + primary: { main: '#2266aa' }, + text: { primary: '#111111', secondary: '#778899' }, + }, +}) + +const translationColorsFor = (selectedTheme) => ({ + active: selectedTheme.palette.text.secondary, + idle: selectedTheme.palette.text.disabled, +}) + +const panel = (props, selectedTheme = theme) => ( + + + +) + +const renderPanel = (props, selectedTheme = theme) => { + const view = render(panel(props, selectedTheme)) + return { + ...view, + rerenderPanel: (nextProps) => + view.rerender(panel(nextProps, selectedTheme)), + } +} + +const parseCssColor = (value) => { + const hex = String(value).match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i) + if (hex) { + const normalized = + hex[1].length === 3 + ? hex[1] + .split('') + .map((channel) => channel.repeat(2)) + .join('') + : hex[1] + return { + channels: [ + parseInt(normalized.slice(0, 2), 16), + parseInt(normalized.slice(2, 4), 16), + parseInt(normalized.slice(4, 6), 16), + ], + alpha: 1, + } + } + + const rgb = String(value).match( + /^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/i, + ) + if (!rgb) throw new Error(`Unsupported CSS color: ${value}`) + return { + channels: rgb.slice(1, 4).map(Number), + alpha: rgb[4] == null ? 1 : Number(rgb[4]), + } +} + +const compositeColor = (foreground, background) => { + const fg = parseCssColor(foreground) + const bg = parseCssColor(background) + return fg.channels.map( + (channel, index) => + channel * fg.alpha + bg.channels[index] * (1 - fg.alpha), + ) +} + +const relativeLuminance = (channels) => + channels + .map((channel) => channel / 255) + .map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ) + .reduce( + (total, channel, index) => + total + channel * [0.2126, 0.7152, 0.0722][index], + 0, + ) + +const contrastRatio = (foreground, background) => { + const foregroundLuminance = relativeLuminance( + compositeColor(foreground, background), + ) + const backgroundLuminance = relativeLuminance( + parseCssColor(background).channels, + ) + const lighter = Math.max(foregroundLuminance, backgroundLuminance) + const darker = Math.min(foregroundLuminance, backgroundLuminance) + return (lighter + 0.05) / (darker + 0.05) +} + +const mainLyric = { + synced: true, + line: [{ start: 0, end: 1000, value: 'Main line' }], +} + +const createTokenizedLyric = (value, first, second) => ({ + synced: true, + line: [{ start: 0, end: 1000, value }], + cueLine: [ + { + index: 0, + start: 0, + end: 1000, + value, + cue: [ + { start: 0, end: 500, value: first, byteStart: 0, byteEnd: 3 }, + { start: 500, end: 1000, value: second, byteStart: 5, byteEnd: 8 }, + ], + }, + ], +}) + +const tokenizedMainLyric = createTokenizedLyric('Main line', 'Main', 'line') +const tokenizedPronunciationLyric = createTokenizedLyric( + 'mein lain', + 'mein', + 'lain', +) + +const voiceCue = (value, agentId, start, end) => ({ + index: 0, + start, + end, + value, + agentId, + cue: [{ start, end, value }], +}) + +const multiAgentLyric = { + synced: true, + agents: [ + { id: 'lead', role: 'main' }, + { id: 'all', role: 'group' }, + { id: 'echo', role: 'bg' }, + ], + line: [{ start: 1000, end: 4000, value: 'Lead all echo' }], + cueLine: [ + voiceCue('Lead', 'lead', 1000, 2000), + voiceCue('all', 'all', 1500, 2600), + voiceCue('echo', 'echo', 2200, 3400), + ], +} + +const createAudio = ({ currentTime = 0, duration = 10 } = {}) => { + const audio = new EventTarget() + audio.currentTime = currentTime + audio.duration = duration + audio.paused = true + audio.playbackRate = 1 + audio.seeking = false + return audio +} + +describe('', () => { + const originalMatchMedia = window.matchMedia + const originalResizeObserver = window.ResizeObserver + + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + window.matchMedia = originalMatchMedia + window.ResizeObserver = originalResizeObserver + cleanup() + }) + + it('renders main, stacked pronunciation, and translation in layer order', () => { + renderPanel({ + mainLyric: tokenizedMainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translation line' }], + }, + showPronunciation: true, + showTranslation: true, + }) + + const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') + expect(pronunciation).toHaveLength(2) + expect(pronunciation[0]).toHaveTextContent('mein') + expect(pronunciation[1]).toHaveTextContent('lain') + expect(screen.getByText('translation line')).toBeInTheDocument() + }) + + it('updates every pronunciation-only token with a distinct registry key', () => { + const audioInstance = createAudio() + renderPanel({ + mainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + showPronunciation: true, + audioInstance, + }) + + const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') + act(() => { + audioInstance.currentTime = 0.75 + audioInstance.dispatchEvent(new Event('seeking')) + }) + + expect(pronunciation[0]).toHaveAttribute('data-lyrics-state', 'completed') + expect(pronunciation[1]).toHaveAttribute('data-lyrics-state', 'active') + }) + + it('renders incomplete pronunciation timing as static text', () => { + renderPanel({ + mainLyric, + pronunciationLyric: { + synced: true, + line: [{ value: 'mein lain' }], + cueLine: [ + { + index: 0, + value: 'mein lain', + cue: [{ value: 'mein' }, { value: 'lain' }], + }, + ], + }, + showPronunciation: true, + }) + + screen.getAllByTestId('lyrics-pronunciation-token').forEach((token) => { + expect(token).toHaveAttribute('data-timed', 'false') + expect(token).not.toHaveAttribute('aria-label') + }) + }) + + it('keeps token ref callbacks stable across equivalent rerenders', () => { + const registerToken = vi.fn() + const line = { + start: 0, + end: 1000, + value: 'Main', + tokens: [ + { + start: 0, + end: 1000, + value: 'Main', + byteStart: 0, + byteEnd: 3, + }, + ], + } + const row = () => ( + + ) + const { rerender } = render(row()) + + expect(registerToken).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(HTMLElement), + ) + registerToken.mockClear() + + rerender(row()) + + expect(registerToken).not.toHaveBeenCalled() + }) + + it('renders each translation line under only its closest main line', () => { + renderPanel({ + mainLyric: { + synced: true, + line: [ + { start: 0, end: 1000, value: 'First main line' }, + { start: 1000, end: 2000, value: 'Closest main line' }, + { start: 2000, end: 3000, value: 'Later main line' }, + ], + }, + translationLyric: { + synced: true, + line: [{ start: 1100, end: 2800, value: 'One translated line' }], + }, + showTranslation: true, + }) + + const translations = screen.getAllByText('One translated line') + expect(translations).toHaveLength(1) + expect( + translations[0].closest('[data-testid="lyrics-line-group"]'), + ).toHaveTextContent('Closest main line') + }) + + it('suppresses duplicate translations without shifting untimed indexes', () => { + renderPanel({ + mainLyric: { + synced: false, + line: [{ value: 'Hello' }, { value: 'World' }], + }, + translationLyric: { + synced: false, + line: [{ value: 'Hello' }, { value: 'Mundo' }], + }, + showTranslation: true, + }) + + const groups = screen.getAllByTestId('lyrics-line-group') + expect(groups[0]).toHaveTextContent('Hello') + expect(groups[0]).not.toHaveTextContent('Mundo') + expect(groups[1]).toHaveTextContent('World') + expect(groups[1]).toHaveTextContent('Mundo') + }) + + it('renders line-level pronunciation without inventing word timing', () => { + renderPanel({ + mainLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: '我总要给一些别的' }], + }, + pronunciationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'wo zong yao gei yi xie bie de' }], + }, + showPronunciation: true, + audioInstance: { currentTime: 0.2, paused: true }, + }) + + expect(screen.getByText('我总要给一些别的')).toBeInTheDocument() + const pronunciation = screen.getByText('wo zong yao gei yi xie bie de') + const group = pronunciation.closest('[data-testid="lyrics-line-group"]') + expect(group).toHaveAttribute('data-active', 'true') + expect( + group.style.getPropertyValue('--lyrics-pronunciation-active-color'), + ).not.toBe('') + expect(pronunciation.style.color).toBe('') + }) + + it('highlights translations with the shared layer transition', () => { + const propsAt = (currentTime) => ({ + mainLyric, + pronunciationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'main pronunciation' }], + }, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showPronunciation: true, + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(0.5)) + + const lineGroup = screen.getByTestId('lyrics-line-group') + const mainRow = lineGroup.querySelector('[data-tokenized]') + const translationRow = screen + .getByText('translated line') + .closest('[data-tokenized]') + const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token') + + ;[mainRow, translationRow].forEach((row) => { + expect(row).toHaveAttribute('data-tokenized', 'false') + expect(row.style.opacity).toBe('') + }) + expect(window.getComputedStyle(mainRow).transition).toContain( + `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) + expect(window.getComputedStyle(translationRow).transition).toContain( + `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) + pronunciation.forEach((token) => + expect(token).toHaveAttribute('data-timed', 'false'), + ) + expect(window.getComputedStyle(translationRow).opacity).toBe('1') + const translationColors = translationColorsFor(theme) + expect( + lineGroup.style.getPropertyValue('--lyrics-translation-idle-color'), + ).toBe(translationColors.idle) + expect( + lineGroup.style.getPropertyValue('--lyrics-translation-active-color'), + ).toBe(translationColors.active) + expect(window.getComputedStyle(translationRow).transform).toBe('') + + rerenderPanel(propsAt(1.1)) + + expect(lineGroup).toHaveAttribute('data-active', 'false') + expect(window.getComputedStyle(translationRow).opacity).toBe('1') + expect(window.getComputedStyle(translationRow).transition).toContain( + `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) + expect(window.getComputedStyle(translationRow).transform).toBe('') + }) + + it('keeps line-timed main and translation rows still', () => { + const { rerenderPanel } = renderPanel({ + mainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showTranslation: true, + audioInstance: { currentTime: 0.5, paused: true }, + }) + + const group = screen.getByTestId('lyrics-line-group') + const translation = screen + .getByText('translated line') + .closest('[data-tokenized]') + const mainRow = screen.getByText('Main line').closest('[data-tokenized]') + const activeStyle = window.getComputedStyle(group) + expect(group).toHaveAttribute('data-highlight-active', 'true') + expect(group).toHaveAttribute('data-raised', 'true') + expect(group).toHaveAttribute('data-line-motion', 'line') + expect(group).toHaveAttribute('data-character-wave', 'false') + expect(activeStyle.transform).toBe('translateY(0)') + expect(window.getComputedStyle(mainRow).transform).toBe('') + expect(window.getComputedStyle(mainRow).transition).toContain( + `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`, + ) + expect(window.getComputedStyle(mainRow).transition).not.toContain( + 'transform', + ) + expect(window.getComputedStyle(translation).transform).toBe('') + + rerenderPanel({ + mainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showTranslation: true, + audioInstance: { currentTime: 1.1, paused: true }, + }) + + const releasedStyle = window.getComputedStyle(group) + expect(group).toHaveAttribute('data-highlight-active', 'false') + expect(group).toHaveAttribute('data-raised', 'true') + expect(releasedStyle.transform).toBe('translateY(0)') + expect(window.getComputedStyle(mainRow).transform).toBe('') + expect(window.getComputedStyle(translation).transform).toBe('') + }) + + it('keeps word-timed translations still while main and pronunciation rise', () => { + const settledFirstCharacterTime = + (KARAOKE_CHARACTER_RISE_MS - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000 + const propsAt = (currentTime) => ({ + mainLyric: tokenizedMainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showPronunciation: true, + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(settledFirstCharacterTime)) + + const group = screen.getByTestId('lyrics-line-group') + const translation = screen + .getByText('translated line') + .closest('[data-tokenized]') + const mainToken = screen.getAllByTestId('lyrics-token')[0] + const pronunciationToken = screen.getAllByTestId( + 'lyrics-pronunciation-token', + )[0] + const mainCharacters = mainToken.querySelectorAll( + '[data-lyrics-character="true"]', + ) + const pronunciationCharacters = pronunciationToken.querySelectorAll( + '[data-lyrics-character="true"]', + ) + + expect(group).toHaveAttribute('data-line-motion', 'character') + expect(group).toHaveAttribute('data-character-wave', 'true') + expect(window.getComputedStyle(group).transform).toBe('translateY(0)') + expect(window.getComputedStyle(translation).transform).toBe('') + expect(mainCharacters).toHaveLength(4) + expect(pronunciationCharacters).toHaveLength(4) + expect(mainCharacters[0].style.transform).toBe( + `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px, 0)`, + ) + expect(window.getComputedStyle(mainCharacters[0]).willChange).toBe( + 'transform', + ) + expect(mainCharacters[3].style.transform).not.toBe( + mainCharacters[0].style.transform, + ) + expect(pronunciationCharacters[0].style.transform).toBe( + mainCharacters[0].style.transform, + ) + expect(mainToken.style.backgroundImage).toBe('none') + expect(mainCharacters[0].style.backgroundImage).toContain('linear-gradient') + + rerenderPanel(propsAt(1.1)) + + expect(group).toHaveAttribute('data-highlight-active', 'false') + expect(group).toHaveAttribute('data-raised', 'true') + expect(group).toHaveAttribute('data-character-wave', 'false') + expect( + group.querySelectorAll('[data-lyrics-character="true"]'), + ).toHaveLength(0) + expect(window.getComputedStyle(group).transform).toBe('translateY(0)') + const releasedMainRow = screen + .getAllByTestId('lyrics-token')[0] + .closest('[data-tokenized]') + expect(window.getComputedStyle(releasedMainRow).transform).toBe( + `translateY(-${KARAOKE_LINE_LIFT_PX}px)`, + ) + expect(window.getComputedStyle(releasedMainRow).transition).not.toContain( + 'transform', + ) + expect(window.getComputedStyle(translation).transform).toBe('') + const idleText = screen + .getAllByTestId('lyrics-token')[0] + .querySelector('[data-lyrics-wave-text="true"]') + expect(idleText.style.backgroundImage).toContain('linear-gradient') + }) + + it('keeps the translation still when a word-timed line wraps or unwraps', () => { + let wrapped = false + const observers = [] + window.ResizeObserver = class { + constructor(callback) { + this.callback = callback + this.targets = [] + observers.push(this) + } + + observe(target) { + this.targets.push(target) + } + + unobserve(target) { + this.targets = this.targets.filter((candidate) => candidate !== target) + } + + disconnect() { + this.targets = [] + } + } + vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation( + function getOffsetTop() { + if (!wrapped || this.dataset.stackedToken !== 'true') return 0 + const tokens = Array.from( + this.parentElement?.querySelectorAll('[data-stacked-token="true"]') || + [], + ) + return tokens.indexOf(this) > 0 ? 32 : 0 + }, + ) + const notifyResizeObservers = () => { + observers.forEach(({ callback, targets }) => { + callback(targets.map((target) => ({ target }))) + }) + } + const propsAt = (currentTime) => ({ + mainLyric: tokenizedMainLyric, + pronunciationLyric: tokenizedPronunciationLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'translated line' }], + }, + showPronunciation: true, + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(0.25)) + const row = screen + .getAllByTestId('lyrics-token')[0] + .closest('[data-wrapped]') + const translation = screen + .getByText('translated line') + .closest('[data-tokenized]') + + expect(row).toHaveAttribute('data-wrapped', 'false') + expect(window.getComputedStyle(translation).transform).toBe('') + + wrapped = true + act(notifyResizeObservers) + expect(row).toHaveAttribute('data-wrapped', 'true') + expect(window.getComputedStyle(translation).transform).toBe('') + + rerenderPanel(propsAt(1.1)) + expect(window.getComputedStyle(translation).transform).toBe('') + + wrapped = false + act(notifyResizeObservers) + expect(row).toHaveAttribute('data-wrapped', 'false') + expect(window.getComputedStyle(translation).transform).toBe('') + }) + + it('keeps detailed grapheme markup on active lines without changing token layout', () => { + const values = ['First word', 'Second word', 'Active word', 'Fourth word'] + const lyricFor = (pronunciation = false) => ({ + synced: true, + line: values.map((value, index) => ({ + start: index * 1000, + end: (index + 1) * 1000, + value: pronunciation ? `spoken ${index}` : value, + })), + cueLine: values.map((value, index) => ({ + index, + start: index * 1000, + end: (index + 1) * 1000, + value: pronunciation ? `spoken ${index}` : value, + cue: (pronunciation ? `spoken ${index}` : value) + .split(' ') + .map((word, wordIndex, words) => ({ + start: index * 1000 + (wordIndex * 1000) / words.length, + end: index * 1000 + ((wordIndex + 1) * 1000) / words.length, + value: word, + })), + })), + }) + const propsAt = (currentTime) => ({ + mainLyric: lyricFor(), + pronunciationLyric: lyricFor(true), + showPronunciation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(2.5)) + + let groups = screen.getAllByTestId('lyrics-line-group') + expect( + groups.map((group) => + Boolean(group.querySelector('[data-lyrics-character="true"]')), + ), + ).toEqual([false, false, true, false]) + expect( + groups[2].querySelectorAll('[data-lyrics-wave-measure="true"]'), + ).not.toHaveLength(0) + const activeRow = groups[2].querySelector('[data-wrapped]') + const spacers = activeRow.querySelectorAll( + ':scope > span:not([data-stacked-token="true"])', + ) + expect(activeRow).toHaveAttribute('data-wrapped', 'false') + expect(spacers).not.toHaveLength(0) + spacers.forEach((spacer) => + expect(window.getComputedStyle(spacer).display).toBe('inline'), + ) + + rerenderPanel(propsAt(3.5)) + groups = screen.getAllByTestId('lyrics-line-group') + expect( + groups[2].querySelectorAll('[data-lyrics-character="true"]'), + ).toHaveLength(0) + expect( + groups[3].querySelectorAll('[data-lyrics-character="true"]'), + ).not.toHaveLength(0) + }) + + it('renders unsynced lyrics as static selectable text', () => { + renderPanel({ + mainLyric: { + synced: false, + line: [{ value: 'first plain line' }, { value: 'second plain line' }], + }, + }) + + const groups = screen.getAllByTestId('lyrics-line-group') + expect(groups).toHaveLength(2) + groups.forEach((group) => { + expect(group).toHaveAttribute('data-active', 'true') + expect(group).toHaveAttribute('data-lifecycle', 'active') + expect(group).toHaveAttribute('data-highlight-active', 'true') + expect(group).not.toHaveAttribute('aria-current') + expect(group).toHaveAttribute('data-scroll-target', 'false') + }) + }) + + it('tracks overlapping lines while selecting one primary line', () => { + renderPanel({ + mainLyric: { + synced: true, + line: [ + { start: 1000, end: 4000, value: 'Lead vocal' }, + { start: 2000, end: 3000, value: 'Answer vocal' }, + { start: 5000, end: 6000, value: 'Later vocal' }, + ], + }, + audioInstance: { currentTime: 2.5, paused: true }, + }) + + const groups = screen.getAllByTestId('lyrics-line-group') + expect(groups[0]).toHaveAttribute('data-active', 'true') + expect(groups[1]).toHaveAttribute('data-active', 'true') + expect(groups[2]).toHaveAttribute('data-active', 'false') + expect(groups[0]).not.toHaveAttribute('aria-current') + expect(groups[1]).toHaveAttribute('aria-current', 'true') + expect(groups[1]).toHaveAttribute('data-scroll-target', 'true') + }) + + it('keeps multi-agent cue lines in separate voice lanes', () => { + renderPanel({ + mainLyric: multiAgentLyric, + audioInstance: { currentTime: 2.5, paused: true }, + }) + + const lanes = screen.getAllByTestId('lyrics-voice-lane') + expect(lanes).toHaveLength(3) + expect(lanes[0]).toHaveTextContent('Lead') + expect(lanes[1]).toHaveTextContent('all') + expect(lanes[1].style.fontStyle).toBe('italic') + expect(lanes[2]).toHaveTextContent('echo') + expect(lanes[2].style.fontStyle).toBe('italic') + + const mainToken = lanes[0].querySelector('[data-testid="lyrics-token"]') + const emphasisToken = lanes[1].querySelector('[data-testid="lyrics-token"]') + const emphasisCharacter = emphasisToken.querySelector( + '[data-lyrics-character="true"]', + ) + expect(mainToken).toHaveTextContent('Lead') + expect(emphasisToken.style.paddingInlineEnd).not.toBe('') + expect(emphasisToken.style.marginInlineEnd).toBe( + `-${emphasisToken.style.paddingInlineEnd}`, + ) + expect(emphasisCharacter.style.paddingInlineEnd).toBe( + emphasisToken.style.paddingInlineEnd, + ) + }) + + it('seeks and synchronizes immediately when a line is activated', () => { + const audioInstance = { currentTime: 0 } + renderPanel({ + mainLyric: { + synced: true, + line: [{ start: 2300, end: 3200, value: 'Seek line' }], + }, + audioInstance, + }) + + const group = screen + .getByText('Seek line') + .closest('[data-testid="lyrics-line-group"]') + fireEvent.click(group) + expect(audioInstance.currentTime).toBe(2.3) + expect(group).toHaveAttribute('data-active', 'true') + + audioInstance.currentTime = 0 + fireEvent.keyDown(group, { key: 'Enter' }) + expect(audioInstance.currentTime).toBe(2.3) + }) + + it('uses roving focus for seekable lines without seeking on navigation', () => { + const audioInstance = createAudio({ currentTime: 1.5 }) + renderPanel({ + mainLyric: { + synced: true, + line: [ + { start: 0, end: 900, value: 'First seekable line' }, + { value: 'Untimed line' }, + { start: 1000, end: 1900, value: 'Active seekable line' }, + { start: 2000, end: 2900, value: 'Last seekable line' }, + ], + }, + audioInstance, + }) + + const first = screen + .getByText('First seekable line') + .closest('[data-testid="lyrics-line-group"]') + const untimed = screen + .getByText('Untimed line') + .closest('[data-testid="lyrics-line-group"]') + const active = screen + .getByText('Active seekable line') + .closest('[data-testid="lyrics-line-group"]') + const last = screen + .getByText('Last seekable line') + .closest('[data-testid="lyrics-line-group"]') + + expect(first).toHaveAttribute('tabindex', '-1') + expect(untimed).not.toHaveAttribute('tabindex') + expect(active).toHaveAttribute('tabindex', '0') + expect(last).toHaveAttribute('tabindex', '-1') + + active.focus() + fireEvent.keyDown(active, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(last) + expect(last).toHaveAttribute('tabindex', '0') + expect(audioInstance.currentTime).toBe(1.5) + + fireEvent.keyDown(last, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(last) + fireEvent.keyDown(last, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(active) + fireEvent.keyDown(active, { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(first) + expect(audioInstance.currentTime).toBe(1.5) + + fireEvent.keyDown(first, { key: 'End' }) + expect(document.activeElement).toBe(last) + fireEvent.keyDown(last, { key: 'Home' }) + expect(document.activeElement).toBe(first) + expect(audioInstance.currentTime).toBe(1.5) + + fireEvent.keyDown(first, { key: ' ' }) + expect(audioInstance.currentTime).toBe(0) + fireEvent.keyDown(last, { key: 'Enter' }) + expect(audioInstance.currentTime).toBe(2) + }) + + it('keeps lyrics mounted but pauses timeline frames while obscured', () => { + const audioInstance = createAudio({ currentTime: 0.25 }) + audioInstance.paused = false + window.requestAnimationFrame.mockImplementation(() => 7) + window.requestAnimationFrame.mockClear() + window.cancelAnimationFrame.mockClear() + + const { rerenderPanel } = renderPanel({ + mainLyric: tokenizedMainLyric, + audioInstance, + obscured: true, + }) + + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + expect(window.requestAnimationFrame).not.toHaveBeenCalled() + + rerenderPanel({ + mainLyric: tokenizedMainLyric, + audioInstance, + obscured: false, + }) + expect(window.requestAnimationFrame).toHaveBeenCalled() + + rerenderPanel({ + mainLyric: tokenizedMainLyric, + audioInstance, + obscured: true, + }) + expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument() + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7) + }) + + it('pauses auto-scroll only for genuine manual scroll intent', async () => { + vi.useFakeTimers() + const requestAnimationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(() => 0) + renderPanel({ + mainLyric, + audioInstance: { currentTime: 0.5, paused: true }, + }) + + const body = screen.getByTestId('lyrics-scroll-body') + const initialFrames = requestAnimationFrameSpy.mock.calls.length + fireEvent.wheel(body) + expect(body).toHaveAttribute('data-scrollbar-visible', 'true') + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(initialFrames) + + act(() => { + vi.advanceTimersByTime(KARAOKE_MANUAL_SCROLL_PAUSE_MS) + }) + await waitFor(() => { + expect(requestAnimationFrameSpy.mock.calls.length).toBeGreaterThan( + initialFrames, + ) + }) + }) + + it('keeps scrollbar geometry stable before, during, and after manual scroll', () => { + vi.useFakeTimers() + renderPanel({ mainLyric }) + + const body = screen.getByTestId('lyrics-scroll-body') + const initialStyle = window.getComputedStyle(body) + expect(initialStyle.scrollbarGutter).toBe('stable') + expect(initialStyle.scrollbarWidth).toBe('thin') + + fireEvent.wheel(body) + expect(body).toHaveAttribute('data-scrollbar-visible', 'true') + expect(window.getComputedStyle(body).scrollbarGutter).toBe('stable') + expect(window.getComputedStyle(body).scrollbarWidth).toBe('thin') + + act(() => { + vi.advanceTimersByTime(KARAOKE_SCROLLBAR_VISIBLE_MS) + }) + expect(body).toHaveAttribute('data-scrollbar-visible', 'false') + expect(window.getComputedStyle(body).scrollbarGutter).toBe('stable') + expect(window.getComputedStyle(body).scrollbarWidth).toBe('thin') + }) + + it('resets scroll position when lyric content changes', () => { + const { rerenderPanel } = renderPanel({ mainLyric }) + const body = screen.getByTestId('lyrics-scroll-body') + body.scrollTop = 180 + + rerenderPanel({ + mainLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Different song' }], + }, + }) + expect(body.scrollTop).toBe(0) + }) + + it('respects reduced motion and empty states', async () => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + const { unmount } = renderPanel({ mainLyric }) + await waitFor(() => { + expect(screen.getByTestId('lyrics-scroll-body')).toHaveAttribute( + 'data-reduced-motion', + 'true', + ) + }) + unmount() + + renderPanel({ mainLyric: null, loading: true }) + expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent( + 'Loading lyrics', + ) + }) + + it('explains a temporary lyrics cooldown', () => { + renderPanel({ + mainLyric: null, + error: new Error('rate limited'), + errorMessage: 'Remote lyrics are rate-limited. Retrying in 30 seconds.', + }) + + expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent( + 'Remote lyrics are rate-limited. Retrying in 30 seconds.', + ) + }) + + it.each(['light', 'dark'])( + 'keeps future tokens readable and translations subordinate in the %s theme', + (type) => { + const accessibleTheme = createTheme({ palette: { type } }) + const background = accessibleTheme.palette.background.default + + const propsAt = (currentTime) => ({ + mainLyric: tokenizedMainLyric, + translationLyric: { + synced: true, + line: [{ start: 0, end: 1000, value: 'Readable translation' }], + }, + showTranslation: true, + audioInstance: { currentTime, paused: true }, + }) + const { rerenderPanel } = renderPanel(propsAt(0.25), accessibleTheme) + + const translation = screen + .getByText('Readable translation') + .closest('[data-tokenized]') + expect(translation).toBeInTheDocument() + expect(window.getComputedStyle(translation).opacity).toBe('1') + const token = screen.getAllByTestId('lyrics-token')[0] + const gradientNode = [token, ...token.querySelectorAll('*')].find( + (node) => node.style.backgroundImage.includes('linear-gradient'), + ) + const gradientColors = Array.from( + gradientNode.style.backgroundImage.matchAll(/rgba?\([^)]+\)/g), + (match) => match[0], + ) + const futureColor = gradientColors.at(-1) + const translationColors = translationColorsFor(accessibleTheme) + + expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA) + expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5) + expect( + contrastRatio(translationColors.active, background), + ).toBeGreaterThanOrEqual(4.5) + expect(contrastRatio(translationColors.idle, background)).toBeLessThan( + contrastRatio(translationColors.active, background), + ) + + rerenderPanel(propsAt(1.25)) + expect(screen.getByTestId('lyrics-line-group')).toHaveAttribute( + 'data-active', + 'false', + ) + expect(window.getComputedStyle(translation).opacity).toBe('1') + }, + ) +}) diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx new file mode 100644 index 000000000..af8f1038b --- /dev/null +++ b/ui/src/audioplayer/LyricsSidebar.jsx @@ -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 ( + + ) +} + +export default LyricsSidebar diff --git a/ui/src/audioplayer/LyricsSidebar.test.jsx b/ui/src/audioplayer/LyricsSidebar.test.jsx new file mode 100644 index 000000000..0bafd3bcc --- /dev/null +++ b/ui/src/audioplayer/LyricsSidebar.test.jsx @@ -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 ( + + ) +} + +const sidebarView = (props = {}) => ( + + + +) + +const renderSidebar = (props) => render(sidebarView(props)) + +describe('', () => { + 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() + }) +}) diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx new file mode 100644 index 000000000..34750f84e --- /dev/null +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx @@ -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( +
+ {children} +
, + host, + ) +} + +export default MobileKaraokeLyricsPortal diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx new file mode 100644 index 000000000..eb2748684 --- /dev/null +++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx @@ -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) => ( + + {text} + +) + +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 ( + <> + + {mobileLyricsSurface} + + ) +} + +describe('', () => { + 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( + + + , + ) + const layerControl = within(host).getByRole('button', { + name: 'Layer control', + }) + layerControl.focus() + + rerender( + + + , + ) + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + 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() + const toggle = screen.getByRole('button', { name: 'Toggle lyrics' }) + + fireEvent.click(toggle) + expect( + within(host).getByText('Persistent panel lyrics'), + ).toBeInTheDocument() + + rerender() + 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() + }) +}) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index c3d795b26..f20828d44 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -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: ( - + ), 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} ) diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx index 4812141ab..e146bc238 100644 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -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 = ( { /> ) + const currentLyricsLabel = lyricsLoading ? lyricsLoadingLabel : lyricsLabel + const lyricsButton = ( + + + + + + + + ) + return ( <> @@ -106,11 +144,13 @@ const PlayerToolbar = ({ id, isRadio }) => {
  • {saveQueueButton} {loveButton} + {lyricsButton}
  • ) : ( <>
  • {saveQueueButton}
  • {loveButton}
  • +
  • {lyricsButton}
  • )} diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx index d0368b0f0..ef03b1cad 100644 --- a/ui/src/audioplayer/PlayerToolbar.test.jsx +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -61,37 +61,22 @@ describe('', () => { useMediaQuery.mockReturnValue(true) // isDesktop = true }) - it('renders desktop toolbar with both buttons', () => { + it('renders desktop toolbar with all buttons', () => { render() - // 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() - - 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() - - const loveButton = screen.getByTestId('love-button') - expect(loveButton).toBeDisabled() - }) - it('opens save queue dialog when save button is clicked', () => { render() @@ -102,6 +87,32 @@ describe('', () => { type: 'OPEN_SAVE_QUEUE_DIALOG', }) }) + + it('toggles lyrics from an accessible toolbar button', () => { + const onToggleLyrics = vi.fn() + const lyricsToggleRef = React.createRef() + render( + , + ) + + 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('', () => { // 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() + it('disables lyrics toggle when lyrics are unavailable', () => { + render( + , + ) - 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( + , + ) - render() - - 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() + + 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() + + expect(screen.getByTestId('love-button')).toBeDisabled() + }) + it('renders global hotkeys in both layouts', () => { // Test desktop layout useMediaQuery.mockReturnValue(true) diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js new file mode 100644 index 000000000..05193d271 --- /dev/null +++ b/ui/src/audioplayer/lyrics.js @@ -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, + ))), + ) diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js new file mode 100644 index 000000000..208d82ac6 --- /dev/null +++ b/ui/src/audioplayer/lyrics.test.js @@ -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) + }) +}) diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js new file mode 100644 index 000000000..ff4983cbd --- /dev/null +++ b/ui/src/audioplayer/lyricsKaraokeConstants.js @@ -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 +} diff --git a/ui/src/audioplayer/lyricsKaraokeStyles.js b/ui/src/audioplayer/lyricsKaraokeStyles.js new file mode 100644 index 000000000..6e751133f --- /dev/null +++ b/ui/src/audioplayer/lyricsKaraokeStyles.js @@ -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 +} diff --git a/ui/src/audioplayer/lyricsScroll.js b/ui/src/audioplayer/lyricsScroll.js new file mode 100644 index 000000000..adf05d728 --- /dev/null +++ b/ui/src/audioplayer/lyricsScroll.js @@ -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 +} diff --git a/ui/src/audioplayer/lyricsScroll.test.js b/ui/src/audioplayer/lyricsScroll.test.js new file mode 100644 index 000000000..f75a44fe7 --- /dev/null +++ b/ui/src/audioplayer/lyricsScroll.test.js @@ -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() + }) +}) diff --git a/ui/src/audioplayer/lyricsSegments.js b/ui/src/audioplayer/lyricsSegments.js new file mode 100644 index 000000000..81b6eb5c7 --- /dev/null +++ b/ui/src/audioplayer/lyricsSegments.js @@ -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 }] +} diff --git a/ui/src/audioplayer/lyricsSegments.test.js b/ui/src/audioplayer/lyricsSegments.test.js new file mode 100644 index 000000000..209b42e5c --- /dev/null +++ b/ui/src/audioplayer/lyricsSegments.test.js @@ -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 }, + ]) + }) +}) diff --git a/ui/src/audioplayer/lyricsSidebarState.js b/ui/src/audioplayer/lyricsSidebarState.js new file mode 100644 index 000000000..15322a366 --- /dev/null +++ b/ui/src/audioplayer/lyricsSidebarState.js @@ -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) +} diff --git a/ui/src/audioplayer/lyricsSidebarState.test.js b/ui/src/audioplayer/lyricsSidebarState.test.js new file mode 100644 index 000000000..db2f02360 --- /dev/null +++ b/ui/src/audioplayer/lyricsSidebarState.test.js @@ -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) + }) +}) diff --git a/ui/src/audioplayer/lyricsSidebarWidth.js b/ui/src/audioplayer/lyricsSidebarWidth.js new file mode 100644 index 000000000..2d0152d69 --- /dev/null +++ b/ui/src/audioplayer/lyricsSidebarWidth.js @@ -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. + } +} diff --git a/ui/src/audioplayer/lyricsTimeline.js b/ui/src/audioplayer/lyricsTimeline.js new file mode 100644 index 000000000..325d6d494 --- /dev/null +++ b/ui/src/audioplayer/lyricsTimeline.js @@ -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)), + ) +} diff --git a/ui/src/audioplayer/lyricsTimeline.test.js b/ui/src/audioplayer/lyricsTimeline.test.js new file mode 100644 index 000000000..f357d60eb --- /dev/null +++ b/ui/src/audioplayer/lyricsTimeline.test.js @@ -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) + }) +}) diff --git a/ui/src/audioplayer/playerBreakpoints.js b/ui/src/audioplayer/playerBreakpoints.js new file mode 100644 index 000000000..a646b76fa --- /dev/null +++ b/ui/src/audioplayer/playerBreakpoints.js @@ -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}` diff --git a/ui/src/audioplayer/playerBreakpoints.test.js b/ui/src/audioplayer/playerBreakpoints.test.js new file mode 100644 index 000000000..4d40b56c8 --- /dev/null +++ b/ui/src/audioplayer/playerBreakpoints.test.js @@ -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)`, + ) + }) +}) diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 30a14d4db..dae045807 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -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': diff --git a/ui/src/audioplayer/useEnhancedLyrics.js b/ui/src/audioplayer/useEnhancedLyrics.js new file mode 100644 index 000000000..8577e9280 --- /dev/null +++ b/ui/src/audioplayer/useEnhancedLyrics.js @@ -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 diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js new file mode 100644 index 000000000..9e871c873 --- /dev/null +++ b/ui/src/audioplayer/useEnhancedLyrics.test.js @@ -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() + }) +}) diff --git a/ui/src/audioplayer/useEnterExitTransition.js b/ui/src/audioplayer/useEnterExitTransition.js new file mode 100644 index 000000000..cdabf58f8 --- /dev/null +++ b/ui/src/audioplayer/useEnterExitTransition.js @@ -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 diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js new file mode 100644 index 000000000..5316e8703 --- /dev/null +++ b/ui/src/audioplayer/useLyricsTimeline.js @@ -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 diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js new file mode 100644 index 000000000..54a70aa88 --- /dev/null +++ b/ui/src/audioplayer/useLyricsTimeline.test.js @@ -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) + }) +}) diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx new file mode 100644 index 000000000..de8f2114c --- /dev/null +++ b/ui/src/audioplayer/usePlayerLyrics.jsx @@ -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( + () => ( + + + + + ), + [ + 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 diff --git a/ui/src/audioplayer/useRestoreFocusOnExit.js b/ui/src/audioplayer/useRestoreFocusOnExit.js new file mode 100644 index 000000000..83b9cf170 --- /dev/null +++ b/ui/src/audioplayer/useRestoreFocusOnExit.js @@ -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 diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index de96d47c0..00e2b45e6 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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", diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index 44cf9b42c..9b54c5aff 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -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} - /> + > + {props.children} + ) } diff --git a/ui/src/layout/LyricsLayoutFrame.jsx b/ui/src/layout/LyricsLayoutFrame.jsx new file mode 100644 index 000000000..8164b37ec --- /dev/null +++ b/ui/src/layout/LyricsLayoutFrame.jsx @@ -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 ( +
    + {children} + {desktopLyricsProps && ( + + )} +
    + ) +} + +export default LyricsLayoutFrame diff --git a/ui/src/layout/LyricsLayoutFrame.test.jsx b/ui/src/layout/LyricsLayoutFrame.test.jsx new file mode 100644 index 000000000..632acf6b6 --- /dev/null +++ b/ui/src/layout/LyricsLayoutFrame.test.jsx @@ -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) => ( + + + + + +
    Albums grid
    +
    +
    +
    +
    +) + +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('', () => { + 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() + }) +}) diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx index 45f40b26d..899c2e869 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.jsx @@ -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, diff --git a/ui/src/layout/layoutWidths.js b/ui/src/layout/layoutWidths.js new file mode 100644 index 000000000..4587891f6 --- /dev/null +++ b/ui/src/layout/layoutWidths.js @@ -0,0 +1,2 @@ +export const APP_SIDEBAR_OPEN_WIDTH = 240 +export const APP_SIDEBAR_CLOSED_WIDTH = 55 diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 9f0ad28d7..8fb0be528 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -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, } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 32c699a41..d50e686b8 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -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', () => { diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js index aaf1c0e16..a1428461e 100644 --- a/ui/src/themes/nutball.css.js +++ b/ui/src/themes/nutball.css.js @@ -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;