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