29 Commits

Author SHA1 Message Date
eddyizm
3086a8b9f9 chore: bumped version for build fix 2026-03-01 20:20:08 -08:00
eddyizm
10c2172be0 fix: updated constraints causing fata lint build failures (#478) 2026-03-01 20:19:05 -08:00
eddyizm
918bf6928e chore: bumped version and change log for release 2026-03-01 19:59:28 -08:00
Tom
c9cf86acb5 feat: toggle player bitrate visibility on touch (#466)
* feat: touch player chip to toggle bitrate visibility

* feat: player bitrate visibility is remembered

* fix: player landscape layout not grouping chip with textview

* feat: touch bitrate to toggle its visibility

This catches the edge case where the the chip is not reachable due to insuficient horizontal space

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-01 19:48:15 -08:00
eddyizm
0487f3bb9b fix: returns filtered list and reset correctly (#476) 2026-03-01 19:36:48 -08:00
Tom
c7f2524085 feat: feat: advertise existing long press to refresh per section (#467)
* feat: advertise existing long press to refresh per section

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-01 19:36:03 -08:00
eddyizm
88c2129cd4 chore: bumping version for release 2026-02-28 09:07:59 -08:00
Angelo Suzuki
aa5d0f92db Support specifying a client certificate for mTLS auth (#458)
* feat: collect and save client certificate

* feat: use client certificate for Retrofit, Glide and ExoPlayer

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-26 21:20:01 -08:00
MaFo-28
3ba2255205 Android Auto: improve media service browsing (#437)
* Add Android Auto icons and improve media service browsing

* chore: changelog and build updated for release

* add grid/list setting for playlist, podcast and radio

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-26 21:09:49 -08:00
Tom
145bb82eb0 feat: enhance navigation (#450)
* feat: enhance navigation

* fix: leaving settings always unlocks drawer

* feat: set app settings inside a frame layout

In order to add a toolbar with a back button in settings I needed to extend from a fragment
so I converted SettingsFragment into a fragment and created SettingsContainerFragment,
the latter is injected as a child of SettingsFragment inside a FrameLayout.

Since SettingsContainerFragment extends from PreferenceFragmentCompat, this allows
to swap it for other and, in the bigger picture, allow an arbitrary organization.

* fix: onStop declaration on wrong class

* fix: equalizer not respecting navigation ui directives

* Revert "fix: equalizer not respecting navigation ui directives"

This reverts commit eeb125542d.

* fix: navbar + bottom sheet behavior on equalizer fragment

* Revert "fix: onStop declaration on wrong class"

This reverts commit 34d354d803.

* Revert "feat: set app settings inside a frame layout"

This reverts commit 52cfd36b09.

* chore: set experimental label to settings title

Hide bottom navigation bar on portrait and unlock drawer on portrait
2026-02-26 07:14:42 -08:00
Tom
932d1aaa8c fix: artist sort by name case sensitive (#462) 2026-02-25 17:40:50 -08:00
Tom
4f8212d491 Port remove song of playlist from tempus ng (#457)
* feat: implement track removal from playlists with real-time UI updates

- Added 'Remove from playlist' option to song bottom sheet (appears only when inside a playlist)
- Implemented immediate UI refresh for track count and duration in playlist header
- Fixed a bug where shuffling for covers scrambled the actual playlist song order
- Improved PlaylistPageViewModel to clear stale data and handle isolated updates correctly
- Added dedicated success/failure messages for track removal in English and Italian
- Unified heart icon size to 14dp across all track list items

* fix: missing code from port process

The cherry-pick was missing the database getter
and the function to remove a song from a playlist

---------

Co-authored-by: beeetfarmer <176325048+beeetfarmer@users.noreply.github.com>
2026-02-25 11:37:43 -08:00
Denis Machard
b403d69982 feat: radio logos support for AndroidAuto (#435)
* feat: radio logos support for AndroidAuto

* resolve a merge conflict.

* fix auto lint

* fix auto lint

* fix auto break line

* fix auto break line

* fix auto break line

* fix: add alternate serialized name for InternetRadioStation homePageUrl to support both `homePageUrl` and `homepageUrl` JSON keys.

* improve internet radio station cover art handling by prioritizing home page URLs

* fix: remove unnecessary blank line and adjust formatting in MusicUtil

* refactor: improve formatting and clean up whitespace in MappingUtil and MusicUtil
2026-02-22 08:08:01 -08:00
eddyizm
a49f2b97a2 Merge branch 'main' into development 2026-02-21 22:12:56 -08:00
skajmer
c44e60c0e5 chore(i18n): Update Polish translation (#441)
* Add #338

* Add #3700 (strings.xml)

* Add #370 (arrays.xml)

* Add #386

* Add #394

* Add #411 and #413

* Add #411 (arrays)

* misspelling
2026-02-16 09:45:57 -08:00
eddyizm
4cd15b4284 chore: changelog and build updated for release 2026-02-15 10:35:22 -08:00
eddyizm
72d7aea6e3 fix: release build errors 2026-02-15 10:30:01 -08:00
Tom
9adaf8c013 feat: improve playlist chooser dialog UI (#439)
* fix: lock buttons at dialog bottom

The previous implementation appended the buttons to the RecyclerView programmatically
this disabled the scroll and pushed the buttons outside the visible dialog area
if too there were too many playlists.

To fix this now the XML defines a fixed location for the buttons, enabling
the scroll of the RecyclerView and preventing the buttons to become unreachable

* feat: improve playlist chooser dialog UI

Implement it in the XML layout and not programmatically.

* fix: detached listeners from XML layout

* fix: missing dialog title
2026-02-15 09:42:07 -08:00
TrackArcher
661346ca3a feat: radio metadata (#352)
* feat: support dynamic metadata for internet radio stations

- Implemented `onMetadata` in `BaseMediaService` to extract "Artist - Title" info from ICY, ID3, and Vorbis streams.
- Added a fallback mechanism to periodically check HTTP headers (e.g., `icy-name`, `StreamTitle`) for radio metadata.
- Updated `PlayerControllerFragment` and `TrackInfoDialog` to display the station name alongside dynamic track information.
- Enhanced `TrackInfoDialog` layout to include a dedicated "Station" field for radio tracks.
- Modified `MappingUtil` to preserve station names in media metadata extras.

* fix crashing issue

* radio bob metadata works now. fix crashing issue

* Fixing unchecked operation warnings in SongHorizontalAdapter.java.

* optimizing a bit and better format for notification

* removed xml files affecting build and enviroment

* removed xml files affecting build and enviroment

* fix ui internet radio bottomview

* Revert "fix ui internet radio bottomview"

This reverts commit c237ed451f.

* rebased to upstream/development and fixed metadata to show up for radio after the rebase

* misc.xml restored

* Apply suggestion from @eddyizm

---------

Co-authored-by: eddyizm <wtfisup@hotmail.com>
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-15 08:03:00 -08:00
eddyizm
dbd32baa12 feat: prefer locally downloaded media vs server stream (#433)
resolves #404 and should address #285
2026-02-11 21:31:46 -08:00
Tom
3958cbcc1c fix: local url used in share link instead of server url (#431)
fix: use explicitly Server Public URl in link sharing
2026-02-09 20:02:15 -08:00
Tom
fb568d1d74 fix: speed button overlaps with shuffle on landscape (#430)
fix: buttons overlap on landscape player
2026-02-09 20:01:02 -08:00
Denis Machard
e06a168350 fix: radio playback "source error" on android auto (#426) 2026-02-09 20:00:33 -08:00
Tom
b8dc985279 fix: visual glitches on landscape navbar (#429) 2026-02-09 20:00:03 -08:00
Jaime García
090701b92b chore(i18n): Update Spanish translation (#427) 2026-02-09 19:59:39 -08:00
Jaime García
7767a66fb8 fix: Use Bluetooth tethering connection (#428) 2026-02-09 19:59:20 -08:00
eddyizm
d1122bef4e fix: updated album art provider from hardcoded to build config id 2026-02-09 17:49:30 -08:00
eddyizm
72d4495582 fix: added dynamic application id from gradle variant (#425) 2026-02-08 21:23:35 -08:00
eddyizm
499644d041 fix: bungled the last release 2026-02-08 16:34:14 -08:00
103 changed files with 3890 additions and 600 deletions

View File

@@ -1,8 +1,50 @@
# Changelog
## Pending release
## What's Changed
## [4.12.4](https://github.com/eddyizm/tempo/releases/tag/v4.12.4) (2026-03-01)
* feat: advertise existing long press to refresh per section on library page by @tvillega in https://github.com/eddyizm/tempus/pull/467
* fix: playlist filter returns properly filtered list and reset correctly by @eddyizm in https://github.com/eddyizm/tempus/pull/476
* feat: toggle player bitrate visibility on touch by @tvillega in https://github.com/eddyizm/tempus/pull/466
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.0...v4.12.3
## What's Changed
## [4.12.0](https://github.com/eddyizm/tempo/releases/tag/v4.12.0) (2026-02-28)
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/441
* feat: radio logos support for AndroidAuto by @dmachard in https://github.com/eddyizm/tempus/pull/435
* feat: Port remove song of playlist from tempus ng by @tvillega in https://github.com/eddyizm/tempus/pull/457
* fix: artist sort by name case sensitive by @tvillega in https://github.com/eddyizm/tempus/pull/462
* feat: added slide out enhanced navigation for tab mode and optionally portrait mode by @tvillega in https://github.com/eddyizm/tempus/pull/450
* feat: Android Auto: improve media service browsing by @MaFo-28 in https://github.com/eddyizm/tempus/pull/437
* feat: Support specifying a client certificate for mTLS auth by @tinsukE in https://github.com/eddyizm/tempus/pull/458
## New Contributors
* @MaFo-28 made their first contribution in https://github.com/eddyizm/tempus/pull/437
* @tinsukE made their first contribution in https://github.com/eddyizm/tempus/pull/458
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.11.0...v4.12.0
## What's Changed
## [4.11.0](https://github.com/eddyizm/tempo/releases/tag/v4.11.0) (2026-02-15)
* fix: added dynamic application id from gradle variant by @eddyizm in https://github.com/eddyizm/tempus/pull/425
* fix: Use Bluetooth tethering connection by @jaime-grj in https://github.com/eddyizm/tempus/pull/428
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/427
* fix: visual glitches on landscape navbar by @tvillega in https://github.com/eddyizm/tempus/pull/429
* fix: radio playback "source error" on android auto by @dmachard in https://github.com/eddyizm/tempus/pull/426
* fix: speed button overlaps with shuffle on landscape by @tvillega in https://github.com/eddyizm/tempus/pull/430
* fix: local url used in share link instead of server url by @tvillega in https://github.com/eddyizm/tempus/pull/431
* Feat :prefer downloaded files by @eddyizm in https://github.com/eddyizm/tempus/pull/433
* fix: radio metadata displayed by @TrackArcher in https://github.com/eddyizm/tempus/pull/352
* feat: improve playlist chooser dialog UI by @tvillega in https://github.com/eddyizm/tempus/pull/439
## New Contributors
* @dmachard made their first contribution in https://github.com/eddyizm/tempus/pull/426
* @TrackArcher made their first contribution in https://github.com/eddyizm/tempus/pull/352
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.10.1...v4.11.0
## What's Changed
## [4.10.1](https://github.com/eddyizm/tempo/releases/tag/v4.10.1) (2026-02-08)
* 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
@@ -12,7 +54,7 @@
* @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
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.8...v4.10.1
## What's Changed
## [4.9.8](https://github.com/eddyizm/tempo/releases/tag/v4.9.8) (2026-02-02)

View File

@@ -10,8 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 18
versionName '4.10.0'
versionCode 22
versionName '4.12.4'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {
@@ -101,6 +101,7 @@ dependencies {
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0"
// Android Material
implementation 'com.google.android.material:material:1.10.0'

File diff suppressed because it is too large Load Diff

View File

@@ -98,7 +98,7 @@
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="com.cappielloantonio.tempo.provider"
android:authorities="${applicationId}.albumart.provider"
android:enabled="true"
android:exported="true"
/>

View File

@@ -11,6 +11,7 @@ import com.cappielloantonio.tempo.github.Github;
import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.SubsonicPreferences;
import com.cappielloantonio.tempo.util.ClientCertManager;
import com.cappielloantonio.tempo.util.Preferences;
public class App extends Application {
@@ -31,6 +32,8 @@ public class App extends Application {
instance = new App();
context = getApplicationContext();
preferences = PreferenceManager.getDefaultSharedPreferences(context);
ClientCertManager.setupSslSocketFactory(context);
}
public static App getInstance() {
@@ -55,6 +58,48 @@ public class App extends Application {
}
return subsonic;
}
public static Subsonic getSubsonicPublicClientInstance(boolean override) {
/*
If I do the shortcut that the IDE suggests:
SubsonicPreferences preferences = getSubsonicPreferences1();
During the chain of calls it will run the following:
String server = Preferences.getInUseServerAddress();
Which could return Local URL, causing issues like generating public shares with Local URL
To prevent this I just replicated the entire chain of functions here,
if you need a call to Subsonic using the Server (Public) URL use this function.
*/
String server = Preferences.getServer();
String username = Preferences.getUser();
String password = Preferences.getPassword();
String token = Preferences.getToken();
String salt = Preferences.getSalt();
boolean isLowSecurity = Preferences.isLowScurity();
SubsonicPreferences preferences = new SubsonicPreferences();
preferences.setServerUrl(server);
preferences.setUsername(username);
preferences.setAuthentication(password, token, salt, isLowSecurity);
if (subsonic == null || override) {
if (preferences.getAuthentication() != null) {
if (preferences.getAuthentication().getPassword() != null)
Preferences.setPassword(preferences.getAuthentication().getPassword());
if (preferences.getAuthentication().getToken() != null)
Preferences.setToken(preferences.getAuthentication().getToken());
if (preferences.getAuthentication().getSalt() != null)
Preferences.setSalt(preferences.getAuthentication().getSalt());
}
}
return new Subsonic(preferences);
}
public static Github getGithubClientInstance() {
if (github == null) {

View File

@@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
version = 13,
version = 14,
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
autoMigrations = {
@AutoMigration(from = 10, to = 11),
@AutoMigration(from = 11, to = 12),
@AutoMigration(from = 13, to = 14),
}
)
@TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase {

View File

@@ -19,6 +19,9 @@ public interface PlaylistDao {
@Query("SELECT * FROM playlist")
LiveData<List<Playlist>> getAll();
@Query("SELECT * FROM playlist")
List<Playlist> getAllSync();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(Playlist playlist);

View File

@@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.model
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.annotation.Nullable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -35,5 +34,8 @@ data class Server(
val timestamp: Long,
@ColumnInfo(name = "low_security", defaultValue = "false")
val isLowSecurity: Boolean
val isLowSecurity: Boolean,
@ColumnInfo(name = "client_cert")
val clientCert: String?,
) : Parcelable

View File

@@ -195,11 +195,20 @@ class SessionMediaItem() {
title = internetRadioStation.name
streamUrl = internetRadioStation.streamUrl
type = Constants.MEDIA_TYPE_RADIO
val homePageUrl = internetRadioStation.homePageUrl
if (homePageUrl != null && homePageUrl.isNotEmpty() && MusicUtil.isImageUrl(homePageUrl)) {
val encodedUrl = android.util.Base64.encodeToString(
homePageUrl.toByteArray(java.nio.charset.StandardCharsets.UTF_8),
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
)
coverArtId = "ir_$encodedUrl"
}
}
fun getMediaItem(): MediaItem {
val uri: Uri = getStreamUri()
val artworkUri = AlbumArtContentProvider.contentUri(coverArtId)
val artworkUri = if (coverArtId != null) AlbumArtContentProvider.contentUri(coverArtId!!) else null
val bundle = Bundle()
bundle.putString("id", id)
@@ -229,7 +238,7 @@ class SessionMediaItem() {
bundle.putLong("starred", starred?.time ?: 0)
bundle.putString("albumId", albumId)
bundle.putString("artistId", artistId)
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC)
bundle.putString("type", type)
bundle.putLong("bookmarkPosition", bookmarkPosition ?: 0)
bundle.putInt("originalWidth", originalWidth ?: 0)
bundle.putInt("originalHeight", originalHeight ?: 0)

View File

@@ -8,12 +8,14 @@ import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.util.Preferences;
@@ -28,7 +30,7 @@ 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 AUTHORITY = BuildConfig.APPLICATION_ID + ".albumart.provider";
public static final String ALBUM_ART = "albumArt";
private ExecutorService executor;
@@ -52,7 +54,15 @@ public class AlbumArtContentProvider extends ContentProvider {
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()));
Uri artworkUri;
if (albumId != null && albumId.startsWith("ir_")) {
String encodedUrl = albumId.substring("ir_".length());
String decodedUrl = new String(Base64.decode(encodedUrl, Base64.URL_SAFE | Base64.NO_WRAP));
artworkUri = Uri.parse(decodedUrl);
} else {
artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize()));
}
try {
// use pipe to communicate between background thread and caller of openFile()

View File

@@ -1,6 +1,5 @@
package com.cappielloantonio.tempo.repository;
import android.content.ContentResolver;
import android.net.Uri;
import android.view.View;
@@ -69,6 +68,16 @@ public class AutomotiveRepository {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
List<AlbumID3> albums = response.body().getSubsonicResponse().getAlbumList2().getAlbums();
// add by MFO
// Hack for artist view
if("alphabeticalByArtist".equals(type))for(AlbumID3 album : albums){
String artistName = album.getArtist();
String albumName = album.getName();
album.setName(artistName);
album.setArtist(albumName);
}
// end add by MFO
List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) {
@@ -606,20 +615,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (InternetRadioStation radioStation : radioStations) {
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(radioStation.getName())
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
.build();
MediaItem mediaItem = new MediaItem.Builder()
.setMediaId(radioStation.getId())
.setMediaMetadata(mediaMetadata)
.setUri(radioStation.getStreamUrl())
.build();
mediaItems.add(mediaItem);
mediaItems.add(MappingUtil.mapInternetRadioStation(radioStation));
}
setInternetRadioStationsMetadata(radioStations);

View File

@@ -3,8 +3,11 @@ package com.cappielloantonio.tempo.repository;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R;
@@ -23,8 +26,45 @@ import retrofit2.Callback;
import retrofit2.Response;
public class PlaylistRepository {
private static final MutableLiveData<Boolean> playlistUpdateTrigger = new MutableLiveData<>();
public LiveData<Boolean> getPlaylistUpdateTrigger() {
return playlistUpdateTrigger;
}
public void notifyPlaylistChanged() {
playlistUpdateTrigger.postValue(true);
refreshAllPlaylists();
}
@androidx.media3.common.util.UnstableApi
private final PlaylistDao playlistDao = AppDatabase.getInstance().playlistDao();
private static final MutableLiveData<List<Playlist>> allPlaylistsLiveData = new MutableLiveData<>();
public LiveData<List<Playlist>> getAllPlaylists(LifecycleOwner owner) {
refreshAllPlaylists();
return allPlaylistsLiveData;
}
public void refreshAllPlaylists() {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylists()
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) {
List<Playlist> playlists = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
allPlaylistsLiveData.postValue(playlists);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
public MutableLiveData<List<Playlist>> getPlaylists(boolean random, int size) {
MutableLiveData<List<Playlist>> listLivePlaylists = new MutableLiveData<>(new ArrayList<>());
@@ -104,9 +144,16 @@ public class PlaylistRepository {
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
public interface AddToPlaylistCallback {
void onSuccess();
void onFailure();
void onAllSkipped();
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic, AddToPlaylistCallback callback) {
android.util.Log.d("PlaylistRepository", "addSongToPlaylist: id=" + playlistId + ", songs=" + songsId);
if (songsId.isEmpty()) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
if (callback != null) callback.onAllSkipped();
} else{
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@@ -114,17 +161,45 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
if (response.isSuccessful()) notifyPlaylistChanged();
if (callback != null) callback.onSuccess();
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
if (callback != null) callback.onFailure();
}
});
}
}
public void removeSongFromPlaylist(String playlistId, int index, AddToPlaylistCallback callback) {
ArrayList<Integer> indexes = new ArrayList<>();
indexes.add(index);
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, null, indexes)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
if (callback != null) {
if (response.isSuccessful()) callback.onSuccess();
else callback.onFailure();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
if (callback != null) callback.onFailure();
}
});
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null);
}
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@@ -132,7 +207,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
}
@Override
@@ -145,20 +220,45 @@ public class PlaylistRepository {
public void updatePlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.deletePlaylist(playlistId)
.updatePlaylist(playlistId, name, true, null, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
createPlaylist(null, name, songsId);
if (response.isSuccessful()) {
// After renaming, we need to handle the song list update.
// Subsonic doesn't have a "replace all songs" in updatePlaylist.
// So we might still need to recreate if the songs changed significantly,
// but if we just renamed, we should update the local pinned database.
updateLocalPinnedPlaylistName(playlistId, name);
notifyPlaylistChanged();
}
// If songsId is provided, we might want to re-sync them.
// For now, let's at least fix the name duplication issue.
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
@OptIn(markerClass = UnstableApi.class)
private void updateLocalPinnedPlaylistName(String id, String newName) {
new Thread(() -> {
List<Playlist> pinned = playlistDao.getAllSync();
if (pinned != null) {
for (Playlist p : pinned) {
if (p.getId().equals(id)) {
p.setName(newName);
playlistDao.insert(p); // Replace strategy will update it
break;
}
}
}
}).start();
}
public void deletePlaylist(String playlistId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@@ -166,7 +266,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
}
@Override
@@ -194,6 +294,49 @@ public class PlaylistRepository {
thread.start();
}
@androidx.media3.common.util.UnstableApi
public void updatePinnedPlaylists() {
updatePinnedPlaylists(null);
}
@androidx.media3.common.util.UnstableApi
public void updatePinnedPlaylists(List<String> forceIds) {
new Thread(() -> {
List<Playlist> pinned = playlistDao.getAllSync();
if (pinned != null && !pinned.isEmpty()) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylists()
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) {
List<Playlist> remotes = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
new Thread(() -> {
for (Playlist p : pinned) {
for (Playlist r : remotes) {
if (p.getId().equals(r.getId())) {
p.setName(r.getName());
p.setSongCount(r.getSongCount());
p.setDuration(r.getDuration());
p.setCoverArtId(r.getCoverArtId());
playlistDao.insert(p);
break;
}
}
}
}).start();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
}).start();
}
private static class InsertThreadSafe implements Runnable {
private final PlaylistDao playlistDao;
private final Playlist playlist;

View File

@@ -41,7 +41,7 @@ public class SharingRepository {
public MutableLiveData<Share> createShare(String id, String description, Long expires) {
MutableLiveData<Share> share = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
App.getSubsonicPublicClientInstance(false)
.getSharingClient()
.createShare(id, description, expires)
.enqueue(new Callback<ApiResponse>() {
@@ -64,7 +64,7 @@ public class SharingRepository {
}
public void updateShare(String id, String description, Long expires) {
App.getSubsonicClientInstance(false)
App.getSubsonicPublicClientInstance(false)
.getSharingClient()
.updateShare(id, description, expires)
.enqueue(new Callback<ApiResponse>() {

View File

@@ -24,6 +24,9 @@ import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo
import androidx.media3.extractor.metadata.icy.IcyInfo
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.extractor.metadata.vorbis.VorbisComment
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
@@ -32,6 +35,12 @@ import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
private const val TAG = "BaseMediaService"
@@ -70,6 +79,13 @@ open class BaseMediaService : MediaLibraryService() {
}
}
private val radioHeaderCheckExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private var radioHeaderCheckScheduled = false
private var radioHeaderCheckFuture: ScheduledFuture<*>? = null
private val radioHeaderCheckRunnable = Runnable {
checkRadioHttpHeaders()
}
private val binder = LocalBinder()
open fun playerInitHook() {
@@ -120,6 +136,9 @@ open class BaseMediaService : MediaLibraryService() {
updateWidget(player)
}
private var lastRadioArtist: String? = null
private var lastRadioTitle: String? = null
fun initializePlayerListener(player: Player) {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@@ -129,6 +148,16 @@ open class BaseMediaService : MediaLibraryService() {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
// Restart header checks for radio streams when media item changes
val mediaType = mediaItem.mediaMetadata.extras?.getString("type")
if (mediaType == Constants.MEDIA_TYPE_RADIO && player.isPlaying) {
stopRadioHeaderChecks()
scheduleRadioHeaderChecks()
} else if (mediaType != Constants.MEDIA_TYPE_RADIO) {
stopRadioHeaderChecks()
}
updateWidget(player)
}
@@ -170,6 +199,96 @@ open class BaseMediaService : MediaLibraryService() {
}
}
override fun onMetadata(metadata: Metadata) {
// Handle streaming metadata (ICY, ID3) for radio / streaming content
val currentItem = player.currentMediaItem ?: return
val extras = currentItem.mediaMetadata.extras
if (extras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return
var artist: String? = null
var title: String? = null
// Extract metadata from ICY/ID3/Vorbis
for (i in 0 until metadata.length()) {
when (val entry = metadata[i]) {
is IcyInfo -> {
entry.title?.let { icyTitle ->
val parts = icyTitle.split(" - ", limit = 2)
if (parts.size == 2) {
artist = parts[0].trim().ifEmpty { null }
title = parts[1].trim().ifEmpty { null }
} else {
title = icyTitle.trim().ifEmpty { null }
}
}
}
is TextInformationFrame -> {
@Suppress("DEPRECATION")
val value = entry.value
when (entry.id) {
"TPE1" -> if (!value.isNullOrBlank()) artist = value
"TIT2" -> if (!value.isNullOrBlank()) title = value
}
}
is VorbisComment -> {
@Suppress("DEPRECATION")
val value = entry.value
when (entry.key) {
"ARTIST" -> if (!value.isNullOrBlank()) artist = value
"TITLE" -> if (!value.isNullOrBlank()) title = value
}
}
}
}
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
lastRadioArtist = artist
lastRadioTitle = title
// Stop HTTP header checks since we have embedded metadata
stopRadioHeaderChecks()
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return
val metadataBuilder = currentItem.mediaMetadata.buildUpon()
val newExtras = Bundle(extras ?: Bundle())
// Store individual values in extras for UI
artist?.let { newExtras.putString("radioArtist", it) }
title?.let { newExtras.putString("radioTitle", it) }
// Get station name (preserve if already set)
val stationName = extras?.getString("stationName")
?: currentItem.mediaMetadata.title?.toString()
?: ""
if (stationName.isNotBlank()) {
newExtras.putString("stationName", stationName)
}
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
val formattedTitle = when {
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
!title.isNullOrBlank() -> title
!artist.isNullOrBlank() -> artist
else -> stationName
}
metadataBuilder.setTitle(formattedTitle)
if (stationName.isNotBlank()) {
metadataBuilder.setArtist(stationName)
}
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, currentItem.buildUpon()
.setMediaMetadata(metadataBuilder.setExtras(newExtras).build())
.build())
updateWidget(exo)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex)
if (!isPlaying) {
@@ -182,8 +301,10 @@ open class BaseMediaService : MediaLibraryService() {
}
if (isPlaying) {
scheduleWidgetUpdates()
scheduleRadioHeaderChecks()
} else {
stopWidgetUpdates()
stopRadioHeaderChecks()
}
updateWidget(player)
}
@@ -287,6 +408,8 @@ open class BaseMediaService : MediaLibraryService() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
stopRadioHeaderChecks()
radioHeaderCheckExecutor.shutdown()
releasePlayers()
mediaLibrarySession.release()
super.onDestroy()
@@ -405,6 +528,148 @@ open class BaseMediaService : MediaLibraryService() {
widgetUpdateScheduled = false
}
private fun scheduleRadioHeaderChecks() {
val player = mediaLibrarySession.player
val currentItem = player.currentMediaItem ?: return
val mediaType = currentItem.mediaMetadata.extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
if (radioHeaderCheckScheduled) return
// Check immediately, then periodically
checkRadioHttpHeaders()
radioHeaderCheckFuture = radioHeaderCheckExecutor.scheduleWithFixedDelay(
radioHeaderCheckRunnable,
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
TimeUnit.SECONDS
)
radioHeaderCheckScheduled = true
}
private fun stopRadioHeaderChecks() {
if (!radioHeaderCheckScheduled) return
radioHeaderCheckFuture?.cancel(false)
radioHeaderCheckFuture = null
radioHeaderCheckScheduled = false
}
private fun checkRadioHttpHeaders() {
val player = mediaLibrarySession.player
val currentItem = player.currentMediaItem ?: return
val extras = currentItem.mediaMetadata.extras
val mediaType = extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
// Skip if we already have embedded metadata (ICY/ID3) - HTTP headers are only fallback
val hasEmbeddedMetadata = !currentItem.mediaMetadata.artist.isNullOrBlank() ||
!currentItem.mediaMetadata.title.isNullOrBlank() ||
(extras != null && !extras.getString("radioArtist").isNullOrBlank()) ||
(extras != null && !extras.getString("radioTitle").isNullOrBlank())
if (hasEmbeddedMetadata) return
val streamUrl = extras?.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString()
if (streamUrl.isNullOrBlank()) return
try {
val url = URL(streamUrl)
val connection = url.openConnection() as? HttpURLConnection ?: return
// Only try HEAD request (lightweight) - skip GET fallback as it's unreliable
connection.requestMethod = "HEAD"
connection.setRequestProperty("Icy-MetaData", "1")
connection.setRequestProperty("User-Agent", "Tempus/1.0")
connection.connectTimeout = 3000 // Reduced timeout
connection.readTimeout = 3000
connection.connect()
if (connection.responseCode >= 400) {
connection.disconnect()
return
}
// Check for metadata in HTTP headers
val streamTitle = connection.getHeaderField("icy-name")
?: connection.getHeaderField("StreamTitle")
?: connection.getHeaderField("stream-title")
connection.disconnect()
if (!streamTitle.isNullOrBlank()) {
processStreamTitle(streamTitle, player)
}
} catch (e: Exception) {
// Silently fail - this is a fallback mechanism, ICY metadata is primary
}
}
private fun processStreamTitle(streamTitle: String, player: Player) {
// Parse "Artist - Title" format
val parts = streamTitle.split(" - ", limit = 2)
val artist = if (parts.size == 2) parts[0].trim().ifEmpty { null } else null
val title = if (parts.size == 2) parts[1].trim().ifEmpty { null } else streamTitle.trim().ifEmpty { null }
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
lastRadioArtist = artist
lastRadioTitle = title
// Update on main thread
widgetUpdateHandler.post {
val currentItemNow = player.currentMediaItem ?: return@post
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return@post
val currentExtras = currentItemNow.mediaMetadata.extras
if (currentExtras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post
// Double-check we still don't have embedded metadata (might have arrived since check)
val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() ||
!currentItemNow.mediaMetadata.title.isNullOrBlank() ||
(currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) ||
(currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank())
if (hasEmbeddedMetadata) return@post
val metadataBuilder = currentItemNow.mediaMetadata.buildUpon()
val newExtras = Bundle(currentExtras ?: Bundle())
// Store individual values in extras for UI
artist?.let { newExtras.putString("radioArtist", it) }
title?.let { newExtras.putString("radioTitle", it) }
// Get station name (preserve if already set)
val stationName = currentExtras?.getString("stationName")
?: currentItemNow.mediaMetadata.title?.toString()
?: ""
if (stationName.isNotBlank()) {
newExtras.putString("stationName", stationName)
}
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
val formattedTitle = when {
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
!title.isNullOrBlank() -> title
!artist.isNullOrBlank() -> artist
else -> stationName
}
metadataBuilder.setTitle(formattedTitle)
if (stationName.isNotBlank()) {
metadataBuilder.setArtist(stationName)
}
metadataBuilder.setExtras(newExtras)
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, currentItemNow.buildUpon()
.setMediaMetadata(metadataBuilder.build())
.build())
updateWidget(exo)
}
}
}
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
if (audioSessionId == 0 || audioSessionId == -1) return false
val attached = equalizerManager.attachToSession(audioSessionId)
@@ -595,4 +860,5 @@ open class BaseMediaService : MediaLibraryService() {
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails

View File

@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.cappielloantonio.tempo.util.ClientCertManager
import com.google.gson.GsonBuilder
import okhttp3.Cache
import okhttp3.OkHttpClient
@@ -13,7 +14,7 @@ import java.util.Date
import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit
val retrofit: Retrofit
init {
val gson = GsonBuilder()
@@ -50,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) {
.addInterceptor(cacheUtil.offlineInterceptor)
// .addNetworkInterceptor(cacheUtil.onlineInterceptor)
.cache(getCache())
.setupSsl()
.build()
}
@@ -63,4 +65,11 @@ class RetrofitClient(subsonic: Subsonic) {
val cacheSize = 10 * 1024 * 1024
return Cache(App.getContext().cacheDir, cacheSize.toLong())
}
private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder {
ClientCertManager.sslSocketFactory?.let { sslSocketFactory ->
sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager)
}
return this
}
}

View File

@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import kotlinx.parcelize.Parcelize
import com.google.gson.annotations.SerializedName
@Keep
@Parcelize
@@ -10,5 +11,6 @@ class InternetRadioStation(
var id: String? = null,
var name: String? = null,
var streamUrl: String? = null,
@SerializedName("homePageUrl", alternate = ["homepageUrl"])
var homePageUrl: String? = null,
) : Parcelable

View File

@@ -62,7 +62,8 @@ public class CacheUtil {
boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET);
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH);
if (!hasAppropriateTransport) {
return false;
}

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.content.IntentFilter;
@@ -11,18 +12,22 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
@@ -48,6 +53,7 @@ import com.cappielloantonio.tempo.viewmodel.MainViewModel;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.color.DynamicColors;
import com.google.android.material.navigation.NavigationView;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.Objects;
@@ -63,9 +69,12 @@ public class MainActivity extends BaseActivity {
private FragmentManager fragmentManager;
private NavHostFragment navHostFragment;
private BottomNavigationView bottomNavigationView;
private FrameLayout bottomNavigationViewFrame;
public NavController navController;
private DrawerLayout drawerLayout;
private NavigationView navigationView;
private BottomSheetBehavior bottomSheetBehavior;
private boolean isLandscape = false;
public boolean isLandscape = false;
private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink;
@@ -111,6 +120,7 @@ public class MainActivity extends BaseActivity {
protected void onResume() {
super.onResume();
pingServer();
toggleNavigationDrawerLockOnOrientationChange();
}
@Override
@@ -148,14 +158,8 @@ public class MainActivity extends BaseActivity {
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);
}
toggleNavigationDrawerLockOnOrientationChange();
}
// BOTTOM SHEET/NAVIGATION
@@ -259,8 +263,12 @@ public class MainActivity extends BaseActivity {
private void initNavigation() {
bottomNavigationView = findViewById(R.id.bottom_navigation);
bottomNavigationViewFrame = findViewById(R.id.bottom_navigation_frame);
navHostFragment = (NavHostFragment) fragmentManager.findFragmentById(R.id.nav_host_fragment);
navController = Objects.requireNonNull(navHostFragment).getNavController();
// This is the lateral slide-in drawer
drawerLayout = findViewById(R.id.drawer_layout);
navigationView = findViewById(R.id.nav_view);
/*
* In questo modo intercetto il cambio schermata tramite navbar e se il bottom sheet è aperto,
@@ -277,16 +285,90 @@ public class MainActivity extends BaseActivity {
});
NavigationUI.setupWithNavController(bottomNavigationView, navController);
NavigationUI.setupWithNavController(navigationView, navController);
}
public void setBottomNavigationBarVisibility(boolean visibility) {
if (visibility) {
bottomNavigationView.setVisibility(View.VISIBLE);
bottomNavigationViewFrame.setVisibility(View.VISIBLE);
} else {
bottomNavigationView.setVisibility(View.GONE);
bottomNavigationViewFrame.setVisibility(View.GONE);
}
}
public void toggleBottomNavigationBarVisibilityOnOrientationChange() {
// Ignore orientation change, bottom navbar always hidden
if (Preferences.getHideBottomNavbarOnPortrait()) {
setBottomNavigationBarVisibility(false);
setPortraitPlayerBottomSheetPeekHeight(56);
setSystemBarsVisibility(!isLandscape);
return;
}
if (!isLandscape) {
// Show app navbar + show system bars
setPortraitPlayerBottomSheetPeekHeight(136);
setBottomNavigationBarVisibility(true);
setSystemBarsVisibility(true);
} else {
// Hide app navbar + hide system bars
setPortraitPlayerBottomSheetPeekHeight(56);
setBottomNavigationBarVisibility(false);
setSystemBarsVisibility(false);
}
}
public void setNavigationDrawerLock(boolean locked) {
int mode = locked
? DrawerLayout.LOCK_MODE_LOCKED_CLOSED
: DrawerLayout.LOCK_MODE_UNLOCKED;
drawerLayout.setDrawerLockMode(mode);
}
public void toggleNavigationDrawerLockOnOrientationChange() {
// Ignore orientation check, drawer always unlocked
if (Preferences.getEnableDrawerOnPortrait()) {
setNavigationDrawerLock(false);
return;
}
if (!isLandscape) {
setNavigationDrawerLock(true);
} else {
setNavigationDrawerLock(false);
}
}
public void setSystemBarsVisibility(boolean visibility) {
WindowInsetsControllerCompat insetsController;
View decorView = getWindow().getDecorView();
insetsController = new WindowInsetsControllerCompat(getWindow(), decorView);
if (visibility) {
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
insetsController.show(WindowInsetsCompat.Type.navigationBars());
insetsController.show(WindowInsetsCompat.Type.statusBars());
insetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_DEFAULT);
} else {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
insetsController.hide(WindowInsetsCompat.Type.navigationBars());
insetsController.hide(WindowInsetsCompat.Type.statusBars());
insetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
private void setPortraitPlayerBottomSheetPeekHeight(int peekHeight) {
FrameLayout bottomSheet = findViewById(R.id.player_bottom_sheet);
BottomSheetBehavior<FrameLayout> behavior =
BottomSheetBehavior.from(bottomSheet);
int newPeekPx = (int) (peekHeight * getResources().getDisplayMetrics().density);
behavior.setPeekHeight(newPeekPx);
}
private void initService() {
MediaManager.check(getMediaBrowserListenableFuture());
@@ -368,6 +450,7 @@ public class MainActivity extends BaseActivity {
Preferences.setServer(null);
Preferences.setLocalAddress(null);
Preferences.setUser(null);
Preferences.setClientCert(null);
// TODO Enter all settings to be reset
Preferences.setOpenSubsonic(false);
@@ -570,4 +653,4 @@ public class MainActivity extends BaseActivity {
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
}
}
}

View File

@@ -146,7 +146,7 @@ public class ArtistCatalogueAdapter extends RecyclerView.Adapter<ArtistCatalogue
public void sort(String order) {
switch (order) {
case Constants.ARTIST_ORDER_BY_NAME:
artists.sort(Comparator.comparing(ArtistID3::getName));
artists.sort(Comparator.comparing(ArtistID3::getName,String.CASE_INSENSITIVE_ORDER));
break;
case Constants.ARTIST_ORDER_BY_RANDOM:
Collections.shuffle(artists);

View File

@@ -42,8 +42,13 @@ public class InternetRadioStationAdapter extends RecyclerView.Adapter<InternetRa
holder.item.internetRadioStationTitleTextView.setText(internetRadioStation.getName());
holder.item.internetRadioStationSubtitleTextView.setText(internetRadioStation.getStreamUrl());
String imageId = internetRadioStation.getHomePageUrl();
if (imageId == null || imageId.isEmpty()) {
imageId = internetRadioStation.getStreamUrl();
}
CustomGlideRequest.Builder
.from(holder.itemView.getContext(), internetRadioStation.getStreamUrl(), CustomGlideRequest.ResourceType.Radio)
.from(holder.itemView.getContext(), imageId, CustomGlideRequest.ResourceType.Radio)
.build()
.into(holder.item.internetRadioStationCoverImageView);
}

View File

@@ -47,6 +47,7 @@ public class PlaylistHorizontalAdapter extends RecyclerView.Adapter<PlaylistHori
FilterResults results = new FilterResults();
results.values = filteredList;
results.count = filteredList.size();
return results;
}
@@ -54,7 +55,9 @@ public class PlaylistHorizontalAdapter extends RecyclerView.Adapter<PlaylistHori
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
playlists.clear();
if (results.count > 0) playlists.addAll((List) results.values);
if (results.values != null) {
playlists.addAll((List<Playlist>) results.values);
}
notifyDataSetChanged();
}
};

View File

@@ -359,6 +359,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
private boolean onLongClick() {
Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
bundle.putInt(Constants.ITEM_POSITION, getBindingAdapterPosition());
click.onMediaLongClick(bundle);

View File

@@ -6,7 +6,6 @@ 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,41 +19,30 @@ import com.cappielloantonio.tempo.util.Constants;
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;
private PlaylistChooserViewModel playlistChooserViewModel;
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
DialogPlaylistChooserBinding.inflate(getLayoutInflater());
bind = DialogPlaylistChooserBinding.inflate(getLayoutInflater());
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)
};
bind.playlistDialogChooserVisibilitySwitch.setOnCheckedChangeListener(
(buttonView,
isChecked) -> playlistChooserViewModel.setIsPlaylistPublic(isChecked)
);
bind.playlistChooserDialogCreateButton.setOnClickListener(v -> launchPlaylistEditor());
bind.playlistChooserDialogCancelButton.setOnClickListener(v -> dismiss());
return new MaterialAlertDialogBuilder(getActivity())
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext())
.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();
.setTitle(R.string.playlist_chooser_dialog_title);
return builder.create();
}
@Override
@@ -69,25 +57,26 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
initPlaylistView();
setSongInfo();
setButtonAction();
}
private void setSongInfo() {
playlistChooserViewModel.setSongsToAdd(requireArguments().getParcelableArrayList(Constants.TRACKS_OBJECT));
}
private void setButtonAction() {
androidx.appcompat.app.AlertDialog alertDialog = (androidx.appcompat.app.AlertDialog) Objects.requireNonNull(getDialog());
alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> {
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, playlistChooserViewModel.getSongsToAdd());
private void launchPlaylistEditor() {
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(
Constants.TRACKS_OBJECT,
playlistChooserViewModel.getSongsToAdd()
);
PlaylistEditorDialog dialog = new PlaylistEditorDialog(null);
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
PlaylistEditorDialog editorDialog = new PlaylistEditorDialog(null);
editorDialog.setArguments(bundle);
editorDialog.show(
requireActivity().getSupportFragmentManager(),
null);
Objects.requireNonNull(getDialog()).dismiss();
});
dismiss();
}
private void initPlaylistView() {

View File

@@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.security.KeyChain;
import android.text.TextUtils;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -32,11 +32,21 @@ public class ServerSignupDialog extends DialogFragment {
private String server;
private String localAddress;
private boolean lowSecurity = false;
private String clientCertAlias;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
bind = DialogServerSignupBinding.inflate(getLayoutInflater());
bind.clientCertTextView.setOnClickListener(v -> {
if (TextUtils.isEmpty(bind.clientCertTextView.getText())) {
KeyChain.choosePrivateKeyAlias(requireActivity(), alias -> {
bind.clientCertTextView.setText(alias);
}, null, null, null, null);
} else {
bind.clientCertTextView.setText(null);
}
});
loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);
@@ -74,6 +84,7 @@ public class ServerSignupDialog extends DialogFragment {
bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress());
bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress());
bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity());
bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert());
}
} else {
loginViewModel.setServerToEdit(null);
@@ -106,6 +117,7 @@ public class ServerSignupDialog extends DialogFragment {
server = bind.serverTextView.getText() != null && !bind.serverTextView.getText().toString().trim().isBlank() ? bind.serverTextView.getText().toString().trim() : null;
localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null;
lowSecurity = bind.lowSecurityCheckbox.isChecked();
clientCertAlias = bind.clientCertTextView.getText() != null && !bind.clientCertTextView.getText().toString().trim().isBlank() ? bind.clientCertTextView.getText().toString().trim() : null;
if (TextUtils.isEmpty(serverName)) {
bind.serverNameTextView.setError(getString(R.string.error_required));
@@ -137,6 +149,6 @@ public class ServerSignupDialog extends DialogFragment {
private void saveServerPreference() {
String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString();
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity));
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity, this.clientCertAlias));
}
}

View File

@@ -61,13 +61,47 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() {
genreLink = null;
yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO);
if (isRadio) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!android.text.TextUtils.isEmpty(artist) && !android.text.TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!android.text.TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!android.text.TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.trakTitleInfoTextView.setText(mainTitle);
bind.trakArtistInfoTextView.setText(stationName);
} else {
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: "");
}
if (mediaMetadata.extras != null) {
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
@@ -90,6 +124,27 @@ public class TrackInfoDialog extends DialogFragment {
String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder));
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
int yearValue = mediaMetadata.extras.getInt("year", 0);
// Handle radio-specific metadata
if (isRadio) {
String stationName = mediaMetadata.extras.getString("stationName", getString(R.string.label_placeholder));
String radioArtist = mediaMetadata.extras.getString("radioArtist", "");
String radioTitle = mediaMetadata.extras.getString("radioTitle", "");
// Show station name in station section
bind.stationInfoSector.setVisibility(android.view.View.VISIBLE);
bind.stationValueSector.setText(stationName);
// Use radio metadata for title/artist if available
if (!android.text.TextUtils.isEmpty(radioTitle)) {
titleValue = radioTitle;
}
if (!android.text.TextUtils.isEmpty(radioArtist)) {
artistValue = radioArtist;
}
} else {
bind.stationInfoSector.setVisibility(android.view.View.GONE);
}
if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);

View File

@@ -83,7 +83,7 @@ public class DownloadFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
activity.setBottomNavigationBarVisibility(true);
activity.toggleBottomNavigationBarVisibilityOnOrientationChange();
activity.setBottomSheetVisibility(true);
}

View File

@@ -21,18 +21,26 @@ import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.service.EqualizerManager
import com.cappielloantonio.tempo.service.BaseMediaService
import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.Preferences
class EqualizerFragment : Fragment() {
private lateinit var activity: MainActivity
private var equalizerManager: EqualizerManager? = null
private lateinit var eqBandsContainer: LinearLayout
private lateinit var eqSwitch: Switch
private lateinit var resetButton: Button
private lateinit var safeSpace: Space
private val bandSeekBars = mutableListOf<SeekBar>()
private var receiverRegistered = false
@OptIn(UnstableApi::class)
override fun onAttach(context: Context) {
super.onAttach(context)
activity = requireActivity() as MainActivity
}
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) {
@@ -73,6 +81,8 @@ class EqualizerFragment : Fragment() {
)
receiverRegistered = true
}
val showBottomBar = !Preferences.getHideBottomNavbarOnPortrait()
activity.setBottomNavigationBarVisibility(showBottomBar)
}
override fun onStop() {

View File

@@ -53,7 +53,7 @@ public class HomeFragment extends Fragment {
public void onStart() {
super.onStart();
activity.setBottomNavigationBarVisibility(true);
activity.toggleBottomNavigationBarVisibilityOnOrientationChange();
activity.setBottomSheetVisibility(true);
}

View File

@@ -9,6 +9,8 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
@@ -16,8 +18,11 @@ import androidx.media3.session.SessionToken;
import androidx.navigation.Navigation;
import android.content.ComponentName;
import android.widget.Toast;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.FragmentLibraryBinding;
@@ -43,6 +48,7 @@ import java.util.Objects;
@UnstableApi
public class LibraryFragment extends Fragment implements ClickCallback {
private static final String TAG = "LibraryFragment";
private static final String TOAST_MSG = "Long press to refresh" ;
private FragmentLibraryBinding bind;
private MainActivity activity;
@@ -81,13 +87,14 @@ public class LibraryFragment extends Fragment implements ClickCallback {
initArtistView();
initGenreView();
initPlaylistView();
initSwipeToRefresh();
}
@Override
public void onStart() {
super.onStart();
initializeMediaBrowser();
activity.setBottomNavigationBarVisibility(true);
activity.toggleBottomNavigationBarVisibilityOnOrientationChange();
}
@Override
@@ -112,22 +119,41 @@ public class LibraryFragment extends Fragment implements ClickCallback {
activity.navController.navigate(R.id.action_libraryFragment_to_playlistCatalogueFragment, bundle);
});
// Album
bind.albumCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshAlbumSample(getViewLifecycleOwner());
return true;
});
bind.albumCatalogueSampleTextViewRefreshable.setOnClickListener( v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
// Artist
bind.artistCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshArtistSample(getViewLifecycleOwner());
return true;
});
bind.artistCatalogueSampleTextViewRefreshable.setOnClickListener( v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
// Genre
bind.genreCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshGenreSample(getViewLifecycleOwner());
return true;
});
bind.genreCatalogueSampleTextViewRefreshable.setOnClickListener(v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
// Playlist
bind.playlistCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshPlaylistSample(getViewLifecycleOwner());
return true;
});
bind.playlistCatalogueSampleTextViewRefreshable.setOnClickListener( v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
}
private void initAppBar() {
@@ -304,4 +330,20 @@ public class LibraryFragment extends Fragment implements ClickCallback {
private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
}
public void initSwipeToRefresh() {
bind.swipeLibraryToRefresh.setOnRefreshListener(() -> {
pullToRefresh();
bind.swipeLibraryToRefresh.setRefreshing(false);
});
}
private void pullToRefresh() {
LifecycleOwner lifecycleOwner = getViewLifecycleOwner();
libraryViewModel.refreshAlbumSample(lifecycleOwner);
libraryViewModel.refreshGenreSample(lifecycleOwner);
libraryViewModel.refreshArtistSample(lifecycleOwner);
libraryViewModel.refreshPlaylistSample(lifecycleOwner);
}
}

View File

@@ -117,7 +117,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
@Override
public void onServerClick(Bundle bundle) {
Server server = bundle.getParcelable("server_object");
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity());
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity(), server.getClientCert());
SystemRepository systemRepository = new SystemRepository();
systemRepository.checkUserCredential(new SystemCallback() {
@@ -142,13 +142,14 @@ public class LoginFragment extends Fragment implements ClickCallback {
dialog.show(activity.getSupportFragmentManager(), null);
}
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity) {
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity, String clientCert) {
Preferences.setServerId(serverId);
Preferences.setServer(server);
Preferences.setLocalAddress(localAddress);
Preferences.setUser(user);
Preferences.setPassword(password);
Preferences.setLowSecurity(isLowSecurity);
Preferences.setClientCert(clientCert);
App.getSubsonicClientInstance(true);
}
@@ -161,6 +162,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
Preferences.setToken(null);
Preferences.setSalt(null);
Preferences.setLowSecurity(false);
Preferences.setClientCert(null);
App.getSubsonicClientInstance(true);
}

View File

@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -173,25 +174,54 @@ public class PlayerBottomSheetFragment extends Fragment {
playerBottomSheetViewModel.setLiveArtist(getViewLifecycleOwner(), mediaMetadata.extras.getString("type"), mediaMetadata.extras.getString("artistId"));
playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null));
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title"));
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
String type = mediaMetadata.extras.getString("type");
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: keep header consistent with full player
String stationName = mediaMetadata.extras.getString(
"stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""
);
String artist = mediaMetadata.extras.getString("radioArtist", "");
String title = mediaMetadata.extras.getString("radioTitle", "");
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mainTitle);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(stationName);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
} else {
// Default (music, podcast, etc.)
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title"));
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: ""
);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(
mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), "")
? View.VISIBLE
: View.GONE);
}
CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song)
.build()
.into(bind.playerHeaderLayout.playerHeaderMediaCoverImage);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(
(mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), ""))
|| (Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null)
? View.VISIBLE
: View.GONE);
}
}

View File

@@ -7,9 +7,12 @@ import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.LinearLayout;
@@ -32,6 +35,10 @@ import androidx.media3.session.SessionToken;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.transition.ChangeBounds;
import androidx.transition.Slide;
import androidx.transition.TransitionManager;
import androidx.transition.TransitionSet;
import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R;
@@ -55,7 +62,6 @@ import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -214,12 +220,53 @@ public class PlayerControllerFragment extends Fragment {
}
private void setMetadata(MediaMetadata mediaMetadata) {
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
playerMediaTitleLabel.setText(mainTitle);
playerArtistNameLabel.setText(stationName);
playerMediaTitleLabel.setSelected(true);
playerArtistNameLabel.setSelected(true);
playerMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
playerArtistNameLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
updateAssetLinkChips(mediaMetadata);
return;
}
playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title));
playerArtistNameLabel.setText(
mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist)
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
playerMediaTitleLabel.setSelected(true);
@@ -236,41 +283,80 @@ public class PlayerControllerFragment extends Fragment {
}
private void setMediaInfo(MediaMetadata mediaMetadata) {
boolean isLocal = false;
if (mediaBrowserListenableFuture != null && mediaBrowserListenableFuture.isDone()) {
try {
MediaBrowser browser = mediaBrowserListenableFuture.get();
if (browser != null && browser.getCurrentMediaItem() != null) {
android.net.Uri currentUri = browser.getCurrentMediaItem().requestMetadata.mediaUri;
if (currentUri != null) {
String scheme = currentUri.getScheme();
isLocal = "content".equals(scheme) || "file".equals(scheme);
}
}
} catch (Exception e) {
Log.e("DEBUG_PLAYER", "Error getting browser for UI update", e);
}
}
if (mediaMetadata.extras != null) {
String extension = mediaMetadata.extras.getString("suffix", getString(R.string.player_unknown_format));
String bitrate = mediaMetadata.extras.getInt("bitrate", 0) != 0 ? mediaMetadata.extras.getInt("bitrate", 0) + "kbps" : "Original";
String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ? new DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : "";
int rawBitrate = mediaMetadata.extras.getInt("bitrate", 0);
String bitrate = rawBitrate != 0 ? rawBitrate + "kbps" : "Original";
String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ?
new java.text.DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : "";
String bitDepth = mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + "b" : "";
playerMediaExtension.setText(extension);
if (bitrate.equals("Original")) {
if (bitrate.equals("Original") && !isLocal) {
playerMediaBitrate.setVisibility(View.GONE);
} else {
List<String> mediaQualityItems = new ArrayList<>();
if (!bitrate.trim().isEmpty()) mediaQualityItems.add(bitrate);
if (!bitDepth.trim().isEmpty()) mediaQualityItems.add(bitDepth);
if (!samplingRate.trim().isEmpty()) mediaQualityItems.add(samplingRate);
String mediaQuality = TextUtils.join("", mediaQualityItems);
playerMediaBitrate.setVisibility(View.VISIBLE);
playerMediaBitrate.setText(mediaQuality);
List<String> items = new ArrayList<>();
if (!bitrate.trim().isEmpty()) items.add(bitrate);
if (!bitDepth.trim().isEmpty()) items.add(bitDepth);
if (!samplingRate.trim().isEmpty()) items.add(samplingRate);
String mediaQuality = TextUtils.join("", items);
playerMediaBitrate.setVisibility(Preferences.getBitrateVisible() ? View.VISIBLE : View.GONE);
playerMediaBitrate.setText(isLocal ? mediaQuality : mediaQuality);
}
}
boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw");
boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0");
if (!isLocal) {
boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw");
boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0");
if (isTranscodingExtension || isTranscodingBitrate) {
playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")");
playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ?
MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested));
}
if (isTranscodingExtension || isTranscodingBitrate) {
playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")");
playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ? MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested));
}
playerTrackInfo.setOnClickListener(view -> {
TrackInfoDialog dialog = new TrackInfoDialog(mediaMetadata);
dialog.show(activity.getSupportFragmentManager(), null);
});
});
playerMediaExtension.setOnClickListener( v -> toggleBitrateVisibility() );
playerMediaBitrate.setOnClickListener(v -> toggleBitrateVisibility() );
}
private void toggleBitrateVisibility() {
ViewGroup parent = (ViewGroup) playerMediaBitrate.getParent();
TransitionSet transition = new TransitionSet()
.addTransition(new Slide(Gravity.START))
.addTransition(new ChangeBounds())
.setDuration(500)
.setInterpolator(new AccelerateDecelerateInterpolator());
TransitionManager.beginDelayedTransition(parent, transition);
playerMediaBitrate.setVisibility(Preferences.getBitrateVisible() ? View.GONE : View.VISIBLE);
Preferences.setBitrateVisible(!Preferences.getBitrateVisible());
}
private void updateAssetLinkChips(MediaMetadata mediaMetadata) {

View File

@@ -216,8 +216,9 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
});
bind.playlistPageShuffleButton.setOnClickListener(v -> {
Collections.shuffle(songs);
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
java.util.List<com.cappielloantonio.tempo.subsonic.models.Child> shuffledSongs = new java.util.ArrayList<>(songs);
java.util.Collections.shuffle(shuffledSongs);
MediaManager.startQueue(mediaBrowserListenableFuture, shuffledSongs, 0);
activity.setBottomSheetInPeek(true);
});
}
@@ -227,32 +228,33 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
private void initBackCover() {
playlistPageViewModel.getPlaylistSongLiveList().observe(requireActivity(), songs -> {
if (bind != null && songs != null && !songs.isEmpty()) {
Collections.shuffle(songs);
java.util.List<com.cappielloantonio.tempo.subsonic.models.Child> randomSongs = new java.util.ArrayList<>(songs);
java.util.Collections.shuffle(randomSongs);
// Pic top-left
CustomGlideRequest.Builder
.from(requireContext(), !songs.isEmpty() ? songs.get(0).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.from(requireContext(), !randomSongs.isEmpty() ? randomSongs.get(0).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build()
.transform(new GranularRoundedCorners(CustomGlideRequest.CORNER_RADIUS, 0, 0, 0))
.into(bind.playlistCoverImageViewTopLeft);
// Pic top-right
CustomGlideRequest.Builder
.from(requireContext(), songs.size() > 1 ? songs.get(1).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.from(requireContext(), randomSongs.size() > 1 ? randomSongs.get(1).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build()
.transform(new GranularRoundedCorners(0, CustomGlideRequest.CORNER_RADIUS, 0, 0))
.into(bind.playlistCoverImageViewTopRight);
// Pic bottom-left
CustomGlideRequest.Builder
.from(requireContext(), songs.size() > 2 ? songs.get(2).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.from(requireContext(), randomSongs.size() > 2 ? randomSongs.get(2).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build()
.transform(new GranularRoundedCorners(0, 0, 0, CustomGlideRequest.CORNER_RADIUS))
.into(bind.playlistCoverImageViewBottomLeft);
// Pic bottom-right
CustomGlideRequest.Builder
.from(requireContext(), songs.size() > 3 ? songs.get(3).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.from(requireContext(), randomSongs.size() > 3 ? randomSongs.get(3).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build()
.transform(new GranularRoundedCorners(0, 0, CustomGlideRequest.CORNER_RADIUS, 0))
.into(bind.playlistCoverImageViewBottomRight);
@@ -271,6 +273,11 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs);
if (songs != null) {
bind.playlistSongCountLabel.setText(getString(R.string.playlist_song_count, songs.size()));
long totalDuration = songs.stream().mapToLong(s -> s.getDuration() != null ? s.getDuration() : 0).sum();
bind.playlistDurationLabel.setText(getString(R.string.playlist_duration, MusicUtil.getReadableDurationString(totalDuration, false)));
}
reapplyPlayback();
});
}
@@ -291,6 +298,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
@Override
public void onMediaLongClick(Bundle bundle) {
bundle.putString(Constants.PLAYLIST_ID, playlistPageViewModel.getPlaylist().getId());
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}

View File

@@ -130,6 +130,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
super.onStart();
activity.setBottomNavigationBarVisibility(false);
activity.setBottomSheetVisibility(false);
activity.setNavigationDrawerLock(true);
activity.setSystemBarsVisibility(!activity.isLandscape);
}
@Override
@@ -167,6 +169,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onStop() {
super.onStop();
activity.setBottomSheetVisibility(true);
activity.toggleNavigationDrawerLockOnOrientationChange();
activity.setSystemBarsVisibility(!activity.isLandscape);
}
@Override

View File

@@ -229,6 +229,34 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
});
updateDownloadButtons();
String playlistId = requireArguments().getString(Constants.PLAYLIST_ID);
int itemPosition = requireArguments().getInt(Constants.ITEM_POSITION, -1);
TextView removeFromPlaylist = view.findViewById(R.id.remove_from_playlist_text_view);
if (playlistId != null && itemPosition != -1) {
removeFromPlaylist.setVisibility(View.VISIBLE);
removeFromPlaylist.setOnClickListener(v -> {
songBottomSheetViewModel.removeFromPlaylist(playlistId, itemPosition, new com.cappielloantonio.tempo.repository.PlaylistRepository.AddToPlaylistCallback() {
@Override
public void onSuccess() {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_success, Toast.LENGTH_SHORT).show();
dismissBottomSheet();
}
@Override
public void onFailure() {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_failure, Toast.LENGTH_SHORT).show();
dismissBottomSheet();
}
@Override
public void onAllSkipped() {
dismissBottomSheet();
}
});
});
}
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> {

View File

@@ -0,0 +1,95 @@
package com.cappielloantonio.tempo.util
import android.content.Context
import android.security.KeyChain
import android.util.Log
import androidx.core.net.toUri
import okhttp3.internal.platform.Platform
import java.net.Socket
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509KeyManager
object ClientCertManager {
private const val TAG = "ClientCertManager"
val trustManager = Platform.get().platformTrustManager()
var sslSocketFactory: SSLSocketFactory? = null
private set
@JvmStatic
fun setupSslSocketFactory(context: Context) {
sslSocketFactory = createSslSocketFactory(context)
sslSocketFactory?.let {
// HttpsURLConnection is used both by:
// - Glide: in IPv6StringLoader
// - ExoPlayer: in DefaultHttpDataSource
HttpsURLConnection.setDefaultSSLSocketFactory(it)
}
}
private fun createSslSocketFactory(context: Context): SSLSocketFactory? {
return try {
val clientKeyManager = object : X509KeyManager {
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?) = null
override fun chooseClientAlias(
keyType: Array<String>?,
issuers: Array<Principal>?,
socket: Socket?
): String? {
val clientCert = Preferences.getClientCert() ?: return null
val server = Preferences.getServer() ?: return null
return if (server.toUri().host == socket?.inetAddress?.hostName) {
clientCert
} else null
}
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?) = null
override fun chooseServerAlias(
keyType: String?,
issuers: Array<Principal>?,
socket: Socket?
) = null
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
val clientCert = Preferences.getClientCert()
return if (alias == clientCert && clientCert != null) {
KeyChain.getCertificateChain(
context,
clientCert
)
} else null
}
override fun getPrivateKey(alias: String?): PrivateKey? {
val clientCert = Preferences.getClientCert()
return if (alias == clientCert && clientCert != null) {
KeyChain.getPrivateKey(
context,
clientCert
)
} else null
}
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
sslContext.socketFactory
} catch (e: NoSuchAlgorithmException) {
Log.e(TAG, "Failed setting mTLS", e)
null
} catch (e: KeyManagementException) {
Log.e(TAG, "Failed setting mTLS", e)
null
}
}
}

View File

@@ -11,6 +11,7 @@ object Constants {
const val ARTIST_OBJECT = "ARTIST_OBJECT"
const val GENRE_OBJECT = "GENRE_OBJECT"
const val PLAYLIST_OBJECT = "PLAYLIST_OBJECT"
const val PLAYLIST_ID = "PLAYLIST_ID"
const val PODCAST_OBJECT = "PODCAST_OBJECT"
const val PODCAST_CHANNEL_OBJECT = "PODCAST_CHANNEL_OBJECT"
const val INTERNET_RADIO_STATION_OBJECT = "INTERNET_RADIO_STATION_OBJECT"

View File

@@ -29,6 +29,8 @@ import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
@UnstableApi
@@ -78,12 +80,33 @@ public final class DownloadUtil {
return httpDataSourceFactory;
}
public static synchronized DataSource.Factory getHttpDataSourceFactoryForRadio() {
CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
CookieHandler.setDefault(cookieManager);
// Create a factory with ICY metadata support for radio streams
Map<String, String> defaultRequestProperties = new HashMap<>();
defaultRequestProperties.put("Icy-MetaData", "1");
defaultRequestProperties.put("User-Agent", "Tempus/1.0");
return new DefaultHttpDataSource
.Factory()
.setAllowCrossProtocolRedirects(true)
.setDefaultRequestProperties(defaultRequestProperties);
}
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
return dataSourceFactory;
}
public static synchronized DataSource.Factory getUpstreamDataSourceFactoryForRadio(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactoryForRadio());
return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
}
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))

View File

@@ -20,10 +20,15 @@ class DynamicMediaSourceFactory(
) : MediaSource.Factory {
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
// Detect radio streams in a backwards-compatible way.
// Older Tempus versions tagged radio items via MediaMetadata extras
// (`type == MEDIA_TYPE_RADIO`), while newer upstream changes use an
// "ir-" mediaId prefix. Support BOTH so radio works after rebases.
val mediaType = mediaItem.mediaMetadata.extras?.getString("type", "")
val isRadio = mediaType == Constants.MEDIA_TYPE_RADIO || mediaItem.mediaId.startsWith("ir-")
val streamingCacheSize = Preferences.getStreamingCacheSize()
val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
val bypassCache = isRadio
val useUpstream = when {
streamingCacheSize.toInt() == 0 -> true
@@ -32,7 +37,10 @@ class DynamicMediaSourceFactory(
else -> true
}
val dataSourceFactory: DataSource.Factory = if (useUpstream) {
val dataSourceFactory: DataSource.Factory = if (bypassCache) {
// For radio streams, use a DataSourceFactory with ICY metadata support
DownloadUtil.getUpstreamDataSourceFactoryForRadio(context)
} else if (useUpstream) {
DownloadUtil.getUpstreamDataSourceFactory(context)
} else {
DownloadUtil.getCacheDataSourceFactory(context)

View File

@@ -4,6 +4,7 @@ import android.content.ContentResolver;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.util.Base64;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
@@ -25,6 +26,7 @@ import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
import java.nio.charset.StandardCharsets;
@OptIn(markerClass = UnstableApi.class)
public class MappingUtil {
@@ -207,18 +209,34 @@ public class MappingUtil {
public static MediaItem mapInternetRadioStation(InternetRadioStation internetRadioStation) {
Uri uri = Uri.parse(internetRadioStation.getStreamUrl());
Uri artworkUri = null;
String homePageUrl = internetRadioStation.getHomePageUrl();
String coverArtId = null;
if (homePageUrl != null && !homePageUrl.isEmpty() && MusicUtil.isImageUrl(homePageUrl)) {
String encodedUrl = Base64.encodeToString(homePageUrl.getBytes(StandardCharsets.UTF_8),
Base64.URL_SAFE | Base64.NO_WRAP);
coverArtId = "ir_" + encodedUrl;
artworkUri = AlbumArtContentProvider.contentUri(coverArtId);
}
Bundle bundle = new Bundle();
bundle.putString("id", internetRadioStation.getId());
bundle.putString("title", internetRadioStation.getName());
bundle.putString("stationName", internetRadioStation.getName());
bundle.putString("uri", uri.toString());
bundle.putString("type", Constants.MEDIA_TYPE_RADIO);
bundle.putString("coverArtId", coverArtId);
if (homePageUrl != null) {
bundle.putString("homepageUrl", homePageUrl);
}
return new MediaItem.Builder()
.setMediaId(internetRadioStation.getId())
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(internetRadioStation.getName())
.setArtworkUri(artworkUri)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -288,13 +306,24 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
// Check if it's in our local SQL Database
DownloadRepository repo = new DownloadRepository();
Download localDownload = repo.getDownload(media.getId());
if (localDownload != null && localDownload.getDownloadUri() != null && !localDownload.getDownloadUri().isEmpty()) {
Log.d(TAG, "Playing local file for: " + media.getTitle());
return Uri.parse(localDownload.getDownloadUri());
}
// Legacy check for external directory, i think this was broken/buggy
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(media);
return local != null ? local : MusicUtil.getStreamUri(media.getId());
if (local != null) return local;
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
// Fallback to streaming
Log.d(TAG, "No local file found. Streaming: " + media.getTitle());
return MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
@@ -318,4 +347,4 @@ public class MappingUtil {
}
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
}
}
}

View File

@@ -52,6 +52,10 @@ public class MusicUtil {
if (params.containsKey("c") && params.get("c") != null)
uri.append("&c=").append(params.get("c"));
String selectedBitrate = getBitratePreference();
String selectedFormat = getTranscodingFormatPreference();
Log.i(TAG, "DEBUG: Requesting Format: " + selectedFormat + " at Bitrate: " + selectedBitrate);
if (!Preferences.isServerPrioritized())
uri.append("&maxBitRate=").append(getBitratePreference());
if (!Preferences.isServerPrioritized())
@@ -73,7 +77,17 @@ public class MusicUtil {
}
public static Uri updateStreamUri(Uri uri) {
if (uri == null) return null;
String scheme = uri.getScheme();
// If it is local (content:// or file://), return it IMMEDIATELY.
// This prevents the code below from appending &maxBitRate to a local path.
if (scheme != null && (scheme.equals("content") || scheme.equals("file"))) {
return uri;
}
String s = uri.toString();
Matcher m1 = BITRATE_PATTERN.matcher(s);
s = m1.replaceAll("");
Matcher m2 = FORMAT_PATTERN.matcher(s);
@@ -157,7 +171,6 @@ public class MusicUtil {
return Uri.parse(uri.toString());
}
public static String getReadableDurationString(Long duration, boolean millis) {
long lenght = duration != null ? duration : 0;
@@ -303,13 +316,17 @@ public class MusicUtil {
if (network == null || networkCapabilities == null) return "raw";
String format;
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
return Preferences.getAudioTranscodeFormatWifi();
format = Preferences.getAudioTranscodeFormatWifi();
Log.d(TAG, "DEBUG: Using WIFI Format: " + format);
} else if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
return Preferences.getAudioTranscodeFormatMobile();
format = Preferences.getAudioTranscodeFormatMobile();
Log.d(TAG, "DEBUG: Using MOBILE Format: " + format);
} else {
return Preferences.getAudioTranscodeFormatWifi();
format = Preferences.getAudioTranscodeFormatWifi();
}
return format;
}
public static String getBitratePreferenceForDownload() {
@@ -360,4 +377,15 @@ public class MusicUtil {
toFilter.addAll(filtered);
}
public static boolean isImageUrl(String url) {
if (url == null || url.isEmpty())
return false;
String path = url.toLowerCase().trim().split("\\?")[0];
return path.endsWith(".jpg") || path.endsWith(".jpeg") ||
path.endsWith(".png") || path.endsWith(".webp") ||
path.endsWith(".gif") || path.endsWith(".bmp") ||
path.endsWith(".svg");
}
}

View File

@@ -16,6 +16,7 @@ object Preferences {
private const val TOKEN = "token"
private const val SALT = "salt"
private const val LOW_SECURITY = "low_security"
private const val CLIENT_CERT = "client_cert"
private const val BATTERY_OPTIMIZATION = "battery_optimization"
private const val SERVER_ID = "server_id"
private const val OPEN_SUBSONIC = "open_subsonic"
@@ -24,12 +25,15 @@ object Preferences {
private const val IN_USE_SERVER_ADDRESS = "in_use_server_address"
private const val NEXT_SERVER_SWITCH = "next_server_switch"
private const val PLAYBACK_SPEED = "playback_speed"
private const val BITRATE_VISIBLE = "bitrate_visible"
private const val SKIP_SILENCE = "skip_silence"
private const val SHUFFLE_MODE = "shuffle_mode"
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 ENABLE_DRAWER_ON_PORTRAIT = "enable_drawer_on_portrait"
private const val HIDE_BOTTOM_NAVBAR_ON_PORTRAIT = "hide_bottom_navbar_on_portrait"
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"
@@ -88,8 +92,17 @@ object Preferences {
private const val ARTIST_DISPLAY_BIOGRAPHY= "artist_display_biography"
private const val NETWORK_PING_TIMEOUT = "network_ping_timeout_base"
@JvmStatic
private const val AA_ALBUM_VIEW = "androidauto_album_view"
private const val AA_HOME_VIEW = "androidauto_home_view"
private const val AA_PLAYLIST_VIEW = "androidauto_playlist_view"
private const val AA_PODCAST_VIEW = "androidauto_podcast_view"
private const val AA_RADIO_VIEW = "androidauto_radio_view"
private const val AA_FIRST_TAB = "androidauto_first_tab"
private const val AA_SECOND_TAB = "androidauto_second_tab"
private const val AA_THIRD_TAB = "androidauto_third_tab"
private const val AA_FOURTH_TAB = "androidauto_fourth_tab"
@JvmStatic
fun getServer(): String? {
return App.getInstance().preferences.getString(SERVER, null)
}
@@ -162,6 +175,16 @@ object Preferences {
App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply()
}
@JvmStatic
fun getClientCert(): String? {
return App.getInstance().preferences.getString(CLIENT_CERT, null)
}
@JvmStatic
fun setClientCert(clientCert: String?) {
App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply()
}
@JvmStatic
fun getServerId(): String? {
return App.getInstance().preferences.getString(SERVER_ID, null)
@@ -270,6 +293,16 @@ object Preferences {
App.getInstance().preferences.edit().putFloat(PLAYBACK_SPEED, playbackSpeed).apply()
}
@JvmStatic
fun getBitrateVisible(): Boolean {
return App.getInstance().preferences.getBoolean(BITRATE_VISIBLE, true)
}
@JvmStatic
fun setBitrateVisible(bitrateVisible: Boolean) {
App.getInstance().preferences.edit().putBoolean(BITRATE_VISIBLE, bitrateVisible).apply()
}
@JvmStatic
fun isSkipSilenceMode(): Boolean {
return App.getInstance().preferences.getBoolean(SKIP_SILENCE, false)
@@ -310,6 +343,16 @@ object Preferences {
return App.getInstance().preferences.getString(LANDSCAPE_ITEMS_PER_ROW, "4")!!.toInt()
}
@JvmStatic
fun getEnableDrawerOnPortrait(): Boolean {
return App.getInstance().preferences.getBoolean(ENABLE_DRAWER_ON_PORTRAIT, false)
}
@JvmStatic
fun getHideBottomNavbarOnPortrait(): Boolean {
return App.getInstance().preferences.getBoolean(HIDE_BOTTOM_NAVBAR_ON_PORTRAIT, false)
}
@JvmStatic
fun getImageSize(): Int {
return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt()
@@ -724,4 +767,50 @@ object Preferences {
fun setArtistDisplayBiography(displayBiographyEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(ARTIST_DISPLAY_BIOGRAPHY, displayBiographyEnabled).apply()
}
@JvmStatic
fun isAndroidAutoAlbumViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_ALBUM_VIEW, true)
}
@JvmStatic
fun isAndroidAutoHomeViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_HOME_VIEW, false)
}
@JvmStatic
fun isAndroidAutoPlaylistViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_PLAYLIST_VIEW, false)
}
@JvmStatic
fun isAndroidAutoPodcastViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_PODCAST_VIEW, false)
}
@JvmStatic
fun isAndroidAutoRadioViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_RADIO_VIEW, false)
}
@JvmStatic
fun getAndroidAutoFirstTab(): Int {
return App.getInstance().preferences.getString(AA_FIRST_TAB, "0")!!.toInt()
}
@JvmStatic
fun getAndroidAutoSecondTab(): Int {
return App.getInstance().preferences.getString(AA_SECOND_TAB, "1")!!.toInt()
}
@JvmStatic
fun getAndroidAutoThirdTab(): Int {
return App.getInstance().preferences.getString(AA_THIRD_TAB, "2")!!.toInt()
}
@JvmStatic
fun getAndroidAutoFourthTab(): Int {
return App.getInstance().preferences.getString(AA_FOURTH_TAB, "3")!!.toInt()
}
}

View File

@@ -33,12 +33,18 @@ class TranscodingMediaSource(
init {
val extras = mediaItem.mediaMetadata.extras
if (extras != null && extras.containsKey("duration")) {
val uri = mediaItem.localConfiguration?.uri
val isLocal = uri?.scheme == "content" || uri?.scheme == "file"
// Only apply the override if it's NOT a local file
if (!isLocal && extras != null && extras.containsKey("duration")) {
val seconds = extras.getInt("duration")
if (seconds > 0) {
durationUs = Util.msToUs(seconds * 1000L)
}
}
currentSource = progressiveMediaSourceFactory.createMediaSource(mediaItem)
}
override fun getMediaItem() = mediaItem

View File

@@ -20,14 +20,36 @@ public class PlaylistPageViewModel extends AndroidViewModel {
private Playlist playlist;
private boolean isOffline;
private final MutableLiveData<List<Child>> songLiveList = new MutableLiveData<>();
public PlaylistPageViewModel(@NonNull Application application) {
super(application);
playlistRepository = new PlaylistRepository();
playlistRepository.getPlaylistUpdateTrigger().observeForever(needsRefresh -> {
if (needsRefresh != null && needsRefresh && playlist != null) {
refreshSongs();
}
});
}
public LiveData<List<Child>> getPlaylistSongLiveList() {
return playlistRepository.getPlaylistSongs(playlist.getId());
if (songLiveList.getValue() == null && playlist != null) {
refreshSongs();
}
return songLiveList;
}
private void refreshSongs() {
if (playlist == null) return;
LiveData<List<Child>> remoteData = playlistRepository.getPlaylistSongs(playlist.getId());
remoteData.observeForever(new androidx.lifecycle.Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
songLiveList.postValue(songs);
remoteData.removeObserver(this);
}
});
}
public Playlist getPlaylist() {
@@ -35,7 +57,10 @@ public class PlaylistPageViewModel extends AndroidViewModel {
}
public void setPlaylist(Playlist playlist) {
this.playlist = playlist;
if (this.playlist == null || !this.playlist.getId().equals(playlist.getId())) {
this.playlist = playlist;
this.songLiveList.setValue(null); // Clear old data immediately
}
}
public LiveData<Boolean> isPinned(LifecycleOwner owner) {

View File

@@ -16,6 +16,7 @@ import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.repository.SharingRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
@@ -39,6 +40,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository;
private final SharingRepository sharingRepository;
private final PlaylistRepository playlistRepository;
private Child song;
@@ -52,6 +54,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository();
sharingRepository = new SharingRepository();
playlistRepository = new PlaylistRepository();
}
public Child getSong() {
@@ -62,6 +65,10 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
this.song = song;
}
public void removeFromPlaylist(String playlistId, int index, PlaylistRepository.AddToPlaylistCallback callback) {
playlistRepository.removeSongFromPlaylist(playlistId, index, callback);
}
public void setFavorite(Context context) {
if (song.getStarred() != null) {
if (NetworkUtil.isOffline()) {

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M720,800L720,680L600,680L600,600L720,600L720,480L800,480L800,600L920,600L920,680L800,680L800,800L720,800ZM120,840Q87,840 63.5,816.5Q40,793 40,760L40,200Q40,167 63.5,143.5Q87,120 120,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,400L680,400L680,320L120,320L120,760Q120,760 120,760Q120,760 120,760L640,760L640,840L120,840ZM120,240L680,240L680,200Q680,200 680,200Q680,200 680,200L120,200Q120,200 120,200Q120,200 120,200L120,240ZM120,240L120,200Q120,200 120,200Q120,200 120,200L120,200Q120,200 120,200Q120,200 120,200L120,240Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,468Q821,459 801,452.5Q781,446 760,443L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L442,760Q445,782 451.5,802Q458,822 467,840L200,840ZM200,720Q200,731 200,740.5Q200,750 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,443Q200,441 200,440.5Q200,440 200,440Q200,440 200,522Q200,604 200,720ZM280,680L443,680Q446,659 452.5,639Q459,619 467,600L280,600L280,680ZM280,520L524,520Q556,490 595.5,470Q635,450 680,443L680,440L280,440L280,520ZM280,360L680,360L680,280L280,280L280,360ZM720,920Q637,920 578.5,861.5Q520,803 520,720Q520,637 578.5,578.5Q637,520 720,520Q803,520 861.5,578.5Q920,637 920,720Q920,803 861.5,861.5Q803,920 720,920ZM700,840L740,840L740,740L840,740L840,700L740,700L740,600L700,600L700,700L600,700L600,740L700,740L700,840Z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="960"
android:viewportWidth="960"
android:width="24dp">
<path
android:fillColor="@android:color/white"
android:pathData="M480,660Q555,660 607.5,607.5Q660,555 660,480Q660,405 607.5,352.5Q555,300 480,300Q405,300 352.5,352.5Q300,405 300,480Q300,555 352.5,607.5Q405,660 480,660ZM451.5,508.5Q440,497 440,480Q440,463 451.5,451.5Q463,440 480,440Q497,440 508.5,451.5Q520,463 520,480Q520,497 508.5,508.5Q497,520 480,520Q463,520 451.5,508.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="960"
android:viewportWidth="960"
android:width="24dp">
<path
android:fillColor="@android:color/white"
android:pathData="M740,400L880,400L880,480L800,480L800,700Q800,742 771,771Q742,800 700,800Q658,800 629,771Q600,742 600,700Q600,658 629,629Q658,600 700,600Q708,600 718,601.5Q728,603 740,608L740,400ZM120,800L120,688Q120,653 137.5,625Q155,597 184,582Q246,551 310,535.5Q374,520 440,520Q482,520 523.5,526.5Q565,533 607,546Q587,558 571,575Q555,592 543,612Q517,606 491.5,603Q466,600 440,600Q383,600 328,614Q273,628 220,654Q211,659 205.5,668Q200,677 200,688L200,720L521,720Q523,740 530.5,760Q538,780 551,800L120,800ZM327,433Q280,386 280,320Q280,254 327,207Q374,160 440,160Q506,160 553,207Q600,254 600,320Q600,386 553,433Q506,480 440,480Q374,480 327,433ZM496.5,376.5Q520,353 520,320Q520,287 496.5,263.5Q473,240 440,240Q407,240 383.5,263.5Q360,287 360,320Q360,353 383.5,376.5Q407,400 440,400Q473,400 496.5,376.5ZM440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320ZM440,720L440,720L440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M120,840Q87,840 63.5,816.5Q40,793 40,760L40,240L120,240L120,760Q120,760 120,760Q120,760 120,760L800,760L800,840L120,840ZM280,680Q247,680 223.5,656.5Q200,633 200,600L200,160Q200,127 223.5,103.5Q247,80 280,80L480,80L560,160L840,160Q873,160 896.5,183.5Q920,207 920,240L920,600Q920,633 896.5,656.5Q873,680 840,680L280,680ZM280,600L840,600Q840,600 840,600Q840,600 840,600L840,240Q840,240 840,240Q840,240 840,240L527,240L447,160L280,160Q280,160 280,160Q280,160 280,160L280,600Q280,600 280,600Q280,600 280,600ZM280,600Q280,600 280,600Q280,600 280,600L280,160Q280,160 280,160Q280,160 280,160L280,160L280,240L280,240Q280,240 280,240Q280,240 280,240L280,600Q280,600 280,600Q280,600 280,600Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M649,463.5Q737,447 800,420L800,820Q740,847 654,863.5Q568,880 480,880Q392,880 306,863.5Q220,847 160,820L160,420Q223,447 311,463.5Q399,480 480,480Q561,480 649,463.5ZM720,760L720,530Q670,544 604.5,552Q539,560 480,560Q421,560 355.5,552Q290,544 240,530L240,760Q290,778 355,789Q420,800 480,800Q540,800 605,789Q670,778 720,760ZM593,127Q640,174 640,240Q640,306 593,353Q546,400 480,400Q414,400 367,353Q320,306 320,240Q320,174 367,127Q414,80 480,80Q546,80 593,127ZM536.5,296.5Q560,273 560,240Q560,207 536.5,183.5Q513,160 480,160Q447,160 423.5,183.5Q400,207 400,240Q400,273 423.5,296.5Q447,320 480,320Q513,320 536.5,296.5ZM480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240ZM480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665L480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M240,760L360,760L360,520L600,520L600,760L720,760L720,400L480,220L240,400L240,760ZM160,840L160,360L480,120L800,360L800,840L520,840L520,600L440,600L440,840L160,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M400,640L640,480L400,320L400,640ZM324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,437 89,395.5Q98,354 115,315L177,377Q169,403 164.5,428.5Q160,454 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q453,160 427.5,164.5Q402,169 377,177L316,116Q356,98 396,89Q436,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM177.5,262.5Q160,245 160,220Q160,195 177.5,177.5Q195,160 220,160Q245,160 262.5,177.5Q280,195 280,220Q280,245 262.5,262.5Q245,280 220,280Q195,280 177.5,262.5ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M120,880L120,800L840,800L840,880L120,880ZM120,640L120,560L840,560L840,640L120,640ZM120,400L120,320L840,320L840,400L120,400ZM120,160L120,80L840,80L840,160L120,160Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M120,640L120,560L440,560L440,640L120,640ZM120,480L120,400L600,400L600,480L120,480ZM120,320L120,240L600,240L600,320L120,320ZM640,840L640,520L880,680L640,840Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M440,880L440,549Q422,538 411,520.5Q400,503 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,503 549,521Q538,539 520,549L520,880L440,880ZM204,770Q147,715 113.5,640.5Q80,566 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,566 846.5,641Q813,716 756,770L700,714Q746,670 773,609.5Q800,549 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,549 187,609Q214,669 261,713L204,770ZM317,657Q282,624 261,578.5Q240,533 240,480Q240,380 310,310Q380,240 480,240Q580,240 650,310Q720,380 720,480Q720,533 699,579Q678,625 643,657L586,600Q611,577 625.5,546Q640,515 640,480Q640,414 593,367Q546,320 480,320Q414,320 367,367Q320,414 320,480Q320,516 334.5,546.5Q349,577 374,600L317,657Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,320Q80,295 93.5,275Q107,255 130,246L636,40L662,106L332,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,800Q880,833 856.5,856.5Q833,880 800,880L160,880ZM160,800L800,800Q800,800 800,800Q800,800 800,800L800,520L160,520L160,800Q160,800 160,800Q160,800 160,800ZM391,731Q420,702 420,660Q420,618 391,589Q362,560 320,560Q278,560 249,589Q220,618 220,660Q220,702 249,731Q278,760 320,760Q362,760 391,731ZM160,440L640,440L640,360L720,360L720,440L800,440L800,320Q800,320 800,320Q800,320 800,320L160,320Q160,320 160,320Q160,320 160,320L160,440ZM160,800Q160,800 160,800Q160,800 160,800L160,520L160,520L160,800Q160,800 160,800Q160,800 160,800Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M280,800L80,600L280,400L336,457L233,560L520,560L520,640L233,640L336,743L280,800ZM680,560L624,503L727,400L440,400L440,320L727,320L624,217L680,160L880,360L680,560Z"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="
M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z
"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M380,660L380,300L660,480L380,660ZM480,920Q372,920 277.5,870.5Q183,821 120,732L120,840L40,840L40,600L280,600L280,680L182,680Q233,755 311.5,797.5Q390,840 480,840Q595,840 688.5,774Q782,708 820,599L898,617Q853,753 738,836.5Q623,920 480,920ZM42,440Q49,373 74,311.5Q99,250 143,198L200,255Q168,296 148,342.5Q128,389 123,440L42,440ZM256,199L199,142Q252,98 313,72.5Q374,47 440,42L440,122Q389,127 343,147Q297,167 256,199ZM705,199Q664,167 617.5,147Q571,127 520,122L520,42Q587,48 648.5,73Q710,98 762,142L705,199ZM838,440Q833,389 813,342.5Q793,296 761,255L818,198Q862,250 887,311.5Q912,373 918,440L838,440Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M320,720L480,598L640,720L580,522L740,408L544,408L480,200L416,408L220,408L380,522L320,720ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M489,500L580,445L671,500L647,396L727,327L622,318L580,220L538,318L433,327L513,396L489,500ZM508,760L732,760Q725,786 708,802Q691,818 664,822L228,875Q195,880 168.5,859.5Q142,839 138,806L85,369Q81,336 101,310Q121,284 154,280L200,274L200,354L164,359Q164,359 164,359Q164,359 164,359L218,796Q218,796 218,796Q218,796 218,796L508,760ZM360,680Q327,680 303.5,656.5Q280,633 280,600L280,160Q280,127 303.5,103.5Q327,80 360,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,600Q880,633 856.5,656.5Q833,680 800,680L360,680ZM360,600L800,600Q800,600 800,600Q800,600 800,600L800,160Q800,160 800,160Q800,160 800,160L360,160Q360,160 360,160Q360,160 360,160L360,600Q360,600 360,600Q360,600 360,600ZM580,380Q580,380 580,380Q580,380 580,380L580,380Q580,380 580,380Q580,380 580,380L580,380Q580,380 580,380Q580,380 580,380L580,380Q580,380 580,380Q580,380 580,380ZM218,796L218,796L218,796L218,796L218,796Q218,796 218,796Q218,796 218,796Z"/>
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2,17.4V2.6C2,2.269 2.269,2 2.6,2H17.4C17.731,2 18,2.269 18,2.6V17.4C18,17.731 17.731,18 17.4,18H2.6C2.269,18 2,17.731 2,17.4Z"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M8,22H21.4C21.731,22 22,21.731 22,21.4V8"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M11,12.5C11,13.328 10.328,14 9.5,14C8.672,14 8,13.328 8,12.5C8,11.672 8.672,11 9.5,11C10.328,11 11,11.672 11,12.5ZM11,12.5V6.6C11,6.269 11.269,6 11.6,6H13"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,32 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M1,20V19C1,15.134 4.134,12 8,12V12C11.866,12 15,15.134 15,19V20"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M13,14V14C13,11.239 15.239,9 18,9V9C20.761,9 23,11.239 23,14V14.5"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M8,12C10.209,12 12,10.209 12,8C12,5.791 10.209,4 8,4C5.791,4 4,5.791 4,8C4,10.209 5.791,12 8,12Z"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M18,9C19.657,9 21,7.657 21,6C21,4.343 19.657,3 18,3C16.343,3 15,4.343 15,6C15,7.657 16.343,9 18,9Z"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="49dp"
android:height="49dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,17.4V6.6C3,6.269 3.269,6 3.6,6H16.679C16.879,6 17.067,6.1 17.178,6.267L20.778,11.667C20.913,11.869 20.913,12.131 20.778,12.333L17.178,17.733C17.067,17.9 16.879,18 16.679,18H3.6C3.269,18 3,17.731 3,17.4Z"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>

View File

@@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="49dp"
android:height="49dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2,11L16,11"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M2,17L13,17"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M2,5L20,5"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M20,18.5C20,19.328 19.328,20 18.5,20C17.672,20 17,19.328 17,18.5C17,17.672 17.672,17 18.5,17C19.328,17 20,17.672 20,18.5ZM20,18.5V10.6C20,10.269 20.269,10 20.6,10H22"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>

View File

@@ -1,16 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
android:background="?attr/colorSurface">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
@@ -18,20 +17,17 @@
android:orientation="horizontal">
<FrameLayout
android:layout_width="75dp"
android:id="@+id/bottom_navigation_frame"
android:layout_width="55dp"
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_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:visibility="gone"
android:rotation="90"
app:menu="@menu/bottom_nav_menu" />
</FrameLayout>
<androidx.fragment.app.FragmentContainerView
@@ -42,7 +38,6 @@
android:layout_weight="1"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
</LinearLayout>
<FrameLayout
@@ -52,9 +47,16 @@
app:behavior_hideable="true"
app:behavior_peekHeight="@dimen/bottom_sheet_peek_height"
app:layout_behavior="@string/bottom_sheet_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/nav_drawer"
app:headerLayout="@layout/nav_drawer_header" />
<TextView
android:id="@+id/offline_mode_text_view"
style="@style/NoConnectionTextView"
@@ -64,5 +66,4 @@
android:text="@string/activity_info_offline_mode"
android:textSize="6sp"
android:visibility="gone" />
</LinearLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/now_playing_media_controller_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -23,30 +24,42 @@
app:layout_constraintStart_toEndOf="@+id/vertical_guideline"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.chip.Chip
android:id="@+id/player_media_extension"
style="@style/Widget.Material3.Chip.Suggestion"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/player_media_quality_sector_center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:clickable="false"
android:text="Unknown"
app:chipStrokeWidth="0dp"
android:layout_marginVertical="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/player_media_bitrate"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"/>
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/player_media_bitrate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="@id/player_media_extension"
app:layout_constraintBottom_toBottomOf="@id/player_media_extension"
app:layout_constraintStart_toEndOf="@id/player_media_extension"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/player_media_bitrate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="@id/player_media_extension"
app:layout_constraintBottom_toBottomOf="@id/player_media_extension"
app:layout_constraintStart_toEndOf="@id/player_media_extension"
app:layout_constraintEnd_toEndOf="parent"/>
<com.google.android.material.chip.Chip
android:id="@+id/player_media_extension"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:checked="true"
android:focusable="true"
android:text="Unknown"
app:chipStrokeWidth="0dp"
app:chipBackgroundColor="@color/material_dynamic_secondary40"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/player_media_bitrate"
app:layout_constraintHorizontal_chainStyle="packed"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageButton
android:id="@+id/player_info_track"
@@ -57,11 +70,25 @@
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/player_media_extension"
app:layout_constraintBottom_toBottomOf="@id/player_media_extension"
app:layout_constraintTop_toTopOf="@id/player_media_quality_sector_center"
app:layout_constraintBottom_toBottomOf="@id/player_media_quality_sector_center"
app:srcCompat="@drawable/ic_info_stream"
app:tint="?attr/colorOnPrimaryContainer" />
<Button
android:id="@+id/player_playback_speed_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="64dp"
android:layout_height="64dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
app:cornerRadius="30dp"
app:tint="?attr/colorOnPrimaryContainer"
tools:layout_editor_absoluteX="36dp"
tools:layout_editor_absoluteY="2dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.viewpager2.widget.ViewPager2
@@ -244,23 +271,6 @@
app:layout_constraintStart_toEndOf="@+id/placeholder_view_middle_right"
app:layout_constraintTop_toTopOf="@+id/placeholder_view_middle_right" />
<Button
android:id="@+id/player_playback_speed_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="24dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
app:cornerRadius="30dp"
app:layout_constraintBottom_toBottomOf="@+id/placeholder_view_middle_left"
app:layout_constraintEnd_toStartOf="@+id/placeholder_view_middle_left"
app:layout_constraintStart_toEndOf="@+id/vertical_guideline"
app:layout_constraintTop_toTopOf="@+id/placeholder_view_middle_left"
app:tint="?attr/colorOnPrimaryContainer" />
<ImageButton
android:id="@+id/exo_shuffle"
android:layout_width="32dp"

View File

@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
android:background="?attr/colorSurface">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
@@ -35,11 +35,31 @@
android:layout_gravity="bottom"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:visibility="gone"
app:menu="@menu/bottom_nav_menu" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<!--
This FrameLayout id is always called,
if removed the app crashes
-->
<FrameLayout
android:id="@+id/bottom_navigation_frame"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
android:clickable="false"
android:focusable="false" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/nav_drawer"
app:headerLayout="@layout/nav_drawer_header" />
<TextView
android:id="@+id/offline_mode_text_view"
style="@style/NoConnectionTextView"
@@ -47,8 +67,6 @@
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/activity_info_offline_mode"
android:textSize="12sp"
android:textStyle="bold"
android:textSize="6sp"
android:visibility="gone" />
</LinearLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -164,6 +164,20 @@
android:paddingBottom="12dp"
android:text="@string/song_bottom_sheet_remove" />
<TextView
android:id="@+id/remove_from_playlist_text_view"
style="@style/LabelMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:paddingStart="20dp"
android:paddingTop="12dp"
android:paddingEnd="20dp"
android:paddingBottom="12dp"
android:text="@string/song_bottom_sheet_remove_from_playlist"
android:visibility="gone" />
<TextView
android:id="@+id/add_to_playlist_text_view"
style="@style/LabelMedium"

View File

@@ -3,6 +3,26 @@
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/playlist_dialog_chooser_visibility_switch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:paddingStart="30dp"
android:paddingEnd="30dp"
android:checked="false"
android:showText="false"
android:text="@string/playlist_chooser_dialog_visibility_switch_label" />
<TextView
android:id="@+id/playlist_dialog_chooser_visibility_summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="30dp"
android:paddingEnd="30dp"
android:text="@string/playlist_chooser_dialog_visibility_summary"
android:layout_marginTop="8dp"/>
<TextView
android:id="@+id/no_playlists_created_text_view"
style="@style/TitleMedium"
@@ -23,4 +43,35 @@
android:layout_weight="1"
android:layout_marginTop="8dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/button_bar"
style="?android:attr/buttonBarStyle"
android:orientation="horizontal"
android:gravity="bottom|center_horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:weightSum="2"
android:layout_marginTop="16dp">
<Button
android:id="@+id/playlist_chooser_dialog_create_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="start"
android:text="@string/playlist_chooser_dialog_create_button" />
<Button
android:id="@+id/playlist_chooser_dialog_cancel_button"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:layout_gravity="end"
android:text="@string/playlist_chooser_dialog_cancel_button"/>
</LinearLayout>
</LinearLayout>

View File

@@ -129,6 +129,25 @@
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:text="@string/server_signup_dialog_action_low_security" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:id="@+id/client_cert_text_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:textColorHint="?android:textColorHint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/client_cert_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusableInTouchMode="false"
android:hint="@string/server_signup_dialog_hint_client_certificate"
android:inputType="textNoSuggestions"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -131,6 +131,33 @@
android:text="@string/label_placeholder" />
</LinearLayout>
<View
style="@style/Divider"
android:layout_gravity="center_vertical"
android:layout_marginVertical="8dp" />
<LinearLayout
android:id="@+id/station_info_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<TextView
android:id="@+id/station_key_sector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="4"
android:paddingEnd="8dp"
android:text="@string/track_info_station" />
<TextView
android:id="@+id/station_value_sector"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="7"
android:text="@string/label_placeholder" />
</LinearLayout>
<View
style="@style/Divider"
android:layout_gravity="center_vertical"

View File

@@ -11,6 +11,12 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_library_to_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.core.widget.NestedScrollView
android:id="@+id/fragment_library_nested_scroll_view"
android:layout_width="match_parent"
@@ -77,21 +83,41 @@
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<TextView
<!-- Refreshable area -->
<LinearLayout
android:id="@+id/album_catalogue_sample_text_view_refreshable"
style="@style/TitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
style="@style/TitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_album" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:alpha="0.4"
android:src="@drawable/ic_refresh"/>
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_album" />
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/album_catalogue_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_album_see_all_button" />
@@ -130,22 +156,41 @@
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<TextView
<!-- Refreshable area -->
<LinearLayout
android:id="@+id/artist_catalogue_sample_text_view_refreshable"
style="@style/TitleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_artist" />
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
style="@style/TitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_artist" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:alpha="0.4"
android:src="@drawable/ic_refresh"/>
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/artist_catalogue_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_artist_see_all_button" />
@@ -184,25 +229,45 @@
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<TextView
<!-- Refreshable area -->
<LinearLayout
android:id="@+id/genre_catalogue_sample_text_view_refreshable"
style="@style/TitleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_genre" />
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
style="@style/TitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_genre" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:alpha="0.4"
android:src="@drawable/ic_refresh"/>
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/genre_catalogue_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_genre_see_all_button" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
@@ -236,21 +301,41 @@
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<TextView
<!-- Refreshable area -->
<LinearLayout
android:id="@+id/playlist_catalogue_sample_text_view_refreshable"
style="@style/TitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<TextView
style="@style/TitleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_playlist" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:alpha="0.4"
android:src="@drawable/ic_refresh"/>
</LinearLayout>
<View
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_playlist" />
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/playlist_catalogue_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_playlist_see_all_button" />
@@ -270,4 +355,5 @@
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -33,30 +33,42 @@
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/colorOnPrimaryContainer" />
<com.google.android.material.chip.Chip
android:id="@+id/player_media_extension"
style="@style/Widget.Material3.Chip.Suggestion"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/player_media_quality_sector_center"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:clickable="false"
android:text="Unknown"
app:chipStrokeWidth="0dp"
android:layout_marginVertical="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/player_media_bitrate"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"/>
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/player_media_bitrate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="@id/player_media_extension"
app:layout_constraintBottom_toBottomOf="@id/player_media_extension"
app:layout_constraintStart_toEndOf="@id/player_media_extension"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/player_media_bitrate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintTop_toTopOf="@id/player_media_extension"
app:layout_constraintBottom_toBottomOf="@id/player_media_extension"
app:layout_constraintStart_toEndOf="@id/player_media_extension"
app:layout_constraintEnd_toEndOf="parent"/>
<com.google.android.material.chip.Chip
android:id="@+id/player_media_extension"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:checked="true"
android:focusable="true"
android:text="Unknown"
app:chipStrokeWidth="0dp"
app:chipBackgroundColor="@color/material_dynamic_secondary40"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/player_media_bitrate"
app:layout_constraintHorizontal_chainStyle="packed"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageButton
android:id="@+id/player_info_track"
@@ -67,8 +79,8 @@
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/player_media_extension"
app:layout_constraintBottom_toBottomOf="@id/player_media_extension"
app:layout_constraintTop_toTopOf="@id/player_media_quality_sector_center"
app:layout_constraintBottom_toBottomOf="@id/player_media_quality_sector_center"
app:srcCompat="@drawable/ic_info_stream"
app:tint="?attr/colorOnPrimaryContainer" />

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="28dp"
android:paddingTop="30dp">
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="8dp"
android:background="@drawable/ic_toolbar_tempo" />
<TextView
style="@style/HeadlineMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/app_name" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

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

View File

@@ -4,6 +4,7 @@
android:id="@+id/homeFragment"
android:icon="@drawable/ic_home"
android:title="@string/menu_home_label" />
<item
android:id="@+id/libraryFragment"
android:icon="@drawable/ic_graphic_eq"

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/searchFragment"
android:icon="@drawable/ic_search"
android:title="@string/menu_search_button" />
<item android:title="Index" >
<menu>
<item
android:id="@+id/homeFragment"
android:icon="@drawable/ic_home"
android:title="@string/menu_home_label" />
<item
android:id="@+id/libraryFragment"
android:icon="@drawable/ic_graphic_eq"
android:title="@string/menu_library_label" />
<item
android:id="@+id/downloadFragment"
android:icon="@drawable/ic_play_for_work"
android:title="@string/menu_download_label" />
<item
android:id="@+id/settingsFragment"
android:icon="@drawable/ic_settings"
android:title="@string/menu_settings_button" />
</menu>
</item>
<item android:title="All" >
<menu>
<item
android:id="@+id/albumCatalogueFragment"
android:icon="@drawable/ic_albums"
android:title="Albums" />
<item
android:id="@+id/artistCatalogueFragment"
android:icon="@drawable/ic_artists"
android:title="Artists" />
<item
android:id="@+id/genreCatalogueFragment"
android:icon="@drawable/ic_genres"
android:title="Genres" />
<item
android:id="@+id/playlistCatalogueFragment"
android:icon="@drawable/ic_playlist"
android:title="Playlists"
android:defaultValue="ALL"/>
</menu>
</item>
</menu>

View File

@@ -220,6 +220,10 @@
<action
android:id="@+id/action_playlistCatalogueFragment_to_playlistPageFragment"
app:destination="@id/playlistPageFragment" />
<argument
android:name="playlist_all"
app:argType="string"
android:defaultValue="ALL" />
</fragment>
<fragment

View File

@@ -224,8 +224,8 @@
<string name="playlist_catalogue_title">Catàleg de llistes de reproducció</string>
<string name="playlist_catalogue_title_expanded">Exploració de llistes de reproducció</string>
<string name="playlist_chooser_dialog_empty">No s\'ha creat cap llista de reproducció</string>
<string name="playlist_chooser_dialog_negative_button">Cancel·la</string>
<string name="playlist_chooser_dialog_neutral_button">Crea</string>
<string name="playlist_chooser_dialog_cancel_button">Cancel·la</string>
<string name="playlist_chooser_dialog_create_button">Crea</string>
<string name="playlist_chooser_dialog_title">Addició a una llista de reproducció</string>
<string name="playlist_chooser_dialog_toast_add_success">S\'han afegit les cançons a la llista de reproducció</string>
<string name="playlist_chooser_dialog_toast_add_failure">No s\'han pogut afegir les cançons a la llista de reproducció</string>

View File

@@ -188,8 +188,8 @@
<string name="playlist_catalogue_title">Playlisten</string>
<string name="playlist_catalogue_title_expanded">Playlisten durchsuchen</string>
<string name="playlist_chooser_dialog_empty">Keine Playlisten erstellt</string>
<string name="playlist_chooser_dialog_negative_button">Abbrechen</string>
<string name="playlist_chooser_dialog_neutral_button">Erstellen</string>
<string name="playlist_chooser_dialog_cancel_button">Abbrechen</string>
<string name="playlist_chooser_dialog_create_button">Erstellen</string>
<string name="playlist_chooser_dialog_title">Zu einer Playliste hinzufügen</string>
<string name="playlist_chooser_dialog_toast_add_success">Lied zu Playlist hinzugefügt</string>
<string name="playlist_chooser_dialog_toast_add_failure">Titel kann nicht zur Playlist hinzugefügt werden</string>

View File

@@ -212,6 +212,7 @@
<string name="menu_unpin_button">Eliminar de la pantalla de inicio</string>
<string name="menu_sort_year">Año</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="playback_speed_dialog_negative_button">Cancelar</string>
<string name="player_queue_clean_all_button">Limpiar la cola de reproducción</string>
<string name="player_queue_save_queue_success">Cola de reproducción guardada</string>
<string name="player_lyrics_download_failure">La letra no se puede descargar</string>
@@ -222,8 +223,8 @@
<string name="playlist_catalogue_title">Catálogo de listas de reproducción</string>
<string name="playlist_catalogue_title_expanded">Explorar listas de reproducción</string>
<string name="playlist_chooser_dialog_empty">No hay listas de reproducción</string>
<string name="playlist_chooser_dialog_negative_button">Cancelar</string>
<string name="playlist_chooser_dialog_neutral_button">Crear</string>
<string name="playlist_chooser_dialog_cancel_button">Cancelar</string>
<string name="playlist_chooser_dialog_create_button">Crear</string>
<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>
@@ -327,6 +328,8 @@
<string name="settings_delete_download_storage_summary">Al continuar se eliminarán de forma irreversible todos los elementos guardados.</string>
<string name="settings_delete_download_storage_title">Eliminar elementos guardados</string>
<string name="settings_download_storage_title">Almacenamiento de descargas</string>
<string name="settings_ping_timeout_summary">Establece el tiempo de espera de la URL local. Por defecto son 2 segundos (el servidor remoto usará este valor x3 hasta un máximo de 10 segundos).</string>
<string name="settings_ping_timeout_dialog">Establece el tiempo de espera base en segundos</string>
<string name="settings_max_bitrate_download">Tasa de bits para las descargas</string>
<string name="settings_max_bitrate_mobile">Tasa de bits en datos móviles</string>
<string name="settings_max_bitrate_wifi">Tasa de bits en Wi-Fi</string>
@@ -406,6 +409,7 @@
<string name="settings_title_transcoding">Transcodificación</string>
<string name="settings_title_transcoding_download">Transcodificación en descargas</string>
<string name="settings_title_ui">Interfaz de usuario</string>
<string name="settings_title_ui_landscape_items_per_row_dialog">Número de elementos por fila</string>
<string name="settings_transcoded_download">Descargas transcodificadas</string>
<string name="settings_version_title">Versión</string>
<string name="settings_wifi_only_title">Aviso de streaming solo por Wi-Fi</string>
@@ -497,6 +501,7 @@
<string name="settings_show_mini_shuffle_button">Mostrar el botón «Aleatorio»</string>
<string name="settings_auto_download_lyrics">Descargar automáticamente las letras</string>
<string name="starred_artist_sync_dialog_summary">Descargar los artistas destacados podría consumir una gran cantidad de datos.</string>
<string name="settings_summary_landscape_items_per_row">Aplica a todos los listados de álbumes y artistas. Por defecto es 4</string>
<string name="settings_sync_starred_artists_for_offline_use_summary">Si se habilita, los artistas destacados se descargarán para uso sin conexión.</string>
<string name="widget_time_elapsed_placeholder">0:00</string>
<string name="exo_controls_heart_off_description">Eliminar de favoritos</string>
@@ -528,4 +533,7 @@
<string name="folder_play_no_songs">No se encontraron pistas en la carpeta</string>
<string name="search_sort_title">Ordenar las búsquedas recientes cronológicamente</string>
<string name="search_sort_summary">Si se habilita, se ordenan las búsquedas en orden cronológico. En caso contrario, se ordenan por nombre.</string>
<string name="settings_ping_timeout_title">Tiempo de espera de ping al servidor</string>
<string name="playback_speed_dialog_title">Velocidad de reproducción</string>
<string name="settings_title_ui_landscape_items_per_row">Elementos por fila en modo horizontal</string>
</resources>

View File

@@ -254,4 +254,46 @@
<item>3</item>
<item>4</item>
</string-array>
<!-- Add by MFO -->
<string-array name="aa_tab_titles">
<item>Ne pas afficher</item>
<item>Accueil</item>
<item>Récent</item>
<item>Albums</item>
<item>Artists</item>
<item>Playlists</item>
<item>Podcast</item>
<item>Radio</item>
<item>Dossiers</item>
<item>Albums plus joués</item>
<!-- <item>Titres joués</item> -->
<item>Albums ajouté</item>
<!-- <item>Pour vous</item> -->
<item>Titres favoris</item>
<item>Albums favoris</item>
<item>Artistes favoris</item>
<item>Aléatoire</item>
</string-array>
<string-array name="aa_tab_values">
<item>-1</item>
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
<item>5</item>
<item>6</item>
<item>7</item>
<item>8</item>
<item>9</item>
<item>10</item>
<item>11</item>
<item>12</item>
<item>13</item>
<item>14</item>
<item>15</item>
</string-array>
<!-- end Add by MFO -->
</resources>

View File

@@ -1,4 +1,22 @@
<resources>
<string name="aa_albums">Albums</string>
<string name="aa_album_most_played">Albums plus joués</string>
<string name="aa_album_recently_added">Albums ajoutés</string>
<string name="aa_artists">Artistes</string>
<string name="aa_home">Accueil</string>
<string name="aa_made_for_you">Pour vous</string>
<string name="aa_more">Plus</string>
<string name="aa_music_folder">Dossiers</string>
<string name="aa_playlists">Playlists</string>
<string name="aa_podcast">Podcast</string>
<string name="aa_radio">Radio</string>
<string name="aa_random">Aléatoire</string>
<string name="aa_recent_albums">Récents</string>
<string name="aa_song_recently_played">Titres joués</string>
<string name="aa_starred_albums">★ Albums</string>
<string name="aa_starred_artists">★ Artistes</string>
<string name="aa_starred_tracks">★ Titres</string>
<string name="activity_battery_optimizations_conclusion">Si vous rencontrez un problème, visitez https://dontkillmyapp.com. Des instructions pour désactiver les fonctions de sauvegarde d\'énergie qui pourrait affecter les performance de l\'app y sont disponibles.</string>
<string name="activity_battery_optimizations_summary">Veuillez désactiver les optimisations de la batterie pour permettre la lecture des médias lorsque l\'écran est éteint.</string>
<string name="activity_battery_optimizations_title">Optimisations de la batterie</string>
@@ -230,8 +248,8 @@
<string name="playlist_catalogue_title">Catalogue des Playlists</string>
<string name="playlist_catalogue_title_expanded">Parcourir les playlists</string>
<string name="playlist_chooser_dialog_empty">Pas de playlist</string>
<string name="playlist_chooser_dialog_negative_button">Annuler</string>
<string name="playlist_chooser_dialog_neutral_button">Créer</string>
<string name="playlist_chooser_dialog_cancel_button">Annuler</string>
<string name="playlist_chooser_dialog_create_button">Créer</string>
<string name="playlist_chooser_dialog_title">Ajouter à une playlist</string>
<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>
@@ -310,6 +328,16 @@
<string name="settings_always_on_display">Toujours visible</string>
<string name="settings_allow_playlist_duplicates">Autoriser l\'ajout de doublons à une playlist</string>
<string name="settings_allow_playlist_duplicates_summary">Si activé, les doublons ne seront pas détectés à l\'ajout d\'un titre à une playlist.</string>
<string name="settings_androidauto">Android Auto</string>
<string name="settings_androidauto_album_view">Vue en grille des albums</string>
<string name="settings_androidauto_home_view">Vue en grille du menu accueil</string>
<string name="settings_androidauto_playlist_view">Vue en grille des playlists</string>
<string name="settings_androidauto_podcast_view">Vue en grille des podcasts</string>
<string name="settings_androidauto_radio_view">Vue en grille des radios</string>
<string name="settings_androidauto_first_tab">Affichage du premier onglet</string>
<string name="settings_androidauto_second_tab">Affichage du deuxième onglet</string>
<string name="settings_androidauto_third_tab">Affichage du troisième onglet</string>
<string name="settings_androidauto_fourth_tab">Affichage du quatrième onglet</string>
<string name="settings_audio_transcode_download_format">Format de transcodage</string>
<string name="settings_audio_transcode_download_priority_summary">Si activé, Tempus ne forcera pas le téléchargement de la piste avec les paramètres de transcodage ci-dessous.</string>
<string name="settings_audio_transcode_download_priority_title">Prioriser les paramètres du serveurs, utilisés pour le streaming, dans les téléchargements</string>
@@ -404,7 +432,7 @@
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si activé, les pistes favorites seront téléchargées pour l\'écoute hors-ligne.</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">Synchronisation des pistes favorites pour écoute hors-ligne</string>
<string name="settings_theme">Thème</string>
<string name="settings_title_data">Données</string>
<string name="settings_title_data">Données</string>
<string name="settings_title_general">Géneral</string>
<string name="settings_title_playlist">Playlist</string>
<string name="settings_title_rating">Note</string>
@@ -463,7 +491,7 @@
<string name="song_list_page_downloaded">Téléchargé</string>
<string name="song_list_page_most_played">Titres les plus joués</string>
<string name="song_list_page_recently_added">Titres ajoutés récemment</string>
<string name="song_list_page_recently_played">Titrés joués récemment</string>
<string name="song_list_page_recently_played">Titres joués récemment</string>
<string name="song_list_page_starred">Titres favoris</string>
<string name="song_list_page_top">Les meilleurs titres de %1$s</string>
<string name="song_list_page_year">Année %1$d</string>

View File

@@ -223,11 +223,13 @@
<string name="playlist_catalogue_title">Catalogo playlist</string>
<string name="playlist_catalogue_title_expanded">Sfoglia le playlist</string>
<string name="playlist_chooser_dialog_empty">Nessuna playlist creata</string>
<string name="playlist_chooser_dialog_negative_button">Annulla</string>
<string name="playlist_chooser_dialog_neutral_button">Crea</string>
<string name="playlist_chooser_dialog_cancel_button">Annulla</string>
<string name="playlist_chooser_dialog_create_button">Crea</string>
<string name="playlist_chooser_dialog_title">Aggiungi a una playlist</string>
<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_remove_success">Canzone rimossa dalla playlist</string>
<string name="playlist_chooser_dialog_toast_remove_failure">Impossibile rimuovere la canzone dalla 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>
@@ -448,7 +450,8 @@
<string name="song_bottom_sheet_instant_mix">Mix istantaneo</string>
<string name="song_bottom_sheet_play_next">Riproduci dopo</string>
<string name="song_bottom_sheet_rate">Valuta</string>
<string name="song_bottom_sheet_remove">Rimuovi</string>
<string name="song_bottom_sheet_remove">Rimuovi dal dispositivo</string>
<string name="song_bottom_sheet_remove_from_playlist">Rimuovi dalla playlist</string>
<string name="song_bottom_sheet_share">Condividi</string>
<string name="song_list_page_downloaded">Scaricato</string>
<string name="song_list_page_most_played">Tracce più riprodotte</string>

View File

@@ -172,8 +172,8 @@
<string name="playlist_catalogue_title">플레이리스트 카탈로그</string>
<string name="playlist_catalogue_title_expanded">플레이리스트 찾아보기</string>
<string name="playlist_chooser_dialog_empty">플레이리스트가 없습니다.</string>
<string name="playlist_chooser_dialog_negative_button">취소</string>
<string name="playlist_chooser_dialog_neutral_button">생성</string>
<string name="playlist_chooser_dialog_cancel_button">취소</string>
<string name="playlist_chooser_dialog_create_button">생성</string>
<string name="playlist_chooser_dialog_title">플레이리스트 추가</string>
<string name="playlist_chooser_dialog_toast_add_success">재생 목록에 음악 추가</string>
<string name="playlist_chooser_dialog_toast_add_failure">재생 목록에 음악을 추가하지 못했습니다.</string>

View File

@@ -263,4 +263,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

@@ -207,6 +207,8 @@
<string name="menu_unpin_button">Usuń z ekranu głównego</string>
<string name="menu_sort_year">Rok</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="playback_speed_dialog_title">Prędkość odtwarzania</string>
<string name="playback_speed_dialog_negative_button">Anuluj</string>
<string name="player_queue_clean_all_button">Wyczyść kolejkę odtwarzania</string>
<string name="player_queue_save_queue_success">Zapisano kolejkę odtwarzania</string>
<string name="player_queue_save_to_playlist">Zapisz kolejkę do playlisty</string>
@@ -222,8 +224,8 @@
<string name="playlist_catalogue_title">Katalog Playlist</string>
<string name="playlist_catalogue_title_expanded">Przeglądaj Playlisty</string>
<string name="playlist_chooser_dialog_empty">Nie utworzono playlist</string>
<string name="playlist_chooser_dialog_negative_button">Anuluj</string>
<string name="playlist_chooser_dialog_neutral_button">Utwórz</string>
<string name="playlist_chooser_dialog_cancel_button">Anuluj</string>
<string name="playlist_chooser_dialog_create_button">Utwórz</string>
<string name="playlist_chooser_dialog_title">Dodaj do playlisty</string>
<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>
@@ -393,6 +395,7 @@
<string name="settings_summary_transcoding">Priorytet dawany trybowi transkodowania. Jeżeli ustawiony na \"Odtwarzanie bezpośrednie\" bitrate pliku nie zostanie zmieniony.</string>
<string name="settings_summary_transcoding_download">Pobieraj transkdowane media. Jeżeli włączone, endpoint pobierania nie będzie używnany, poza następującymi ustawieniami. \n\n Jeżeli \"Format transkodowania dla pobierania\" jest ustawiony na \"Pobieranie bezpośrednie\" bitrate pliku nie zostanie zmieniony.</string>
<string name="settings_summary_transcoding_estimate_content_length">Kiedy plik jest transkodowany w locie, klient nie pokazuje zwykle długości utworu.Jest możliwe odpytanie serwera który wspiera tą funkcjonalność aby oszacował długość odtwarzanego utworu, ale czasy odpowiedzi mogą być dłuższe.</string>
<string name="settings_summary_landscape_items_per_row">Ma zastosowanie do wszystkich list albumów i wykonawców. Domyślnie to 4</string>
<string name="settings_sync_starred_artists_for_offline_use_summary">Jeżeli włączone, utwory wykonawców oznaczonych gwiazdką będą pobierane do użycia offline.</string>
<string name="settings_sync_starred_artists_for_offline_use_title">Synchronizuj wykonawców oznacznych gwiazdką do użycia offline</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">Jeżeli włączone, albumy oznaczone gwiazdką będą pobieranew do użycia offline.</string>
@@ -413,6 +416,8 @@
<string name="settings_title_transcoding">Transkodowanie</string>
<string name="settings_title_transcoding_download">Transkodowanie Pobrań</string>
<string name="settings_title_ui">Interfejs</string>
<string name="settings_title_ui_landscape_items_per_row">Elementów na wiersz w poziomie</string>
<string name="settings_title_ui_landscape_items_per_row_dialog">Liczba elementów na wiersz</string>
<string name="settings_transcoded_download">Transkodowane pobieranie</string>
<string name="settings_version_summary" translatable="false">3.1.0</string>
<string name="settings_version_title">Wersja</string>

View File

@@ -159,8 +159,8 @@
<string name="playlist_catalogue_title">Catálogo de Playlists</string>
<string name="playlist_catalogue_title_expanded">Navegar pelas Playlists</string>
<string name="playlist_chooser_dialog_empty">Nenhuma playlist criada</string>
<string name="playlist_chooser_dialog_negative_button">Cancelar</string>
<string name="playlist_chooser_dialog_neutral_button">Criar</string>
<string name="playlist_chooser_dialog_cancel_button">Cancelar</string>
<string name="playlist_chooser_dialog_create_button">Criar</string>
<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>

View File

@@ -233,8 +233,8 @@
<string name="playlist_catalogue_title">Catalogul Playlisturi</string>
<string name="playlist_catalogue_title_expanded">Răsfoiți Playlisturi</string>
<string name="playlist_chooser_dialog_empty">Niciun playlist creat</string>
<string name="playlist_chooser_dialog_negative_button">Anulati</string>
<string name="playlist_chooser_dialog_neutral_button">Creaţi</string>
<string name="playlist_chooser_dialog_cancel_button">Anulati</string>
<string name="playlist_chooser_dialog_create_button">Creaţi</string>
<string name="playlist_chooser_dialog_title">Adăugați la un playlist</string>
<string name="playlist_chooser_dialog_toast_add_success">Piesa(e) adăugată(e) la playlist</string>
<string name="playlist_chooser_dialog_toast_add_failure">Eșec la adăugarea piese(lor) la playlist</string>

View File

@@ -200,8 +200,8 @@
<string name="playlist_catalogue_title">Каталог плейлистов</string>
<string name="playlist_catalogue_title_expanded">Просмотр плейлистов</string>
<string name="playlist_chooser_dialog_empty">Плейлисты не созданы</string>
<string name="playlist_chooser_dialog_negative_button">Отмена</string>
<string name="playlist_chooser_dialog_neutral_button">Создать</string>
<string name="playlist_chooser_dialog_cancel_button">Отмена</string>
<string name="playlist_chooser_dialog_create_button">Создать</string>
<string name="playlist_chooser_dialog_title">Добавить в плейлист</string>
<string name="playlist_chooser_dialog_toast_add_success">Добавьте песню в плейлист</string>
<string name="playlist_chooser_dialog_toast_add_failure">Не удалось добавить песню в список воспроизведения</string>

View File

@@ -203,8 +203,8 @@
<string name="playlist_catalogue_title">Çalma Listesi Kataloğu</string>
<string name="playlist_catalogue_title_expanded">Çalma listelerine göz at</string>
<string name="playlist_chooser_dialog_empty">Henüz çalma listesi oluşturulmadı</string>
<string name="playlist_chooser_dialog_negative_button">İptal</string>
<string name="playlist_chooser_dialog_neutral_button">Oluştur</string>
<string name="playlist_chooser_dialog_cancel_button">İptal</string>
<string name="playlist_chooser_dialog_create_button">Oluştur</string>
<string name="playlist_chooser_dialog_title">Çalma listesine ekle</string>
<string name="playlist_chooser_dialog_toast_add_success">Şarkı çalma listesine eklendi</string>
<string name="playlist_chooser_dialog_toast_add_failure">Şarkı çalma listesine eklenemedi</string>

View File

@@ -260,8 +260,8 @@
<string name="playlist_catalogue_title">播放列表目录</string>
<string name="playlist_catalogue_title_expanded">浏览播放列表</string>
<string name="playlist_chooser_dialog_empty">尚未创建播放列表</string>
<string name="playlist_chooser_dialog_negative_button">取消</string>
<string name="playlist_chooser_dialog_neutral_button">新建</string>
<string name="playlist_chooser_dialog_cancel_button">取消</string>
<string name="playlist_chooser_dialog_create_button">新建</string>
<string name="playlist_chooser_dialog_title">添加到播放列表</string>
<string name="playlist_chooser_dialog_toast_add_failure">未能将歌曲添加到播放列表</string>
<string name="playlist_chooser_dialog_toast_add_success">将歌曲添加到播放列表</string>

View File

@@ -278,4 +278,46 @@
<item>6</item>
<item>7</item>
</string-array>
<!-- Add by MFO -->
<string-array name="aa_tab_titles">
<item>Do not display</item>
<item>Home</item>
<item>Recent</item>
<item>Albums</item>
<item>Artists</item>
<item>Playlists</item>
<item>Podcast</item>
<item>Radio</item>
<item>Folder</item>
<item>Albums most played</item>
<!-- <item>Tracks played</item> -->
<item>Albums added</item>
<!-- <item>For you</item> -->
<item>Star tracks</item>
<item>Star albums</item>
<item>Star artistes</item>
<item>Random</item>
</string-array>
<string-array name="aa_tab_values">
<item>-1</item>
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
<item>5</item>
<item>6</item>
<item>7</item>
<item>8</item>
<item>9</item>
<item>10</item>
<item>11</item>
<item>12</item>
<item>13</item>
<item>14</item>
<item>15</item>
</string-array>
<!-- end Add by MFO -->
</resources>

View File

@@ -1,4 +1,22 @@
<resources>
<string name="aa_albums">Albums</string>
<string name="aa_album_most_played">Albums most played</string>
<string name="aa_album_recently_added">Albums added</string>
<string name="aa_artists">Artists</string>
<string name="aa_home">Home</string>
<string name="aa_made_for_you">For you</string>
<string name="aa_more">More</string>
<string name="aa_music_folder">Folder</string>
<string name="aa_playlists">Playlists</string>
<string name="aa_podcast">Podcast</string>
<string name="aa_radio">Radio</string>
<string name="aa_random">Random</string>
<string name="aa_recent_albums">Recent</string>
<string name="aa_song_recently_played">Song played</string>
<string name="aa_starred_albums">★ Albums</string>
<string name="aa_starred_artists">★ Artists</string>
<string name="aa_starred_tracks">★ Tracks</string>
<string name="activity_battery_optimizations_conclusion">If in trouble visit https://dontkillmyapp.com. It provides detailed instructions on how to disable any power-saving features that may affect app\'s performance.</string>
<string name="activity_battery_optimizations_summary">Please disable battery optimizations for media playback while the screen is off.</string>
<string name="activity_battery_optimizations_title">Battery Optimizations</string>
@@ -234,14 +252,18 @@
<string name="playlist_catalogue_title">Playlist Catalogue</string>
<string name="playlist_catalogue_title_expanded">Browse Playlists</string>
<string name="playlist_chooser_dialog_empty">No playlists created</string>
<string name="playlist_chooser_dialog_negative_button">Cancel</string>
<string name="playlist_chooser_dialog_neutral_button">Create</string>
<string name="playlist_chooser_dialog_cancel_button">Cancel</string>
<string name="playlist_chooser_dialog_create_button">Create</string>
<string name="playlist_chooser_dialog_title">Add to a playlist</string>
<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_remove_success">Removed song from playlist</string>
<string name="playlist_chooser_dialog_toast_remove_failure">Failed to remove song from 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_chooser_dialog_visibility_switch_label">Mark the playlist as public</string>
<string name="playlist_chooser_dialog_visibility_summary">The server updates the visibility on each request. By default it is set to 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>
@@ -300,6 +322,7 @@
<string name="server_signup_dialog_hint_password">Password</string>
<string name="server_signup_dialog_hint_url">Server URL</string>
<string name="server_signup_dialog_hint_username">Username</string>
<string name="server_signup_dialog_hint_client_certificate">Client certificate (optional)</string>
<string name="server_signup_dialog_negative_button">Cancel</string>
<string name="server_signup_dialog_neutral_button">Delete</string>
<string name="server_signup_dialog_positive_button">Save</string>
@@ -369,6 +392,16 @@
<string name="settings_podcast">Show podcast</string>
<string name="settings_podcast_summary">If enabled, show the podcast section. Restart the app for it to take full effect.</string>
<string name="settings_playlist_sort">Playlist sorting</string>
<string name="settings_androidauto">Android Auto</string>
<string name="settings_androidauto_album_view">Grid view for albums</string>
<string name="settings_androidauto_home_view">Grid view for home</string>
<string name="settings_androidauto_playlist_view">Grid view for playlists</string>
<string name="settings_androidauto_podcast_view">Grid view for podcast</string>
<string name="settings_androidauto_radio_view">Grid view for radio</string>
<string name="settings_androidauto_first_tab">First tab display</string>
<string name="settings_androidauto_second_tab">Second tab display</string>
<string name="settings_androidauto_third_tab">Third tab display</string>
<string name="settings_androidauto_fourth_tab">Fourth tab display</string>
<string name="settings_audio_quality">Show audio quality</string>
<string name="settings_audio_quality_summary">The bitrate and audio format will be shown for each audio track.</string>
<string name="settings_song_rating">Show song star rating</string>
@@ -382,6 +415,10 @@
<string name="settings_show_mini_shuffle_button_summary">If enabled, show the shuffle button, remove the heart in the mini player</string>
<string name="settings_radio">Show radio</string>
<string name="settings_radio_summary">If enabled, show the radio section. Restart the app for it to take full effect.</string>
<string name="settings_enable_drawer_on_landscape">Enable drawer on portrait [Experimental]</string>
<string name="settings_enable_drawer_on_landscape_summary">Unlocks the lateral landscape menu drawer on portrait. The changes will take effect on restart.</string>
<string name="settings_hide_bottom_navbar_on_portrait">Hide bottom navbar on portrait [Experimental]</string>
<string name="settings_hide_bottom_navbar_on_portrait_summary">Experimental.Increases vertical space by removing the bottom navbar. The changes will take effect on restart.</string>
<string name="settings_auto_download_lyrics">Auto download lyrics</string>
<string name="settings_auto_download_lyrics_summary">Automatically save lyrics when they are available so they can be shown while offline.</string>
<string name="settings_replay_gain">Set replay gain mode</string>
@@ -470,7 +507,8 @@
<string name="song_bottom_sheet_instant_mix">Instant mix</string>
<string name="song_bottom_sheet_play_next">Play next</string>
<string name="song_bottom_sheet_rate">Rate</string>
<string name="song_bottom_sheet_remove">Remove</string>
<string name="song_bottom_sheet_remove">Remove from device</string>
<string name="song_bottom_sheet_remove_from_playlist">Remove from playlist</string>
<string name="song_bottom_sheet_share">Share</string>
<string name="song_list_page_downloaded">Downloaded</string>
<string name="song_list_page_most_played">Most played tracks</string>
@@ -517,6 +555,7 @@
<string name="track_info_summary_transcoding_codec">The application will request the server to transcode the file. The requested codec by the user is %1$s, while the bitrate will be the same as the source file. The potential transcoding of the file into the chosen format is dependent on the server, as it may or may not support the operation.</string>
<string name="track_info_title">Title</string>
<string name="track_info_track_number">Track number</string>
<string name="track_info_station">Station</string>
<string name="track_info_transcoded_content_type">Transcoded content type</string>
<string name="track_info_transcoded_suffix">Transcoded suffix</string>
<string name="track_info_year">Year</string>

View File

@@ -54,6 +54,16 @@
android:defaultValue="false"
android:key="always_on_display" />
<SwitchPreference
android:title="@string/settings_enable_drawer_on_landscape"
android:key="enable_drawer_on_portrait"
android:summary="@string/settings_enable_drawer_on_landscape_summary"/>
<SwitchPreference
android:title="@string/settings_hide_bottom_navbar_on_portrait"
android:key="hide_bottom_navbar_on_portrait"
android:summary="@string/settings_hide_bottom_navbar_on_portrait_summary"/>
<SwitchPreference
android:layout_height="match_parent"
android:defaultValue="true"
@@ -136,7 +146,7 @@
android:defaultValue="false"
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"
@@ -145,7 +155,7 @@
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">
@@ -444,6 +454,76 @@
android:key="github_update_check" />
</PreferenceCategory>
<!-- Android Auto configuration -->
<PreferenceCategory app:title="@string/settings_androidauto">
<Preference
app:selectable="false"
app:summary="@string/home_rearrangement_dialog_subtitle" />
<SwitchPreference
android:title="@string/settings_androidauto_home_view"
android:defaultValue="false"
android:key="androidauto_home_view" />
<SwitchPreference
android:title="@string/settings_androidauto_album_view"
android:defaultValue="true"
android:key="androidauto_album_view" />
<SwitchPreference
android:title="@string/settings_androidauto_playlist_view"
android:defaultValue="false"
android:key="androidauto_playlist_view" />
<SwitchPreference
android:title="@string/settings_androidauto_radio_view"
android:defaultValue="false"
android:key="androidauto_radio_view" />
<SwitchPreference
android:title="@string/settings_androidauto_podcast_view"
android:defaultValue="false"
android:key="androidauto_podcast_view" />
<ListPreference
app:defaultValue="0"
app:dialogTitle="@string/settings_androidauto_first_tab"
app:entries="@array/aa_tab_titles"
app:entryValues="@array/aa_tab_values"
app:key="androidauto_first_tab"
app:title="@string/settings_androidauto_first_tab"
app:useSimpleSummaryProvider="true" />
<ListPreference
app:defaultValue="1"
app:dialogTitle="@string/settings_androidauto_second_tab"
app:entries="@array/aa_tab_titles"
app:entryValues="@array/aa_tab_values"
app:key="androidauto_second_tab"
app:title="@string/settings_androidauto_second_tab"
app:useSimpleSummaryProvider="true" />
<ListPreference
app:defaultValue="2"
app:dialogTitle="@string/settings_androidauto_third_tab"
app:entries="@array/aa_tab_titles"
app:entryValues="@array/aa_tab_values"
app:key="androidauto_third_tab"
app:title="@string/settings_androidauto_third_tab"
app:useSimpleSummaryProvider="true" />
<ListPreference
app:defaultValue="3"
app:dialogTitle="@string/settings_androidauto_fourth_tab"
app:entries="@array/aa_tab_titles"
app:entryValues="@array/aa_tab_values"
app:key="androidauto_fourth_tab"
app:title="@string/settings_androidauto_fourth_tab"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<!-- end Add by MFO -->
<PreferenceCategory app:title="@string/settings_about_title">
<Preference
app:selectable="false"

View File

@@ -1,37 +1,47 @@
package com.cappielloantonio.tempo.service
import android.content.Context
import android.net.Uri
import androidx.lifecycle.LifecycleOwner
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MediaMetadata
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import com.cappielloantonio.tempo.BuildConfig
import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.util.Preferences.getServerId
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.util.Preferences
object MediaBrowserTree {
private lateinit var appContext: Context
private lateinit var automotiveRepository: AutomotiveRepository
private var treeNodes: MutableMap<String, MediaItemNode> = mutableMapOf()
private var isInitialized = false
/* data class FunctionItem(
val id: String,
var isDisplayed: Boolean
)
*/
// Root
private const val ROOT_ID = "[rootID]"
// First level
// Available functions
private const val HOME_ID = "[homeID]"
private const val LIBRARY_ID = "[libraryID]"
private const val OTHER_ID = "[otherID]"
// Second level HOME_ID
private const val MOST_PLAYED_ID = "[mostPlayedID]"
private const val LAST_PLAYED_ID = "[lastPlayedID]"
private const val ALBUMS_ID = "[albumsID]"
private const val ARTISTS_ID = "[artistsID]"
private const val MOST_PLAYED_ID = "[mostPlayedID]"
private const val PLAYLIST_ID = "[playlistID]"
private const val PODCAST_ID = "[podcastID]"
private const val RADIO_ID = "[radioID]"
private const val RECENTLY_ADDED_ID = "[recentlyAddedID]"
private const val RECENT_SONGS_ID = "[recentSongsID]"
private const val MADE_FOR_YOU_ID = "[madeForYouID]"
@@ -39,20 +49,17 @@ object MediaBrowserTree {
private const val STARRED_ALBUMS_ID = "[starredAlbumsID]"
private const val STARRED_ARTISTS_ID = "[starredArtistsID]"
private const val RANDOM_ID = "[randomID]"
// Second level LIBRARY_ID
private const val FOLDER_ID = "[folderID]"
// System functions
private const val INDEX_ID = "[indexID]"
private const val DIRECTORY_ID = "[directoryID]"
private const val PLAYLIST_ID = "[playlistID]"
// Second level OTHER_ID
private const val PODCAST_ID = "[podcastID]"
private const val RADIO_ID = "[radioID]"
private const val ALBUM_ID = "[albumID]"
private const val ARTIST_ID = "[artistID]"
private fun iconUri(resId: Int): Uri =
Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/$resId")
private class MediaItemNode(val item: MediaItem) {
private val children: MutableList<MediaItem> = ArrayList()
@@ -71,6 +78,7 @@ object MediaBrowserTree {
}
private fun buildMediaItem(
gridView: Boolean,
title: String,
mediaId: String,
isPlayable: Boolean,
@@ -83,18 +91,43 @@ object MediaBrowserTree {
sourceUri: Uri? = null,
imageUri: Uri? = null
): MediaItem {
val metadata =
MediaMetadata.Builder()
.setAlbumTitle(album)
.setTitle(title)
.setArtist(artist)
.setGenre(genre)
.setIsBrowsable(isBrowsable)
.setIsPlayable(isPlayable)
.setArtworkUri(imageUri)
.setMediaType(mediaType)
.build()
var extras = Bundle()
if( gridView ) {
extras = Bundle().apply {
putInt(
MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
)
putInt(
MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
)
}
}
else{
extras = Bundle().apply {
putInt(
MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
)
putInt(
MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
)
}
}
val metadata = MediaMetadata.Builder()
.setAlbumTitle(album)
.setTitle(title)
.setArtist(artist)
.setGenre(genre)
.setIsBrowsable(isBrowsable)
.setIsPlayable(isPlayable)
.setArtworkUri(imageUri)
.setMediaType(mediaType)
.setExtras(extras)
.build()
return MediaItem.Builder()
.setMediaId(mediaId)
.setSubtitleConfigurations(subtitleConfigurations)
@@ -102,19 +135,57 @@ object MediaBrowserTree {
.setUri(sourceUri)
.build()
}
fun initialize(automotiveRepository: AutomotiveRepository) {
fun initialize(
context: Context,
automotiveRepository: AutomotiveRepository) {
this.automotiveRepository = automotiveRepository
appContext = context.applicationContext
if (isInitialized) return
isInitialized = true
}
fun buildTree() {
val albumView: Boolean = Preferences.isAndroidAutoAlbumViewEnabled()
val homeView: Boolean = Preferences.isAndroidAutoHomeViewEnabled()
val playlistView: Boolean = Preferences.isAndroidAutoPlaylistViewEnabled()
val podcastView: Boolean = Preferences.isAndroidAutoPodcastViewEnabled()
val radioView: Boolean = Preferences.isAndroidAutoRadioViewEnabled()
val tabIndex = listOf(
Preferences.getAndroidAutoFirstTab(),
Preferences.getAndroidAutoSecondTab(),
Preferences.getAndroidAutoThirdTab(),
Preferences.getAndroidAutoFourthTab()
)
// clear before rebuild
treeNodes.clear()
// This list must be exactly the same as the one in aa_tab_titles
val allFunctions = listOf(
HOME_ID,
LAST_PLAYED_ID,
ALBUMS_ID,
ARTISTS_ID,
PLAYLIST_ID,
PODCAST_ID,
RADIO_ID,
FOLDER_ID,
MOST_PLAYED_ID,
// RECENT_SONGS_ID, // => doesn't work !
RECENTLY_ADDED_ID,
// MADE_FOR_YOU_ID, // => doesn't work !
STARRED_TRACKS_ID,
STARRED_ALBUMS_ID,
STARRED_ARTISTS_ID,
RANDOM_ID
)
// Root level
treeNodes[ROOT_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = "Root Folder",
mediaId = ROOT_ID,
isPlayable = false,
@@ -123,192 +194,98 @@ object MediaBrowserTree {
)
)
// First level
treeNodes[HOME_ID] =
MediaItemNode(
buildMediaItem(
title = "Home",
mediaId = HOME_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[LIBRARY_ID] =
MediaItemNode(
buildMediaItem(
title = "Library",
mediaId = LIBRARY_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[OTHER_ID] =
MediaItemNode(
buildMediaItem(
title = "Other",
mediaId = OTHER_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[ROOT_ID]!!.addChild(HOME_ID)
treeNodes[ROOT_ID]!!.addChild(LIBRARY_ID)
treeNodes[ROOT_ID]!!.addChild(OTHER_ID)
// Second level HOME_ID
treeNodes[MOST_PLAYED_ID] =
MediaItemNode(
buildMediaItem(
title = "Most played",
mediaId = MOST_PLAYED_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
// All available functions
// if HOME is in first place or no item is selected
if (tabIndex.firstOrNull() == 0 || tabIndex.all { it == -1 }){
treeNodes[HOME_ID] =
MediaItemNode(
buildMediaItem(
gridView = homeView,
title = appContext.getString(R.string.aa_home),
mediaId = HOME_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_home),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
}
else { // More instead of Home
treeNodes[HOME_ID] =
MediaItemNode(
buildMediaItem(
gridView = homeView,
title = appContext.getString(R.string.aa_more),
mediaId = HOME_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_other),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
}
treeNodes[LAST_PLAYED_ID] =
MediaItemNode(
buildMediaItem(
title = "Last played",
gridView = albumView,
title = appContext.getString(R.string.aa_recent_albums),
mediaId = LAST_PLAYED_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_recent),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[RECENTLY_ADDED_ID] =
treeNodes[ALBUMS_ID] =
MediaItemNode(
buildMediaItem(
title = "Recently added",
mediaId = RECENTLY_ADDED_ID,
gridView = albumView,
title = appContext.getString(R.string.aa_albums),
mediaId = ALBUMS_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_albums),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[RECENT_SONGS_ID] =
MediaItemNode(
buildMediaItem(
title = "Recent songs",
mediaId = RECENT_SONGS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[MADE_FOR_YOU_ID] =
treeNodes[ARTISTS_ID] =
MediaItemNode(
buildMediaItem(
title = "Made for you",
mediaId = MADE_FOR_YOU_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
)
)
treeNodes[STARRED_TRACKS_ID] =
MediaItemNode(
buildMediaItem(
title = "Starred tracks",
mediaId = STARRED_TRACKS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[STARRED_ALBUMS_ID] =
MediaItemNode(
buildMediaItem(
title = "Starred albums",
mediaId = STARRED_ALBUMS_ID,
gridView = albumView,
title = appContext.getString(R.string.aa_artists),
mediaId = ARTISTS_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_artists),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[STARRED_ARTISTS_ID] =
MediaItemNode(
buildMediaItem(
title = "Starred artists",
mediaId = STARRED_ARTISTS_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
)
)
treeNodes[RANDOM_ID] =
MediaItemNode(
buildMediaItem(
title = "Random",
mediaId = RANDOM_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[HOME_ID]!!.addChild(MOST_PLAYED_ID)
treeNodes[HOME_ID]!!.addChild(LAST_PLAYED_ID)
treeNodes[HOME_ID]!!.addChild(RECENTLY_ADDED_ID)
treeNodes[HOME_ID]!!.addChild(RECENT_SONGS_ID)
treeNodes[HOME_ID]!!.addChild(MADE_FOR_YOU_ID)
treeNodes[HOME_ID]!!.addChild(STARRED_TRACKS_ID)
treeNodes[HOME_ID]!!.addChild(STARRED_ALBUMS_ID)
treeNodes[HOME_ID]!!.addChild(STARRED_ARTISTS_ID)
treeNodes[HOME_ID]!!.addChild(RANDOM_ID)
// Second level LIBRARY_ID
treeNodes[FOLDER_ID] =
MediaItemNode(
buildMediaItem(
title = "Folders",
mediaId = FOLDER_ID,
isPlayable = false,
isBrowsable = true,
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[PLAYLIST_ID] =
MediaItemNode(
buildMediaItem(
title = "Playlists",
gridView = playlistView,
title = appContext.getString(R.string.aa_playlists),
mediaId = PLAYLIST_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_playlist),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
)
)
treeNodes[LIBRARY_ID]!!.addChild(FOLDER_ID)
treeNodes[LIBRARY_ID]!!.addChild(PLAYLIST_ID)
// Second level OTHER_ID
treeNodes[PODCAST_ID] =
MediaItemNode(
buildMediaItem(
title = "Podcasts",
gridView = podcastView,
title = appContext.getString(R.string.aa_podcast),
mediaId = PODCAST_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_podcasts),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS
)
)
@@ -316,18 +293,161 @@ object MediaBrowserTree {
treeNodes[RADIO_ID] =
MediaItemNode(
buildMediaItem(
title = "Radio stations",
gridView = radioView,
title = appContext.getString(R.string.aa_radio),
mediaId = RADIO_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_radio),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_RADIO_STATIONS
)
)
treeNodes[OTHER_ID]!!.addChild(PODCAST_ID)
treeNodes[OTHER_ID]!!.addChild(RADIO_ID)
}
treeNodes[MOST_PLAYED_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_album_most_played),
mediaId = MOST_PLAYED_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_mostplayed),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[RECENTLY_ADDED_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_album_recently_added),
mediaId = RECENTLY_ADDED_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_added_album),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[RECENT_SONGS_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_song_recently_played),
mediaId = RECENT_SONGS_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_recent_title),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[MADE_FOR_YOU_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_made_for_you),
mediaId = MADE_FOR_YOU_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_for_you),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
)
)
treeNodes[STARRED_TRACKS_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_starred_tracks),
mediaId = STARRED_TRACKS_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_star_title),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[STARRED_ALBUMS_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_starred_albums),
mediaId = STARRED_ALBUMS_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_star_album),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
)
)
treeNodes[STARRED_ARTISTS_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_starred_artists),
mediaId = STARRED_ARTISTS_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_artists),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
)
)
treeNodes[FOLDER_ID] =
MediaItemNode(
buildMediaItem(
gridView = false,
title = appContext.getString(R.string.aa_music_folder),
mediaId = FOLDER_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_folders),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
treeNodes[RANDOM_ID] =
MediaItemNode(
buildMediaItem(
gridView = albumView,
title = appContext.getString(R.string.aa_random),
mediaId = RANDOM_ID,
isPlayable = false,
isBrowsable = true,
imageUri = iconUri(R.drawable.ic_aa_random),
mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
)
)
val root = treeNodes[ROOT_ID]!!
val selectedIds = mutableSetOf<String>()
// First level
// add functions selected by user for the 4 tabs
tabIndex
.filter { it != -1 }
.forEach { index ->
allFunctions.getOrNull(index)?.let { function ->
if (selectedIds.add(function)) {
root.addChild(function)
}
}
}
// if no function is selected, add at least HOME_ID
if (selectedIds.isEmpty()) {
root.addChild(HOME_ID)
selectedIds.add(HOME_ID)
}
// Second level for HOME_ID even there is no HOME_ID displayed
// add all functions not previously added
allFunctions
.filter { it !in selectedIds }
.forEach { function ->
treeNodes[HOME_ID]?.addChild(function)
}
}
fun getRootItem(): MediaItem {
return treeNodes[ROOT_ID]!!.item
}
@@ -337,125 +457,83 @@ object MediaBrowserTree {
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return when (id) {
ROOT_ID -> treeNodes[ROOT_ID]?.getChildren()!!
HOME_ID -> treeNodes[HOME_ID]?.getChildren()!!
LIBRARY_ID -> treeNodes[LIBRARY_ID]?.getChildren()!!
OTHER_ID -> treeNodes[OTHER_ID]?.getChildren()!!
MOST_PLAYED_ID -> automotiveRepository.getAlbums(id, "frequent", 100)
LAST_PLAYED_ID -> automotiveRepository.getAlbums(id, "recent", 100)
RECENTLY_ADDED_ID -> automotiveRepository.getAlbums(id, "newest", 100)
RECENT_SONGS_ID -> automotiveRepository.getRecentlyPlayedSongs(getServerId(),100)
HOME_ID -> treeNodes[HOME_ID]?.getChildren()!!
LAST_PLAYED_ID -> automotiveRepository.getAlbums(id, "recent", 15)
ALBUMS_ID -> automotiveRepository.getAlbums(id, "alphabeticalByName", 500)
ARTISTS_ID -> automotiveRepository.getAlbums(id, "alphabeticalByArtist", 500)
PLAYLIST_ID -> automotiveRepository.getPlaylists(id)
PODCAST_ID -> automotiveRepository.getNewestPodcastEpisodes(100)
RADIO_ID -> automotiveRepository.internetRadioStations
FOLDER_ID -> automotiveRepository.getMusicFolders(id)
MOST_PLAYED_ID -> automotiveRepository.getAlbums(id, "frequent", 15)
RECENT_SONGS_ID -> automotiveRepository.getRecentlyPlayedSongs(getServerId(),30)
RECENTLY_ADDED_ID -> automotiveRepository.getAlbums(id, "newest", 15)
MADE_FOR_YOU_ID -> automotiveRepository.getStarredArtists(id)
STARRED_TRACKS_ID -> automotiveRepository.starredSongs
STARRED_ALBUMS_ID -> automotiveRepository.getStarredAlbums(id)
STARRED_ARTISTS_ID -> automotiveRepository.getStarredArtists(id)
RANDOM_ID -> automotiveRepository.getRandomSongs(100)
FOLDER_ID -> automotiveRepository.getMusicFolders(id)
PLAYLIST_ID -> automotiveRepository.getPlaylists(id)
PODCAST_ID -> automotiveRepository.getNewestPodcastEpisodes(100)
RADIO_ID -> automotiveRepository.internetRadioStations
else -> {
if (id.startsWith(MOST_PLAYED_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
MOST_PLAYED_ID
)
)
if (id.startsWith(LAST_PLAYED_ID)) {
return automotiveRepository.getAlbumTracks(id.removePrefix(LAST_PLAYED_ID))
}
if (id.startsWith(LAST_PLAYED_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
LAST_PLAYED_ID
)
)
if (id.startsWith(ALBUMS_ID)) {
return automotiveRepository.getAlbumTracks(id.removePrefix(ALBUMS_ID))
}
if (id.startsWith(ARTISTS_ID)) {
return automotiveRepository.getAlbumTracks(id.removePrefix(ARTISTS_ID))
}
if (id.startsWith(HOME_ID)) {
return automotiveRepository.getAlbumTracks(id.removePrefix(HOME_ID))
}
if (id.startsWith(MOST_PLAYED_ID)) {
return automotiveRepository.getAlbumTracks(id.removePrefix(MOST_PLAYED_ID))
}
if (id.startsWith(RECENTLY_ADDED_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
RECENTLY_ADDED_ID
)
)
return automotiveRepository.getAlbumTracks(id.removePrefix(RECENTLY_ADDED_ID))
}
if (id.startsWith(MADE_FOR_YOU_ID)) {
return automotiveRepository.getMadeForYou(
id.removePrefix(
MADE_FOR_YOU_ID
),
20
)
return automotiveRepository.getMadeForYou(id.removePrefix(MADE_FOR_YOU_ID),20)
}
if (id.startsWith(STARRED_ALBUMS_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
STARRED_ALBUMS_ID
)
)
return automotiveRepository.getAlbumTracks(id.removePrefix(STARRED_ALBUMS_ID))
}
if (id.startsWith(STARRED_ARTISTS_ID)) {
return automotiveRepository.getArtistAlbum(
STARRED_ALBUMS_ID,
id.removePrefix(
STARRED_ARTISTS_ID
)
)
}
if (id.startsWith(FOLDER_ID)) {
return automotiveRepository.getIndexes(
INDEX_ID,
id.removePrefix(
FOLDER_ID
)
)
}
if (id.startsWith(INDEX_ID)) {
return automotiveRepository.getDirectories(
DIRECTORY_ID,
id.removePrefix(
INDEX_ID
)
)
}
if (id.startsWith(DIRECTORY_ID)) {
return automotiveRepository.getDirectories(
DIRECTORY_ID,
id.removePrefix(
DIRECTORY_ID
)
)
return automotiveRepository.getArtistAlbum(STARRED_ALBUMS_ID,id.removePrefix(STARRED_ARTISTS_ID))
}
if (id.startsWith(PLAYLIST_ID)) {
return automotiveRepository.getPlaylistSongs(
id.removePrefix(
PLAYLIST_ID
)
)
return automotiveRepository.getPlaylistSongs(id.removePrefix(PLAYLIST_ID))
}
if (id.startsWith(ALBUM_ID)) {
return automotiveRepository.getAlbumTracks(
id.removePrefix(
ALBUM_ID
)
)
return automotiveRepository.getAlbumTracks(id.removePrefix(ALBUM_ID))
}
if (id.startsWith(ARTIST_ID)) {
return automotiveRepository.getArtistAlbum(
ALBUM_ID,
id.removePrefix(
ARTIST_ID
)
)
return automotiveRepository.getArtistAlbum(ALBUM_ID,id.removePrefix(ARTIST_ID))
}
if (id.startsWith(FOLDER_ID)) {
return automotiveRepository.getIndexes(INDEX_ID,id.removePrefix(FOLDER_ID))
}
if (id.startsWith(INDEX_ID)) {
return automotiveRepository.getDirectories(DIRECTORY_ID,id.removePrefix(INDEX_ID))
}
if (id.startsWith(DIRECTORY_ID)) {
return automotiveRepository.getDirectories(DIRECTORY_ID,id.removePrefix(DIRECTORY_ID))
}
return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
@@ -490,6 +568,7 @@ object MediaBrowserTree {
fun search(query: String): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return automotiveRepository.search(
query,
// ALBUM_ID,
ALBUM_ID,
ARTIST_ID
)

View File

@@ -32,6 +32,7 @@ import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MO
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
import com.google.common.collect.ImmutableList
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.Preferences
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
@@ -40,13 +41,14 @@ import retrofit2.Callback
import retrofit2.Response
open class MediaLibrarySessionCallback(
context: Context,
automotiveRepository: AutomotiveRepository
private val context: Context,
private val automotiveRepository: AutomotiveRepository
) :
MediaLibraryService.MediaLibrarySession.Callback {
init {
MediaBrowserTree.initialize(automotiveRepository)
// modified by MFO
MediaBrowserTree.initialize(context, automotiveRepository)
}
private val customCommandToggleShuffleModeOn = CommandButton.Builder()
@@ -347,6 +349,8 @@ open class MediaLibrarySessionCallback(
browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> {
// added by MFO
MediaBrowserTree.buildTree()
return Futures.immediateFuture(LibraryResult.ofItem(MediaBrowserTree.getRootItem(), params))
}
@@ -366,11 +370,31 @@ open class MediaLibrarySessionCallback(
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
return super.onAddMediaItems(
mediaSession,
controller,
MediaBrowserTree.getItems(mediaItems)
)
val firstItem = mediaItems.firstOrNull()
val isRadio = firstItem?.mediaId?.startsWith("ir-") == true
if (isRadio) {
return Futures.transformAsync(
automotiveRepository.internetRadioStations,
{ result ->
val stations = result?.value
val selected = stations?.find { item -> item.mediaId == firstItem?.mediaId }
if (selected != null) {
val updatedSelected = selected.buildUpon()
.setMimeType(selected.localConfiguration?.mimeType)
.build()
Futures.immediateFuture(listOf(updatedSelected))
} else {
Futures.immediateFuture(emptyList())
}
},
androidx.core.content.ContextCompat.getMainExecutor(context)
)
}
val resolvedItems = MediaBrowserTree.getItems(mediaItems)
return super.onAddMediaItems(mediaSession, controller, resolvedItems)
}
override fun onSearch(

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

Some files were not shown because too many files have changed in this diff Show More