7 Commits

Author SHA1 Message Date
eddyizm
eaac728a26 chore: bump version and change logs 2026-02-02 20:25:40 -08:00
skajmer
65d2f8e33f chore(i18n): Update Polish translation (#402)
* Add #338

* Add #3700 (strings.xml)

* Add #370 (arrays.xml)

* Add #386

* Add #394
2026-02-02 07:09:29 -08:00
Tom
baf4e0f0fc chore(i18n): set links as untranslatable (#400) 2026-01-31 17:37:41 -08:00
Tom
26c7bee106 feat: Add selector for playlist visibility (#394)
* feat: add selector for playlist visiblity when adding a song

* fix: wrong number of arguments

* feat: make dialog text localized

* chore: add es, fr, it, pt localization for playlist visibility dialog

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-01-31 17:10:58 -08:00
Pascal Grittmann
6e51611867 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
2026-01-31 08:16:13 -08:00
eddyizm
d67e432731 chore: added playlist strings for pr #394 2026-01-31 08:10:30 -08:00
Pascal Grittmann
8b61396b0f Fix missing Replay Gain metadata from .m4a files (#396)
fix missing replay gain metadata from .m4a files
2026-01-29 20:22:09 -08:00
14 changed files with 141 additions and 72 deletions

View File

@@ -2,6 +2,18 @@
## Pending release
## What's Changed
## [4.9.8](https://github.com/eddyizm/tempo/releases/tag/v4.9.8) (2026-02-02)
* fix: missing Replay Gain metadata from .m4a files by @pgrit in https://github.com/eddyizm/tempus/pull/396
* fix: Improve Synced Lyrics by @pgrit in https://github.com/eddyizm/tempus/pull/384
* fix: Add selector for playlist visibility by @tvillega in https://github.com/eddyizm/tempus/pull/394
* chore(i18n): set links as untranslatable by @tvillega in https://github.com/eddyizm/tempus/pull/400
## New Contributors
* @tvillega made their first contribution in https://github.com/eddyizm/tempus/pull/394
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.5...v4.5.8
## What's Changed
## [4.9.5](https://github.com/eddyizm/tempo/releases/tag/v4.9.5) (2026-01-26)
* fix: Avoid crash when server has no songs by @jaime-grj in https://github.com/eddyizm/tempus/pull/389

View File

@@ -10,8 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 16
versionName '4.9.5'
versionCode 17
versionName '4.9.8'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {

View File

@@ -1,8 +1,5 @@
package com.cappielloantonio.tempo.repository;
import static android.provider.Settings.System.getString;
import android.provider.Settings;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -107,13 +104,13 @@ public class PlaylistRepository {
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
if (songsId.isEmpty()) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
} else{
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null)
.updatePlaylist(playlistId, null, playlistVisibilityIsPublic, songsId, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {

View File

@@ -6,6 +6,7 @@ import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.viewmodel.PlaylistChooserViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
public class PlaylistChooserDialog extends DialogFragment implements ClickCallback {
private DialogPlaylistChooserBinding bind;
@@ -35,9 +37,21 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class);
String[] playlistVisibilityChoice = {
getString(R.string.playlist_chooser_dialog_visibility_public),
getString(R.string.playlist_chooser_dialog_visibility_private)
};
return new MaterialAlertDialogBuilder(getActivity())
.setView(bind.getRoot())
.setTitle(R.string.playlist_chooser_dialog_title)
.setSingleChoiceItems(
playlistVisibilityChoice,
0,
(dialog, which) -> {
boolean isPublic = (which == 0);
playlistChooserViewModel.setIsPlaylistPublic(isPublic);
})
.setNeutralButton(R.string.playlist_chooser_dialog_neutral_button, (dialog, id) -> { })
.setNegativeButton(R.string.playlist_chooser_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();

View File

@@ -7,7 +7,9 @@ import android.os.Handler;
import android.text.Layout;
import android.text.Spannable;
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.view.LayoutInflater;
import android.view.View;
@@ -51,6 +53,7 @@ public class PlayerLyricsFragment extends Fragment {
private Runnable syncLyricsRunnable;
private String currentLyrics;
private LyricsList currentLyricsList;
private Integer lastLineIdx;
private String currentDescription;
@Override
@@ -109,6 +112,7 @@ public class PlayerLyricsFragment extends Fragment {
currentLyrics = null;
currentLyricsList = null;
currentDescription = null;
lastLineIdx = null;
}
private void initOverlay() {
@@ -162,6 +166,7 @@ public class PlayerLyricsFragment extends Fragment {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
currentLyricsList = lyricsList;
lastLineIdx = null;
updatePanelContent();
});
@@ -194,7 +199,7 @@ public class PlayerLyricsFragment extends Fragment {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
if (hasStructuredLyrics(currentLyricsList)) {
setSyncLirics(currentLyricsList);
setSyncLyrics(currentLyricsList);
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
@@ -241,7 +246,7 @@ public class PlayerLyricsFragment extends Fragment {
}
@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) {
StringBuilder lyricsBuilder = new StringBuilder();
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
@@ -288,67 +293,75 @@ public class PlayerLyricsFragment extends Fragment {
int timestamp = (int) (mediaBrowser.getCurrentPosition());
if (hasStructuredLyrics(lyricsList)) {
StringBuilder lyricsBuilder = new StringBuilder();
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) {
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) {
String lyrics = lyricsBuilder.toString();
Spannable spannableString = new SpannableString(lyrics);
int len = lines.get(i).getValue().length() + 1;
final int lineStart = lines.get(i).getStart();
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);
int endingPosition = startingPosition + toHighlight.getValue().length();
@Override
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);
spannableString.setSpan(new ForegroundColorSpan(requireContext().getResources().getColor(R.color.lyricsTextColor, null)), startingPosition, endingPosition, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
bind.nowPlayingSongLyricsTextView.setMovementMethod(LinkMovementMethod.getInstance());
bind.nowPlayingSongLyricsTextView.setText(spannableString);
bind.nowPlayingSongLyricsTextView.setText(spannableString);
if (playerBottomSheetViewModel.getSyncLyricsState()) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(lines, toHighlight));
}
// Scroll to the highlighted line, but only if there is one
if (highlightStart >= 0 && playerBottomSheetViewModel.getSyncLyricsState()) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(highlightStart));
}
}
}
private int getStartPosition(List<Line> lines, Line toHighlight) {
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);
private int getScroll(int startIndex) {
Layout layout = bind.nowPlayingSongLyricsTextView.getLayout();
if (layout == null) return 0;

View File

@@ -7,6 +7,7 @@ import androidx.media3.common.Metadata;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.Player;
import androidx.media3.extractor.metadata.id3.InternalFrame;
import com.cappielloantonio.tempo.model.ReplayGain;
@@ -82,26 +83,32 @@ public class ReplayGainUtil {
private static ReplayGain setReplayGains(Metadata.Entry entry) {
ReplayGain replayGain = new ReplayGain();
if (entry.toString().contains(tags[0])) {
replayGain.setTrackGain(parseReplayGainTag(entry));
// The logic below assumes .toString() contains the dB value. That's not the case for InternalFrame
String str = entry.toString();
if (entry instanceof InternalFrame) {
str = ((InternalFrame) entry).description + ((InternalFrame) entry).text;
}
if (entry.toString().contains(tags[1])) {
replayGain.setAlbumGain(parseReplayGainTag(entry));
if (str.contains(tags[0])) {
replayGain.setTrackGain(parseReplayGainTag(str));
}
if (entry.toString().contains(tags[2])) {
replayGain.setTrackGain(parseReplayGainTag(entry) / 256f);
if (str.contains(tags[1])) {
replayGain.setAlbumGain(parseReplayGainTag(str));
}
if (entry.toString().contains(tags[3])) {
replayGain.setAlbumGain(parseReplayGainTag(entry) / 256f);
if (str.contains(tags[2])) {
replayGain.setTrackGain(parseReplayGainTag(str) / 256f);
}
if (str.contains(tags[3])) {
replayGain.setAlbumGain(parseReplayGainTag(str) / 256f);
}
return replayGain;
}
private static Float parseReplayGainTag(Metadata.Entry entry) {
private static Float parseReplayGainTag(String entry) {
try {
return Float.parseFloat(entry.toString().replaceAll("[^\\d.-]", ""));
} catch (NumberFormatException exception) {

View File

@@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.app.Dialog;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
@@ -21,8 +20,17 @@ import java.util.List;
public class PlaylistChooserViewModel extends AndroidViewModel {
private final PlaylistRepository playlistRepository;
private final MutableLiveData<List<Playlist>> playlists = new MutableLiveData<>(null);
private final MutableLiveData<Boolean> playlistIsPublic = new MutableLiveData<>(false);
public Boolean getIsPlaylistPublic() {
return playlistIsPublic.getValue();
}
public void setIsPlaylistPublic(boolean isPublic) {
playlistIsPublic.setValue(isPublic);
}
private ArrayList<Child> toAdd = new ArrayList<>();
public PlaylistChooserViewModel(@NonNull Application application) {
@@ -39,7 +47,7 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) {
List<String> songIds = Lists.transform(toAdd, Child::getId);
if (Preferences.allowPlaylistDuplicates()) {
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds), getIsPlaylistPublic());
dialog.dismiss();
} else {
playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> {
@@ -47,7 +55,7 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
List<String> playlistSongIds = Lists.transform(playlistSongs, Child::getId);
songIds.removeAll(playlistSongIds);
}
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds), getIsPlaylistPublic());
dialog.dismiss();
});
}

View File

@@ -227,6 +227,8 @@
<string name="playlist_chooser_dialog_title">Añadir a una lista de reproducción</string>
<string name="playlist_chooser_dialog_toast_add_failure">Error al añadir a la lista</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Todas las pistas se han descartado porque están repetidas</string>
<string name="playlist_chooser_dialog_visibility_public">Público</string>
<string name="playlist_chooser_dialog_visibility_private">Privado</string>
<string name="playlist_counted_tracks">%1$d pistas • %2$s</string>
<string name="playlist_duration">Duración • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Pulsación larga para eliminar</string>

View File

@@ -236,6 +236,8 @@
<string name="playlist_chooser_dialog_toast_add_success">Titre ajouté à la playlist</string>
<string name="playlist_chooser_dialog_toast_add_failure">Échec d\'ajout du titre à la playlist</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Tous les titres ont été traités comme des doublons et ignorés</string>
<string name="playlist_chooser_dialog_visibility_public">Publique</string>
<string name="playlist_chooser_dialog_visibility_private">Privé</string>
<string name="playlist_counted_tracks">%1$d titres • %2$s</string>
<string name="playlist_duration">Durée • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Appui long pour supprimer</string>

View File

@@ -229,6 +229,8 @@
<string name="playlist_chooser_dialog_toast_add_success">Aggiunta di un brano alla playlist</string>
<string name="playlist_chooser_dialog_toast_add_failure">Impossibile aggiungere un brano alla playlist</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Tutte le canzoni sono state saltate perché duplicate</string>
<string name="playlist_chooser_dialog_visibility_public">Pubblico</string>
<string name="playlist_chooser_dialog_visibility_private">Privato</string>
<string name="playlist_counted_tracks">%1$d brani • %2$s</string>
<string name="playlist_duration">Durata • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Premi a lungo per eliminare</string>

View File

@@ -228,6 +228,8 @@
<string name="playlist_chooser_dialog_toast_add_success">Dodano piosenki do playlisty</string>
<string name="playlist_chooser_dialog_toast_add_failure">Nie udało się dodać piosenek do playlisty</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Pominięto wszystkie piosenki jako duplikaty</string>
<string name="playlist_chooser_dialog_visibility_public">Publiczna</string>
<string name="playlist_chooser_dialog_visibility_private">Prywatna</string>
<string name="playlist_counted_tracks">%1$d utworów • %2$s</string>
<string name="playlist_duration">Długość • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Przytrzymaj aby usunąć</string>
@@ -343,6 +345,9 @@
<string name="settings_image_size">Rozdzielczość obrazów</string>
<string name="settings_language">Język</string>
<string name="settings_logout_title">Wyloguj</string>
<string name="settings_ping_timeout_title">Timeout pingów serwera</string>
<string name="settings_ping_timeout_summary">Timeout lokalnego adresu URL. Domyślnie to 2 sekundy. (Serwer zdalny będzie używał trzykrotności tej wartości maksymalnie do 10 sekund.)</string>
<string name="settings_ping_timeout_dialog">Bazowy timeout w sekundach.</string>
<string name="settings_max_bitrate_download">Bitrate dla pobierania</string>
<string name="settings_max_bitrate_mobile">Bitrate dla danych komórkowych</string>
<string name="settings_max_bitrate_wifi">Bitrate dla Wi-Fi</string>

View File

@@ -164,6 +164,8 @@
<string name="playlist_chooser_dialog_title">Adicionar a uma playlist</string>
<string name="playlist_chooser_dialog_toast_add_success">Adicionada playlist de reprodução</string>
<string name="playlist_chooser_dialog_toast_add_failure">Falha ao adicionar uma playlist de reprodução</string>
<string name="playlist_chooser_dialog_visibility_public">Pública</string>
<string name="playlist_chooser_dialog_visibility_private">Privada</string>
<string name="playlist_counted_tracks">%1$d faixas • %2$s</string>
<string name="playlist_duration">Duração • %1$s</string>
<string name="playlist_editor_dialog_hint_name">Nome da playlist</string>

View File

@@ -238,6 +238,8 @@
<string name="playlist_chooser_dialog_toast_add_success">Added song(s) to playlist</string>
<string name="playlist_chooser_dialog_toast_add_failure">Failed to add song(s) to playlist</string>
<string name="playlist_chooser_dialog_toast_all_skipped">All songs were skipped as duplicates</string>
<string name="playlist_chooser_dialog_visibility_public">Public</string>
<string name="playlist_chooser_dialog_visibility_private">Private</string>
<string name="playlist_counted_tracks">%1$d tracks • %2$s</string>
<string name="playlist_duration">Duration • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Long press to delete</string>
@@ -343,7 +345,7 @@
<string name="settings_github_link">https://github.com/eddyizm/tempus</string>
<string name="settings_github_summary">Follow the development</string>
<string name="settings_github_title">Github</string>
<string name="settings_support_discussion_link">https://github.com/eddyizm/tempus/discussions</string>
<string name="settings_support_discussion_link" translatable="false">https://github.com/eddyizm/tempus/discussions</string>
<string name="settings_github_update">Updates</string>
<string name="settings_github_update_title">Check github for release updates</string>
<string name="settings_github_update_summary">If using the github version, by default app will check for new apk release. Toggle to disable automatic github checks</string>
@@ -487,7 +489,7 @@
<string name="streaming_cache_storage_dialog_title">Select storage option</string>
<string name="streaming_cache_storage_external_dialog_positive_button">External</string>
<string name="streaming_cache_storage_internal_dialog_negative_button">Internal</string>
<string name="support_url">https://ko-fi.com/eddyizm</string>
<string name="support_url" translatable="false">https://ko-fi.com/eddyizm</string>
<string name="track_info_album">Album</string>
<string name="track_info_artist">Artist</string>
<string name="track_info_bit_depth">Bit depth</string>
@@ -515,7 +517,7 @@
<string name="track_info_year">Year</string>
<string name="undraw_page">unDraw</string>
<string name="undraw_thanks">A special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful.</string>
<string name="undraw_url">https://undraw.co/</string>
<string name="undraw_url" translatable="false">https://undraw.co/</string>
<string name="widget_label">Tempus Widget</string>
<string name="widget_not_playing">Not playing</string>
<string name="widget_placeholder_subtitle">Open Tempus</string>

View File

@@ -0,0 +1,3 @@
fix: missing Replay Gain metadata from .m4a files
fix: Improve Synced Lyrics
fix: Add selector for playlist visibility