68 Commits

Author SHA1 Message Date
eddyizm
ccea7674bd chore: bumped version, added fastlane metadata 2025-12-22 19:14:29 -08:00
eddyizm
7f332c26ad chore: changeup bump for version 2025-12-22 19:10:10 -08:00
eddyizm
206a7f38ca chore: updated changelog 2025-12-22 18:39:40 -08:00
eddyizm
16e0a5e12e feat: added regular playlist to home view (#322) 2025-12-22 18:36:56 -08:00
eddyizm
c6896939e2 fix: serialized corrected mapping for playlist cover art to appear 2025-12-22 18:30:49 -08:00
eddyizm
526253723b feat: added regular playlist to home view 2025-12-22 11:04:25 -08:00
eddyizm
9350a9cc2e chore: updating pending release info 2025-12-21 08:20:50 -08:00
eddyizm
e2ec2e4602 Update description_empty_title in French and Spanish (#315) 2025-12-20 10:06:27 -08:00
eddyizm
bca2e8fcae Merge branch 'development' into main 2025-12-20 10:06:03 -08:00
pochopsp
43674ea1f9 Update description_empty_title in French and Spanish 2025-12-20 18:40:55 +01:00
eddyizm
373a1f87a1 Update description_empty_title in Italian (#314) 2025-12-20 07:56:43 -08:00
eddyizm
e14a595fba Merge branch 'development' into patch-1 2025-12-20 07:56:19 -08:00
pochopsp
727e137008 Update description_empty_title in Italian 2025-12-20 12:07:00 +01:00
eddyizm
883d853129 fix: checks preference and writes files externally, updates the ui (#312) 2025-12-17 22:28:20 -08:00
eddyizm
0d329aff64 fix: checks preferecen and writes files externally, updates the ui 2025-12-17 22:27:00 -08:00
eddyizm
94cb6fa279 chore(i18n): Update Polish translation (#310) 2025-12-17 21:25:14 -08:00
eddyizm
257d80ecac Merge branch 'development' into development 2025-12-17 21:25:02 -08:00
eddyizm
d0f77fe0fc Update description_empty_title in English and Polish (#307)
resolves #306
2025-12-17 21:23:11 -08:00
skajmer
e95b504dbb Merge branch 'eddyizm:development' into development 2025-12-17 12:11:19 +01:00
Tymon Flower
0b68799507 Update description_empty_title in English and Polish 2025-12-15 18:25:28 +01:00
eddyizm
9167be2cf2 chore: added new version details 2025-12-12 20:52:05 -08:00
eddyizm
d426c08cdd chore: version bump 2025-12-12 20:45:42 -08:00
eddyizm
972c32b9d8 chore: updated change log, fastlane for pending release 2025-12-12 20:14:10 -08:00
eddyizm
a279e20a49 feat: add heart to artist/album pages, fixed artist cover art failing (#303) 2025-12-12 07:15:11 -08:00
eddyizm
fe60fea928 feat: add heart to artist/album pages, fixed artist cover art failing 2025-12-11 22:07:44 -08:00
skajmer
c6df43da9c left some english in by accident 2025-12-10 22:01:19 +01:00
skajmer
475ed3e7c8 Add #300 2025-12-10 21:59:32 +01:00
skajmer
fb4c762655 Merge branch 'eddyizm:development' into development 2025-12-10 21:56:49 +01:00
eddyizm
a110faabe3 feat: integrate sort recent searches chronologically (#300) 2025-12-08 20:43:15 -08:00
j4mm3ris
df2bf43492 Default the search sort setting to former sorting behavior. 2025-12-07 21:39:10 +02:00
j4mm3ris
b46fea6890 Fix indentation according to previous versions 2025-12-07 21:30:21 +02:00
skajmer
213a0d5293 Add #298 2025-12-07 20:18:40 +01:00
eddyizm
08b6379601 chore: updated readme 2025-12-07 10:48:58 -08:00
eddyizm
3fbadc2521 fix: handle empty albums and null mappings (#301) 2025-12-07 10:05:30 -08:00
eddyizm
9e78caeda4 fix: updates to starred syncing to user defined directory (#298) 2025-12-07 10:05:13 -08:00
eddyizm
e072a49288 fix: handle empty albums and null mappings 2025-12-07 10:04:05 -08:00
j4mm3ris
b89e18eebf feat: integrate sort recent searches chronologically 2025-12-07 13:24:03 +02:00
eddyizm
63607794d6 fix: updates to starred syncing to user defined directory 2025-12-02 21:46:04 -08:00
eddyizm
37842fd897 chore: bumped verison 2025-11-30 17:51:22 -08:00
eddyizm
a1397a224b chore: adding pending release and fastlane updates 2025-11-30 16:43:12 -08:00
eddyizm
804d6af6c3 chore(i18n): Update Polish translation (#291) 2025-11-30 13:49:15 -08:00
skajmer
e315169005 Add #288 2025-11-30 19:57:56 +01:00
skajmer
ea76afee09 Merge branch 'eddyizm:development' into development 2025-11-30 19:52:39 +01:00
eddyizm
45dda3af9b Feat/playerqueue fab (#288) 2025-11-30 10:25:11 -08:00
eddyizm
3d70b51244 fix: updated order of buttons 2025-11-30 09:10:02 -08:00
eddyizm
22f196c8c0 fix: refactor start queue to put the db writing in the background (#287) 2025-11-28 10:00:35 -08:00
eddyizm
540aa9ba73 feat: implemented download queue fab 2025-11-28 09:57:29 -08:00
eddyizm
1ff0b83a19 feat: implemented load queue, adding logging 2025-11-27 13:28:07 -08:00
eddyizm
27f5a47cc9 feat: save q to playlist, removed save queue button, added style to fab. 2025-11-27 08:04:40 -08:00
eddyizm
732b6ad09d fix: moved existing functionality to fab buttons, removed queue text/button from top 2025-11-25 15:48:48 -08:00
eddyizm
0df7346a14 Merge branch 'development' into feat/playerqueue-FAB 2025-11-24 20:52:20 -08:00
eddyizm
786697109d chore: bringing in media service refactor for more testing (#286) 2025-11-24 20:49:48 -08:00
eddyizm
1bfadb0669 fix: refactor start queue to put the db writing in the background 2025-11-24 20:46:46 -08:00
eddyizm
79dc1cc93b chore: bringing in media service refactor for more testing 2025-11-24 13:11:29 -08:00
eddyizm
38fb2c69f1 wip: added fab, need to implement actions 2025-11-24 11:36:56 -08:00
skajmer
b34f827bc0 Add #276 2025-11-23 20:29:20 +01:00
eddyizm
97d1b408e1 chore: bumped version, updated change logs 2025-11-23 09:58:04 -08:00
eddyizm
a5065578ca Fix/start queue blocking UI (#283) 2025-11-23 09:36:31 -08:00
eddyizm
aac5c6067d Merge branch 'library-play' into fix/start-queue-blocking-ui 2025-11-23 09:34:39 -08:00
eddyizm
cfd7cf314b feat: add play functionality to library folder/index items (#276) 2025-11-23 09:33:44 -08:00
eddyizm
c4b73f6014 Merge branch 'development' into main 2025-11-23 09:33:26 -08:00
eddyizm
35d377ce31 Revert "refactor MediaService" (#282) 2025-11-23 09:30:34 -08:00
eddyizm
5e330ac451 Revert "refactor MediaService"
This reverts commit 7aa325f914.
2025-11-23 09:18:32 -08:00
eddyizm
8188ef169c fix: put queue into background thread 2025-11-23 09:09:20 -08:00
eddyizm
3496918ce6 Merge branch 'development' into library-play 2025-11-22 14:30:20 -08:00
eddyizm
c72f368f6a Add Obtainium badge to README (#280) 2025-11-22 11:52:04 -08:00
Mikael Dúi Bolinder
eb089847e0 Add Obtainium badge to README 2025-11-22 21:29:04 +02:00
Ante Budimir
be33401b6f feat: add play functionality to library folder/index items
- add play button to inner folders in library
- implement recursive song collection from folders and subfolders
- filter out video files, play only audio tracks
- add user feedback with toast notifications
2025-11-20 19:13:46 +02:00
54 changed files with 2764 additions and 428 deletions

View File

@@ -2,6 +2,53 @@
## Pending release...
## [4.6.0](https://github.com/eddyizm/tempo/releases/tag/v4.6.0) (2025-12-22)
* chore: Update description_empty_title in English and Polish by @tyren234 in https://github.com/eddyizm/tempus/pull/307
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/310
* fix: checks preference and writes files externally, updates the ui by @eddyizm in https://github.com/eddyizm/tempus/pull/312
* chore: Update description_empty_title in Italian by @pochopsp in https://github.com/eddyizm/tempus/pull/314
* chore: Update description_empty_title in French and Spanish by @pochopsp in https://github.com/eddyizm/tempus/pull/315
* feat: added regular playlist to home view by @eddyizm in https://github.com/eddyizm/tempus/pull/322
## New Contributors
* @tyren234 made their first contribution in https://github.com/eddyizm/tempus/pull/307
* @pochopsp made their first contribution in https://github.com/eddyizm/tempus/pull/314
## [4.5.0](https://github.com/eddyizm/tempo/releases/tag/v4.5.0) (2025-12-12)
## What's Changed
* fix: updates starred syncing downloads to user defined directory by @eddyizm in https://github.com/eddyizm/tempus/pull/298
* fix: handle empty albums and null mappings by @eddyizm in https://github.com/eddyizm/tempus/pull/301
* feat: integrate sort recent searches chronologically by @J4mm3ris in https://github.com/eddyizm/tempus/pull/300
* feat: add heart to artist/album pages, fixed artist cover art failing by @eddyizm in https://github.com/eddyizm/tempus/pull/303
## New Contributors
* @J4mm3ris made their first contribution in https://github.com/eddyizm/tempus/pull/300
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.4.0...v4.5.0
## [4.4.0](https://github.com/eddyizm/tempo/releases/tag/v4.4.0) (2025-11-29)
## What's Changed
* chore: bringing in media service refactor previously reverted after more testing by @eddyizm in https://github.com/eddyizm/tempus/pull/286
* fix: refactor start queue to put the db writing in the background to address instant mix bug by @eddyizm in https://github.com/eddyizm/tempus/pull/287
* Feat: playerqueue fab allows playqueue actions -> saving to playlist, download all, load queue, shuffle, clean queue by @eddyizm in https://github.com/eddyizm/tempus/pull/288
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/291
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.3.0...v4.4.0
## [4.3.0](https://github.com/eddyizm/tempo/releases/tag/v4.3.0) (2025-11-23)
## What's Changed
* chore: Add Obtainium badge to README by @mikaeldui in https://github.com/eddyizm/tempus/pull/280
* fix: Revert "refactor MediaService" by @eddyizm in https://github.com/eddyizm/tempus/pull/282
* feat: add play functionality to library folder/index items by @antebudimir in https://github.com/eddyizm/tempus/pull/276
* fix: start queue blocking UI by @eddyizm in https://github.com/eddyizm/tempus/pull/283
## New Contributors
* @mikaeldui made their first contribution in https://github.com/eddyizm/tempus/pull/280
* @antebudimir made their first contribution in https://github.com/eddyizm/tempus/pull/276
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.6...v4.3.0
## [4.2.6](https://github.com/eddyizm/tempo/releases/tag/v4.2.6) (2025-11-22)
## What's Changed
* fix: Fix player queue soft-lock by @shrapnelnet in https://github.com/eddyizm/tempus/pull/266

View File

@@ -10,14 +10,15 @@
<div align="center">
<!-- Reproducible build -->
[<img src="https://shields.rbtlog.dev/simple/com.eddyizm.degoogled.tempus" alt="RB Status">](https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus)
<!-- Reproducible build -->
<a href="https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus"><img src="https://shields.rbtlog.dev/simple/com.eddyizm.degoogled.tempus" alt="RB Status"></a>
</div>
<p align="center">
<a href="https://github.com/eddyizm/tempus/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.eddyizm.degoogled.tempus"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.eddyizm.tempus%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Feddyizm%2Ftempus%22%2C%22author%22%3A%22eddyizm%22%2C%22name%22%3A%22Tempus%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22tempus%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22overrideSource%22%3A%22GitHub%22%7D"><img width="200" src="https://github.com/user-attachments/assets/119e7ff4-2636-43cb-ab7f-1b6a58ac3570" /></a>
</p>
<!--
<a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a>
@@ -123,4 +124,4 @@ Tempus is released under the [GNU General Public License v3.0](LICENSE). Feel fr
## Credits
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (forked from v3.9.0)
[Opensvg.org](https://opensvg.org) for the new turntable logo.
[Opensvg.org](https://opensvg.org) for the new turntable logo.

View File

@@ -69,6 +69,21 @@ However, if you want to limit or change libraries you could use a workaround, if
You can create multiple users , one for each library, and save each of them in Tempus app.
### Folder or index playback
If your Subsonic-compatible server exposes the folder tree **or** provides an artist index (for example Gonic, Navidrome, or any backend with folder browsing enabled), Tempus lets you play an entire folder from anywhere in the library hierarchy:
<p align="left">
<img src="mockup/usage/music_folders_root.png" width=317 style="margin-right:16px;">
<img src="mockup/usage/music_folders_playback.png" width=317>
</p>
- The **Library ▸ Music folders** screen shows each top-level folder with a play icon only after you drill into it. The root entry remains a simple navigator.
- When viewing **inner folders** **or artist index entries**, tap the new play button to immediately enqueue every audio track inside that folder/index and all nested subfolders.
- Video files are excluded automatically, so only playable audio ends up in the queue.
No extra config is needed—Tempus adjusts based on the connected backend.
### Now Playing Screen
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.

View File

@@ -10,8 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 7
versionName '4.2.6'
versionCode 11
versionName '4.6.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
version = 12,
version = 13,
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)}
)

View File

@@ -12,9 +12,12 @@ import java.util.List;
@Dao
public interface RecentSearchDao {
@Query("SELECT * FROM recent_search ORDER BY search DESC")
@Query("SELECT search FROM recent_search ORDER BY timestamp DESC")
List<String> getRecent();
@Query("SELECT search FROM recent_search ORDER BY search DESC")
List<String> getAlpha();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(RecentSearch search);

View File

@@ -27,8 +27,11 @@ public interface ClickCallback {
default void onInternetRadioStationClick(Bundle bundle) {}
default void onInternetRadioStationLongClick(Bundle bundle) {}
default void onMusicFolderClick(Bundle bundle) {}
default void onMusicFolderPlay(Bundle bundle) {}
default void onMusicDirectoryClick(Bundle bundle) {}
default void onMusicDirectoryPlay(Bundle bundle) {}
default void onMusicIndexClick(Bundle bundle) {}
default void onMusicIndexPlay(Bundle bundle) {}
default void onDownloadGroupLongClick(Bundle bundle) {}
default void onShareClick(Bundle bundle) {}
default void onShareLongClick(Bundle bundle) {}

View File

@@ -13,5 +13,8 @@ import kotlinx.parcelize.Parcelize
data class RecentSearch(
@PrimaryKey
@ColumnInfo(name = "search")
var search: String
var search: String,
@ColumnInfo(name = "timestamp", defaultValue = "0")
var timestamp: Long
) : Parcelable

View File

@@ -205,6 +205,8 @@ public class AlbumRepository {
}
public void getInstantMix(AlbumID3 album, int count, MediaCallback callback) {
Log.d("AlbumRepository", "Attempting getInstantMix for AlbumID: " + album.getId());
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSimilarSongs2(album.getId(), count)
@@ -213,8 +215,17 @@ public class AlbumRepository {
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
songs.addAll(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
if (response.isSuccessful()
&& response.body() != null
&& response.body().getSubsonicResponse().getSimilarSongs2() != null) {
List<Child> similarSongs = response.body().getSubsonicResponse().getSimilarSongs2().getSongs();
if (similarSongs == null) {
Log.w("AlbumRepository", "API successful but 'songs' list was NULL for AlbumID: " + album.getId());
} else {
songs.addAll(similarSongs);
}
}
callback.onLoadMedia(songs);
@@ -298,4 +309,4 @@ public class AlbumRepository {
}
});
}
}
}

View File

@@ -1,8 +1,11 @@
package com.cappielloantonio.tempo.repository;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.database.AppDatabase;
@@ -52,6 +55,8 @@ public class QueueRepository {
public MutableLiveData<PlayQueue> getPlayQueue() {
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
Log.d(TAG, "Getting play queue from server...");
App.getSubsonicClientInstance(false)
.getBookmarksClient()
.getPlayQueue()
@@ -59,12 +64,19 @@ public class QueueRepository {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue());
PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue();
Log.d(TAG, "Server returned play queue with " +
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
playQueue.setValue(serverQueue);
} else {
Log.d(TAG, "Server returned no play queue");
playQueue.setValue(null);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Failed to get play queue", t);
playQueue.setValue(null);
}
});
@@ -73,18 +85,24 @@ public class QueueRepository {
}
public void savePlayQueue(List<String> ids, String current, long position) {
Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current);
App.getSubsonicClientInstance(false)
.getBookmarksClient()
.savePlayQueue(ids, current, position)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Play queue saved successfully");
} else {
Log.d(TAG, "Play queue save failed with code: " + response.code());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Play queue save failed", t);
}
});
}
@@ -123,10 +141,9 @@ public class QueueRepository {
private boolean isMediaInQueue(List<Queue> queue, Child media) {
if (queue == null || media == null) return false;
return queue.stream().anyMatch(queueItem ->
queueItem != null && media.getId() != null &&
queueItem.getId().equals(media.getId())
return queue.stream().anyMatch(queueItem ->
queueItem != null && media.getId() != null &&
queueItem.getId().equals(media.getId())
);
}
@@ -146,8 +163,8 @@ public class QueueRepository {
List<Child> filteredToAdd = toAdd;
final List<Queue> finalMedia = media;
filteredToAdd = toAdd.stream()
.filter(child -> !isMediaInQueue(finalMedia, child))
.collect(Collectors.toList());
.filter(child -> !isMediaInQueue(finalMedia, child))
.collect(Collectors.toList());
for (int i = 0; i < filteredToAdd.size(); i++) {
Queue queueItem = new Queue(filteredToAdd.get(i));

View File

@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.SearchResult2;
import com.cappielloantonio.tempo.subsonic.models.SearchResult3;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.ArrayList;
import java.util.LinkedHashSet;
@@ -186,7 +187,12 @@ public class SearchingRepository {
@Override
public void run() {
recent = recentSearchDao.getRecent();
if(Preferences.isSearchSortingChronologicallyEnabled()){
recent = recentSearchDao.getRecent();
}
else {
recent = recentSearchDao.getAlpha();
}
}
public List<String> getRecent() {

View File

@@ -1,12 +1,13 @@
package com.cappielloantonio.tempo.service;
import android.content.ComponentName;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.MediaItem;
@@ -36,6 +37,8 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class MediaManager {
@@ -43,6 +46,8 @@ public class MediaManager {
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
public static AtomicBoolean justStarted = new AtomicBoolean(false);
private static final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
public static void registerPlaybackObserver(
ListenableFuture<MediaBrowser> browserFuture,
PlaybackViewModel playbackViewModel
@@ -175,36 +180,44 @@ public class MediaManager {
}
}
@OptIn(markerClass = UnstableApi.class)
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, List<Child> media, int startIndex) {
if (mediaBrowserListenableFuture != null) {
mediaBrowserListenableFuture.addListener(() -> {
try {
if (mediaBrowserListenableFuture.isDone()) {
MediaBrowser browser = mediaBrowserListenableFuture.get();
justStarted.set(true);
browser.setMediaItems(MappingUtil.mapMediaItems(media), startIndex, 0);
browser.prepare();
final MediaBrowser browser = mediaBrowserListenableFuture.get();
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
new Handler(Looper.getMainLooper()).post(() -> {
justStarted.set(true);
browser.setMediaItems(items, startIndex, 0);
browser.prepare();
Player.Listener timelineListener = new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0);
browser.play();
browser.removeListener(this);
Player.Listener timelineListener = new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0);
browser.play();
browser.removeListener(this);
}
}
}
};
browser.addListener(timelineListener);
};
browser.addListener(timelineListener);
});
enqueueDatabase(media, true, 0);
backgroundExecutor.execute(() -> {
enqueueDatabase(media, true, 0);
});
}
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
Log.e(TAG, "Error executing startQueue logic: " + e.getMessage(), e);
}
}, MoreExecutors.directExecutor());
}
}
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, Child media) {

View File

@@ -22,6 +22,7 @@ open class Playlist(
var name: String? = null,
@ColumnInfo(name = "duration")
var duration: Long = 0,
@SerializedName("coverArt")
@ColumnInfo(name = "coverArt")
var coverArtId: String? = null,
) : Parcelable {

View File

@@ -53,7 +53,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
.into(holder.item.musicDirectoryCoverImageView);
holder.item.musicDirectoryMoreButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.INVISIBLE : View.VISIBLE);
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
}
@Override
@@ -80,6 +80,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
itemView.setOnLongClickListener(v -> onLongClick());
item.musicDirectoryMoreButton.setOnClickListener(v -> onClick());
item.musicDirectoryPlayButton.setOnClickListener(v -> onPlayClick());
}
public void onClick() {
@@ -107,5 +108,13 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
return false;
}
}
public void onPlayClick() {
if (children.get(getBindingAdapterPosition()).isDir()) {
Bundle bundle = new Bundle();
bundle.putString(Constants.MUSIC_DIRECTORY_ID, children.get(getBindingAdapterPosition()).getId());
click.onMusicDirectoryPlay(bundle);
}
}
}
}

View File

@@ -76,6 +76,7 @@ public class MusicIndexAdapter extends RecyclerView.Adapter<MusicIndexAdapter.Vi
itemView.setOnClickListener(v -> onClick());
item.musicIndexMoreButton.setOnClickListener(v -> onClick());
item.musicIndexPlayButton.setOnClickListener(v -> onPlayClick());
}
public void onClick() {
@@ -83,5 +84,11 @@ public class MusicIndexAdapter extends RecyclerView.Adapter<MusicIndexAdapter.Vi
bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId());
click.onMusicIndexClick(bundle);
}
public void onPlayClick() {
Bundle bundle = new Bundle();
bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId());
click.onMusicIndexPlay(bundle);
}
}
}

View File

@@ -18,9 +18,12 @@ import com.cappielloantonio.tempo.databinding.ItemPlayerQueueSongBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.interfaces.MediaIndexCallback;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture;
@@ -29,7 +32,9 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> {
private static final String TAG = "PlayerSongQueueAdapter";
@@ -37,7 +42,7 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private List<Child> songs;
private final Map<String, Boolean> downloadStatusCache = new ConcurrentHashMap<>();
private String currentPlayingId;
private boolean isPlaying;
private List<Integer> currentPlayingPositions = Collections.emptyList();
@@ -78,7 +83,6 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
.build()
.thumbnail(thumbnail)
.into(holder.item.queueSongCoverImageView);
MediaManager.getCurrentIndex(mediaBrowserListenableFuture, new MediaIndexCallback() {
@Override
public void onRecovery(int index) {
@@ -94,6 +98,23 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
}
});
boolean isDownloaded = false;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(holder.itemView.getContext());
if (downloaderManager != null) {
isDownloaded = downloaderManager.isDownloaded(song.getId());
}
} else {
isDownloaded = ExternalAudioReader.getUri(song) != null;
}
if (isDownloaded) {
holder.item.downloadIndicatorIcon.setVisibility(View.VISIBLE);
} else {
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
}
if (Preferences.showItemRating()) {
if (song.getStarred() == null && song.getUserRating() == null) {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
@@ -153,7 +174,7 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
}
}
public List<Child> getItems() {
return this.songs;
}

View File

@@ -4,7 +4,7 @@ import android.content.ComponentName;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -12,6 +12,7 @@ import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -60,12 +61,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private SongHorizontalAdapter songHorizontalAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
/** @noinspection deprecation*/
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
/** @noinspection deprecation*/
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
@@ -81,7 +84,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
init(view);
initAppBar();
initAlbumInfoTextButton();
initAlbumNotes();
@@ -119,12 +122,13 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind = null;
}
/** @noinspection deprecation*/
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.action_rate_album) {
Bundle bundle = new Bundle();
AlbumID3 album = albumPageViewModel.getAlbum().getValue();
bundle.putParcelable(Constants.ALBUM_OBJECT, (Parcelable) album);
bundle.putParcelable(Constants.ALBUM_OBJECT, album);
RatingDialog dialog = new RatingDialog();
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
@@ -159,8 +163,21 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
return false;
}
private void init() {
albumPageViewModel.setAlbum(getViewLifecycleOwner(), requireArguments().getParcelable(Constants.ALBUM_OBJECT));
private void init(View view) {
AlbumID3 albumArg = requireArguments().getParcelable(Constants.ALBUM_OBJECT);
assert albumArg != null;
albumPageViewModel.setAlbum(getViewLifecycleOwner(), albumArg);
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(albumArg.getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
albumPageViewModel.setFavorite();
});
albumPageViewModel.getAlbum().observe(getViewLifecycleOwner(), album -> {
if (album != null) {
favoriteToggle.setChecked(album.getStarred() != null);
}
});
}
private void initAppBar() {

View File

@@ -2,14 +2,18 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
@@ -40,6 +44,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@UnstableApi
public class ArtistPageFragment extends Fragment implements ClickCallback {
@@ -63,7 +68,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
init(view);
initAppBar();
initArtistInfo();
initPlayButtons();
@@ -100,7 +105,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind = null;
}
private void init() {
private void init(View view) {
artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT));
bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> {
@@ -109,6 +114,10 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bundle.putParcelable(Constants.ARTIST_OBJECT, artistPageViewModel.getArtist());
activity.navController.navigate(R.id.action_artistPageFragment_to_songListPageFragment, bundle);
});
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(artistPageViewModel.getArtist().getStarred() != null);
favoriteToggle.setOnClickListener(v -> artistPageViewModel.setFavorite(requireContext()));
}
private void initAppBar() {
@@ -133,10 +142,54 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
if (bind != null)
bind.bioMoreTextViewClickable.setVisibility(artistInfo.getLastFmUrl() != null ? View.VISIBLE : View.GONE);
if (getContext() != null && bind != null) CustomGlideRequest.Builder
.from(requireContext(), artistPageViewModel.getArtist().getId(), CustomGlideRequest.ResourceType.Artist)
.build()
.into(bind.artistBackdropImageView);
if (getContext() != null && bind != null) {
ArtistID3 currentArtist = artistPageViewModel.getArtist();
String primaryId = currentArtist.getCoverArtId() != null && !currentArtist.getCoverArtId().trim().isEmpty()
? currentArtist.getCoverArtId()
: currentArtist.getId();
final String fallbackId = (Objects.requireNonNull(primaryId).equals(currentArtist.getCoverArtId()) &&
currentArtist.getId() != null &&
!currentArtist.getId().equals(primaryId))
? currentArtist.getId()
: null;
CustomGlideRequest.Builder
.from(requireContext(), primaryId, CustomGlideRequest.ResourceType.Artist)
.build()
.listener(new com.bumptech.glide.request.RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
@NonNull com.bumptech.glide.request.target.Target<Drawable> target,
boolean isFirstResource) {
if (e != null) {
e.getMessage();
if (e.getMessage().contains("400") && fallbackId != null) {
Log.d("ArtistCover", "Primary ID failed (400), trying fallback: " + fallbackId);
CustomGlideRequest.Builder
.from(requireContext(), fallbackId, CustomGlideRequest.ResourceType.Artist)
.build()
.into(bind.artistBackdropImageView);
return true;
}
}
return false;
}
@Override
public boolean onResourceReady(@NonNull Drawable resource,
@NonNull Object model,
com.bumptech.glide.request.target.Target<Drawable> target,
@NonNull com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
return false;
}
})
.into(bind.artistBackdropImageView);
}
if (bind != null) bind.bioTextView.setText(normalizedBio);
@@ -150,29 +203,24 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
}
});
}
private void initPlayButtons() {
bind.artistPageShuffleButton.setOnClickListener(v -> {
artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> {
if (!songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
}
});
});
bind.artistPageShuffleButton.setOnClickListener(v -> artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> {
if (!songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
}
}));
bind.artistPageRadioButton.setOnClickListener(v -> {
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show();
}
});
});
bind.artistPageRadioButton.setOnClickListener(v -> artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show();
}
}));
}
private void initTopSongsView() {

View File

@@ -27,7 +27,13 @@ import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.repository.DirectoryRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Directory;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
@@ -53,6 +59,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
private MusicDirectoryAdapter musicDirectoryAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private DirectoryRepository directoryRepository;
private MenuItem menuItem;
@@ -77,6 +84,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
bind = FragmentDirectoryBinding.inflate(inflater, container, false);
View view = bind.getRoot();
directoryViewModel = new ViewModelProvider(requireActivity()).get(DirectoryViewModel.class);
directoryRepository = new DirectoryRepository();
initAppBar();
initDirectoryListView();
@@ -197,4 +205,57 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
public void onMusicDirectoryClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle);
}
@Override
public void onMusicDirectoryPlay(Bundle bundle) {
String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID);
if (directoryId != null) {
Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show();
collectAndPlayDirectorySongs(directoryId);
}
}
private void collectAndPlayDirectorySongs(String directoryId) {
List<Child> allSongs = new ArrayList<>();
AtomicInteger pendingRequests = new AtomicInteger(0);
collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> {
if (!allSongs.isEmpty()) {
activity.runOnUiThread(() -> {
MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0);
activity.setBottomSheetInPeek(true);
Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show();
});
} else {
activity.runOnUiThread(() -> {
Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show();
});
}
});
}
private void collectSongsFromDirectory(String directoryId, List<Child> allSongs, AtomicInteger pendingRequests, Runnable onComplete) {
pendingRequests.incrementAndGet();
directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> {
if (directory != null && directory.getChildren() != null) {
for (Child child : directory.getChildren()) {
if (child.isDir()) {
// It's a subdirectory, recurse into it
collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete);
} else if (!child.isVideo()) {
// It's a song, add it to the list
synchronized (allSongs) {
allSongs.add(child);
}
}
}
}
// Decrement pending requests and check if we're done
if (pendingRequests.decrementAndGet() == 0) {
onComplete.run();
}
});
}
}

View File

@@ -38,10 +38,10 @@ import com.cappielloantonio.tempo.model.HomeSector;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
@@ -57,6 +57,8 @@ import com.cappielloantonio.tempo.ui.dialog.HomeRearrangementDialog;
import com.cappielloantonio.tempo.ui.dialog.PlaylistEditorDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
@@ -66,8 +68,6 @@ import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture;
import androidx.media3.common.MediaItem;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -228,6 +228,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
activity.navController.navigate(R.id.action_homeFragment_to_albumListPageFragment, bundle);
});
bind.playlistCatalogueTextViewClickable.setOnClickListener(v -> {
Bundle bundle = new Bundle();
bundle.putString(Constants.PLAYLIST_ALL, Constants.PLAYLIST_ALL);
activity.navController.navigate(R.id.action_homeFragment_to_playlistCatalogueFragment, bundle);
});
bind.recentlyPlayedAlbumsTextViewClickable.setOnClickListener(v -> {
Bundle bundle = new Bundle();
bundle.putString(Constants.ALBUM_RECENTLY_PLAYED, Constants.ALBUM_RECENTLY_PLAYED);
@@ -279,51 +285,113 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
}
private void initSyncStarredView() {
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
if (Preferences.isStarredSyncEnabled()) {
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
List<String> toSync = new ArrayList<>();
if (songs != null && !songs.isEmpty()) {
int songsToSyncCount = 0;
List<String> toSyncSample = new ArrayList<>();
for (Child song : songs) {
if (!manager.isDownloaded(song.getId())) {
toSync.add(song.getTitle());
}
}
if (!toSync.isEmpty()) {
bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
bind.homeSyncStarredTracksToSync.setText(String.join(", ", toSync));
}
}
homeViewModel.getAllStarredTracks().removeObserver(this);
}
});
}
bind.homeSyncStarredCancel.setOnClickListener(v -> bind.homeSyncStarredCard.setVisibility(View.GONE));
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null) {
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : songs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToSyncCount++;
if (toSyncSample.size() < 3) {
toSyncSample.add(song.getTitle());
}
}
}
} else {
for (Child song : songs) {
if (ExternalAudioReader.getUri(song) == null) {
songsToSyncCount++;
if (toSyncSample.size() < 3) {
toSyncSample.add(song.getTitle());
}
}
}
}
homeViewModel.getAllStarredTracks().removeObserver(this);
if (songsToSyncCount > 0) {
bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
StringBuilder displayText = new StringBuilder();
if (!toSyncSample.isEmpty()) {
displayText.append(String.join(", ", toSyncSample));
if (songsToSyncCount > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_songs_count,
songsToSyncCount,
songsToSyncCount
);
if (displayText.length() > 0) {
bind.homeSyncStarredTracksToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredTracksToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else {
bind.homeSyncStarredCard.setVisibility(View.GONE);
}
}
}
});
}
bind.homeSyncStarredCancel.setOnClickListener(v -> {
bind.homeSyncStarredCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
});
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
int downloadedCount = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : songs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
downloadedCount++;
}
}
} else {
for (Child song : songs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
downloadedCount++;
}
}
}
if (downloadedCount > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadedCount, downloadedCount),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
}
});
}
@@ -331,6 +399,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
}
private void initSyncStarredAlbumsView() {
if (Preferences.isStarredAlbumsSyncEnabled()) {
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() {
@Override
@@ -344,6 +413,9 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.homeSyncStarredAlbumsCancel.setOnClickListener(v -> {
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
});
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
@@ -351,24 +423,36 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
}
}
} else {
for (Child song : allSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
songsToDownload++;
}
}
}
if (songsToDownload > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
}
});
});
@@ -379,33 +463,73 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
List<String> albumsNeedingSync = new ArrayList<>();
for (AlbumID3 album : albums) {
boolean albumNeedsSync = false;
// Check if any songs from this album need downloading
for (Child song : allSongs) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
!manager.isDownloaded(song.getId())) {
songsToDownload++;
albumNeedsSync = true;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (AlbumID3 album : albums) {
boolean albumNeedsSync = false;
for (Child song : allSongs) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
!manager.isDownloaded(song.getId())) {
songsToDownload++;
albumNeedsSync = true;
}
}
if (albumNeedsSync) {
albumsNeedingSync.add(album.getName());
}
}
if (albumNeedsSync) {
albumsNeedingSync.add(album.getName());
} else {
for (AlbumID3 album : albums) {
boolean albumNeedsSync = false;
for (Child song : allSongs) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
ExternalAudioReader.getUri(song) == null) {
songsToDownload++;
albumNeedsSync = true;
}
}
if (albumNeedsSync) {
albumsNeedingSync.add(album.getName());
}
}
}
if (songsToDownload > 0) {
bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
String message = getResources().getQuantityString(
R.plurals.home_sync_starred_albums_count,
albumsNeedingSync.size(),
StringBuilder displayText = new StringBuilder();
List<String> sampleAlbums = new ArrayList<>();
for (int i = 0; i < Math.min(albumsNeedingSync.size(), 3); i++) {
sampleAlbums.add(albumsNeedingSync.get(i));
}
if (!sampleAlbums.isEmpty()) {
displayText.append(String.join(", ", sampleAlbums));
if (albumsNeedingSync.size() > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_albums_count,
albumsNeedingSync.size(),
albumsNeedingSync.size()
);
bind.homeSyncStarredAlbumsToSync.setText(message);
if (displayText.length() > 0) {
bind.homeSyncStarredAlbumsToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredAlbumsToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else {
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
}
@@ -428,6 +552,9 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
});
bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
@@ -435,24 +562,36 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
}
}
} else {
for (Child song : allSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
songsToDownload++;
}
}
}
if (songsToDownload > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
}
});
});
@@ -463,33 +602,73 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
List<String> artistsNeedingSync = new ArrayList<>();
for (ArtistID3 artist : artists) {
boolean artistNeedsSync = false;
// Check if any songs from this artist need downloading
for (Child song : allSongs) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
!manager.isDownloaded(song.getId())) {
songsToDownload++;
artistNeedsSync = true;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (ArtistID3 artist : artists) {
boolean artistNeedsSync = false;
for (Child song : allSongs) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
!manager.isDownloaded(song.getId())) {
songsToDownload++;
artistNeedsSync = true;
}
}
if (artistNeedsSync) {
artistsNeedingSync.add(artist.getName());
}
}
if (artistNeedsSync) {
artistsNeedingSync.add(artist.getName());
} else {
for (ArtistID3 artist : artists) {
boolean artistNeedsSync = false;
for (Child song : allSongs) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
ExternalAudioReader.getUri(song) == null) {
songsToDownload++;
artistNeedsSync = true;
}
}
if (artistNeedsSync) {
artistsNeedingSync.add(artist.getName());
}
}
}
if (songsToDownload > 0) {
bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE);
String message = getResources().getQuantityString(
R.plurals.home_sync_starred_artists_count,
artistsNeedingSync.size(),
StringBuilder displayText = new StringBuilder();
List<String> sampleArtists = new ArrayList<>();
for (int i = 0; i < Math.min(artistsNeedingSync.size(), 3); i++) {
sampleArtists.add(artistsNeedingSync.get(i));
}
if (!sampleArtists.isEmpty()) {
displayText.append(String.join(", ", sampleArtists));
if (artistsNeedingSync.size() > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_artists_count,
artistsNeedingSync.size(),
artistsNeedingSync.size()
);
bind.homeSyncStarredArtistsToSync.setText(message);
if (displayText.length() > 0) {
bind.homeSyncStarredArtistsToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredArtistsToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else {
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
}
@@ -497,7 +676,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
}
});
}
private void initDiscoverSongSlideView() {
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
@@ -962,6 +1141,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
if (bind != null && homeViewModel.getHomeSectorList() != null) {
bind.homeLinearLayoutContainer.removeAllViews();
if (bind.homeSyncStarredCard.getVisibility() == View.VISIBLE) {
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredCard);
}
if (bind.homeSyncStarredAlbumsCard.getVisibility() == View.VISIBLE) {
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredAlbumsCard);
}
if (bind.homeSyncStarredArtistsCard.getVisibility() == View.VISIBLE) {
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredArtistsCard);
}
for (HomeSector sector : homeViewModel.getHomeSectorList()) {
if (!sector.isVisible()) continue;

View File

@@ -1,27 +1,40 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.LinearLayoutManager;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.FragmentIndexBinding;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.repository.DirectoryRepository;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.MusicFolder;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.MusicIndexAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.IndexUtil;
import com.cappielloantonio.tempo.viewmodel.IndexViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@UnstableApi
public class IndexFragment extends Fragment implements ClickCallback {
@@ -32,6 +45,8 @@ public class IndexFragment extends Fragment implements ClickCallback {
private IndexViewModel indexViewModel;
private MusicIndexAdapter musicIndexAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private DirectoryRepository directoryRepository;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -40,6 +55,7 @@ public class IndexFragment extends Fragment implements ClickCallback {
bind = FragmentIndexBinding.inflate(inflater, container, false);
View view = bind.getRoot();
indexViewModel = new ViewModelProvider(requireActivity()).get(IndexViewModel.class);
directoryRepository = new DirectoryRepository();
initAppBar();
initDirectoryListView();
@@ -48,6 +64,18 @@ public class IndexFragment extends Fragment implements ClickCallback {
return view;
}
@Override
public void onStart() {
super.onStart();
initializeMediaBrowser();
}
@Override
public void onStop() {
releaseMediaBrowser();
super.onStop();
}
@Override
public void onDestroyView() {
super.onDestroyView();
@@ -107,4 +135,65 @@ public class IndexFragment extends Fragment implements ClickCallback {
public void onMusicIndexClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle);
}
@Override
public void onMusicIndexPlay(Bundle bundle) {
String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID);
if (directoryId != null) {
Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show();
collectAndPlayDirectorySongs(directoryId);
}
}
private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
}
private void releaseMediaBrowser() {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
}
private void collectAndPlayDirectorySongs(String directoryId) {
List<Child> allSongs = new ArrayList<>();
AtomicInteger pendingRequests = new AtomicInteger(0);
collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> {
if (!allSongs.isEmpty()) {
activity.runOnUiThread(() -> {
MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0);
activity.setBottomSheetInPeek(true);
Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show();
});
} else {
activity.runOnUiThread(() -> {
Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show();
});
}
});
}
private void collectSongsFromDirectory(String directoryId, List<Child> allSongs, AtomicInteger pendingRequests, Runnable onComplete) {
pendingRequests.incrementAndGet();
directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> {
if (directory != null && directory.getChildren() != null) {
for (Child child : directory.getChildren()) {
if (child.isDir()) {
// It's a subdirectory, recurse into it
collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete);
} else if (!child.isVideo()) {
// It's a song, add it to the list
synchronized (allSongs) {
allSongs.add(child);
}
}
}
}
// Decrement pending requests and check if we're done
if (pendingRequests.decrementAndGet() == 0) {
onComplete.run();
}
});
}
}

View File

@@ -11,7 +11,11 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import androidx.navigation.Navigation;
import android.content.ComponentName;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -31,6 +35,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.LibraryViewModel;
import com.google.android.material.appbar.MaterialToolbar;
import com.cappielloantonio.tempo.service.MediaService;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.Objects;
@@ -49,6 +55,7 @@ public class LibraryFragment extends Fragment implements ClickCallback {
private PlaylistHorizontalAdapter playlistHorizontalAdapter;
private MaterialToolbar materialToolbar;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable
@Override
@@ -79,6 +86,7 @@ public class LibraryFragment extends Fragment implements ClickCallback {
@Override
public void onStart() {
super.onStart();
initializeMediaBrowser();
activity.setBottomNavigationBarVisibility(true);
}
@@ -292,4 +300,8 @@ public class LibraryFragment extends Fragment implements ClickCallback {
public void onMusicFolderClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.indexFragment, bundle);
}
private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
}
}

View File

@@ -2,28 +2,41 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.common.MediaItem;
import androidx.media3.session.SessionToken;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.PlayQueue;
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -31,6 +44,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@UnstableApi
@@ -39,6 +53,18 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
private InnerFragmentPlayerQueueBinding bind;
private com.google.android.material.floatingactionbutton.FloatingActionButton fabMenuToggle;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabClearQueue;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabShuffleQueue;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveToPlaylist;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabDownloadAll;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabLoadQueue;
private boolean isMenuOpen = false;
private final int ANIMATION_DURATION = 250;
private final float FAB_VERTICAL_SPACING_DP = 70f;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private PlaybackViewModel playbackViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@@ -53,6 +79,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
fabMenuToggle = bind.fabMenuToggle;
fabClearQueue = bind.fabClearQueue;
fabShuffleQueue = bind.fabShuffleQueue;
fabSaveToPlaylist = bind.fabSaveToPlaylist;
fabDownloadAll = bind.fabDownloadAll;
fabLoadQueue = bind.fabLoadQueue;
fabMenuToggle.setOnClickListener(v -> toggleFabMenu());
fabClearQueue.setOnClickListener(v -> handleClearQueueClick());
fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick());
fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick());
fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick());
fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick());
// Hide Load Queue FAB if sync is disabled
if (!Preferences.isSyncronizationEnabled()) {
fabLoadQueue.setVisibility(View.GONE);
}
initQueueRecyclerView();
return view;
@@ -62,8 +109,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeBrowser();
bindMediaController();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@@ -105,18 +150,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
}
private void bindMediaController() {
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
initShuffleButton(mediaBrowser);
initCleanButton(mediaBrowser);
} catch (Exception exception) {
exception.printStackTrace();
}
}, MoreExecutors.directExecutor());
}
private void setMediaBrowserListenableFuture() {
playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
@@ -149,18 +182,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
fromPosition = viewHolder.getBindingAdapterPosition();
toPosition = target.getBindingAdapterPosition();
/*
* Per spostare un elemento nella coda devo:
* - Spostare graficamente la traccia da una posizione all'altra con Collections.swap()
* - Spostare nel db la traccia, tramite QueueRepository
* - Notificare il Service dell'avvenuto spostamento con MusicPlayerRemote.moveSong()
*
* In onMove prendo la posizione di inizio e fine, ma solo al rilascio dell'elemento procedo allo spostamento
* In questo modo evito che ad ogni cambio di posizione vada a riscrivere nel db
* Al rilascio dell'elemento chiamo il metodo clearView()
*/
Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition);
recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition);
@@ -188,46 +209,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
}).attachToRecyclerView(bind.playerQueueRecyclerView);
}
private void initShuffleButton(MediaBrowser mediaBrowser) {
bind.playerShuffleQueueFab.setOnClickListener(view -> {
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
if (startPosition < endPosition) {
ArrayList<Integer> pool = new ArrayList<>();
for (int i = startPosition; i <= endPosition; i++) {
pool.add(i);
}
while (pool.size() >= 2) {
int fromPosition = (int) (Math.random() * (pool.size()));
int positionA = pool.get(fromPosition);
pool.remove(fromPosition);
int toPosition = (int) (Math.random() * (pool.size()));
int positionB = pool.get(toPosition);
pool.remove(toPosition);
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
}
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
}
});
}
private void initCleanButton(MediaBrowser mediaBrowser) {
bind.playerCleanQueueButton.setOnClickListener(view -> {
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size();
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition);
});
}
private void updateNowPlayingItem() {
playerSongQueueAdapter.notifyDataSetChanged();
}
@@ -259,4 +240,250 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
}
/**
* Toggles the visibility and animates all six secondary FABs.
*/
private void toggleFabMenu() {
if (isMenuOpen) {
// CLOSE MENU (Reverse order for visual effect)
if (Preferences.isSyncronizationEnabled()) {
closeFab(fabLoadQueue, 4);
}
closeFab(fabSaveToPlaylist, 3);
closeFab(fabClearQueue, 2);
closeFab(fabDownloadAll, 1);
closeFab(fabShuffleQueue, 0);
fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start();
} else {
// OPEN MENU (lowest index at bottom)
openFab(fabShuffleQueue, 0);
openFab(fabDownloadAll, 1);
openFab(fabClearQueue, 2);
openFab(fabSaveToPlaylist, 3);
if (Preferences.isSyncronizationEnabled()) {
openFab(fabLoadQueue, 4);
}
fabMenuToggle.animate().rotation(45f).setDuration(ANIMATION_DURATION).start();
}
isMenuOpen = !isMenuOpen;
}
private void openFab(View fab, int index) {
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
fab.setVisibility(View.VISIBLE);
fab.setAlpha(0f);
fab.setTranslationY(displacement); // Start at the hidden (closed) position
fab.animate()
.translationY(0f)
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.start();
}
private void closeFab(View fab, int index) {
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
fab.animate()
.translationY(displacement)
.alpha(0f)
.setDuration(ANIMATION_DURATION)
.withEndAction(() -> fab.setVisibility(View.GONE))
.start();
}
private void handleShuffleQueueClick() {
Log.d(TAG, "Shuffle Queue Clicked!");
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
if (startPosition < endPosition) {
ArrayList<Integer> pool = new ArrayList<>();
for (int i = startPosition; i <= endPosition; i++) {
pool.add(i);
}
while (pool.size() >= 2) {
int fromPosition = (int) (Math.random() * (pool.size()));
int positionA = pool.get(fromPosition);
pool.remove(fromPosition);
int toPosition = (int) (Math.random() * (pool.size()));
int positionB = pool.get(toPosition);
pool.remove(toPosition);
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
}
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
}
} catch (Exception e) {
Log.e(TAG, "Error shuffling queue", e);
}
toggleFabMenu();
}, MoreExecutors.directExecutor());
}
private void handleClearQueueClick() {
Log.d(TAG, "Clear Queue Clicked!");
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size();
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition - startPosition);
} catch (Exception e) {
Log.e(TAG, "Error clearing queue", e);
}
toggleFabMenu();
}, MoreExecutors.directExecutor());
}
private void handleSaveToPlaylistClick() {
Log.d(TAG, "Save to Playlist Clicked!");
List<Child> queueSongs = playerSongQueueAdapter.getItems();
if (queueSongs == null || queueSongs.isEmpty()) {
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
toggleFabMenu();
return;
}
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(queueSongs));
PlaylistChooserDialog dialog = new PlaylistChooserDialog();
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
toggleFabMenu();
}
private void handleDownloadAllClick() {
Log.d(TAG, "Download All Clicked!");
List<Child> queueSongs = playerSongQueueAdapter.getItems();
if (queueSongs == null || queueSongs.isEmpty()) {
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
toggleFabMenu();
return;
}
int downloadCount = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
List<MediaItem> mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs);
List<com.cappielloantonio.tempo.model.Download> downloadModels = new ArrayList<>();
for (Child child : queueSongs) {
com.cappielloantonio.tempo.model.Download downloadModel =
new com.cappielloantonio.tempo.model.Download(child);
downloadModel.setArtist(child.getArtist());
downloadModel.setAlbum(child.getAlbum());
downloadModel.setCoverArtId(child.getCoverArtId());
downloadModels.add(downloadModel);
}
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(requireContext());
if (downloaderManager != null) {
downloaderManager.download(mediaItemsToDownload, downloadModels);
downloadCount = queueSongs.size();
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadCount, downloadCount),
Toast.LENGTH_SHORT).show();
new Handler().postDelayed(() -> {
if (playerSongQueueAdapter != null) {
playerSongQueueAdapter.notifyDataSetChanged();
}
}, 1000);
} else {
Log.e(TAG, "DownloaderManager not initialized. Check DownloadUtil.");
Toast.makeText(requireContext(), "Download service unavailable.", Toast.LENGTH_SHORT).show();
}
} else {
for (Child song : queueSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
downloadCount++;
}
}
if (downloadCount > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadCount, downloadCount),
Toast.LENGTH_SHORT).show();
new Handler().postDelayed(() -> {
if (playerSongQueueAdapter != null) {
playerSongQueueAdapter.notifyDataSetChanged();
}
}, 2000);
} else {
Toast.makeText(requireContext(), "All songs already downloaded", Toast.LENGTH_SHORT).show();
}
}
toggleFabMenu();
}
private void handleLoadQueueClick() {
Log.d(TAG, "Load Queue Clicked!");
if (!Preferences.isSyncronizationEnabled()) {
toggleFabMenu();
return;
}
PlayerBottomSheetViewModel playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playerBottomSheetViewModel.getPlayQueue().observe(getViewLifecycleOwner(), new Observer<PlayQueue>() {
@Override
public void onChanged(PlayQueue playQueue) {
playerBottomSheetViewModel.getPlayQueue().removeObserver(this);
if (playQueue != null && playQueue.getEntries() != null && !playQueue.getEntries().isEmpty()) {
int currentIndex = 0;
for (int i = 0; i < playQueue.getEntries().size(); i++) {
if (playQueue.getEntries().get(i).getId().equals(playQueue.getCurrent())) {
currentIndex = i;
break;
}
}
MediaManager.startQueue(mediaBrowserListenableFuture, playQueue.getEntries(), currentIndex);
Toast.makeText(requireContext(), "Queue loaded", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "No saved queue found", Toast.LENGTH_SHORT).show();
}
toggleFabMenu();
}
});
new Handler().postDelayed(() -> {
if (isMenuOpen) {
toggleFabMenu();
}
}, 1000);
}
}

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.util;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
@@ -35,84 +36,106 @@ public class MappingUtil {
return mediaItems;
}
private static final String TAG = "MappingUtil";
public static MediaItem mapMediaItem(Child media) {
Uri uri = getUri(media);
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(media.getCoverArtId(), Preferences.getImageSize()));
try {
Uri uri = getUri(media);
String coverArtId = media.getCoverArtId();
Uri artworkUri = null;
Bundle bundle = new Bundle();
bundle.putString("id", media.getId());
bundle.putString("parentId", media.getParentId());
bundle.putBoolean("isDir", media.isDir());
bundle.putString("title", media.getTitle());
bundle.putString("album", media.getAlbum());
bundle.putString("artist", media.getArtist());
bundle.putInt("track", media.getTrack() != null ? media.getTrack() : 0);
bundle.putInt("year", media.getYear() != null ? media.getYear() : 0);
bundle.putString("genre", media.getGenre());
bundle.putString("coverArtId", media.getCoverArtId());
bundle.putLong("size", media.getSize() != null ? media.getSize() : 0);
bundle.putString("contentType", media.getContentType());
bundle.putString("suffix", media.getSuffix());
bundle.putString("transcodedContentType", media.getTranscodedContentType());
bundle.putString("transcodedSuffix", media.getTranscodedSuffix());
bundle.putInt("duration", media.getDuration() != null ? media.getDuration() : 0);
bundle.putInt("bitrate", media.getBitrate() != null ? media.getBitrate() : 0);
bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
bundle.putString("path", media.getPath());
bundle.putBoolean("isVideo", media.isVideo());
bundle.putInt("userRating", media.getUserRating() != null ? media.getUserRating() : 0);
bundle.putDouble("averageRating", media.getAverageRating() != null ? media.getAverageRating() : 0);
bundle.putLong("playCount", media.getPlayCount() != null ? media.getPlayCount() : 0);
bundle.putInt("discNumber", media.getDiscNumber() != null ? media.getDiscNumber() : 0);
bundle.putLong("created", media.getCreated() != null ? media.getCreated().getTime() : 0);
bundle.putLong("starred", media.getStarred() != null ? media.getStarred().getTime() : 0);
bundle.putString("albumId", media.getAlbumId());
bundle.putString("artistId", media.getArtistId());
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC);
bundle.putLong("bookmarkPosition", media.getBookmarkPosition() != null ? media.getBookmarkPosition() : 0);
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString());
bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()));
bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()));
bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()));
bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
Integer year = media.getYear();
bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
if (coverArtId != null) {
artworkUri = Uri.parse(CustomGlideRequest.createUrl(coverArtId, Preferences.getImageSize()));
}
return new MediaItem.Builder()
.setMediaId(media.getId())
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(media.getTitle())
.setTrackNumber(media.getTrack() != null ? media.getTrack() : 0)
.setDiscNumber(media.getDiscNumber() != null ? media.getDiscNumber() : 0)
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
Bundle bundle = new Bundle();
bundle.putString("id", media.getId());
bundle.putString("parentId", media.getParentId());
bundle.putBoolean("isDir", media.isDir());
bundle.putString("title", media.getTitle());
bundle.putString("album", media.getAlbum());
bundle.putString("artist", media.getArtist());
bundle.putInt("track", media.getTrack() != null ? media.getTrack() : 0);
bundle.putInt("year", media.getYear() != null ? media.getYear() : 0);
bundle.putString("genre", media.getGenre());
bundle.putString("coverArtId", coverArtId);
bundle.putLong("size", media.getSize() != null ? media.getSize() : 0);
bundle.putString("contentType", media.getContentType());
bundle.putString("suffix", media.getSuffix());
bundle.putString("transcodedContentType", media.getTranscodedContentType());
bundle.putString("transcodedSuffix", media.getTranscodedSuffix());
bundle.putInt("duration", media.getDuration() != null ? media.getDuration() : 0);
bundle.putInt("bitrate", media.getBitrate() != null ? media.getBitrate() : 0);
bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
bundle.putString("path", media.getPath());
bundle.putBoolean("isVideo", media.isVideo());
bundle.putInt("userRating", media.getUserRating() != null ? media.getUserRating() : 0);
bundle.putDouble("averageRating", media.getAverageRating() != null ? media.getAverageRating() : 0);
bundle.putLong("playCount", media.getPlayCount() != null ? media.getPlayCount() : 0);
bundle.putInt("discNumber", media.getDiscNumber() != null ? media.getDiscNumber() : 0);
bundle.putLong("created", media.getCreated() != null ? media.getCreated().getTime() : 0);
bundle.putLong("starred", media.getStarred() != null ? media.getStarred().getTime() : 0);
bundle.putString("albumId", media.getAlbumId());
bundle.putString("artistId", media.getArtistId());
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC);
bundle.putLong("bookmarkPosition", media.getBookmarkPosition() != null ? media.getBookmarkPosition() : 0);
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString());
bundle.putString("assetLinkSong", media.getId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()) : null);
bundle.putString("assetLinkAlbum", media.getAlbumId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()) : null);
bundle.putString("assetLinkArtist", media.getArtistId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()) : null);
bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
Integer year = media.getYear();
bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
return new MediaItem.Builder()
.setMediaId(media.getId())
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(media.getTitle())
.setTrackNumber(media.getTrack() != null ? media.getTrack() : 0)
.setDiscNumber(media.getDiscNumber() != null ? media.getDiscNumber() : 0)
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
ImmutableList.of(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
.build()
)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setExtras(bundle)
.build()
)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.setUri(uri)
.build();
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
.build()
)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setExtras(bundle)
.build()
)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.setUri(uri)
.build();
} catch (Exception e) {
String id = media != null ? media.getId() : "NULL_MEDIA_OBJECT";
String title = media != null ? media.getTitle() : "N/A";
Log.e(TAG, "Instant Mix CRASH! Failed to map song to MediaItem. " +
"Problematic Song ID: " + id +
", Title: " + title +
". Inspect this song's Subsonic data for missing fields.", e);
throw new RuntimeException("Mapping failed for song ID: " + id, e);
}
}
public static MediaItem mapMediaItem(MediaItem old) {

View File

@@ -81,6 +81,7 @@ object Preferences {
private const val ALBUM_SORT_ORDER = "album_sort_order"
private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
private const val ARTIST_SORT_BY_ALBUM_COUNT= "artist_sort_by_album_count"
private const val SORT_SEARCH_CHRONOLOGICALLY= "sort_search_chronologically"
@JvmStatic
fun getServer(): String? {
@@ -674,4 +675,9 @@ object Preferences {
else
return Constants.ARTIST_ORDER_BY_NAME
}
@JvmStatic
fun isSearchSortingChronologicallyEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SORT_SEARCH_CHRONOLOGICALLY, false)
}
}

View File

@@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.util.Constants;
@@ -21,7 +20,6 @@ import java.util.List;
public class AlbumListPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository;
private final DownloadRepository downloadRepository;
public String title;
public ArtistID3 artist;
@@ -32,9 +30,7 @@ public class AlbumListPageViewModel extends AndroidViewModel {
public AlbumListPageViewModel(@NonNull Application application) {
super(application);
albumRepository = new AlbumRepository();
downloadRepository = new DownloadRepository();
}
public LiveData<List<AlbumID3>> getAlbumList(LifecycleOwner owner) {

View File

@@ -8,18 +8,23 @@ import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.AlbumInfo;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.NetworkUtil;
import java.util.Date;
import java.util.List;
public class AlbumPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository;
private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository;
private String albumId;
private String artistId;
private final MutableLiveData<AlbumID3> album = new MutableLiveData<>(null);
@@ -29,6 +34,7 @@ public class AlbumPageViewModel extends AndroidViewModel {
albumRepository = new AlbumRepository();
artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository();
}
public LiveData<List<Child>> getAlbumSongLiveList() {
@@ -49,6 +55,61 @@ public class AlbumPageViewModel extends AndroidViewModel {
});
}
public void setFavorite() {
AlbumID3 currentAlbum = album.getValue();
if (currentAlbum == null) return;
if (currentAlbum.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline(currentAlbum);
} else {
removeFavoriteOnline(currentAlbum);
}
} else {
if (NetworkUtil.isOffline()) {
setFavoriteOffline(currentAlbum);
} else {
setFavoriteOnline(currentAlbum);
}
}
}
private void removeFavoriteOffline(AlbumID3 album) {
favoriteRepository.starLater(null, album.getId(), null, false);
album.setStarred(null);
this.album.postValue(album);
}
private void removeFavoriteOnline(AlbumID3 album) {
favoriteRepository.unstar(null, album.getId(), null, new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, album.getId(), null, false);
}
});
album.setStarred(null);
this.album.postValue(album);
}
private void setFavoriteOffline(AlbumID3 album) {
favoriteRepository.starLater(null, album.getId(), null, true);
album.setStarred(new Date());
this.album.postValue(album);
}
private void setFavoriteOnline(AlbumID3 album) {
favoriteRepository.star(null, album.getId(), null, new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, album.getId(), null, true);
}
});
album.setStarred(new Date());
this.album.postValue(album);
}
public LiveData<ArtistID3> getArtist() {
return artistRepository.getArtistInfo(artistId);
}

View File

@@ -1,23 +1,37 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
public class ArtistPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository;
private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository;
private ArtistID3 artist;
@@ -26,6 +40,7 @@ public class ArtistPageViewModel extends AndroidViewModel {
albumRepository = new AlbumRepository();
artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository();
}
public LiveData<List<AlbumID3>> getAlbumList() {
@@ -55,4 +70,71 @@ public class ArtistPageViewModel extends AndroidViewModel {
public void setArtist(ArtistID3 artist) {
this.artist = artist;
}
public void setFavorite(Context context) {
if (artist.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline();
} else {
removeFavoriteOnline();
}
} else {
if (NetworkUtil.isOffline()) {
setFavoriteOffline();
} else {
setFavoriteOnline(context);
}
}
}
private void removeFavoriteOffline() {
favoriteRepository.starLater(null, null, artist.getId(), false);
artist.setStarred(null);
}
private void removeFavoriteOnline() {
favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, null, artist.getId(), false);
}
});
artist.setStarred(null);
}
private void setFavoriteOffline() {
favoriteRepository.starLater(null, null, artist.getId(), true);
artist.setStarred(new Date());
}
private void setFavoriteOnline(Context context) {
favoriteRepository.star(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, null, artist.getId(), true);
}
});
artist.setStarred(new Date());
if (Preferences.isStarredArtistsSyncEnabled()) {
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
@OptIn(markerClass = UnstableApi.class)
@Override
public void onSongsCollected(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
} else {
}
}
});
} else {
Log.d("ArtistSync", "Artist sync preference is disabled");
}
}
}

View File

@@ -248,15 +248,15 @@ public class HomeViewModel extends AndroidViewModel {
pinnedPlaylists.setValue(Collections.emptyList());
playlistRepository.getPlaylists(false, -1).observe(owner, remotes -> {
playlistRepository.getPinnedPlaylists().observe(owner, locals -> {
if (remotes != null && locals != null) {
List<Playlist> toReturn = remotes.stream()
.filter(remote -> locals.stream().anyMatch(local -> local.getId().equals(remote.getId())))
.collect(Collectors.toList());
if (remotes != null && !remotes.isEmpty()) {
List<Playlist> playlists = new ArrayList<>(remotes);
Collections.shuffle(playlists);
List<Playlist> randomPlaylists = playlists.size() > 5
? playlists.subList(0, 5)
: playlists;
pinnedPlaylists.setValue(toReturn);
}
});
pinnedPlaylists.setValue(randomPlaylists);
}
});
return pinnedPlaylists;

View File

@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@@ -12,6 +13,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download;
@@ -291,13 +293,13 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
List<String> ids = queue.stream().map(Child::getId).collect(Collectors.toList());
if (media != null) {
queueRepository.savePlayQueue(ids, media.getId(), 0);
// TODO: We need to get the actual playback position here
Log.d(TAG, "Saving play queue - Current: " + media.getId() + ", Items: " + ids.size());
queueRepository.savePlayQueue(ids, media.getId(), 0); // Still hardcoded to 0 for now
return true;
}
return false;
}
private void observeCachedLyrics(LifecycleOwner owner, String songId) {
if (TextUtils.isEmpty(songId)) {
return;

View File

@@ -48,11 +48,11 @@ public class SearchViewModel extends AndroidViewModel {
}
public void insertNewSearch(String search) {
searchingRepository.insert(new RecentSearch(search));
searchingRepository.insert(new RecentSearch(search, System.currentTimeMillis() / 1000L));
}
public void deleteRecentSearch(String search) {
searchingRepository.delete(new RecentSearch(search));
searchingRepository.delete(new RecentSearch(search, 0));
}
public LiveData<List<String>> getSearchSuggestion(String query) {

View File

@@ -174,7 +174,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_notes_textview" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
@@ -188,43 +187,69 @@
app:layout_constraintTop_toBottomOf="@+id/album_detail_view" />
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upper_button_divider">
<Button
android:id="@+id/album_page_play_button"
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/album_page_play_button"
android:textAllCaps="false"
app:icon="@drawable/ic_play"
app:iconGravity="textStart"
app:iconPadding="18dp" />
android:orientation="horizontal"
android:gravity="center_vertical">
<Button
android:id="@+id/album_page_play_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:padding="10dp"
android:text="@string/album_page_play_button"
android:textAllCaps="false"
app:icon="@drawable/ic_play"
app:iconGravity="textStart"
app:iconPadding="18dp" />
<Button
android:id="@+id/album_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:padding="10dp"
android:text="@string/album_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<ToggleButton
android:id="@+id/button_favorite"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="0dp"
android:background="@drawable/button_favorite_selector"
android:checked="false"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center"
android:text=""
android:textOff=""
android:textOn="" />
<Button
android:id="@+id/album_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/album_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<TextView
@@ -239,7 +264,8 @@
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_page_button_layout" />
app:layout_constraintTop_toBottomOf="@id/album_page_button_layout"
tools:ignore="NotSibling" />
<View
android:id="@+id/bottom_button_divider"
@@ -249,7 +275,7 @@
android:layout_marginBottom="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_page_button_layout" />
app:layout_constraintTop_toBottomOf="@+id/album_bio_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>

View File

@@ -63,40 +63,67 @@
android:layout_marginEnd="18dp" />
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
android:paddingBottom="4dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:gravity="center_vertical">
<Button
android:id="@+id/artist_page_shuffle_button"
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/artist_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
android:orientation="horizontal"
android:gravity="center_vertical">
<Button
android:id="@+id/artist_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:padding="10dp"
android:text="@string/artist_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
<Button
android:id="@+id/artist_page_radio_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:padding="10dp"
android:text="@string/artist_page_radio_button"
android:textAllCaps="false"
app:icon="@drawable/ic_feed"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<ToggleButton
android:id="@+id/button_favorite"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="0dp"
android:background="@drawable/button_favorite_selector"
android:checked="false"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center"
android:text=""
android:textOff=""
android:textOn="" />
<Button
android:id="@+id/artist_page_radio_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/artist_page_radio_button"
android:textAllCaps="false"
app:icon="@drawable/ic_feed"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<View

View File

@@ -379,16 +379,6 @@
android:paddingTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp" />
</LinearLayout>
<!-- Best of -->
<LinearLayout
android:id="@+id/home_best_of_artist_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/most_streamed_song_pre_text_view"
@@ -400,6 +390,16 @@
android:paddingEnd="16dp"
android:text="@string/home_subtitle_best_of"
android:textAllCaps="true" />
</LinearLayout>
<!-- Best of -->
<LinearLayout
android:id="@+id/home_best_of_artist_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/best_of_artist_text_view_refreshable"
@@ -913,16 +913,36 @@
android:visibility="gone"
tools:visibility="visible">
<!-- Label and button -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="16dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/pinned_playlists_text_view"
style="@style/TitleLarge"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/home_title_pinned_playlists" />
<TextView
android:id="@+id/playlist_catalogue_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_playlist_see_all_button" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pinned_playlists_recycler_view"
android:layout_width="match_parent"

View File

@@ -1,18 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/player_clean_queue_button"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/player_queue_clean_all_button" />
<com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -21,20 +14,74 @@
android:id="@+id/player_queue_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="40dp"
android:paddingTop="8dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/player_shuffle_queue_fab"
<LinearLayout
android:id="@+id/fab_menu_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/content_description_shuffle_button"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:srcCompat="@drawable/ic_shuffle" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior">
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_save_to_playlist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_save_to_playlist"
app:icon="@android:drawable/ic_menu_edit" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_clear_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_clean_all_button"
app:icon="@android:drawable/ic_menu_delete" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_download_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/menu_download_all_button"
app:icon="@android:drawable/stat_sys_download_done" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_load_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_load_queue"
app:icon="@android:drawable/ic_menu_revert" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_shuffle_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/content_description_shuffle_button"
app:icon="@drawable/ic_shuffle" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_menu_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Toggle FAB Action menu"
tools:ignore="HardcodedText"
app:srcCompat="@drawable/ic_add" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -19,12 +19,15 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/cover_image_separator"
android:layout_width="12dp"
android:layout_height="52dp"
<ImageView
android:id="@+id/music_directory_play_button"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:background="@drawable/ic_play"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@+id/music_directory_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_directory_title_text_view"
app:layout_constraintStart_toEndOf="@+id/music_directory_cover_image_view"
app:layout_constraintTop_toTopOf="@+id/music_directory_cover_image_view" />
@@ -33,13 +36,14 @@
style="@style/LabelMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="marquee"
android:paddingEnd="12dp"
android:singleLine="true"
android:text="@string/label_placeholder"
app:layout_constraintBottom_toBottomOf="@id/music_directory_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_directory_more_button"
app:layout_constraintStart_toEndOf="@+id/cover_image_separator"
app:layout_constraintStart_toEndOf="@+id/music_directory_play_button"
app:layout_constraintTop_toTopOf="@+id/music_directory_cover_image_view" />
<ImageView
@@ -54,17 +58,4 @@
app:layout_constraintBottom_toBottomOf="@id/music_directory_title_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/music_directory_title_text_view" />
<ImageView
android:id="@+id/music_directory_play_button"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/ic_play"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@id/music_directory_title_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/music_directory_title_text_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -20,12 +20,14 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/cover_image_separator"
android:layout_width="12dp"
android:layout_height="52dp"
<ImageView
android:id="@+id/music_index_play_button"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:background="@drawable/ic_play"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/music_index_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_index_title_text_view"
app:layout_constraintStart_toEndOf="@+id/music_index_cover_image_view"
app:layout_constraintTop_toTopOf="@+id/music_index_cover_image_view" />
@@ -34,13 +36,14 @@
style="@style/LabelMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="marquee"
android:paddingEnd="12dp"
android:singleLine="true"
android:text="@string/label_placeholder"
app:layout_constraintBottom_toBottomOf="@id/music_index_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_index_more_button"
app:layout_constraintStart_toEndOf="@+id/cover_image_separator"
app:layout_constraintStart_toEndOf="@+id/music_index_play_button"
app:layout_constraintTop_toTopOf="@+id/music_index_cover_image_view" />
<ImageView

View File

@@ -139,6 +139,17 @@
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/download_indicator_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:visibility="gone"
android:src="@drawable/ic_download" app:layout_constraintBottom_toBottomOf="@+id/queue_song_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/queue_song_holder_image"
app:layout_constraintTop_toTopOf="@+id/queue_song_cover_image_view"
tools:visibility="visible" />
<ImageView
android:id="@+id/queue_song_holder_image"
android:layout_width="wrap_content"

View File

@@ -65,6 +65,9 @@
<action
android:id="@+id/action_homeFragment_to_playlistPageFragment"
app:destination="@id/playlistPageFragment" />
<action
android:id="@+id/action_homeFragment_to_playlistCatalogueFragment"
app:destination="@id/playlistCatalogueFragment" />
<action
android:id="@+id/action_homeFragment_to_podcastChannelCatalogueFragment"
app:destination="@id/podcastChannelCatalogueFragment" />

View File

@@ -62,7 +62,7 @@
<string name="delete_download_storage_dialog_positive_button">Continuar</string>
<string name="delete_download_storage_dialog_summary">Por favor, sea consciente de que si continúa, todos los elementos descargados de todos los servidores se eliminarán.</string>
<string name="delete_download_storage_dialog_title">Eliminar elementos guardados</string>
<string name="description_empty_title">Descripción no disponible</string>
<string name="description_empty_title">Letra no disponible</string>
<string name="disc_titlefull">Disco %1$s - %2$s</string>
<string name="disc_titleless">Disco %1$s</string>
<string name="download_directory_dialog_negative_button">Cancelar</string>

View File

@@ -61,7 +61,7 @@
<string name="delete_download_storage_dialog_positive_button">Continuer</string>
<string name="delete_download_storage_dialog_summary">Attention, la poursuite de cette action entraînera la suppression définitive de tous les éléments sauvegardés et téléchargés à partir de tous les serveurs</string>
<string name="delete_download_storage_dialog_title">Supprimer les éléments téléchargés</string>
<string name="description_empty_title">Aucune description disponible</string>
<string name="description_empty_title">Paroles non disponibles</string>
<string name="disc_titlefull">Disque %1$s - %2$s</string>
<string name="disc_titleless">Disque %1$s</string>
<string name="download_directory_dialog_negative_button">Annuler</string>

View File

@@ -61,7 +61,7 @@
<string name="delete_download_storage_dialog_positive_button">Continua</string>
<string name="delete_download_storage_dialog_summary">Attenzione, procedendo questa azione eliminerà definitivamente tutti gli elementi scaricati da tutti i server.</string>
<string name="delete_download_storage_dialog_title">Elimina elementi salvati</string>
<string name="description_empty_title">Descrizione non disponibile</string>
<string name="description_empty_title">Testo non disponibile</string>
<string name="disc_titlefull">Disco %1$s - %2$s</string>
<string name="disc_titleless">Disco %1$s</string>
<string name="download_directory_dialog_negative_button">Annulla</string>

View File

@@ -32,6 +32,8 @@
<item name="android:statusBarColor">?attr/colorSurface</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<item name="android:scrollbars">none</item>
<item name="floatingActionButtonStyle">@style/FloatingActionButtonStyle</item>
</style>
<style name="Divider">
@@ -40,6 +42,21 @@
<item name="android:background">@color/dividerColor</item>
</style>
<style name="FloatingActionButtonStyle" parent="Widget.MaterialComponents.FloatingActionButton">
<item name="backgroundTint">?attr/colorSecondary</item>
<item name="tint">?attr/colorOnPrimary</item>
<item name="shapeAppearanceOverlay">@style/FabShapeStyle</item>
</style>
<style name="FabShapeStyle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerSize">50%</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerFamilyTopLeft">rounded</item>
<item name="cornerFamilyTopRight">rounded</item>
<item name="cornerFamilyBottomLeft">rounded</item>
<item name="cornerFamilyBottomRight">rounded</item>
</style>
<style name="NoConnectionTextView">
<item name="background">?attr/colorErrorContainer</item>
<item name="android:textColor">?attr/colorOnErrorContainer</item>

View File

@@ -61,7 +61,7 @@
<string name="delete_download_storage_dialog_positive_button">Kontynuuj</string>
<string name="delete_download_storage_dialog_summary">Miej na uwadze to że kontynuowanie tej operacji spowoduje usunięcie wszystkich pobranych plików z wszystkich serwerów.</string>
<string name="delete_download_storage_dialog_title">Usuwanie zapisanych plików</string>
<string name="description_empty_title">Brak opisu</string>
<string name="description_empty_title">Brak tekstu</string>
<string name="disc_titlefull">Płyta %1$s - %2$s</string>
<string name="disc_titleless">Płyta %1$s</string>
<string name="download_directory_dialog_negative_button">Anuluj</string>
@@ -124,6 +124,10 @@
<string name="home_sync_starred_albums_subtitle">Albumy oznaczone gwiazdką będą dostępne offline</string>
<string name="home_sync_starred_artists_title">Synchronizacja wykonawców oznaczonych gwiazdką</string>
<string name="home_sync_starred_artists_subtitle">Masz wykonawców oznaczonych gwiazdką, bez pobranej muzyki</string>
<plurals name="home_sync_starred_songs_count">
<item quantity="one">%d piosenka wymaga synchronizacji</item>
<item quantity="other">%d piosenek wymaga synchronizacji</item>
</plurals>
<string name="home_title_best_of">Najlepsze</string>
<string name="home_title_discovery">Odkrywanie</string>
<string name="home_title_discovery_shuffle_all_button">Odtwórz wszystkie losowo</string>
@@ -201,7 +205,9 @@
<string name="menu_sort_year">Rok</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">Wyczyść kolejkę odtwarzania</string>
<string name="player_queue_save_queue_success">Zapisana kolejka 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>
<string name="player_queue_load_queue">Wczytaj kolejkę</string>
<string name="player_lyrics_download_content_description">Pobierz teksty do odtwarzania offline</string>
<string name="player_lyrics_downloaded_content_description">Teksty pobrane do odtwarzania offline</string>
<string name="player_lyrics_download_success">Zapisano tekst do odtwarzania offline.</string>
@@ -522,4 +528,9 @@
<string name="settings_album_detail_summary">Jeżeli włączone, pokaż szczegóły albumu takie jak gatunek, ilość piosenek itp. na stronie albumu</string>
<string name="settings_artist_sort_by_album_count">Sortuj wykonawców po ilości albumów</string>
<string name="settings_artist_sort_by_album_count_summary">Jeżeli włączone, sortuje wykonawców po ilości albumów. Jeżeli wyłączone, sortuje albumy po nazwach.</string>
<string name="folder_play_collecting">Zbieranie piosenek z folderu…</string>
<string name="folder_play_playing">Odtwarzanie %d piosenek</string>
<string name="folder_play_no_songs">Nie znaleziono piosenek w folderze</string>
<string name="search_sort_title">Sortuj ostatnie wyszukiwania chronologicznie</string>
<string name="search_sort_summary">Jeżeli włączone, sortuje wyszukiwania chronologicznie. Sortuje po naziwe jeżeli wyłączone.</string>
</resources>

View File

@@ -61,7 +61,7 @@
<string name="delete_download_storage_dialog_positive_button">Continue</string>
<string name="delete_download_storage_dialog_summary">Please be aware that continuing with this action will result in the permanent deletion of all saved items downloaded from all servers.</string>
<string name="delete_download_storage_dialog_title">Delete saved items</string>
<string name="description_empty_title">No description available</string>
<string name="description_empty_title">No lyrics available</string>
<string name="disc_titlefull">Disc %1$s - %2$s</string>
<string name="disc_titleless">Disc %1$s</string>
<string name="download_directory_dialog_negative_button">Cancel</string>
@@ -133,6 +133,10 @@
<string name="home_sync_starred_albums_subtitle">Albums marked with a star will be available offline</string>
<string name="home_sync_starred_artists_title">Starred Artists Sync</string>
<string name="home_sync_starred_artists_subtitle">You have starred artists with music not downloaded</string>
<plurals name="home_sync_starred_songs_count">
<item quantity="one">%d song needs sync</item>
<item quantity="other">%d songs need sync</item>
</plurals>
<string name="home_title_best_of">Best of</string>
<string name="home_title_discovery">Discovery</string>
<string name="home_title_discovery_shuffle_all_button">Shuffle all</string>
@@ -212,6 +216,8 @@
<string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">Clean play queue</string>
<string name="player_queue_save_queue_success">Saved play queue</string>
<string name="player_queue_save_to_playlist">Save Queue to Playlist</string>
<string name="player_queue_load_queue">Load Queue</string>
<string name="player_lyrics_download_content_description">Download lyrics for offline playback</string>
<string name="player_lyrics_downloaded_content_description">Lyrics downloaded for offline playback</string>
<string name="player_lyrics_download_success">Lyrics saved for offline playback.</string>
@@ -533,4 +539,11 @@
<string name="settings_album_detail_summary">If enabled, show the album details like genre, song count etc. on the album page</string>
<string name="settings_artist_sort_by_album_count">Sort artists by album count</string>
<string name="settings_artist_sort_by_album_count_summary">If enabled, sort the artists by album count. Sort by name if disabled.</string>
<string name="folder_play_collecting">Collecting songs from folder…</string>
<string name="folder_play_playing">Playing %d songs</string>
<string name="folder_play_no_songs">No songs found in folder</string>
<string name="search_sort_title">Sort recent searches chronologically</string>
<string name="search_sort_summary">If enabled, sort searches chronologically. Sort by name if disabled.</string>
</resources>

View File

@@ -39,6 +39,8 @@
<item name="android:statusBarColor">?attr/colorSurface</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<item name="android:scrollbars">none</item>
<item name="floatingActionButtonStyle">@style/FloatingActionButtonStyle</item>
</style>
<style name="Divider">
@@ -47,6 +49,21 @@
<item name="android:background">@color/dividerColor</item>
</style>
<style name="FloatingActionButtonStyle" parent="Widget.MaterialComponents.FloatingActionButton">
<item name="backgroundTint">?attr/colorSecondary</item>
<item name="tint">?attr/colorOnPrimary</item>
<item name="shapeAppearanceOverlay">@style/FabShapeStyle</item>
</style>
<style name="FabShapeStyle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
<item name="cornerSize">50%</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerFamilyTopLeft">rounded</item>
<item name="cornerFamilyTopRight">rounded</item>
<item name="cornerFamilyBottomLeft">rounded</item>
<item name="cornerFamilyBottomRight">rounded</item>
</style>
<style name="NoConnectionTextView">
<item name="background">?attr/colorErrorContainer</item>
<item name="android:textColor">?attr/colorOnErrorContainer</item>

View File

@@ -122,6 +122,12 @@
android:summary="@string/settings_artist_sort_by_album_count_summary"
android:key="artist_sort_by_album_count" />
<SwitchPreference
android:title="@string/search_sort_title"
android:defaultValue="false"
android:summary="@string/search_sort_summary"
android:key="sort_search_chronologically" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_title_playlist">

View File

@@ -0,0 +1,4 @@
* fix: updates starred syncing downloads to user defined directory
* fix: handle empty albums and null mappings
* feat: integrate sort recent searches chronologically
* feat: add heart to artist/album pages, fixed artist cover art failing

View File

@@ -0,0 +1,3 @@
* fix: checks preference and writes files externally, updates the ui when downloading from the directories
* chore: Update description_empty_title in English, Italian, French, Polish and Spanish
* feat: added regular playlist to home view

View File

@@ -0,0 +1,4 @@
* chore: Add Obtainium badge to README
* fix: Revert "refactor MediaService"
* feat: add play functionality to library folder/index items
* fix: start queue blocking UI

View File

@@ -0,0 +1,3 @@
* chore: bringing in media service refactor previously reverted after more testing
* fix: refactor start queue to put the db writing in the background to address instant mix bug
* Feat: playerqueue fab allows playqueue actions -> saving to playlist, download all, load queue, shuffle, clean queue

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB