19 Commits

Author SHA1 Message Date
eddyizm
21ed78d959 chore: bumping version, fastlane and changelog 2026-02-08 16:14:22 -08:00
Tom
5ad99b9f27 feat: increase items per row on landscape view (#411)
* feat: increase items per row on landscape view

This covers the catalogues: artist, album and genre; also the list of albums on artist view.
This was implemented by adierebel/tempo fork, I only cherry-picked some commits.

Co-authored-by: adierebel <adie.rebel@gmail.com>

* feat: add landscape layout to song listing views

This includes the playlist page and the album page.

* fix: bad scaling on small screens

This rollbacks to the original code by adierebel/tempo fork

* fix: remove hardcoded height blocking scroll

This was addressed in 989ca35, forgot to fix it here as well

* fix: wrap content height rather than inheriting it from parent

* feat: add ui of choice selector in setting for items per row

* feat: link getter to landscapes items per row setting an implement it

* fix: wrong default value

Co-authored-by: eddyizm <wtfisup@hotmail.com>

* feat: add default value on setting string

To introduce the new feature of landscape layouts.

Co-authored-by: eddyizm <wtfisup@hotmail.com>

---------

Co-authored-by: adierebel <adie.rebel@gmail.com>
Co-authored-by: eddyizm <wtfisup@hotmail.com>
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-08 15:20:53 -08:00
T R
3de5390140 fix: album art now displays on android auto (#414)
Co-authored-by: Thomas R <tdr@thomasr.co>
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-08 10:34:44 -08:00
eddyizm
d215581e19 fix: keep observer until data is received on continuous play (#421) 2026-02-08 10:18:36 -08:00
tiltshiftfocus
54612c6b74 patch: Addressing some UI/UX quirks (#413)
* beautify lyrics display

* use dialog to select playback speed

to prevent accidental clicks
2026-02-08 10:18:01 -08:00
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
eddyizm
0fb6e55b12 chore: update changelog and fastlane 2026-01-26 21:32:07 -08:00
eddyizm
dd7aa2291b chore: bump version 2026-01-26 21:29:40 -08:00
eddyizm
ec33c32c89 fix: updated dialog import to address crashing on android 15 (#392)
resolves #362
2026-01-26 21:25:27 -08:00
Jaime García
e0ad4e3701 fix: Avoid crash when server has no songs (#389) 2026-01-26 16:24:23 -08:00
eddyizm
253f8033c5 Merge branch 'development' 2026-01-25 11:41:50 -08:00
eddyizm
c1aed1a4c1 chore: version/changelog/fastlane bumps 2026-01-25 11:41:26 -08:00
eddyizm
8d215a7f1c feat: add configurable timeout (#386) 2026-01-25 11:27:12 -08:00
46 changed files with 1260 additions and 144 deletions

View File

@@ -2,13 +2,43 @@
## Pending release
## What's Changed
* fix: Addressing some UI/UX quirks by @tiltshiftfocus in https://github.com/eddyizm/tempus/pull/413
* fix: keep observer until data is received on continuousPlay bug by @eddyizm in https://github.com/eddyizm/tempus/pull/421
* fix: album art now displays on android auto by @trobinson in https://github.com/eddyizm/tempus/pull/414
* feat: improve landscape view and increase items per row on landscape view by @tvillega in https://github.com/eddyizm/tempus/pull/411
## New Contributors
* @tiltshiftfocus made their first contribution in https://github.com/eddyizm/tempus/pull/413
* @trobinson made their first contribution in https://github.com/eddyizm/tempus/pull/414
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.8...v4.10.0
## 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
* fix: updated dialog import to address crashing on android 15 by @eddyizm in https://github.com/eddyizm/tempus/pull/392
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.3...v4.9.5
## What's Changed
## [4.9.3](https://github.com/eddyizm/tempo/releases/tag/v4.9.3) (2026-01-25)
* fix: Proper raw stream detection by @jaime-grj in https://github.com/eddyizm/tempus/pull/382
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/381
* feat: add configurable timeout by @eddyizm in https://github.com/eddyizm/tempus/pull/386
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.1...v4.9.3
## What's Changed

View File

@@ -10,8 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 15
versionName '4.9.3'
versionCode 18
versionName '4.10.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {

View File

@@ -96,7 +96,12 @@
android:resource="@xml/widget_info"/>
</receiver>
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="com.cappielloantonio.tempo.provider"
android:enabled="true"
android:exported="true"
/>
</application>
</manifest>

View File

@@ -1,5 +1,6 @@
package com.cappielloantonio.tempo.model
import android.content.ContentResolver
import android.net.Uri
import android.os.Bundle
import androidx.annotation.Keep
@@ -13,6 +14,7 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.cappielloantonio.tempo.glide.CustomGlideRequest
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider
import com.cappielloantonio.tempo.subsonic.models.Child
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode
@@ -197,7 +199,7 @@ class SessionMediaItem() {
fun getMediaItem(): MediaItem {
val uri: Uri = getStreamUri()
val artworkUri = Uri.parse(CustomGlideRequest.createUrl(coverArtId, getImageSize()))
val artworkUri = AlbumArtContentProvider.contentUri(coverArtId)
val bundle = Bundle()
bundle.putString("id", id)

View File

@@ -0,0 +1,149 @@
package com.cappielloantonio.tempo.provider;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.util.Preferences;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class AlbumArtContentProvider extends ContentProvider {
public static final String AUTHORITY = "com.cappielloantonio.tempo.provider";
public static final String ALBUM_ART = "albumArt";
private ExecutorService executor;
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
uriMatcher.addURI(AUTHORITY, "albumArt/*", 1);
}
public static Uri contentUri(String artworkId) {
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(ALBUM_ART)
.appendPath(artworkId)
.build();
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
Context context = getContext();
String albumId = uri.getLastPathSegment();
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize()));
try {
// use pipe to communicate between background thread and caller of openFile()
ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
ParcelFileDescriptor readSide = pipe[0];
ParcelFileDescriptor writeSide = pipe[1];
// perform loading in background thread to avoid blocking UI
executor.execute(() -> {
try (OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeSide)) {
// request artwork from API using Glide
File file = Glide.with(context)
.asFile()
.load(artworkUri)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.submit()
.get();
// copy artwork down pipe returned by ContentProvider
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
writeSide.closeWithError("Failed to load image: " + e.getMessage());
}
} catch (Exception e) {
try {
writeSide.closeWithError("Failed to load image: " + e.getMessage());
} catch (IOException ignored) {}
}
});
return readSide;
} catch (IOException e) {
throw new FileNotFoundException("Could not create pipe: " + e.getMessage());
}
}
@Override
public boolean onCreate() {
executor = Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2)
);
return true;
}
@Override
public void shutdown() {
if (executor != null) {
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) {
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return "";
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
return null;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
return 0;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
return 0;
}
}

View File

@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.repository;
import android.content.ContentResolver;
import android.net.Uri;
import android.view.View;
@@ -22,6 +23,7 @@ import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.model.Chronology;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.SessionMediaItem;
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
@@ -70,7 +72,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName())
@@ -217,7 +219,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName())
@@ -272,7 +274,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (ArtistID3 artist : artists) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(artist.getName())
@@ -397,7 +399,7 @@ public class AutomotiveRepository {
List<Child> children = response.body().getSubsonicResponse().getIndexes().getChildren();
for (Child song : children) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(song.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(song.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(song.getTitle())
@@ -451,7 +453,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (Child child : directory.getChildren()) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(child.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(child.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(child.getTitle())
@@ -550,7 +552,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (PodcastEpisode episode : episodes) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(episode.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(episode.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(episode.getTitle())
@@ -687,7 +689,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName())
@@ -800,7 +802,7 @@ public class AutomotiveRepository {
if (response.body().getSubsonicResponse().getSearchResult3().getArtists() != null) {
for (ArtistID3 artist : response.body().getSubsonicResponse().getSearchResult3().getArtists()) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(artist.getName())
@@ -822,7 +824,7 @@ public class AutomotiveRepository {
if (response.body().getSubsonicResponse().getSearchResult3().getAlbums() != null) {
for (AlbumID3 album : response.body().getSubsonicResponse().getSearchResult3().getAlbums()) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName())

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

@@ -283,7 +283,10 @@ public class SongRepository {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
songs.addAll(Objects.requireNonNull(response.body().getSubsonicResponse().getRandomSongs().getSongs()));
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs();
if (returned != null) {
songs.addAll(returned);
}
}
randomSongsSample.setValue(songs);
}
@@ -299,7 +302,10 @@ public class SongRepository {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
songs.addAll(Objects.requireNonNull(response.body().getSubsonicResponse().getRandomSongs().getSongs()));
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs();
if (returned != null) {
songs.addAll(returned);
}
}
randomSongsSample.setValue(songs);
}
@@ -342,7 +348,10 @@ public class SongRepository {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
songs.addAll(Objects.requireNonNull(response.body().getSubsonicResponse().getSongsByGenre().getSongs()));
List<Child> returned = response.body().getSubsonicResponse().getSongsByGenre().getSongs();
if (returned != null) {
songs.addAll(returned);
}
}
songsByGenre.setValue(songs);
}

View File

@@ -33,6 +33,8 @@ import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
private const val TAG = "BaseMediaService"
@UnstableApi
open class BaseMediaService : MediaLibraryService() {
companion object {
@@ -82,7 +84,7 @@ open class BaseMediaService : MediaLibraryService() {
}
fun updateMediaItems(player: Player) {
Log.d(javaClass.toString(), "update items")
Log.d(TAG, "update items")
val n = player.mediaItemCount
val k = player.currentMediaItemIndex
val current = player.currentPosition
@@ -121,7 +123,7 @@ open class BaseMediaService : MediaLibraryService() {
fun initializePlayerListener(player: Player) {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Log.d(javaClass.toString(), "onMediaItemTransition" + player.currentMediaItemIndex)
Log.d(TAG, "onMediaItemTransition" + player.currentMediaItemIndex)
if (mediaItem == null) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
@@ -131,7 +133,7 @@ open class BaseMediaService : MediaLibraryService() {
}
override fun onTracksChanged(tracks: Tracks) {
Log.d(javaClass.toString(), "onTracksChanged " + player.currentMediaItemIndex)
Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex)
ReplayGainUtil.setReplayGain(player, tracks)
val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null) {
@@ -151,7 +153,7 @@ open class BaseMediaService : MediaLibraryService() {
if (player is ExoPlayer) {
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
if (MediaManager.justStarted.get()) {
Log.d(javaClass.toString(), "update shuffle order")
Log.d(TAG, "update shuffle order")
MediaManager.justStarted.set(false)
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
shuffledList.shuffle()
@@ -169,7 +171,7 @@ open class BaseMediaService : MediaLibraryService() {
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(javaClass.toString(), "onIsPlayingChanged " + player.currentMediaItemIndex)
Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex)
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
@@ -187,7 +189,7 @@ open class BaseMediaService : MediaLibraryService() {
}
override fun onPlaybackStateChanged(playbackState: Int) {
Log.d(javaClass.toString(), "onPlaybackStateChanged")
Log.d(TAG, "onPlaybackStateChanged")
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
playbackState == Player.STATE_ENDED &&
@@ -204,7 +206,7 @@ open class BaseMediaService : MediaLibraryService() {
newPosition: Player.PositionInfo,
reason: Int
) {
Log.d(javaClass.toString(), "onPositionDiscontinuity")
Log.d(TAG, "onPositionDiscontinuity")
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
@@ -228,7 +230,7 @@ open class BaseMediaService : MediaLibraryService() {
}
override fun onAudioSessionIdChanged(audioSessionId: Int) {
Log.d(javaClass.toString(), "onAudioSessionIdChanged")
Log.d(TAG, "onAudioSessionIdChanged")
attachEqualizerIfPossible(audioSessionId)
}
})
@@ -320,7 +322,7 @@ open class BaseMediaService : MediaLibraryService() {
}
private fun initializeMediaLibrarySession(player: Player) {
Log.d(javaClass.toString(), "initializeMediaLibrarySession")
Log.d(TAG, "initializeMediaLibrarySession")
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(baseContext, MainActivity::class.java))
@@ -467,7 +469,7 @@ open class BaseMediaService : MediaLibraryService() {
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
Log.d(javaClass.toString(), "onCustomCommand")
Log.d(TAG, "onCustomCommand")
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
@@ -492,7 +494,7 @@ open class BaseMediaService : MediaLibraryService() {
controller: ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
Log.d(javaClass.toString(), "onAddMediaItems")
Log.d(TAG, "onAddMediaItems")
val updatedMediaItems = mediaItems.map { mediaItem ->
val mediaMetadata = mediaItem.mediaMetadata
val newMetadata = mediaMetadata.buildUpon()

View File

@@ -444,24 +444,33 @@ public class MediaManager {
}
@OptIn(markerClass = UnstableApi.class)
public static void continuousPlay(MediaItem mediaItem, ListenableFuture<MediaBrowser> existingBrowserFuture) {
if (mediaItem != null && Preferences.isContinuousPlayEnabled() && Preferences.isInstantMixUsable()) {
Preferences.setLastInstantMix();
LiveData<List<Child>> instantMix = getSongRepository().getContinuousMix(mediaItem.mediaId, 25);
instantMix.observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> media) {
if (media != null && existingBrowserFuture != null) {
Log.d(TAG, "Continuous play: adding " + media.size() + " tracks");
enqueue(existingBrowserFuture, media, false);
}
instantMix.removeObserver(this);
}
});
public static void continuousPlay(MediaItem mediaItem,
ListenableFuture<MediaBrowser> existingBrowserFuture) {
if (mediaItem == null
|| !Preferences.isContinuousPlayEnabled()
|| !Preferences.isInstantMixUsable()) {
return;
}
Preferences.setLastInstantMix();
LiveData<List<Child>> instantMix =
getSongRepository().getContinuousMix(mediaItem.mediaId, 25);
instantMix.observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> media) {
if (media == null || media.isEmpty()) {
return;
}
if (existingBrowserFuture != null) {
Log.d(TAG, "Continuous play: adding " + media.size() + " tracks");
enqueue(existingBrowserFuture, media, true);
}
instantMix.removeObserver(this);
}
});
}
public static void saveChronology(MediaItem mediaItem) {

View File

@@ -24,13 +24,15 @@ public class SystemClient {
public Call<ApiResponse> ping() {
Log.d(TAG, "ping()");
int timeoutSeconds = Preferences.getNetworkPingTimeout();
Call<ApiResponse> pingCall = systemService.ping(subsonic.getParams());
if (Preferences.isInUseServerAddressLocal()) {
pingCall.timeout()
.timeout(1, TimeUnit.SECONDS);
.timeout(timeoutSeconds, TimeUnit.SECONDS);
} else {
int finalTimeout = Math.min(timeoutSeconds * 2, 10);
pingCall.timeout()
.timeout(3, TimeUnit.SECONDS);
.timeout(finalTimeout, TimeUnit.SECONDS);
}
return pingCall;
}

View File

@@ -2,6 +2,8 @@ package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
@@ -11,6 +13,7 @@ import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen;
@@ -62,6 +65,7 @@ public class MainActivity extends BaseActivity {
private BottomNavigationView bottomNavigationView;
public NavController navController;
private BottomSheetBehavior bottomSheetBehavior;
private boolean isLandscape = false;
private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink;
@@ -85,6 +89,8 @@ public class MainActivity extends BaseActivity {
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
connectivityStatusReceiverManager(true);
isLandscape = (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
init();
checkConnectionType();
getOpenSubsonicExtensions();
@@ -141,6 +147,15 @@ public class MainActivity extends BaseActivity {
} else {
goToLogin();
}
// Set bottom navigation height
if (isLandscape) {
ViewGroup.LayoutParams layoutParams = bottomNavigationView.getLayoutParams();
Rect windowRect = new Rect();
bottomNavigationView.getWindowVisibleDisplayFrame(windowRect);
layoutParams.width = windowRect.height();
bottomNavigationView.setLayoutParams(layoutParams);
}
}
// BOTTOM SHEET/NAVIGATION
@@ -215,7 +230,9 @@ public class MainActivity extends BaseActivity {
@Override
public void onSlide(@NonNull View view, float slideOffset) {
animateBottomSheet(slideOffset);
animateBottomNavigation(slideOffset, navigationHeight);
if (!isLandscape) {
animateBottomNavigation(slideOffset, navigationHeight);
}
}
};

View File

@@ -173,10 +173,12 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
)
)
) {
holder.item.differentDiskDividerSector.setVisibility(View.VISIBLE);
if (songs.get(position).getDiscNumber() != null && !Objects.requireNonNull(songs.get(position).getDiscNumber()).toString().isBlank()) {
holder.item.discTitleTextView.setText(holder.itemView.getContext().getString(R.string.disc_titleless, songs.get(position).getDiscNumber().toString()));
holder.item.differentDiskDividerSector.setVisibility(View.VISIBLE);
} else {
holder.item.differentDiskDividerSector.setVisibility(View.GONE);
}
if (album.getDiscTitles() != null) {

View File

@@ -0,0 +1,57 @@
package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
public class PlaybackSpeedDialog extends DialogFragment {
private static final String TAG = "PlaybackSpeedDialog";
public interface PlaybackSpeedListener {
void onSpeedSelected(float speed);
}
private PlaybackSpeedListener listener;
private static final float[] SPEED_VALUES = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f};
private static final String[] SPEED_LABELS = {"0.5x", "0.75x", "1.0x", "1.25x", "1.5x", "1.75x", "2.0x"};
public void setPlaybackSpeedListener(PlaybackSpeedListener listener) {
this.listener = listener;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
float currentSpeed = Preferences.getPlaybackSpeed();
int selectedIndex = getSelectedIndex(currentSpeed);
return new MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.playback_speed_dialog_title)
.setSingleChoiceItems(SPEED_LABELS, selectedIndex, (dialog, which) -> {
float selectedSpeed = SPEED_VALUES[which];
Preferences.setPlaybackSpeed(selectedSpeed);
if (listener != null) {
listener.onSpeedSelected(selectedSpeed);
}
dialog.dismiss();
})
.setNegativeButton(R.string.playback_speed_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();
}
private int getSelectedIndex(float currentSpeed) {
for (int i = 0; i < SPEED_VALUES.length; i++) {
if (Math.abs(SPEED_VALUES[i] - currentSpeed) < 0.01f) {
return i;
}
}
return 2; // Default to 1.0x
}
}

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

@@ -1,6 +1,6 @@
package com.cappielloantonio.tempo.ui.dialog;
import android.app.AlertDialog;
import androidx.appcompat.app.AlertDialog;
import android.app.Dialog;
import android.os.Bundle;
import android.text.TextUtils;

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
@@ -49,6 +50,7 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private AlbumCatalogueViewModel albumCatalogueViewModel;
private AlbumCatalogueAdapter albumAdapter;
private int spanCount = 2;
private String currentSortOrder;
private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums;
@@ -90,6 +92,10 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumCatalogueBinding.inflate(inflater, container, false);
View view = bind.getRoot();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
initAppBar();
initAlbumCatalogueView();
initProgressLoader();
@@ -133,8 +139,8 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility")
private void initAlbumCatalogueView() {
bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.albumCatalogueRecyclerView.setHasFixedSize(true);
albumAdapter = new AlbumCatalogueAdapter(this, true);

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
@@ -50,6 +51,7 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
private ArtistCatalogueViewModel artistCatalogueViewModel;
private ArtistCatalogueAdapter artistAdapter;
private int spanCount = 2;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -66,6 +68,10 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
bind = FragmentArtistCatalogueBinding.inflate(inflater, container, false);
View view = bind.getRoot();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
initAppBar();
initArtistCatalogueView();
@@ -108,8 +114,8 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility")
private void initArtistCatalogueView() {
bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.artistCatalogueRecyclerView.setHasFixedSize(true);
artistAdapter = new ArtistCatalogueAdapter(this);

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
@@ -63,6 +64,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private int spanCount = 2;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
@@ -72,6 +75,10 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
init(view);
initAppBar();
initArtistInfo();
@@ -277,8 +284,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
}
private void initAlbumsView() {
bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.albumsRecyclerView.setHasFixedSize(true);
albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false);
@@ -296,8 +303,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
}
private void initSimilarArtistsView() {
bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.similarArtistsRecyclerView.setHasFixedSize(true);
artistCatalogueAdapter = new ArtistCatalogueAdapter(this);

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
@@ -32,6 +33,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.GenreCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.GenreCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class)
@@ -41,6 +43,7 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
private GenreCatalogueViewModel genreCatalogueViewModel;
private GenreCatalogueAdapter genreCatalogueAdapter;
private int spanCount = 2;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -56,6 +59,10 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
View view = bind.getRoot();
genreCatalogueViewModel = new ViewModelProvider(requireActivity()).get(GenreCatalogueViewModel.class);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
init();
initAppBar();
initGenreCatalogueView();
@@ -97,8 +104,8 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility")
private void initGenreCatalogueView() {
bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 16, false));
bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 16, false));
bind.genreCatalogueRecyclerView.setHasFixedSize(true);
genreCatalogueAdapter = new GenreCatalogueAdapter(this);

View File

@@ -39,6 +39,7 @@ import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBindi
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.PlaybackSpeedDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog;
import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager;
@@ -522,13 +523,12 @@ public class PlayerControllerFragment extends Fragment {
private void initPlaybackSpeedButton(MediaBrowser mediaBrowser) {
playbackSpeedButton.setOnClickListener(view -> {
float currentSpeed = Preferences.getPlaybackSpeed();
currentSpeed += 0.25f;
if (currentSpeed > 2.0f) currentSpeed = 0.5f;
mediaBrowser.setPlaybackParameters(new PlaybackParameters(currentSpeed));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, currentSpeed));
Preferences.setPlaybackSpeed(currentSpeed);
PlaybackSpeedDialog dialog = new PlaybackSpeedDialog();
dialog.setPlaybackSpeedListener(speed -> {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(speed));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, speed));
});
dialog.show(requireActivity().getSupportFragmentManager(), null);
});
skipSilenceToggleButton.setOnClickListener(view -> {

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,14 +246,14 @@ 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();
if (lines != null) {
for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n");
lyricsBuilder.append(line.getValue().trim()).append("\n\n");
}
}
@@ -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;
for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n");
if (lines == null || lines.isEmpty()) {
return;
}
Line toHighlight = lines.stream().filter(line -> line != null && line.getStart() != null && line.getStart() < timestamp).reduce((first, second) -> second).orElse(null);
if (toHighlight != null) {
String lyrics = lyricsBuilder.toString();
Spannable spannableString = new SpannableString(lyrics);
int startingPosition = getStartPosition(lines, toHighlight);
int endingPosition = startingPosition + toHighlight.getValue().length();
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.setText(spannableString);
if (playerBottomSheetViewModel.getSyncLyricsState()) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(lines, toHighlight));
// 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;
}
}
}
}
private int getStartPosition(List<Line> lines, Line toHighlight) {
int start = 0;
// Only update if the highlighted line has changed
if (lastLineIdx != null && curIdx == lastLineIdx) {
return;
}
lastLineIdx = curIdx;
for (Line line : lines) {
if (line != toHighlight) {
start = start + line.getValue().length() + 1;
} else {
break;
StringBuilder lyricsBuilder = new StringBuilder();
for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n\n");
}
String lyrics = lyricsBuilder.toString();
Spannable spannableString = new SpannableString(lyrics);
// 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;
int len = lines.get(i).getValue().length() + 2;
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);
}
@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;
}
bind.nowPlayingSongLyricsTextView.setMovementMethod(LinkMovementMethod.getInstance());
bind.nowPlayingSongLyricsTextView.setText(spannableString);
// Scroll to the highlighted line, but only if there is one
if (highlightStart >= 0 && playerBottomSheetViewModel.getSyncLyricsState()) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(highlightStart));
}
}
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

@@ -9,6 +9,8 @@ import android.media.audiofx.AudioEffect;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.text.InputFilter;
import android.text.InputType;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -27,6 +29,7 @@ import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
@@ -141,6 +144,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
setStreamingCacheSize();
setAppLanguage();
setVersion();
setNetorkPingTimeoutBase();
actionLogout();
actionScan();
@@ -261,6 +265,30 @@ public class SettingsFragment extends PreferenceFragmentCompat {
}
}
private void setNetorkPingTimeoutBase() {
EditTextPreference networkPingTimeoutBase = findPreference("network_ping_timeout_base");
if (networkPingTimeoutBase != null) {
networkPingTimeoutBase.setSummaryProvider(EditTextPreference.SimpleSummaryProvider.getInstance());
networkPingTimeoutBase.setOnBindEditTextListener(editText -> {
editText.setInputType(InputType.TYPE_CLASS_NUMBER);
editText.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) -> {
for (int i = start; i < end; i++) {
if (!Character.isDigit(source.charAt(i))) {
return "";
}
}
return null;
}});
});
networkPingTimeoutBase.setOnPreferenceChangeListener((preference, newValue) -> {
String input = (String) newValue;
return input != null && !input.isEmpty();
});
}
}
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");

View File

@@ -1,5 +1,6 @@
package com.cappielloantonio.tempo.util;
import android.content.ContentResolver;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
@@ -15,6 +16,7 @@ import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
@@ -45,7 +47,7 @@ public class MappingUtil {
Uri artworkUri = null;
if (coverArtId != null) {
artworkUri = Uri.parse(CustomGlideRequest.createUrl(coverArtId, Preferences.getImageSize()));
artworkUri = AlbumArtContentProvider.contentUri(coverArtId);
}
Bundle bundle = new Bundle();
@@ -235,7 +237,7 @@ public class MappingUtil {
public static MediaItem mapMediaItem(PodcastEpisode podcastEpisode) {
Uri uri = getUri(podcastEpisode);
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(podcastEpisode.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = AlbumArtContentProvider.contentUri(podcastEpisode.getCoverArtId());
Bundle bundle = new Bundle();
bundle.putString("id", podcastEpisode.getId());

View File

@@ -29,6 +29,7 @@ object Preferences {
private const val REPEAT_MODE = "repeat_mode"
private const val IMAGE_CACHE_SIZE = "image_cache_size"
private const val STREAMING_CACHE_SIZE = "streaming_cache_size"
private const val LANDSCAPE_ITEMS_PER_ROW = "landscape_items_per_row"
private const val IMAGE_SIZE = "image_size"
private const val MAX_BITRATE_WIFI = "max_bitrate_wifi"
private const val MAX_BITRATE_MOBILE = "max_bitrate_mobile"
@@ -85,6 +86,8 @@ object Preferences {
private const val ARTIST_SORT_BY_ALBUM_COUNT= "artist_sort_by_album_count"
private const val SORT_SEARCH_CHRONOLOGICALLY= "sort_search_chronologically"
private const val ARTIST_DISPLAY_BIOGRAPHY= "artist_display_biography"
private const val NETWORK_PING_TIMEOUT = "network_ping_timeout_base"
@JvmStatic
fun getServer(): String? {
@@ -96,6 +99,19 @@ object Preferences {
App.getInstance().preferences.edit().putString(SERVER, server).apply()
}
@JvmStatic
fun getNetworkPingTimeout(): Int {
val timeoutString = App.getInstance().preferences.getString(NETWORK_PING_TIMEOUT, "2") ?: "2"
return (timeoutString.toIntOrNull() ?: 2).coerceAtLeast(1)
}
@JvmStatic
fun setNetworkPingTimeout(pingTimeout: String?) {
App.getInstance().preferences.edit().putString(NETWORK_PING_TIMEOUT, pingTimeout).apply()
}
@JvmStatic
fun getUser(): String? {
return App.getInstance().preferences.getString(USER, null)
@@ -289,6 +305,11 @@ object Preferences {
return App.getInstance().preferences.getString(IMAGE_CACHE_SIZE, "500")!!.toInt()
}
@JvmStatic
fun getLandscapeItemsPerRow(): Int {
return App.getInstance().preferences.getString(LANDSCAPE_ITEMS_PER_ROW, "4")!!.toInt()
}
@JvmStatic
fun getImageSize(): Int {
return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt()

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

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotY="12"
android:pivotX="12"
android:rotation="270">
<path
android:fillColor="#FF000000"
android:pathData="M8,18c0.55,0 1,-0.45 1,-1L9,7c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v10c0,0.55 0.45,1 1,1zM12,22c0.55,0 1,-0.45 1,-1L13,3c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v18c0,0.55 0.45,1 1,1zM4,14c0.55,0 1,-0.45 1,-1v-2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v2c0,0.55 0.45,1 1,1zM16,18c0.55,0 1,-0.45 1,-1L17,7c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v10c0,0.55 0.45,1 1,1zM19,11v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1z" />
</group>
</vector>

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotY="12"
android:pivotX="12"
android:rotation="270">
<path
android:fillColor="#FF000000"
android:pathData="M12,5.69l5,4.5V18h-2v-6H9v6H7v-7.81l5,-4.5M12,3L2,12h3v8h6v-6h2v6h6v-8h3L12,3z" />
</group>
</vector>

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotY="12"
android:pivotX="12"
android:rotation="270">
<path
android:fillColor="#FF000000"
android:pathData="M11,5v5.59L7.5,10.59l4.5,4.5 4.5,-4.5L13,10.59L13,5h-2zM6,14c0,3.31 2.69,6 6,6s6,-2.69 6,-6h-2c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4L6,14z"/>
</group>
</vector>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<FrameLayout
android:layout_width="75dp"
android:layout_height="match_parent">
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="250dp"
android:layout_height="75dp"
android:rotation="90"
android:layout_gravity="center"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:visibility="gone"
app:menu="@menu/bottom_nav_menu" />
</FrameLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
</LinearLayout>
<FrameLayout
android:id="@+id/player_bottom_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:behavior_hideable="true"
app:behavior_peekHeight="@dimen/bottom_sheet_peek_height"
app:layout_behavior="@string/bottom_sheet_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<TextView
android:id="@+id/offline_mode_text_view"
style="@style/NoConnectionTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/activity_info_offline_mode"
android:textSize="6sp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,305 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/anim_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
app:layout_collapseMode="pin"
app:navigationIcon="@drawable/ic_arrow_back" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/album_info_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:paddingStart="20dp"
app:layout_scrollFlags="exitUntilCollapsed">
<ImageView
android:id="@+id/album_cover_image_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginRight="64dp"
android:layout_marginBottom="8dp"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/album_name_label"
style="@style/LabelExtraLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:paddingTop="8dp"
android:singleLine="false"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="@+id/album_cover_image_view"
app:layout_constraintStart_toStartOf="@+id/album_cover_image_view"
app:layout_constraintTop_toBottomOf="@+id/album_cover_image_view" />
<FrameLayout
android:id="@+id/album_other_info_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/album_name_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_name_label"
app:layout_constraintTop_toTopOf="@+id/album_name_label">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@drawable/ic_arrow_down" />
</FrameLayout>
<TextView
android:id="@+id/album_artist_label"
style="@style/LabelMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_name_label" />
<TextView
android:id="@+id/album_release_year_label"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_artist_label" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/album_detail_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:paddingTop="12dp"
android:paddingBottom="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_release_year_label"
tools:visibility="visible">
<TextView
android:id="@+id/album_genres_textview"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<TextView
android:id="@+id/album_song_count_duration_textview"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:paddingVertical="2dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_genres_textview" />
<TextView
android:id="@+id/album_notes_textview"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:justificationMode="inter_word"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_song_count_duration_textview" />
<TextView
android:id="@+id/album_release_years_textview"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:paddingVertical="4dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_notes_textview" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:id="@+id/upper_button_divider"
style="@style/Divider"
android:layout_marginStart="18dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_detail_view" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingTop="4dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upper_button_divider">
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/album_page_play_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/album_page_play_button"
android:textAllCaps="false"
app:icon="@drawable/ic_play"
app:iconGravity="textStart"
app:iconPadding="18dp" />
<Button
android:id="@+id/album_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/album_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<ToggleButton
android:id="@+id/button_favorite"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="0dp"
android:background="@drawable/button_favorite_selector"
android:checked="false"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center"
android:text=""
android:textOff=""
android:textOn="" />
</LinearLayout>
<TextView
android:id="@+id/album_bio_label"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/album_page_button_layout"
tools:ignore="NotSibling" />
<View
android:id="@+id/bottom_button_divider"
style="@style/Divider"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:layout_marginBottom="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_bio_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/song_recycler_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:nestedScrollingEnabled="false"
android:paddingTop="0dp"
android:paddingBottom="75dp"
android:clipToPadding="false"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/anim_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
app:layout_collapseMode="pin"
app:navigationIcon="@drawable/ic_arrow_back" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:orientation="horizontal"
android:paddingBottom="@dimen/global_padding_bottom"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playlist_info_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
<ImageView
android:id="@+id/playlist_cover_image_view_top_left"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="64dp"
android:layout_marginTop="8dp"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toStartOf="@id/playlist_cover_image_view_top_right"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/playlist_cover_image_view_top_right"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="64dp"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/playlist_cover_image_view_top_left"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/playlist_cover_image_view_bottom_left"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="64dp"
android:layout_marginBottom="8dp"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toStartOf="@id/playlist_cover_image_view_bottom_right"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/playlist_cover_image_view_top_left" />
<ImageView
android:id="@+id/playlist_cover_image_view_bottom_right"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="64dp"
android:layout_marginBottom="8dp"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/playlist_cover_image_view_bottom_left"
app:layout_constraintTop_toTopOf="@id/playlist_cover_image_view_bottom_left" />
<TextView
android:id="@+id/playlist_name_label"
style="@style/LabelExtraLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:ellipsize="end"
android:maxLines="2"
android:paddingTop="8dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playlist_cover_image_view_bottom_left" />
<TextView
android:id="@+id/playlist_song_count_label"
style="@style/LabelMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playlist_name_label" />
<TextView
android:id="@+id/playlist_duration_label"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playlist_song_count_label" />
<View
android:id="@+id/upper_button_divider"
style="@style/Divider"
android:layout_marginStart="18dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playlist_duration_label" />
<LinearLayout
android:id="@+id/playlist_page_button_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upper_button_divider">
<Button
android:id="@+id/playlist_page_play_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/playlist_page_play_button"
android:textAllCaps="false"
app:icon="@drawable/ic_play"
app:iconGravity="textStart"
app:iconPadding="18dp" />
<Button
android:id="@+id/playlist_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/playlist_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<TextView
android:id="@+id/album_bio_label"
style="@style/LabelSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:text="@string/label_placeholder"
android:textAlignment="center"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playlist_page_button_layout" />
<View
android:id="@+id/bottom_button_divider"
style="@style/Divider"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:layout_marginBottom="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playlist_page_button_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/song_recycler_view"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingTop="8dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@@ -46,6 +46,8 @@
style="@style/BodyLarge"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:lineSpacingExtra="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/homeFragment"
android:icon="@drawable/ic_home_land"
android:title="@string/menu_home_label" />
<item
android:id="@+id/libraryFragment"
android:icon="@drawable/ic_graphic_eq_land"
android:title="@string/menu_library_label" />
<item
android:id="@+id/downloadFragment"
android:icon="@drawable/ic_play_for_work_land"
android:title="@string/menu_download_label" />
</menu>

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

@@ -264,4 +264,18 @@
<item>3</item>
<item>4</item>
</string-array>
<string-array name="landscape_items_per_row">
<item>3"</item>
<item>4</item>
<item>5</item>
<item>6</item>
<item>7</item>
</string-array>
<string-array name="landscape_items_per_row_values">
<item>3</item>
<item>4</item>
<item>5</item>
<item>6</item>
<item>7</item>
</string-array>
</resources>

View File

@@ -217,6 +217,8 @@
<string name="menu_unpin_button">Remove from home screen</string>
<string name="menu_sort_year">Year</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="playback_speed_dialog_title">Playback Speed</string>
<string name="playback_speed_dialog_negative_button">Cancel</string>
<string name="player_queue_clean_all_button">Clean play queue</string>
<string name="player_queue_save_queue_success">Saved play queue</string>
<string name="player_queue_save_to_playlist">Save Queue to Playlist</string>
@@ -238,6 +240,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 +347,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>
@@ -353,6 +357,9 @@
<string name="settings_image_size">Set image resolution</string>
<string name="settings_language">Language</string>
<string name="settings_logout_title">Log out</string>
<string name="settings_ping_timeout_title">Server Ping Timeout</string>
<string name="settings_ping_timeout_summary">Set Local URL timeout. Default 2 seconds. (Remote server will use this value x3 up to 10 seconds max.)</string>
<string name="settings_ping_timeout_dialog">Set base timeout in seconds.</string>
<string name="settings_max_bitrate_download">Bitrate for downloads</string>
<string name="settings_max_bitrate_mobile">Bitrate in mobile</string>
<string name="settings_max_bitrate_wifi">Bitrate in Wi-Fi</string>
@@ -398,6 +405,7 @@
<string name="settings_summary_transcoding">Priority given to the transcoding mode. If set to \"Direct play\" the bitrate of the file will not be changed.</string>
<string name="settings_summary_transcoding_download">Download transcoded media. If enabled, the download endpoint will not be used, but the following settings. \n\n If \"Transcode format for downloads\" is set to \"Direct download\" the bitrate of the file will not be changed.</string>
<string name="settings_summary_transcoding_estimate_content_length">When the file is transcoded on the fly, the client usually does not show the track length. It is possible to request the servers that support the functionality to estimate the duration of the track being played, but the response times may take longer.</string>
<string name="settings_summary_landscape_items_per_row">Applies to all album and artist listings. Defaults to 4</string>
<string name="settings_sync_starred_artists_for_offline_use_summary">If enabled, starred artists will be downloaded for offline use.</string>
<string name="settings_sync_starred_artists_for_offline_use_title">Sync starred artists for offline use</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">If enabled, starred albums will be downloaded for offline use.</string>
@@ -418,6 +426,8 @@
<string name="settings_title_transcoding">Transcoding</string>
<string name="settings_title_transcoding_download">Transcoding Download</string>
<string name="settings_title_ui">UI</string>
<string name="settings_title_ui_landscape_items_per_row">Items per row on landscape</string>
<string name="settings_title_ui_landscape_items_per_row_dialog">Number of items per row</string>
<string name="settings_transcoded_download">Transcoded download</string>
<string name="settings_version_summary" translatable="false">3.1.0</string>
<string name="settings_version_title">Version</string>
@@ -484,7 +494,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>
@@ -512,7 +522,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

@@ -17,6 +17,15 @@
android:key="scan_library"
android:title="@string/settings_scan_title" />
<EditTextPreference
android:key="network_ping_timeout_base"
android:title="@string/settings_ping_timeout_title"
app:summary="@string/settings_ping_timeout_summary"
android:dialogTitle="@string/settings_ping_timeout_dialog"
android:inputType="number"
android:singleLine="true"
android:defaultValue="2" />
<Preference
android:key="logout"
android:title="@string/settings_logout_title"/>
@@ -128,6 +137,15 @@
android:summary="@string/search_sort_summary"
android:key="sort_search_chronologically" />
<ListPreference
app:defaultValue="4"
app:dialogTitle="@string/settings_title_ui_landscape_items_per_row_dialog"
app:entries="@array/landscape_items_per_row"
app:entryValues="@array/landscape_items_per_row_values"
app:key="landscape_items_per_row"
android:summary="@string/settings_summary_landscape_items_per_row"
app:title="@string/settings_title_ui_landscape_items_per_row" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_title_playlist">

View File

@@ -0,0 +1,2 @@
fix: Avoid crash when server has no songs
fix: updated dialog import to address crashing on android 15

View File

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

View File

@@ -0,0 +1,4 @@
fix: Addressing some UI/UX quirks
fix: keep observer until data is received on continuousPlay bug
fix: album art now displays on android auto
feat: improve landscape view and increase items per row on landscape view