Improve Synced Lyrics (#384)

* feature: click on synced lyrics to navigate in song

* only update lyrics if needed

improves performance and allows user to scroll synced lyrics

* fix: don't scroll to start after end of song
This commit is contained in:
Pascal Grittmann 2026-01-31 17:16:13 +01:00 committed by GitHub
parent d67e432731
commit 6e51611867
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,7 +7,9 @@ import android.os.Handler;
import android.text.Layout; import android.text.Layout;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
import android.text.TextUtils; import android.text.TextPaint;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -51,6 +53,7 @@ public class PlayerLyricsFragment extends Fragment {
private Runnable syncLyricsRunnable; private Runnable syncLyricsRunnable;
private String currentLyrics; private String currentLyrics;
private LyricsList currentLyricsList; private LyricsList currentLyricsList;
private Integer lastLineIdx;
private String currentDescription; private String currentDescription;
@Override @Override
@ -109,6 +112,7 @@ public class PlayerLyricsFragment extends Fragment {
currentLyrics = null; currentLyrics = null;
currentLyricsList = null; currentLyricsList = null;
currentDescription = null; currentDescription = null;
lastLineIdx = null;
} }
private void initOverlay() { private void initOverlay() {
@ -162,6 +166,7 @@ public class PlayerLyricsFragment extends Fragment {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> { playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
currentLyricsList = lyricsList; currentLyricsList = lyricsList;
lastLineIdx = null;
updatePanelContent(); updatePanelContent();
}); });
@ -194,7 +199,7 @@ public class PlayerLyricsFragment extends Fragment {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0); bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
if (hasStructuredLyrics(currentLyricsList)) { if (hasStructuredLyrics(currentLyricsList)) {
setSyncLirics(currentLyricsList); setSyncLyrics(currentLyricsList);
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE); bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
@ -241,7 +246,7 @@ public class PlayerLyricsFragment extends Fragment {
} }
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
private void setSyncLirics(LyricsList lyricsList) { private void setSyncLyrics(LyricsList lyricsList) {
if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) { if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
StringBuilder lyricsBuilder = new StringBuilder(); StringBuilder lyricsBuilder = new StringBuilder();
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine(); List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
@ -288,67 +293,75 @@ public class PlayerLyricsFragment extends Fragment {
int timestamp = (int) (mediaBrowser.getCurrentPosition()); int timestamp = (int) (mediaBrowser.getCurrentPosition());
if (hasStructuredLyrics(lyricsList)) { if (hasStructuredLyrics(lyricsList)) {
StringBuilder lyricsBuilder = new StringBuilder();
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine(); List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
if (lines == null || lines.isEmpty()) {
return;
}
if (lines == null || lines.isEmpty()) return; // Find the index of the currently playing line
int curIdx = 0;
for (; curIdx < lines.size(); ++curIdx) {
Integer start = lines.get(curIdx).getStart();
if (start != null && start > timestamp) {
curIdx--; // Found the first line that starts after the current timestamp
break;
}
}
// Only update if the highlighted line has changed
if (lastLineIdx != null && curIdx == lastLineIdx) {
return;
}
lastLineIdx = curIdx;
StringBuilder lyricsBuilder = new StringBuilder();
for (Line line : lines) { for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n"); lyricsBuilder.append(line.getValue().trim()).append("\n");
} }
String lyrics = lyricsBuilder.toString();
Spannable spannableString = new SpannableString(lyrics);
Line toHighlight = lines.stream().filter(line -> line != null && line.getStart() != null && line.getStart() < timestamp).reduce((first, second) -> second).orElse(null); // Make each line clickable for navigation and highlight the current one
int offset = 0;
int highlightStart = -1;
for (int i = 0; i < lines.size(); ++i) {
boolean highlight = i == curIdx;
if (highlight) highlightStart = offset;
if (toHighlight != null) { int len = lines.get(i).getValue().length() + 1;
String lyrics = lyricsBuilder.toString(); final int lineStart = lines.get(i).getStart();
Spannable spannableString = new SpannableString(lyrics); spannableString.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View view) {
// Seeking to 1ms after the actual start prevents scrolling / highlighting artifacts
mediaBrowser.seekTo(lineStart + 1);
}
int startingPosition = getStartPosition(lines, toHighlight); @Override
int endingPosition = startingPosition + toHighlight.getValue().length(); public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
if (highlight) {
ds.setColor(requireContext().getResources().getColor(R.color.lyricsTextColor, null));
} else {
ds.setColor(requireContext().getResources().getColor(R.color.shadowsLyricsTextColor, null));
}
}
}, offset, offset + len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
offset += len;
}
spannableString.setSpan(new ForegroundColorSpan(requireContext().getResources().getColor(R.color.shadowsLyricsTextColor, null)), 0, lyrics.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); bind.nowPlayingSongLyricsTextView.setMovementMethod(LinkMovementMethod.getInstance());
spannableString.setSpan(new ForegroundColorSpan(requireContext().getResources().getColor(R.color.lyricsTextColor, null)), startingPosition, endingPosition, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); bind.nowPlayingSongLyricsTextView.setText(spannableString);
bind.nowPlayingSongLyricsTextView.setText(spannableString); // Scroll to the highlighted line, but only if there is one
if (highlightStart >= 0 && playerBottomSheetViewModel.getSyncLyricsState()) {
if (playerBottomSheetViewModel.getSyncLyricsState()) { bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(highlightStart));
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(lines, toHighlight));
}
} }
} }
} }
private int getStartPosition(List<Line> lines, Line toHighlight) { private int getScroll(int startIndex) {
int start = 0;
for (Line line : lines) {
if (line != toHighlight) {
start = start + line.getValue().length() + 1;
} else {
break;
}
}
return start;
}
private int getLineCount(List<Line> lines, Line toHighlight) {
int start = 0;
for (Line line : lines) {
if (line != toHighlight) {
bind.tempLyricsLineTextView.setText(line.getValue());
start = start + bind.tempLyricsLineTextView.getLineCount();
} else {
break;
}
}
return start;
}
private int getScroll(List<Line> lines, Line toHighlight) {
int startIndex = getStartPosition(lines, toHighlight);
Layout layout = bind.nowPlayingSongLyricsTextView.getLayout(); Layout layout = bind.nowPlayingSongLyricsTextView.getLayout();
if (layout == null) return 0; if (layout == null) return 0;