diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index d61f91d8..5ca5ef3d 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -3855,6 +3855,15 @@ TextAlign _lyricTextAlign(BuildContext context, LyricLine line) { return context.isMornye ? TextAlign.start : TextAlign.center; } if (voice.isGroup) return TextAlign.left; + // Provider declarations can be missing or ordered by first appearance. + // The standard V labels retain their meaning regardless of that order. + switch (voice.id.toLowerCase()) { + case 'v1': + case 'v3': + return TextAlign.left; + case 'v2': + return TextAlign.right; + } return voice.index.isEven ? TextAlign.left : TextAlign.right; } diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart index 84b2e6d3..44a783f6 100644 --- a/lib/utils/lyrics_parser.dart +++ b/lib/utils/lyrics_parser.dart @@ -111,7 +111,7 @@ class LyricsParser { match.end, match.group(1)!.replaceAll(RegExp(r'\s+'), ''), ), - LyricVoice(id: id, index: (number ?? 1) - 1), + LyricVoice(id: id, index: (number ?? 1) - 1, isGroup: id == 'v3'), ); } @@ -525,7 +525,8 @@ class LyricsParser { namespaceUri: 'http://www.w3.org/XML/1998/namespace', ); if (id == null || id.isEmpty) continue; - final group = agent.getAttribute('type') == 'group'; + final group = + agent.getAttribute('type') == 'group' || id.toLowerCase() == 'v3'; voices[id] = LyricVoice(id: id, index: individualIndex, isGroup: group); if (!group) individualIndex++; } @@ -538,7 +539,11 @@ class LyricsParser { if (id == null || id.isEmpty) return inherited; return voices.putIfAbsent( id, - () => LyricVoice(id: id, index: individualIndex++), + () => LyricVoice( + id: id, + index: individualIndex++, + isGroup: id.toLowerCase() == 'v3', + ), ); } diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index d6bfd38b..1bc8eb98 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -80,17 +80,36 @@ fn apple_vocal_sides(text: &str, payload: &ApplePayload) -> String { let mut voices = BTreeMap::new(); let mut person = 0; for agent in agents { - let voice = match agent.kind.as_str() { - "person" => { + let voice = match (agent.id.to_ascii_lowercase().as_str(), agent.kind.as_str()) { + ("v1", _) | ("v3", _) | (_, "group") => 1, + ("v2", _) => { + person = person.max(2); + 2 + } + (_, "person") => { person += 1; person } - "group" => 1, _ => continue, }; + if agent.id.eq_ignore_ascii_case("v1") { + person = person.max(1); + } voices.insert(agent.id.as_str(), format!("v{voice}")); } let lines = payload.content.as_deref().unwrap_or_default(); + // A proxy may omit agent declarations but retain standard IDs on lines. + // Correct oppositeTurn before it turns a V3 collaboration into V2. + for line in lines { + let voice = match line.agent.to_ascii_lowercase().as_str() { + "v1" | "v3" => "v1", + "v2" => "v2", + _ => continue, + }; + voices + .entry(line.agent.as_str()) + .or_insert_with(|| voice.into()); + } if voices.is_empty() || lines.is_empty() { return text.into(); } @@ -600,6 +619,29 @@ mod supplement_tests { ); } + #[test] + fn apple_v3_does_not_depend_on_agent_order_or_group_metadata() { + for agents in [ + serde_json::Value::Null, + serde_json::json!([ + {"id": "v1", "type": "person"}, + {"id": "v3", "type": "person"}, + {"id": "v2", "type": "person"} + ]), + ] { + let raw = serde_json::json!({ + "type": "Syllable", + "elrcMultiPerson": "[00:01.00]v2:Together\n[00:02.00]v1:Guest", + "content": [{"timestamp": 1000, "agent": "v3"}, {"timestamp": 2000, "agent": "v2"}], + "metadata": {"agents": agents} + }); + assert_eq!( + format_apple(&raw.to_string(), true, true).unwrap(), + "[00:01.00]v1:Together\n[00:02.00]v2:Guest" + ); + } + } + #[test] fn apple_content_fallback_honors_agents_only_when_multi_person_is_enabled() { let raw = serde_json::json!({ diff --git a/test/lyrics_parser_test.dart b/test/lyrics_parser_test.dart index 17d73378..7fbcd2f9 100644 --- a/test/lyrics_parser_test.dart +++ b/test/lyrics_parser_test.dart @@ -50,6 +50,20 @@ ${_tag('translation', 2000, 'Second translation')} expect(backing.vocalGroup, lyrics.lines.first.vocalGroup); }); + test('V3 is a collaboration even without TTML group metadata', () { + for (final source in [ + '[00:01.00]V1:Lead\n[00:02.00]V3:Together\n[00:03.00]V2:Guest', + '' + '

Lead

' + '

Together

' + '

Guest

', + ]) { + final lyrics = LyricsParser.parse(source); + expect(lyrics.lines[1].voice?.isGroup, isTrue); + expect(lyrics.lines[2].voice?.isGroup, isFalse); + } + }); + test( 'backing groups survive sorting, offsets and repeated backing parts', () { diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 771b3382..734ae2a4 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2707,50 +2707,51 @@ void main() { } for (final mornye in [false, true]) { - testWidgets('TTML group vocals keep the primary side ($mornye)', ( - tester, - ) async { - metadataOverrides['lyrics'] = ''' + testWidgets( + 'TTML V3 without group metadata keeps the primary side ($mornye)', + (tester) async { + metadataOverrides['lyrics'] = ''' - +

Guest

Together

Guest returns

'''; - final playback = StreamController.broadcast(); - addTearDown(playback.close); - await pumpNowPlaying( - tester, - theme: mornye ? MornyeTheme.build(Brightness.dark) : null, - size: const Size(390, 1100), - playbackEvents: playback.stream, - ); - mediaItems.add(item('first')); - await tester.pumpAndSettle(); - if (mornye) { - await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - } else { - await tester.drag(find.byType(PageView), const Offset(-350, 0)); - } - await tester.pumpAndSettle(); - for (final seconds in [0, 2, 4, 7, 4]) { - playback.add( - PlaybackState( - playing: false, - processingState: AudioProcessingState.ready, - updatePosition: Duration(seconds: seconds), - ), + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + size: const Size(390, 1100), + playbackEvents: playback.stream, ); + mediaItems.add(item('first')); await tester.pumpAndSettle(); - expect( - tester.widget(find.text('Together')).textAlign, - TextAlign.left, - ); - expect( - tester.widget(find.text('Guest returns')).textAlign, - TextAlign.right, - ); - } - expect(tester.takeException(), isNull); - }); + if (mornye) { + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + } else { + await tester.drag(find.byType(PageView), const Offset(-350, 0)); + } + await tester.pumpAndSettle(); + for (final seconds in [0, 2, 4, 7, 4]) { + playback.add( + PlaybackState( + playing: false, + processingState: AudioProcessingState.ready, + updatePosition: Duration(seconds: seconds), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(find.text('Together')).textAlign, + TextAlign.left, + ); + expect( + tester.widget(find.text('Guest returns')).textAlign, + TextAlign.right, + ); + } + expect(tester.takeException(), isNull); + }, + ); for (final ttml in [false, true]) { testWidgets(