From be4346b3d1d7906af25cc76535e8a04803def979 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Tue, 23 Sep 2025 02:26:26 -0500 Subject: [PATCH 01/42] fix: Lag during startup when local url is not available --- .../subsonic/api/system/SystemClient.java | 13 ++++++++++++- .../tempo/ui/activity/MainActivity.java | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java b/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java index d4a6521a..c5227da7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java @@ -5,6 +5,9 @@ import android.util.Log; import com.cappielloantonio.tempo.subsonic.RetrofitClient; import com.cappielloantonio.tempo.subsonic.Subsonic; import com.cappielloantonio.tempo.subsonic.base.ApiResponse; +import com.cappielloantonio.tempo.util.Preferences; + +import java.util.concurrent.TimeUnit; import retrofit2.Call; @@ -21,7 +24,15 @@ public class SystemClient { public Call ping() { Log.d(TAG, "ping()"); - return systemService.ping(subsonic.getParams()); + Call pingCall = systemService.ping(subsonic.getParams()); + if (Preferences.isInUseServerAddressLocal()) { + pingCall.timeout() + .timeout(1, TimeUnit.SECONDS); + } else { + pingCall.timeout() + .timeout(3, TimeUnit.SECONDS); + } + return pingCall; } public Call getLicense() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index c959648b..e18d2ea4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -40,6 +40,9 @@ import com.google.android.material.color.DynamicColors; import com.google.common.util.concurrent.MoreExecutors; import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @UnstableApi @@ -82,6 +85,7 @@ public class MainActivity extends BaseActivity { @Override protected void onStart() { super.onStart(); + pingServer(); initService(); } @@ -351,6 +355,7 @@ public class MainActivity extends BaseActivity { Preferences.switchInUseServerAddress(); App.refreshSubsonicClient(); pingServer(); + resetView(); } else { Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic()); } @@ -361,6 +366,7 @@ public class MainActivity extends BaseActivity { Preferences.switchInUseServerAddress(); App.refreshSubsonicClient(); pingServer(); + resetView(); } else { mainViewModel.ping().observe(this, subsonicResponse -> { if (subsonicResponse == null) { @@ -376,6 +382,18 @@ public class MainActivity extends BaseActivity { } } + private void resetView() { + resetViewModel(); + try { + int id = Objects.requireNonNull(navController.getCurrentDestination()).getId(); + navController.popBackStack(id, true); + navController.navigate(id); + } catch(NullPointerException e) { + e.printStackTrace(); + quit(); + } + } + private void getOpenSubsonicExtensions() { if (Preferences.getToken() != null) { mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> { From a83495f353b4d8377b8c46d90b39c43c762c992d Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Tue, 23 Sep 2025 02:30:22 -0500 Subject: [PATCH 02/42] fix: Removed unnecessary imports --- .../com/cappielloantonio/tempo/ui/activity/MainActivity.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index e18d2ea4..3ecf41ff 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -40,9 +40,6 @@ import com.google.android.material.color.DynamicColors; import com.google.common.util.concurrent.MoreExecutors; import java.util.Objects; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @UnstableApi From cc0e264a178dfc5098c298166b6f853b1865f227 Mon Sep 17 00:00:00 2001 From: mucahit-kaya <54944321+mucahit-kaya@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:00:51 +0200 Subject: [PATCH 03/42] feat: Add home screen music playback widget Introduces a new app widget for music playback control and display. Adds widget provider classes, update manager, view factory, and related resources (layouts, colors, strings, XML). Integrates widget updates with MediaService to reflect current playback state. Updates AndroidManifest to register the widget. --- app/src/main/AndroidManifest.xml | 17 +- .../tempo/glide/CustomGlideRequest.java | 14 ++ .../tempo/widget/WidgetActions.java | 44 ++++++ .../tempo/widget/WidgetProvider.java | 82 ++++++++++ .../tempo/widget/WidgetProvider4x1.java | 8 + .../tempo/widget/WidgetUpdateManager.java | 146 ++++++++++++++++++ .../tempo/widget/WidgetViewsFactory.java | 68 ++++++++ app/src/main/res/drawable/widget_bg.xml | 6 + .../main/res/layout/widget_layout_compact.xml | 78 ++++++++++ .../main/res/layout/widget_layout_large.xml | 78 ++++++++++ .../res/layout/widget_preview_compact.xml | 82 ++++++++++ .../main/res/values-night/colors_widget.xml | 7 + app/src/main/res/values-tr/strings.xml | 18 +++ app/src/main/res/values/colors_widget.xml | 8 + app/src/main/res/values/integers.xml | 4 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/widget_info.xml | 10 ++ .../tempo/service/MediaService.kt | 26 +++- .../tempo/service/MediaService.kt | 23 +++ .../tempo/service/MediaService.kt | 22 +++ 20 files changed, 746 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java create mode 100644 app/src/main/res/drawable/widget_bg.xml create mode 100644 app/src/main/res/layout/widget_layout_compact.xml create mode 100644 app/src/main/res/layout/widget_layout_large.xml create mode 100644 app/src/main/res/layout/widget_preview_compact.xml create mode 100644 app/src/main/res/values-night/colors_widget.xml create mode 100644 app/src/main/res/values/colors_widget.xml create mode 100644 app/src/main/res/values/integers.xml create mode 100644 app/src/main/res/xml/widget_info.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cc9990e7..816683ca 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,5 +73,20 @@ android:name="autoStoreLocales" android:value="true" /> + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java index fe57c163..8e49111f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java +++ b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java @@ -1,6 +1,7 @@ package com.cappielloantonio.tempo.glide; import android.content.Context; +import android.graphics.Bitmap; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.util.Log; @@ -16,6 +17,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.signature.ObjectKey; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.R; @@ -109,6 +111,18 @@ public class CustomGlideRequest { return uri.toString(); } + public static void loadAlbumArtBitmap(Context context, + String coverId, + int size, + CustomTarget target) { + String url = createUrl(coverId, size); + Glide.with(context) + .asBitmap() + .load(url) + .apply(createRequestOptions(context, coverId, ResourceType.Album)) + .into(target); + } + public static class Builder { private final RequestManager requestManager; private Object item; diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java new file mode 100644 index 00000000..c035fa78 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java @@ -0,0 +1,44 @@ +package com.cappielloantonio.tempo.widget; + +import android.content.ComponentName; +import android.content.Context; +import android.util.Log; + +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionToken; + +import com.cappielloantonio.tempo.service.MediaService; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; + +import java.util.concurrent.ExecutionException; + +public final class WidgetActions { + public static void dispatchToMediaSession(Context ctx, String action) { + Log.d("TempoWidget", "dispatch action=" + action); + Context appCtx = ctx.getApplicationContext(); + SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class)); + ListenableFuture future = new MediaController.Builder(appCtx, token).buildAsync(); + future.addListener(() -> { + try { + if (!future.isDone()) return; + MediaController c = future.get(); + Log.d("TempoWidget", "controller connected, isPlaying=" + c.isPlaying()); + switch (action) { + case WidgetProvider.ACT_PLAY_PAUSE: + if (c.isPlaying()) c.pause(); else c.play(); + break; + case WidgetProvider.ACT_NEXT: + c.seekToNext(); + break; + case WidgetProvider.ACT_PREV: + c.seekToPrevious(); + break; + } + c.release(); + } catch (ExecutionException | InterruptedException e) { + Log.e("TempoWidget", "dispatch failed", e); + } + }, MoreExecutors.directExecutor()); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java new file mode 100644 index 00000000..2b4b1f3c --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java @@ -0,0 +1,82 @@ +package com.cappielloantonio.tempo.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; +import android.widget.RemoteViews; + +import com.cappielloantonio.tempo.R; +import android.app.TaskStackBuilder; +import android.app.PendingIntent; +import com.cappielloantonio.tempo.ui.activity.MainActivity; +import android.util.Log; + +public class WidgetProvider extends AppWidgetProvider { + private static final String TAG = "TempoWidget"; + public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE"; + public static final String ACT_NEXT = "tempo.widget.NEXT"; + public static final String ACT_PREV = "tempo.widget.PREV"; + + @Override public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) { + for (int id : ids) { + RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id); + attachIntents(ctx, rv, id); + mgr.updateAppWidget(id, rv); + } + } + + @Override public void onReceive(Context ctx, Intent intent) { + super.onReceive(ctx, intent); + String a = intent.getAction(); + Log.d(TAG, "onReceive action=" + a); + if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a)) { + WidgetActions.dispatchToMediaSession(ctx, a); + } else if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(a)) { + WidgetUpdateManager.refreshFromController(ctx); + } + } + + @Override public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) { + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); + RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId); + attachIntents(context, rv, appWidgetId); + appWidgetManager.updateAppWidget(appWidgetId, rv); + WidgetUpdateManager.refreshFromController(context); + } + + public static void attachIntents(Context ctx, RemoteViews rv) { + attachIntents(ctx, rv, 0); + } + + public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) { + PendingIntent playPause = PendingIntent.getBroadcast( + ctx, + requestCodeBase + 0, + new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PLAY_PAUSE), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + PendingIntent next = PendingIntent.getBroadcast( + ctx, + requestCodeBase + 1, + new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_NEXT), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + PendingIntent prev = PendingIntent.getBroadcast( + ctx, + requestCodeBase + 2, + new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PREV), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + + rv.setOnClickPendingIntent(R.id.btn_play_pause, playPause); + rv.setOnClickPendingIntent(R.id.btn_next, next); + rv.setOnClickPendingIntent(R.id.btn_prev, prev); + + PendingIntent launch = TaskStackBuilder.create(ctx) + .addNextIntentWithParentStack(new Intent(ctx, MainActivity.class)) + .getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + rv.setOnClickPendingIntent(R.id.root, launch); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java new file mode 100644 index 00000000..79ef6af1 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java @@ -0,0 +1,8 @@ +package com.cappielloantonio.tempo.widget; + +/** + * AppWidget provider entry for the 4x1 widget card. Inherits all behavior + * from {@link WidgetProvider}. + */ +public class WidgetProvider4x1 extends WidgetProvider {} + diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java new file mode 100644 index 00000000..6de08905 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java @@ -0,0 +1,146 @@ +package com.cappielloantonio.tempo.widget; + +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.graphics.Bitmap; +import android.text.TextUtils; +import android.graphics.drawable.Drawable; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.R; +import androidx.media3.session.MediaController; +import androidx.media3.session.SessionToken; +import com.cappielloantonio.tempo.service.MediaService; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import java.util.concurrent.ExecutionException; + +public final class WidgetUpdateManager { + + public static void updateFromState(Context ctx, + String title, + String artist, + Bitmap art, + boolean playing) { + if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing); + if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle); + + AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); + int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); + for (int id : ids) { + android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, art, playing, id); + WidgetProvider.attachIntents(ctx, rv, id); + mgr.updateAppWidget(id, rv); + } + } + + public static void pushNow(Context ctx) { + AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); + int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); + for (int id : ids) { + android.widget.RemoteViews rv = chooseBuild(ctx, id); + WidgetProvider.attachIntents(ctx, rv, id); + mgr.updateAppWidget(id, rv); + } + } + + public static void updateFromState(Context ctx, + String title, + String artist, + String coverArtId, + boolean playing) { + final Context appCtx = ctx.getApplicationContext(); + final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title; + final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist; + final boolean p = playing; + + if (!TextUtils.isEmpty(coverArtId)) { + CustomGlideRequest.loadAlbumArtBitmap( + appCtx, + coverArtId, + com.cappielloantonio.tempo.util.Preferences.getImageSize(), + new CustomTarget() { + @Override public void onResourceReady(Bitmap resource, Transition transition) { + AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); + int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); + for (int id : ids) { + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, resource, p, id); + WidgetProvider.attachIntents(appCtx, rv, id); + mgr.updateAppWidget(id, rv); + } + } + + @Override public void onLoadCleared(Drawable placeholder) { + AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); + int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); + for (int id : ids) { + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, id); + WidgetProvider.attachIntents(appCtx, rv, id); + mgr.updateAppWidget(id, rv); + } + } + } + ); + } else { + AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); + int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); + for (int id : ids) { + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, id); + WidgetProvider.attachIntents(appCtx, rv, id); + mgr.updateAppWidget(id, rv); + } + } + } + + public static void refreshFromController(Context ctx) { + final Context appCtx = ctx.getApplicationContext(); + SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class)); + ListenableFuture future = new MediaController.Builder(appCtx, token).buildAsync(); + future.addListener(() -> { + try { + if (!future.isDone()) return; + MediaController c = future.get(); + androidx.media3.common.MediaItem mi = c.getCurrentMediaItem(); + String title = null, artist = null, coverId = null; + if (mi != null && mi.mediaMetadata != null) { + if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString(); + if (mi.mediaMetadata.artist != null) artist = mi.mediaMetadata.artist.toString(); + if (mi.mediaMetadata.extras != null) { + if (title == null) title = mi.mediaMetadata.extras.getString("title"); + if (artist == null) artist = mi.mediaMetadata.extras.getString("artist"); + coverId = mi.mediaMetadata.extras.getString("coverArtId"); + } + } + updateFromState(appCtx, + title != null ? title : appCtx.getString(R.string.widget_not_playing), + artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle), + coverId, + c.isPlaying()); + c.release(); + } catch (ExecutionException | InterruptedException ignored) { + } + }, MoreExecutors.directExecutor()); + } + + public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) { + if (isLarge(ctx, appWidgetId)) return WidgetViewsFactory.buildLarge(ctx); + return WidgetViewsFactory.buildCompact(ctx); + } + + private static android.widget.RemoteViews choosePopulate(Context ctx, String title, String artist, Bitmap art, boolean playing, int appWidgetId) { + if (isLarge(ctx, appWidgetId)) return WidgetViewsFactory.populateLarge(ctx, title, artist, art, playing); + return WidgetViewsFactory.populate(ctx, title, artist, art, playing); + } + + private static boolean isLarge(Context ctx, int appWidgetId) { + AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); + android.os.Bundle opts = mgr.getAppWidgetOptions(appWidgetId); + int minH = opts != null ? opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) : 0; + int threshold = ctx.getResources().getInteger(com.cappielloantonio.tempo.R.integer.widget_large_min_height_dp); + return minH >= threshold; // dp threshold for 2-row height + } + + +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java new file mode 100644 index 00000000..9dec3d60 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java @@ -0,0 +1,68 @@ +package com.cappielloantonio.tempo.widget; + +import android.content.Context; +import android.graphics.Bitmap; +import android.widget.RemoteViews; +import com.cappielloantonio.tempo.R; + +public final class WidgetViewsFactory { + + public static RemoteViews buildCompact(Context ctx) { + return build(ctx, R.layout.widget_layout_compact); + } + + public static RemoteViews buildLarge(Context ctx) { + return build(ctx, R.layout.widget_layout_large); + } + + + private static RemoteViews build(Context ctx, int layoutRes) { + RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes); + rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing)); + rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle)); + rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play); + // Show Tempo logo when nothing is playing + rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo); + return rv; + } + + public static RemoteViews populate(Context ctx, + String title, + String subtitle, + Bitmap art, + boolean playing) { + return populateWithLayout(ctx, title, subtitle, art, playing, R.layout.widget_layout_compact); + } + + public static RemoteViews populateLarge(Context ctx, + String title, + String subtitle, + Bitmap art, + boolean playing) { + return populateWithLayout(ctx, title, subtitle, art, playing, R.layout.widget_layout_large); + } + + + private static RemoteViews populateWithLayout(Context ctx, + String title, + String subtitle, + Bitmap art, + boolean playing, + int layoutRes) { + RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes); + rv.setTextViewText(R.id.title, title); + rv.setTextViewText(R.id.subtitle, subtitle); + + if (art != null) { + rv.setImageViewBitmap(R.id.album_art, art); + } else { + // Fallback to app logo when art is missing + rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo); + } + + rv.setImageViewResource(R.id.btn_play_pause, + playing ? R.drawable.ic_pause : R.drawable.ic_play); + + return rv; + } +} diff --git a/app/src/main/res/drawable/widget_bg.xml b/app/src/main/res/drawable/widget_bg.xml new file mode 100644 index 00000000..c569bbeb --- /dev/null +++ b/app/src/main/res/drawable/widget_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/widget_layout_compact.xml b/app/src/main/res/layout/widget_layout_compact.xml new file mode 100644 index 00000000..8f139917 --- /dev/null +++ b/app/src/main/res/layout/widget_layout_compact.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_layout_large.xml b/app/src/main/res/layout/widget_layout_large.xml new file mode 100644 index 00000000..08a97931 --- /dev/null +++ b/app/src/main/res/layout/widget_layout_large.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_compact.xml b/app/src/main/res/layout/widget_preview_compact.xml new file mode 100644 index 00000000..e369ffb6 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_compact.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors_widget.xml b/app/src/main/res/values-night/colors_widget.xml new file mode 100644 index 00000000..7bda2da7 --- /dev/null +++ b/app/src/main/res/values-night/colors_widget.xml @@ -0,0 +1,7 @@ + + + #CC000000 + #FFFFFFFF + #B3FFFFFF + #FFFFFFFF + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 698b8eb9..dc6f4c3e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -90,6 +90,7 @@ İndirilenler İki veya daha fazla filtre seçin Filtre + Sanatçıları filtrele Türleri filtrele (%1$d) (+%1$d) @@ -116,6 +117,7 @@ İndir Bu parçaların indirilmesi önemli miktarda veri kullanabilir Eşitlenecek bazı yıldızlı parçalar var gibi görünüyor + Yıldız ile işaretlenen albümler çevrimdışı kullanılabilir olacak En iyiler Keşfet Tümünü karıştır @@ -164,6 +166,7 @@ Ekle Çalma listesine ekle Tümünü indir + Albümü oyla İndir Tümü İndirilenler @@ -192,6 +195,7 @@ Yıl %1$.2fx Çalma sırasını temizle + Kayıtlı oynatma sırası Sunucu önceliği Bilinmeyen format Dönüştürme @@ -311,6 +315,7 @@ Etkinleştirildiğinde podcast bölümü görüntülenir. Tam etkili olması için uygulamayı yeniden başlatın. Ses kalitesini göster Her ses parçası için bit hızı ve ses formatı gösterilecektir. + " " Öğe değerlemesini göster Etkinleştirildiğinde, öğenin puanı ve favori olarak işaretlenip işaretlenmediği görüntülenir. Eşitleme zamanlayıcısı @@ -340,6 +345,7 @@ Dönüştürülmüş medyayı indir. Etkinleştirilirse indirme uç noktası kullanılmaz, bunun yerine aşağıdaki ayarlar geçerli olur. \n\n “İndirmeler için dönüştürme formatı” “Doğrudan indir” olarak ayarlanırsa dosyanın bit hızı değiştirilmez. Dosya anlık olarak dönüştürüldüğünde, istemci genellikle parçanın süresini göstermez. Bu işlevi destekleyen sunuculardan çalınan parçanın süresini tahmin etmeleri istenebilir, ancak yanıt süreleri daha uzun olabilir. + Çevrimdışı kullanım için yıldızlı albümleri senkronize et Etkinleştirildiğinde, yıldızlı parçalar çevrimdışı kullanım için indirilecektir. Çevrimdışı kullanım için yıldızlı parçaları eşitle Tema @@ -395,6 +401,8 @@ Devam et ve indir Yıldızlı parçaların indirilmesi yüksek miktarda veri gerektirebilir. Yıldızlı parçaları eşitle + Yıldızlı albümleri indirmek yüksek miktarda veri kullanımı gerektirebilir. + Yıldızlı albümleri senkronize et Değişikliklerin geçerli olması için uygulamayı yeniden başlatın. Önbelleğe alınmış dosyaların hedefini bir depolamadan diğerine değiştirmek, önceki depolamadaki önbellek dosyalarının silinmesine yol açabilir. Depolama seçeneğini seç @@ -433,4 +441,14 @@ unDraw İllüstrasyonlarıyla bu uygulamayı daha güzel hale getirmemize yardımcı olan unDraw’a özel teşekkürler. https://undraw.co/ + Yıldızlı Albümleri Senkronize Et + Tempo Widget + Şu an oynatılmıyor + Tempo’yu aç + Albüm kapağı + Çal/Duraklat + Sonraki parça + Önceki parça + Şarkının yıldız derecelendirmesini göster + "Etkinleştirildiğinde yıldızlı albümler çevrimdışı kullanım için indirilecek. " diff --git a/app/src/main/res/values/colors_widget.xml b/app/src/main/res/values/colors_widget.xml new file mode 100644 index 00000000..75a9f7f1 --- /dev/null +++ b/app/src/main/res/values/colors_widget.xml @@ -0,0 +1,8 @@ + + + + #CCFFFFFF + #DE000000 + #99000000 + #DE000000 + diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml new file mode 100644 index 00000000..7b968814 --- /dev/null +++ b/app/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 100 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40febf58..483b4958 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,6 +439,13 @@ unDraw A special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful. https://undraw.co/ + Tempo Widget + Not playing + Open Tempo + Album artwork + Play or pause + Next track + Previous track %d album to sync %d albums to sync diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml new file mode 100644 index 00000000..03e072ce --- /dev/null +++ b/app/src/main/res/xml/widget_info.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index 66b12ae5..de6a6f06 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil +import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture @@ -260,6 +261,7 @@ class MediaService : MediaLibraryService() { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { MediaManager.setLastPlayedTimestamp(mediaItem) } + updateWidget() } override fun onTracksChanged(tracks: Tracks) { @@ -279,6 +281,7 @@ class MediaService : MediaLibraryService() { } else { MediaManager.scrobble(player.currentMediaItem, false) } + updateWidget() } override fun onPlaybackStateChanged(playbackState: Int) { @@ -290,6 +293,7 @@ class MediaService : MediaLibraryService() { MediaManager.scrobble(player.currentMediaItem, true) MediaManager.saveChronology(player.currentMediaItem) } + updateWidget() } override fun onPositionDiscontinuity( @@ -383,5 +387,25 @@ class MediaService : MediaLibraryService() { .build() } + private fun updateWidget() { + val mi = player.currentMediaItem + val title = mi?.mediaMetadata?.title?.toString() + ?: mi?.mediaMetadata?.extras?.getString("title") + val artist = mi?.mediaMetadata?.artist?.toString() + ?: mi?.mediaMetadata?.extras?.getString("artist") + val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + WidgetUpdateManager.updateFromState( + this, + title ?: "", + artist ?: "", + coverId, + player.isPlaying + ) + } + + private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) -} \ No newline at end of file + + private fun getMediaSourceFactory() = + DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) +} diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt index 99d6dc22..dadf0d7d 100644 --- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -25,6 +25,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil +import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -161,6 +162,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { MediaManager.setLastPlayedTimestamp(mediaItem) } + updateWidget() } override fun onTracksChanged(tracks: Tracks) { @@ -180,6 +182,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } else { MediaManager.scrobble(player.currentMediaItem, false) } + updateWidget() } override fun onPlaybackStateChanged(playbackState: Int) { @@ -192,6 +195,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { MediaManager.scrobble(player.currentMediaItem, true) MediaManager.saveChronology(player.currentMediaItem) } + updateWidget() } override fun onPositionDiscontinuity( @@ -229,6 +233,25 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { }) } + private fun updateWidget() { + val mi = player.currentMediaItem + val title = mi?.mediaMetadata?.title?.toString() + ?: mi?.mediaMetadata?.extras?.getString("title") + val artist = mi?.mediaMetadata?.artist?.toString() + ?: mi?.mediaMetadata?.extras?.getString("artist") + val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + + WidgetUpdateManager.updateFromState( + this, + title ?: "", + artist ?: "", + coverId, + player.isPlaying, + player.currentPosition, + player.duration + ) + } + private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() .setBufferDurationsMs( diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 486d2352..4c9c77aa 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -25,6 +25,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil +import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @@ -161,6 +162,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { MediaManager.setLastPlayedTimestamp(mediaItem) } + updateWidget() } override fun onTracksChanged(tracks: Tracks) { @@ -180,6 +182,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } else { MediaManager.scrobble(player.currentMediaItem, false) } + updateWidget() } override fun onPlaybackStateChanged(playbackState: Int) { @@ -192,6 +195,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { MediaManager.scrobble(player.currentMediaItem, true) MediaManager.saveChronology(player.currentMediaItem) } + updateWidget() } override fun onPositionDiscontinuity( @@ -229,6 +233,24 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { }) } + private fun updateWidget() { + val mi = player.currentMediaItem + val title = mi?.mediaMetadata?.title?.toString() + ?: mi?.mediaMetadata?.extras?.getString("title") + val artist = mi?.mediaMetadata?.artist?.toString() + ?: mi?.mediaMetadata?.extras?.getString("artist") + val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + WidgetUpdateManager.updateFromState( + this, + title ?: "", + artist ?: "", + coverId, + player.isPlaying, + player.currentPosition, + player.duration + ) + } + private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() .setBufferDurationsMs( From e81e1a53568036843a0726ba44991ef3b39e01f6 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Thu, 18 Sep 2025 13:57:07 +0930 Subject: [PATCH 04/42] fix: Include song position and duration in widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mücahit Kaya Co-authored-by: The Firehawk --- .../tempo/widget/WidgetUpdateManager.java | 92 ++++++++++++++++--- .../tempo/widget/WidgetViewsFactory.java | 38 +++++++- .../main/res/layout/widget_layout_compact.xml | 39 ++++++++ .../main/res/layout/widget_layout_large.xml | 39 ++++++++ app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../tempo/service/MediaService.kt | 6 +- .../tempo/service/MediaService.kt | 6 +- .../tempo/service/MediaService.kt | 6 +- 9 files changed, 210 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java index 6de08905..e9515c50 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java @@ -10,9 +10,11 @@ import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.R; +import androidx.media3.common.C; import androidx.media3.session.MediaController; import androidx.media3.session.SessionToken; import com.cappielloantonio.tempo.service.MediaService; +import com.cappielloantonio.tempo.util.MusicUtil; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import java.util.concurrent.ExecutionException; @@ -23,14 +25,19 @@ public final class WidgetUpdateManager { String title, String artist, Bitmap art, - boolean playing) { + boolean playing, + long positionMs, + long durationMs) { if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing); if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle); + final TimingInfo timing = createTimingInfo(positionMs, durationMs); + AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, art, playing, id); + android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, art, playing, + timing.elapsedText, timing.totalText, timing.progress, id); WidgetProvider.attachIntents(ctx, rv, id); mgr.updateAppWidget(id, rv); } @@ -50,11 +57,14 @@ public final class WidgetUpdateManager { String title, String artist, String coverArtId, - boolean playing) { + boolean playing, + long positionMs, + long durationMs) { final Context appCtx = ctx.getApplicationContext(); final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title; final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist; final boolean p = playing; + final TimingInfo timing = createTimingInfo(positionMs, durationMs); if (!TextUtils.isEmpty(coverArtId)) { CustomGlideRequest.loadAlbumArtBitmap( @@ -66,7 +76,8 @@ public final class WidgetUpdateManager { AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, resource, p, id); + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, resource, p, + timing.elapsedText, timing.totalText, timing.progress, id); WidgetProvider.attachIntents(appCtx, rv, id); mgr.updateAppWidget(id, rv); } @@ -76,7 +87,8 @@ public final class WidgetUpdateManager { AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, id); + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, + timing.elapsedText, timing.totalText, timing.progress, id); WidgetProvider.attachIntents(appCtx, rv, id); mgr.updateAppWidget(id, rv); } @@ -87,7 +99,8 @@ public final class WidgetUpdateManager { AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, id); + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, + timing.elapsedText, timing.totalText, timing.progress, id); WidgetProvider.attachIntents(appCtx, rv, id); mgr.updateAppWidget(id, rv); } @@ -113,25 +126,71 @@ public final class WidgetUpdateManager { coverId = mi.mediaMetadata.extras.getString("coverArtId"); } } + long position = c.getCurrentPosition(); + long duration = c.getDuration(); + if (position == C.TIME_UNSET) position = 0; + if (duration == C.TIME_UNSET) duration = 0; updateFromState(appCtx, title != null ? title : appCtx.getString(R.string.widget_not_playing), artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle), coverId, - c.isPlaying()); + c.isPlaying(), + position, + duration); c.release(); } catch (ExecutionException | InterruptedException ignored) { } }, MoreExecutors.directExecutor()); } + private static TimingInfo createTimingInfo(long positionMs, long durationMs) { + long safePosition = Math.max(0L, positionMs); + long safeDuration = durationMs > 0 ? durationMs : 0L; + if (safeDuration > 0 && safePosition > safeDuration) { + safePosition = safeDuration; + } + + String elapsed = (safeDuration > 0 || safePosition > 0) + ? MusicUtil.getReadableDurationString(safePosition, true) + : null; + String total = safeDuration > 0 + ? MusicUtil.getReadableDurationString(safeDuration, true) + : null; + + int progress = 0; + if (safeDuration > 0) { + long scaled = safePosition * WidgetViewsFactory.PROGRESS_MAX; + long progressLong = scaled / safeDuration; + if (progressLong < 0) { + progress = 0; + } else if (progressLong > WidgetViewsFactory.PROGRESS_MAX) { + progress = WidgetViewsFactory.PROGRESS_MAX; + } else { + progress = (int) progressLong; + } + } + + return new TimingInfo(elapsed, total, progress); + } + public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) { if (isLarge(ctx, appWidgetId)) return WidgetViewsFactory.buildLarge(ctx); return WidgetViewsFactory.buildCompact(ctx); } - private static android.widget.RemoteViews choosePopulate(Context ctx, String title, String artist, Bitmap art, boolean playing, int appWidgetId) { - if (isLarge(ctx, appWidgetId)) return WidgetViewsFactory.populateLarge(ctx, title, artist, art, playing); - return WidgetViewsFactory.populate(ctx, title, artist, art, playing); + private static android.widget.RemoteViews choosePopulate(Context ctx, + String title, + String artist, + Bitmap art, + boolean playing, + String elapsedText, + String totalText, + int progress, + int appWidgetId) { + if (isLarge(ctx, appWidgetId)) { + return WidgetViewsFactory.populateLarge(ctx, title, artist, art, playing, elapsedText, totalText, progress); + } + return WidgetViewsFactory.populate(ctx, title, artist, art, playing, elapsedText, totalText, progress); } private static boolean isLarge(Context ctx, int appWidgetId) { @@ -142,5 +201,16 @@ public final class WidgetUpdateManager { return minH >= threshold; // dp threshold for 2-row height } - + private static final class TimingInfo { + final String elapsedText; + final String totalText; + final int progress; + + TimingInfo(String elapsedText, String totalText, int progress) { + this.elapsedText = elapsedText; + this.totalText = totalText; + this.progress = progress; + } + } + } diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java index 9dec3d60..05346eb3 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java @@ -2,11 +2,14 @@ package com.cappielloantonio.tempo.widget; import android.content.Context; import android.graphics.Bitmap; +import android.text.TextUtils; import android.widget.RemoteViews; import com.cappielloantonio.tempo.R; public final class WidgetViewsFactory { + static final int PROGRESS_MAX = 1000; + public static RemoteViews buildCompact(Context ctx) { return build(ctx, R.layout.widget_layout_compact); } @@ -20,6 +23,9 @@ public final class WidgetViewsFactory { RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes); rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing)); rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle)); + rv.setTextViewText(R.id.time_elapsed, ctx.getString(R.string.widget_time_elapsed_placeholder)); + rv.setTextViewText(R.id.time_total, ctx.getString(R.string.widget_time_duration_placeholder)); + rv.setProgressBar(R.id.progress, PROGRESS_MAX, 0, false); rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play); // Show Tempo logo when nothing is playing rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo); @@ -30,16 +36,22 @@ public final class WidgetViewsFactory { String title, String subtitle, Bitmap art, - boolean playing) { - return populateWithLayout(ctx, title, subtitle, art, playing, R.layout.widget_layout_compact); + boolean playing, + String elapsedText, + String totalText, + int progress) { + return populateWithLayout(ctx, title, subtitle, art, playing, elapsedText, totalText, progress, R.layout.widget_layout_compact); } public static RemoteViews populateLarge(Context ctx, String title, String subtitle, Bitmap art, - boolean playing) { - return populateWithLayout(ctx, title, subtitle, art, playing, R.layout.widget_layout_large); + boolean playing, + String elapsedText, + String totalText, + int progress) { + return populateWithLayout(ctx, title, subtitle, art, playing, elapsedText, totalText, progress, R.layout.widget_layout_large); } @@ -48,6 +60,9 @@ public final class WidgetViewsFactory { String subtitle, Bitmap art, boolean playing, + String elapsedText, + String totalText, + int progress, int layoutRes) { RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes); rv.setTextViewText(R.id.title, title); @@ -63,6 +78,21 @@ public final class WidgetViewsFactory { rv.setImageViewResource(R.id.btn_play_pause, playing ? R.drawable.ic_pause : R.drawable.ic_play); + String elapsed = !TextUtils.isEmpty(elapsedText) + ? elapsedText + : ctx.getString(R.string.widget_time_elapsed_placeholder); + String total = !TextUtils.isEmpty(totalText) + ? totalText + : ctx.getString(R.string.widget_time_duration_placeholder); + + int safeProgress = progress; + if (safeProgress < 0) safeProgress = 0; + if (safeProgress > PROGRESS_MAX) safeProgress = PROGRESS_MAX; + + rv.setTextViewText(R.id.time_elapsed, elapsed); + rv.setTextViewText(R.id.time_total, total); + rv.setProgressBar(R.id.progress, PROGRESS_MAX, safeProgress, false); + return rv; } } diff --git a/app/src/main/res/layout/widget_layout_compact.xml b/app/src/main/res/layout/widget_layout_compact.xml index 8f139917..5e0108dc 100644 --- a/app/src/main/res/layout/widget_layout_compact.xml +++ b/app/src/main/res/layout/widget_layout_compact.xml @@ -43,6 +43,45 @@ android:textColor="@color/widget_subtitle" android:layout_width="match_parent" android:layout_height="wrap_content"/> + + + + + + + + + + + + + + + + + + Tempo Widget Şu an oynatılmıyor Tempo’yu aç + 0:00 + 0:00 Albüm kapağı Çal/Duraklat Sonraki parça diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 483b4958..6a4851cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,6 +442,8 @@ Tempo Widget Not playing Open Tempo + 0:00 + 0:00 Album artwork Play or pause Next track diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index de6a6f06..8ed4ecdf 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -394,12 +394,16 @@ class MediaService : MediaLibraryService() { val artist = mi?.mediaMetadata?.artist?.toString() ?: mi?.mediaMetadata?.extras?.getString("artist") val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L + val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L WidgetUpdateManager.updateFromState( this, title ?: "", artist ?: "", coverId, - player.isPlaying + player.isPlaying, + position, + duration ) } diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt index dadf0d7d..69123de4 100644 --- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -241,14 +241,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ?: mi?.mediaMetadata?.extras?.getString("artist") val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L + val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L WidgetUpdateManager.updateFromState( this, title ?: "", artist ?: "", coverId, player.isPlaying, - player.currentPosition, - player.duration + position, + duration ) } diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 4c9c77aa..b7d507b6 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -240,14 +240,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { val artist = mi?.mediaMetadata?.artist?.toString() ?: mi?.mediaMetadata?.extras?.getString("artist") val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") + val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L + val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L WidgetUpdateManager.updateFromState( this, title ?: "", artist ?: "", coverId, player.isPlaying, - player.currentPosition, - player.duration + position, + duration ) } From b79cfa4af03534c1f7c6cec53b9ec18b6be71614 Mon Sep 17 00:00:00 2001 From: mucahit-kaya <54944321+mucahit-kaya@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:26:43 +0200 Subject: [PATCH 05/42] fix(widget): resume progress updates during playback The widget was only updating on play/pause state changes, so the timer did not advance while playback continued. Added a Handler loop in MediaService that updates the widget every second while playback is running and clears it when playback stops, ensuring the progress bar refreshes regularly. Co-Authored-By: Firehawk --- .../tempo/service/MediaService.kt | 37 +++++++++++++++++++ .../tempo/service/MediaService.kt | 37 +++++++++++++++++++ .../tempo/service/MediaService.kt | 37 +++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index 8ed4ecdf..bdc07e96 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -8,6 +8,8 @@ import android.content.Intent import android.os.Binder import android.os.Bundle import android.os.IBinder +import android.os.Handler +import android.os.Looper import androidx.media3.common.* import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl @@ -40,6 +42,18 @@ class MediaService : MediaLibraryService() { lateinit var equalizerManager: EqualizerManager private var customLayout = ImmutableList.of() + private val widgetUpdateHandler = Handler(Looper.getMainLooper()) + private var widgetUpdateScheduled = false + private val widgetUpdateRunnable = object : Runnable { + override fun run() { + if (!player.isPlaying) { + widgetUpdateScheduled = false + return + } + updateWidget() + widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) + } + } inner class LocalBinder : Binder() { fun getEqualizerManager(): EqualizerManager { @@ -81,6 +95,7 @@ class MediaService : MediaLibraryService() { override fun onDestroy() { equalizerManager.release() + stopWidgetUpdates() releasePlayer() super.onDestroy() } @@ -281,6 +296,11 @@ class MediaService : MediaLibraryService() { } else { MediaManager.scrobble(player.currentMediaItem, false) } + if (isPlaying) { + scheduleWidgetUpdates() + } else { + stopWidgetUpdates() + } updateWidget() } @@ -327,6 +347,9 @@ class MediaService : MediaLibraryService() { mediaLibrarySession.setCustomLayout(customLayout) } }) + if (player.isPlaying) { + scheduleWidgetUpdates() + } } private fun setPlayer(player: Player) { @@ -407,9 +430,23 @@ class MediaService : MediaLibraryService() { ) } + private fun scheduleWidgetUpdates() { + if (widgetUpdateScheduled) return + widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) + widgetUpdateScheduled = true + } + + private fun stopWidgetUpdates() { + if (!widgetUpdateScheduled) return + widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) + widgetUpdateScheduled = false + } + private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) private fun getMediaSourceFactory() = DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) } + +private const val WIDGET_UPDATE_INTERVAL_MS = 1000L diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt index 69123de4..a0af016b 100644 --- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -6,6 +6,8 @@ import android.app.TaskStackBuilder import android.content.Intent import android.os.Binder import android.os.IBinder +import android.os.Handler +import android.os.Looper import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.AudioAttributes @@ -50,6 +52,18 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { companion object { const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" } + private val widgetUpdateHandler = Handler(Looper.getMainLooper()) + private var widgetUpdateScheduled = false + private val widgetUpdateRunnable = object : Runnable { + override fun run() { + if (!player.isPlaying) { + widgetUpdateScheduled = false + return + } + updateWidget() + widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) + } + } override fun onCreate() { super.onCreate() @@ -81,6 +95,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { override fun onDestroy() { equalizerManager.release() + stopWidgetUpdates() releasePlayer() super.onDestroy() } @@ -182,6 +197,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } else { MediaManager.scrobble(player.currentMediaItem, false) } + if (isPlaying) { + scheduleWidgetUpdates() + } else { + stopWidgetUpdates() + } updateWidget() } @@ -231,6 +251,9 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ) } }) + if (player.isPlaying) { + scheduleWidgetUpdates() + } } private fun updateWidget() { @@ -254,6 +277,18 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ) } + private fun scheduleWidgetUpdates() { + if (widgetUpdateScheduled) return + widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) + widgetUpdateScheduled = true + } + + private fun stopWidgetUpdates() { + if (!widgetUpdateScheduled) return + widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) + widgetUpdateScheduled = false + } + private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() .setBufferDurationsMs( @@ -316,3 +351,5 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { player.prepare() } } + +private const val WIDGET_UPDATE_INTERVAL_MS = 1000L diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index b7d507b6..a5164e05 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -6,6 +6,8 @@ import android.app.TaskStackBuilder import android.content.Intent import android.os.Binder import android.os.IBinder +import android.os.Handler +import android.os.Looper import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.AudioAttributes @@ -50,6 +52,18 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { companion object { const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" } + private val widgetUpdateHandler = Handler(Looper.getMainLooper()) + private var widgetUpdateScheduled = false + private val widgetUpdateRunnable = object : Runnable { + override fun run() { + if (!player.isPlaying) { + widgetUpdateScheduled = false + return + } + updateWidget() + widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) + } + } override fun onCreate() { super.onCreate() @@ -81,6 +95,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { override fun onDestroy() { equalizerManager.release() + stopWidgetUpdates() releasePlayer() super.onDestroy() } @@ -182,6 +197,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } else { MediaManager.scrobble(player.currentMediaItem, false) } + if (isPlaying) { + scheduleWidgetUpdates() + } else { + stopWidgetUpdates() + } updateWidget() } @@ -231,6 +251,9 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ) } }) + if (player.isPlaying) { + scheduleWidgetUpdates() + } } private fun updateWidget() { @@ -253,6 +276,18 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ) } + private fun scheduleWidgetUpdates() { + if (widgetUpdateScheduled) return + widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) + widgetUpdateScheduled = true + } + + private fun stopWidgetUpdates() { + if (!widgetUpdateScheduled) return + widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) + widgetUpdateScheduled = false + } + private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() .setBufferDurationsMs( @@ -314,3 +349,5 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { player.prepare() } } + +private const val WIDGET_UPDATE_INTERVAL_MS = 1000L From 35af1f903848c56fc4bacb21b6b369f527fd596b Mon Sep 17 00:00:00 2001 From: mucahit-kaya <54944321+mucahit-kaya@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:13:23 +0200 Subject: [PATCH 06/42] fix(widget): refine layouts and progress UX across sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact (4×1) - Reduce root vertical padding so the 4×1 cell yields ~56dp of content height. - Make album art a true square (50×50dp) and center vertically; keeps edges clear of rounded corners. - Tighten timing block: 2dp progress bar; 10sp labels with no extra font padding; prevents elapsed/total text from slipping below the background. - Wrap album art in a 50×50dp FrameLayout with a new 6dp-radius background drawable; soft corners while remaining visually smaller than the widget body. - Mirror the same structure in the preview layout so Studio preview matches on-device rendering. (app/src/main/res/layout/widget_layout_compact.xml, app/src/main/res/drawable/widget_album_art_bg.xml) Large Short (4×2) - Wrap album art in a fixed 90dp square container and enforce a true square crop via centerCrop. - Tighten vertical spacing: thinner progress bar, closer timing row, controls shifted down for better balance. - Keep album/timing text to the left of the controls but retune spacing so the stack stays fully inside the widget bounds. Large (4×3 and up) - Restructure to a vertical stack: header row (album art + text), full-width progress bar, timing row, primary controls, then secondary controls. - Lock album art to a 150dp square; progress bar spans the widget beneath the header to match the new visual hierarchy. Based-on: https://github.com/eddyizm/tempo/commit/cd28ee0764b35f46988fb31e4fe2aaebcdb69851 Co-authored-by: The Firehawk Co-Authored-By: Mücahit Kaya Co-Authored-By: Firehawk --- .../tempo/widget/WidgetActions.java | 17 ++ .../tempo/widget/WidgetProvider.java | 19 +- .../tempo/widget/WidgetUpdateManager.java | 86 +++++-- .../tempo/widget/WidgetViewsFactory.java | 215 +++++++++++++--- app/src/main/res/drawable/ic_repeat_one.xml | 12 + .../main/res/layout/widget_layout_compact.xml | 84 ++++++- .../main/res/layout/widget_layout_large.xml | 238 ++++++++++++------ .../res/layout/widget_layout_large_short.xml | 198 +++++++++++++++ .../main/res/layout/widget_layout_medium.xml | 216 ++++++++++++++++ .../res/layout/widget_preview_compact.xml | 6 +- app/src/main/res/values/colors_widget.xml | 1 + app/src/main/res/values/integers.xml | 4 +- app/src/main/res/values/strings.xml | 2 + .../tempo/service/MediaService.kt | 5 + .../tempo/service/MediaService.kt | 5 + .../tempo/service/MediaService.kt | 5 + notes | 0 17 files changed, 964 insertions(+), 149 deletions(-) create mode 100644 app/src/main/res/drawable/ic_repeat_one.xml create mode 100644 app/src/main/res/layout/widget_layout_large_short.xml create mode 100644 app/src/main/res/layout/widget_layout_medium.xml create mode 100644 notes diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java index c035fa78..5be730f8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java @@ -4,6 +4,7 @@ import android.content.ComponentName; import android.content.Context; import android.util.Log; +import androidx.media3.common.Player; import androidx.media3.session.MediaController; import androidx.media3.session.SessionToken; @@ -34,7 +35,23 @@ public final class WidgetActions { case WidgetProvider.ACT_PREV: c.seekToPrevious(); break; + case WidgetProvider.ACT_TOGGLE_SHUFFLE: + c.setShuffleModeEnabled(!c.getShuffleModeEnabled()); + break; + case WidgetProvider.ACT_CYCLE_REPEAT: + int repeatMode = c.getRepeatMode(); + int nextMode; + if (repeatMode == Player.REPEAT_MODE_OFF) { + nextMode = Player.REPEAT_MODE_ALL; + } else if (repeatMode == Player.REPEAT_MODE_ALL) { + nextMode = Player.REPEAT_MODE_ONE; + } else { + nextMode = Player.REPEAT_MODE_OFF; + } + c.setRepeatMode(nextMode); + break; } + WidgetUpdateManager.refreshFromController(ctx); c.release(); } catch (ExecutionException | InterruptedException e) { Log.e("TempoWidget", "dispatch failed", e); diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java index 2b4b1f3c..57033c20 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java @@ -18,6 +18,8 @@ public class WidgetProvider extends AppWidgetProvider { public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE"; public static final String ACT_NEXT = "tempo.widget.NEXT"; public static final String ACT_PREV = "tempo.widget.PREV"; + public static final String ACT_TOGGLE_SHUFFLE = "tempo.widget.SHUFFLE"; + public static final String ACT_CYCLE_REPEAT = "tempo.widget.REPEAT"; @Override public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) { for (int id : ids) { @@ -31,7 +33,8 @@ public class WidgetProvider extends AppWidgetProvider { super.onReceive(ctx, intent); String a = intent.getAction(); Log.d(TAG, "onReceive action=" + a); - if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a)) { + if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a) + || ACT_TOGGLE_SHUFFLE.equals(a) || ACT_CYCLE_REPEAT.equals(a)) { WidgetActions.dispatchToMediaSession(ctx, a); } else if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(a)) { WidgetUpdateManager.refreshFromController(ctx); @@ -69,10 +72,24 @@ public class WidgetProvider extends AppWidgetProvider { new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PREV), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT ); + PendingIntent shuffle = PendingIntent.getBroadcast( + ctx, + requestCodeBase + 3, + new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_TOGGLE_SHUFFLE), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + PendingIntent repeat = PendingIntent.getBroadcast( + ctx, + requestCodeBase + 4, + new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_CYCLE_REPEAT), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); rv.setOnClickPendingIntent(R.id.btn_play_pause, playPause); rv.setOnClickPendingIntent(R.id.btn_next, next); rv.setOnClickPendingIntent(R.id.btn_prev, prev); + rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle); + rv.setOnClickPendingIntent(R.id.btn_repeat, repeat); PendingIntent launch = TaskStackBuilder.create(ctx) .addNextIntentWithParentStack(new Intent(ctx, MainActivity.class)) diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java index e9515c50..dbbcc6b3 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java @@ -24,20 +24,24 @@ public final class WidgetUpdateManager { public static void updateFromState(Context ctx, String title, String artist, + String album, Bitmap art, boolean playing, + boolean shuffleEnabled, + int repeatMode, long positionMs, long durationMs) { if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing); if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle); + if (TextUtils.isEmpty(album)) album = ""; final TimingInfo timing = createTimingInfo(positionMs, durationMs); AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, art, playing, - timing.elapsedText, timing.totalText, timing.progress, id); + android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing, + timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id); WidgetProvider.attachIntents(ctx, rv, id); mgr.updateAppWidget(id, rv); } @@ -56,14 +60,20 @@ public final class WidgetUpdateManager { public static void updateFromState(Context ctx, String title, String artist, + String album, String coverArtId, boolean playing, + boolean shuffleEnabled, + int repeatMode, long positionMs, long durationMs) { final Context appCtx = ctx.getApplicationContext(); final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title; final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist; + final String alb = !TextUtils.isEmpty(album) ? album : ""; final boolean p = playing; + final boolean sh = shuffleEnabled; + final int rep = repeatMode; final TimingInfo timing = createTimingInfo(positionMs, durationMs); if (!TextUtils.isEmpty(coverArtId)) { @@ -76,8 +86,8 @@ public final class WidgetUpdateManager { AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, resource, p, - timing.elapsedText, timing.totalText, timing.progress, id); + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p, + timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); WidgetProvider.attachIntents(appCtx, rv, id); mgr.updateAppWidget(id, rv); } @@ -87,8 +97,8 @@ public final class WidgetUpdateManager { AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, - timing.elapsedText, timing.totalText, timing.progress, id); + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, + timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); WidgetProvider.attachIntents(appCtx, rv, id); mgr.updateAppWidget(id, rv); } @@ -99,8 +109,8 @@ public final class WidgetUpdateManager { AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx); int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class)); for (int id : ids) { - android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, null, p, - timing.elapsedText, timing.totalText, timing.progress, id); + android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, + timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); WidgetProvider.attachIntents(appCtx, rv, id); mgr.updateAppWidget(id, rv); } @@ -116,13 +126,15 @@ public final class WidgetUpdateManager { if (!future.isDone()) return; MediaController c = future.get(); androidx.media3.common.MediaItem mi = c.getCurrentMediaItem(); - String title = null, artist = null, coverId = null; + String title = null, artist = null, album = null, coverId = null; if (mi != null && mi.mediaMetadata != null) { if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString(); if (mi.mediaMetadata.artist != null) artist = mi.mediaMetadata.artist.toString(); + if (mi.mediaMetadata.albumTitle != null) album = mi.mediaMetadata.albumTitle.toString(); if (mi.mediaMetadata.extras != null) { if (title == null) title = mi.mediaMetadata.extras.getString("title"); if (artist == null) artist = mi.mediaMetadata.extras.getString("artist"); + if (album == null) album = mi.mediaMetadata.extras.getString("album"); coverId = mi.mediaMetadata.extras.getString("coverArtId"); } } @@ -133,8 +145,11 @@ public final class WidgetUpdateManager { updateFromState(appCtx, title != null ? title : appCtx.getString(R.string.widget_not_playing), artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle), + album, coverId, c.isPlaying(), + c.getShuffleModeEnabled(), + c.getRepeatMode(), position, duration); c.release(); @@ -174,31 +189,68 @@ public final class WidgetUpdateManager { } public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) { - if (isLarge(ctx, appWidgetId)) return WidgetViewsFactory.buildLarge(ctx); - return WidgetViewsFactory.buildCompact(ctx); + LayoutSize size = resolveLayoutSize(ctx, appWidgetId); + switch (size) { + case MEDIUM: + return WidgetViewsFactory.buildMedium(ctx); + case LARGE: + return WidgetViewsFactory.buildLarge(ctx); + case EXPANDED: + return WidgetViewsFactory.buildExpanded(ctx); + case COMPACT: + default: + return WidgetViewsFactory.buildCompact(ctx); + } } private static android.widget.RemoteViews choosePopulate(Context ctx, String title, String artist, + String album, Bitmap art, boolean playing, String elapsedText, String totalText, int progress, + boolean shuffleEnabled, + int repeatMode, int appWidgetId) { - if (isLarge(ctx, appWidgetId)) { - return WidgetViewsFactory.populateLarge(ctx, title, artist, art, playing, elapsedText, totalText, progress); + LayoutSize size = resolveLayoutSize(ctx, appWidgetId); + switch (size) { + case MEDIUM: + return WidgetViewsFactory.populateMedium(ctx, title, artist, album, art, playing, + elapsedText, totalText, progress, shuffleEnabled, repeatMode); + case LARGE: + return WidgetViewsFactory.populateLarge(ctx, title, artist, album, art, playing, + elapsedText, totalText, progress, shuffleEnabled, repeatMode); + case EXPANDED: + return WidgetViewsFactory.populateExpanded(ctx, title, artist, album, art, playing, + elapsedText, totalText, progress, shuffleEnabled, repeatMode); + case COMPACT: + default: + return WidgetViewsFactory.populateCompact(ctx, title, artist, album, art, playing, + elapsedText, totalText, progress, shuffleEnabled, repeatMode); } - return WidgetViewsFactory.populate(ctx, title, artist, art, playing, elapsedText, totalText, progress); } - private static boolean isLarge(Context ctx, int appWidgetId) { + private static LayoutSize resolveLayoutSize(Context ctx, int appWidgetId) { AppWidgetManager mgr = AppWidgetManager.getInstance(ctx); android.os.Bundle opts = mgr.getAppWidgetOptions(appWidgetId); int minH = opts != null ? opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) : 0; - int threshold = ctx.getResources().getInteger(com.cappielloantonio.tempo.R.integer.widget_large_min_height_dp); - return minH >= threshold; // dp threshold for 2-row height + int expandedThreshold = ctx.getResources().getInteger(R.integer.widget_expanded_min_height_dp); + int largeThreshold = ctx.getResources().getInteger(R.integer.widget_large_min_height_dp); + int mediumThreshold = ctx.getResources().getInteger(R.integer.widget_medium_min_height_dp); + if (minH >= expandedThreshold) return LayoutSize.EXPANDED; + if (minH >= largeThreshold) return LayoutSize.LARGE; + if (minH >= mediumThreshold) return LayoutSize.MEDIUM; + return LayoutSize.COMPACT; + } + + private enum LayoutSize { + COMPACT, + MEDIUM, + LARGE, + EXPANDED } private static final class TimingInfo { diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java index 05346eb3..ef960aef 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java @@ -2,76 +2,168 @@ package com.cappielloantonio.tempo.widget; import android.content.Context; import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; import android.text.TextUtils; +import android.util.TypedValue; +import android.view.View; import android.widget.RemoteViews; + +import androidx.core.content.ContextCompat; +import androidx.media3.common.Player; + import com.cappielloantonio.tempo.R; public final class WidgetViewsFactory { static final int PROGRESS_MAX = 1000; + private static final float ALBUM_ART_CORNER_RADIUS_DP = 6f; + + private WidgetViewsFactory() {} public static RemoteViews buildCompact(Context ctx) { - return build(ctx, R.layout.widget_layout_compact); + return build(ctx, R.layout.widget_layout_compact, false, false); + } + + public static RemoteViews buildMedium(Context ctx) { + return build(ctx, R.layout.widget_layout_medium, false, false); } public static RemoteViews buildLarge(Context ctx) { - return build(ctx, R.layout.widget_layout_large); + return build(ctx, R.layout.widget_layout_large_short, true, true); } + public static RemoteViews buildExpanded(Context ctx) { + return build(ctx, R.layout.widget_layout_large, true, true); + } - private static RemoteViews build(Context ctx, int layoutRes) { + private static RemoteViews build(Context ctx, + int layoutRes, + boolean showAlbum, + boolean showSecondaryControls) { RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes); rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing)); rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle)); + rv.setTextViewText(R.id.album, ""); + rv.setViewVisibility(R.id.album, showAlbum ? View.INVISIBLE : View.GONE); rv.setTextViewText(R.id.time_elapsed, ctx.getString(R.string.widget_time_elapsed_placeholder)); rv.setTextViewText(R.id.time_total, ctx.getString(R.string.widget_time_duration_placeholder)); rv.setProgressBar(R.id.progress, PROGRESS_MAX, 0, false); rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play); - // Show Tempo logo when nothing is playing rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo); + applySecondaryControlsDefaults(ctx, rv, showSecondaryControls); return rv; } - public static RemoteViews populate(Context ctx, - String title, - String subtitle, - Bitmap art, - boolean playing, - String elapsedText, - String totalText, - int progress) { - return populateWithLayout(ctx, title, subtitle, art, playing, elapsedText, totalText, progress, R.layout.widget_layout_compact); + private static void applySecondaryControlsDefaults(Context ctx, + RemoteViews rv, + boolean show) { + int visibility = show ? View.VISIBLE : View.GONE; + rv.setViewVisibility(R.id.controls_secondary, visibility); + rv.setViewVisibility(R.id.btn_shuffle, visibility); + rv.setViewVisibility(R.id.btn_repeat, visibility); + if (show) { + int defaultColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint); + rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle); + rv.setImageViewResource(R.id.btn_repeat, R.drawable.ic_repeat); + rv.setInt(R.id.btn_shuffle, "setColorFilter", defaultColor); + rv.setInt(R.id.btn_repeat, "setColorFilter", defaultColor); + } + } + + public static RemoteViews populateCompact(Context ctx, + String title, + String subtitle, + String album, + Bitmap art, + boolean playing, + String elapsedText, + String totalText, + int progress, + boolean shuffleEnabled, + int repeatMode) { + return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText, + progress, R.layout.widget_layout_compact, false, false, shuffleEnabled, repeatMode); + } + + public static RemoteViews populateMedium(Context ctx, + String title, + String subtitle, + String album, + Bitmap art, + boolean playing, + String elapsedText, + String totalText, + int progress, + boolean shuffleEnabled, + int repeatMode) { + return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText, + progress, R.layout.widget_layout_medium, true, true, shuffleEnabled, repeatMode); } public static RemoteViews populateLarge(Context ctx, - String title, - String subtitle, - Bitmap art, - boolean playing, - String elapsedText, - String totalText, - int progress) { - return populateWithLayout(ctx, title, subtitle, art, playing, elapsedText, totalText, progress, R.layout.widget_layout_large); + String title, + String subtitle, + String album, + Bitmap art, + boolean playing, + String elapsedText, + String totalText, + int progress, + boolean shuffleEnabled, + int repeatMode) { + return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText, + progress, R.layout.widget_layout_large_short, true, true, shuffleEnabled, repeatMode); } + public static RemoteViews populateExpanded(Context ctx, + String title, + String subtitle, + String album, + Bitmap art, + boolean playing, + String elapsedText, + String totalText, + int progress, + boolean shuffleEnabled, + int repeatMode) { + return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText, + progress, R.layout.widget_layout_large, true, true, shuffleEnabled, repeatMode); + } private static RemoteViews populateWithLayout(Context ctx, String title, - String subtitle, - Bitmap art, - boolean playing, - String elapsedText, - String totalText, - int progress, - int layoutRes) { + String subtitle, + String album, + Bitmap art, + boolean playing, + String elapsedText, + String totalText, + int progress, + int layoutRes, + boolean showAlbum, + boolean showSecondaryControls, + boolean shuffleEnabled, + int repeatMode) { RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes); rv.setTextViewText(R.id.title, title); rv.setTextViewText(R.id.subtitle, subtitle); - if (art != null) { - rv.setImageViewBitmap(R.id.album_art, art); + if (showAlbum && !TextUtils.isEmpty(album)) { + rv.setTextViewText(R.id.album, album); + rv.setViewVisibility(R.id.album, View.VISIBLE); + } else { + rv.setTextViewText(R.id.album, ""); + rv.setViewVisibility(R.id.album, View.GONE); + } + + if (art != null) { + Bitmap rounded = maybeRoundBitmap(ctx, art); + rv.setImageViewBitmap(R.id.album_art, rounded != null ? rounded : art); } else { - // Fallback to app logo when art is missing rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo); } @@ -93,6 +185,67 @@ public final class WidgetViewsFactory { rv.setTextViewText(R.id.time_total, total); rv.setProgressBar(R.id.progress, PROGRESS_MAX, safeProgress, false); + applySecondaryControls(ctx, rv, showSecondaryControls, shuffleEnabled, repeatMode); + return rv; } + + private static Bitmap maybeRoundBitmap(Context ctx, Bitmap source) { + if (source == null || source.isRecycled()) { + return null; + } + + try { + int width = source.getWidth(); + int height = source.getHeight(); + if (width <= 0 || height <= 0) { + return null; + } + + Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(output); + + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)); + + float radiusPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + ALBUM_ART_CORNER_RADIUS_DP, + ctx.getResources().getDisplayMetrics()); + float maxRadius = Math.min(width, height) / 2f; + float safeRadius = Math.min(radiusPx, maxRadius); + + canvas.drawRoundRect(new RectF(0f, 0f, width, height), safeRadius, safeRadius, paint); + return output; + } catch (RuntimeException | OutOfMemoryError e) { + android.util.Log.w("TempoWidget", "Failed to round album art", e); + return null; + } + } + + private static void applySecondaryControls(Context ctx, + RemoteViews rv, + boolean show, + boolean shuffleEnabled, + int repeatMode) { + if (!show) { + rv.setViewVisibility(R.id.controls_secondary, View.GONE); + rv.setViewVisibility(R.id.btn_shuffle, View.GONE); + rv.setViewVisibility(R.id.btn_repeat, View.GONE); + return; + } + + int inactiveColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint); + int activeColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint_active); + + rv.setViewVisibility(R.id.controls_secondary, View.VISIBLE); + rv.setViewVisibility(R.id.btn_shuffle, View.VISIBLE); + rv.setViewVisibility(R.id.btn_repeat, View.VISIBLE); + rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle); + rv.setImageViewResource(R.id.btn_repeat, + repeatMode == Player.REPEAT_MODE_ONE ? R.drawable.ic_repeat_one : R.drawable.ic_repeat); + rv.setInt(R.id.btn_shuffle, "setColorFilter", shuffleEnabled ? activeColor : inactiveColor); + rv.setInt(R.id.btn_repeat, "setColorFilter", + repeatMode == Player.REPEAT_MODE_OFF ? inactiveColor : activeColor); + } } diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml new file mode 100644 index 00000000..f422f79a --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_one.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_layout_compact.xml b/app/src/main/res/layout/widget_layout_compact.xml index 5e0108dc..78fb72fb 100644 --- a/app/src/main/res/layout/widget_layout_compact.xml +++ b/app/src/main/res/layout/widget_layout_compact.xml @@ -3,13 +3,17 @@ android:id="@+id/root" android:layout_width="match_parent" android:layout_height="64dp" - android:padding="8dp" + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="4dp" + android:paddingBottom="4dp" android:background="@drawable/widget_bg"> @@ -32,6 +36,8 @@ android:textStyle="bold" android:textSize="14sp" android:textColor="@color/widget_title" + android:includeFontPadding="false" + android:freezesText="true" android:layout_width="match_parent" android:layout_height="wrap_content"/> @@ -41,15 +47,30 @@ android:ellipsize="end" android:textSize="12sp" android:textColor="@color/widget_subtitle" + android:includeFontPadding="false" + android:freezesText="true" android:layout_width="match_parent" android:layout_height="wrap_content"/> + + + android:textSize="10sp" + android:includeFontPadding="false"/> + android:textSize="10sp" + android:includeFontPadding="false"/> + + + + + + + @@ -93,22 +145,28 @@ android:layout_width="wrap_content" android:layout_height="match_parent"> - - - - - - + android:orientation="horizontal" + android:baselineAligned="false"> - - - - - + + android:id="@+id/text_container" + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_marginStart="16dp" + android:layout_weight="1" + android:orientation="vertical" + android:gravity="center_vertical"> + android:singleLine="true" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:textStyle="bold" + android:textSize="18sp" + android:textColor="@color/widget_title" + android:includeFontPadding="false" + android:freezesText="true" /> + android:includeFontPadding="false" + android:freezesText="true" /> + + + + + + + + + + + + android:orientation="horizontal"> - + android:src="@drawable/ic_skip_previous" + android:tint="@color/widget_icon_tint" /> - + android:src="@drawable/ic_play" + android:tint="@color/widget_icon_tint" /> - + android:src="@drawable/ic_skip_next" + android:tint="@color/widget_icon_tint" /> - + + + + + + + + + diff --git a/app/src/main/res/layout/widget_layout_large_short.xml b/app/src/main/res/layout/widget_layout_large_short.xml new file mode 100644 index 00000000..6a715f6e --- /dev/null +++ b/app/src/main/res/layout/widget_layout_large_short.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_layout_medium.xml b/app/src/main/res/layout/widget_layout_medium.xml new file mode 100644 index 00000000..802da828 --- /dev/null +++ b/app/src/main/res/layout/widget_layout_medium.xml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_compact.xml b/app/src/main/res/layout/widget_preview_compact.xml index e369ffb6..e863603a 100644 --- a/app/src/main/res/layout/widget_preview_compact.xml +++ b/app/src/main/res/layout/widget_preview_compact.xml @@ -11,8 +11,9 @@ @@ -79,4 +80,3 @@ android:contentDescription="@string/widget_content_desc_next"/> - diff --git a/app/src/main/res/values/colors_widget.xml b/app/src/main/res/values/colors_widget.xml index 75a9f7f1..71a34138 100644 --- a/app/src/main/res/values/colors_widget.xml +++ b/app/src/main/res/values/colors_widget.xml @@ -5,4 +5,5 @@ #DE000000 #99000000 #DE000000 + #FF6750A4 diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index 7b968814..e1a1ac1c 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -1,4 +1,6 @@ - 100 + 100 + 160 + 220 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a4851cd..47a6650e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,6 +448,8 @@ Play or pause Next track Previous track + Toggle shuffle + Change repeat mode %d album to sync %d albums to sync diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index bdc07e96..67b02594 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -416,6 +416,8 @@ class MediaService : MediaLibraryService() { ?: mi?.mediaMetadata?.extras?.getString("title") val artist = mi?.mediaMetadata?.artist?.toString() ?: mi?.mediaMetadata?.extras?.getString("artist") + val album = mi?.mediaMetadata?.albumTitle?.toString() + ?: mi?.mediaMetadata?.extras?.getString("album") val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L @@ -423,8 +425,11 @@ class MediaService : MediaLibraryService() { this, title ?: "", artist ?: "", + album ?: "", coverId, player.isPlaying, + player.shuffleModeEnabled, + player.repeatMode, position, duration ) diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt index a0af016b..5f44ed3a 100644 --- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -262,6 +262,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ?: mi?.mediaMetadata?.extras?.getString("title") val artist = mi?.mediaMetadata?.artist?.toString() ?: mi?.mediaMetadata?.extras?.getString("artist") + val album = mi?.mediaMetadata?.albumTitle?.toString() + ?: mi?.mediaMetadata?.extras?.getString("album") val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L @@ -270,8 +272,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { this, title ?: "", artist ?: "", + album ?: "", coverId, player.isPlaying, + player.shuffleModeEnabled, + player.repeatMode, position, duration ) diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index a5164e05..ac20668a 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -262,6 +262,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { ?: mi?.mediaMetadata?.extras?.getString("title") val artist = mi?.mediaMetadata?.artist?.toString() ?: mi?.mediaMetadata?.extras?.getString("artist") + val album = mi?.mediaMetadata?.albumTitle?.toString() + ?: mi?.mediaMetadata?.extras?.getString("album") val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L @@ -269,8 +271,11 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { this, title ?: "", artist ?: "", + album ?: "", coverId, player.isPlaying, + player.shuffleModeEnabled, + player.repeatMode, position, duration ) diff --git a/notes b/notes new file mode 100644 index 00000000..e69de29b From a9318ec5d092425435d86744ba7c2157aa76ea94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Tue, 23 Sep 2025 23:45:00 +0200 Subject: [PATCH 07/42] fix: Prevent crash when getting artist radio and song list is null --- .../cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java index 1d1cb7d1..bcc00666 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java @@ -160,7 +160,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback { bind.artistPageRadioButton.setOnClickListener(v -> { artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> { - if (!songs.isEmpty()) { + if (songs != null && !songs.isEmpty()) { MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); activity.setBottomSheetInPeek(true); } else { From 9d439b726b75e0c32c3cb82d573016427b2500b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Wed, 24 Sep 2025 04:03:51 +0200 Subject: [PATCH 08/42] fix: Update search query validation to require at least 2 characters instead of 3 --- .../com/cappielloantonio/tempo/ui/fragment/SearchFragment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java index c4b5f3b1..469c6c64 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java @@ -254,7 +254,7 @@ public class SearchFragment extends Fragment implements ClickCallback { } private boolean isQueryValid(String query) { - return !query.equals("") && query.trim().length() > 2; + return !query.equals("") && query.trim().length() > 1; } private void inputFocus() { From 8cc3356b1430c2ee66098b8e00a409a71fb16590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Smith?= <62805693+benoit-smith@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:38:40 +0200 Subject: [PATCH 09/42] Update strings.xml --- app/src/main/res/values-fr/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c9f0e561..45e73574 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -440,4 +440,10 @@ %d album à synchroniser %d albums à synchroniser + Égaliseur + Réinitialiser + Activer + Non supporté sur cet appareil + Égaliseur + Ouvrir l\'égaliseur intégré From 1b4503696363729eae6a051a087c222f0ce0642b Mon Sep 17 00:00:00 2001 From: eddyizm Date: Wed, 24 Sep 2025 22:04:32 -0700 Subject: [PATCH 10/42] fix: removed universalApk ref from build --- app/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 8c98e067..d0393e45 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,7 +62,6 @@ android { minifyEnabled true debuggable false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - universalApk true } debug { From 134a1605ad53c3b465de951e7fcc3f692137f694 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Fri, 26 Sep 2025 05:56:48 -0500 Subject: [PATCH 11/42] fix: Get rid of the try-catch since it's considered bad practice in Java This matches the treatment done at other places in the code, so it should be fine. --- .../tempo/ui/activity/MainActivity.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index 3ecf41ff..c9d3bd41 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -381,14 +381,9 @@ public class MainActivity extends BaseActivity { private void resetView() { resetViewModel(); - try { - int id = Objects.requireNonNull(navController.getCurrentDestination()).getId(); - navController.popBackStack(id, true); - navController.navigate(id); - } catch(NullPointerException e) { - e.printStackTrace(); - quit(); - } + int id = Objects.requireNonNull(navController.getCurrentDestination()).getId(); + navController.popBackStack(id, true); + navController.navigate(id); } private void getOpenSubsonicExtensions() { From e1d63a9eef146fba6d4338d50df33bb283085d25 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Fri, 26 Sep 2025 16:24:21 -0500 Subject: [PATCH 12/42] feat: Support skipping duplicates --- .../tempo/ui/dialog/PlaylistChooserDialog.java | 10 ++++++++-- .../viewmodel/PlaylistChooserViewModel.java | 17 ++++++++++++++--- .../main/res/layout/dialog_playlist_chooser.xml | 10 +++++++++- app/src/main/res/values/strings.xml | 1 + 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java index 360a5ec5..7337e930 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java @@ -27,6 +27,8 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter; + private boolean SkipDuplicates; + @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { @@ -78,10 +80,15 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba private void initPlaylistView() { bind.playlistDialogRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.playlistDialogRecyclerView.setHasFixedSize(true); + SkipDuplicates = true; playlistDialogHorizontalAdapter = new PlaylistDialogHorizontalAdapter(this); bind.playlistDialogRecyclerView.setAdapter(playlistDialogHorizontalAdapter); + bind.playlistChooserDialogSkipDuplicates.setOnCheckedChangeListener((__, isChecked) -> { + SkipDuplicates = isChecked; + }); + playlistChooserViewModel.getPlaylistList(requireActivity()).observe(requireActivity(), playlists -> { if (playlists != null) { if (!playlists.isEmpty()) { @@ -100,8 +107,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba public void onPlaylistClick(Bundle bundle) { if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) { Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT); - playlistChooserViewModel.addSongsToPlaylist(playlist.getId()); - dismiss(); + playlistChooserViewModel.addSongsToPlaylist(this, getDialog(), playlist.getId(), SkipDuplicates); } else { Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show(); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java index 2ec6c21f..82e0418b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java @@ -1,6 +1,7 @@ package com.cappielloantonio.tempo.viewmodel; import android.app.Application; +import android.app.Dialog; import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; @@ -14,7 +15,6 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist; import com.google.common.collect.Lists; import java.util.ArrayList; -import java.util.Collections; import java.util.List; public class PlaylistChooserViewModel extends AndroidViewModel { @@ -34,8 +34,19 @@ public class PlaylistChooserViewModel extends AndroidViewModel { return playlists; } - public void addSongsToPlaylist(String playlistId) { - playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(Lists.transform(toAdd, Child::getId))); + public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId, boolean skipDuplicates) { + List songIds = Lists.transform(toAdd, Child::getId); + if (skipDuplicates) { + playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> { + List playlistSongIds = Lists.transform(playlistSongs, Child::getId); + songIds.removeAll(playlistSongIds); + playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds)); + dialog.dismiss(); + }); + } else { + playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds)); + dialog.dismiss(); + } } public void setSongsToAdd(ArrayList songs) { diff --git a/app/src/main/res/layout/dialog_playlist_chooser.xml b/app/src/main/res/layout/dialog_playlist_chooser.xml index 80b4bfbe..1c7690e4 100644 --- a/app/src/main/res/layout/dialog_playlist_chooser.xml +++ b/app/src/main/res/layout/dialog_playlist_chooser.xml @@ -19,7 +19,15 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 619766d5..fbdcff4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,7 @@ Cancel Create Add to a playlist + Skip duplicates Added song to playlist Failed to add song to playlist %1$d tracks • %2$s From e790bf3eb62403b70b68578f90d7c8d006227cd0 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Fri, 26 Sep 2025 16:39:46 -0500 Subject: [PATCH 13/42] chg: Comment out unused code --- .../tempo/repository/PlaylistRepository.java | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java index 7884159f..2be53b56 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java @@ -131,22 +131,22 @@ public class PlaylistRepository { }); } - public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList songIdToAdd, ArrayList songIndexToRemove) { - App.getSubsonicClientInstance(false) - .getPlaylistClient() - .updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove) - .enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - - } - }); - } +// public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList songIdToAdd, ArrayList songIndexToRemove) { +// App.getSubsonicClientInstance(false) +// .getPlaylistClient() +// .updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove) +// .enqueue(new Callback() { +// @Override +// public void onResponse(@NonNull Call call, @NonNull Response response) { +// +// } +// +// @Override +// public void onFailure(@NonNull Call call, @NonNull Throwable t) { +// +// } +// }); +// } public void deletePlaylist(String playlistId) { App.getSubsonicClientInstance(false) From 9736890e3c20a394867baaf8cab60d6fee95fe2f Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Fri, 26 Sep 2025 16:48:56 -0500 Subject: [PATCH 14/42] fix: Show proper number in add to playlist dialog toast --- .../cappielloantonio/tempo/repository/PlaylistRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java index 2be53b56..0970bbb1 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java @@ -87,7 +87,7 @@ public class PlaylistRepository { .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show(); + Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success) + " x " + songsId.toArray().length, Toast.LENGTH_SHORT).show(); } @Override From 2acf11023af5934894dba9b1c6dfd028987c5024 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Fri, 26 Sep 2025 19:19:23 -0500 Subject: [PATCH 15/42] fix: Crash when trying to add to an empty playlist --- .../tempo/viewmodel/PlaylistChooserViewModel.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java index 82e0418b..89a948e4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java @@ -38,8 +38,10 @@ public class PlaylistChooserViewModel extends AndroidViewModel { List songIds = Lists.transform(toAdd, Child::getId); if (skipDuplicates) { playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> { - List playlistSongIds = Lists.transform(playlistSongs, Child::getId); - songIds.removeAll(playlistSongIds); + if (playlistSongs != null) { + List playlistSongIds = Lists.transform(playlistSongs, Child::getId); + songIds.removeAll(playlistSongIds); + } playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds)); dialog.dismiss(); }); From a22883fdde2210adc01a2baca208e626718fcd9d Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Fri, 26 Sep 2025 22:51:52 -0500 Subject: [PATCH 16/42] fix: The layout should be more in line with the playlist entries --- app/src/main/res/layout/dialog_playlist_chooser.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/layout/dialog_playlist_chooser.xml b/app/src/main/res/layout/dialog_playlist_chooser.xml index 1c7690e4..76a44147 100644 --- a/app/src/main/res/layout/dialog_playlist_chooser.xml +++ b/app/src/main/res/layout/dialog_playlist_chooser.xml @@ -28,6 +28,7 @@ android:id="@+id/playlist_chooser_dialog_skip_duplicates" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginStart="20dp" android:text="@string/playlist_chooser_dialog_skip_duplicates" android:checked="true" /> \ No newline at end of file From ee738bc4c7ae5f6e4dcd391fb8bb470bd59f3afd Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sat, 27 Sep 2025 15:37:59 -0700 Subject: [PATCH 17/42] feat: download starred artists. --- app/build.gradle | 2 +- .../tempo/repository/ArtistRepository.java | 62 +++++++++++- .../ui/dialog/StarredArtistSyncDialog.java | 88 +++++++++++++++++ .../ArtistBottomSheetDialog.java | 4 +- .../tempo/util/Preferences.kt | 13 +++ .../viewmodel/ArtistBottomSheetViewModel.java | 48 ++++++++-- .../StarredArtistsSyncViewModel.java | 95 +++++++++++++++++++ .../res/layout/dialog_starred_artist_sync.xml | 14 +++ app/src/main/res/values/strings.xml | 6 +- app/src/main/res/xml/global_preferences.xml | 6 ++ 10 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java create mode 100644 app/src/main/res/layout/dialog_starred_artist_sync.xml diff --git a/app/build.gradle b/app/build.gradle index d0393e45..9bdd17cc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ android { targetSdk 35 versionCode 32 - versionName '3.15.0' + versionName '3.15.1' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java index f39dbffa..71b43fa9 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java @@ -2,10 +2,12 @@ package com.cappielloantonio.tempo.repository; import androidx.annotation.NonNull; import androidx.lifecycle.MutableLiveData; +import android.util.Log; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; +import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.IndexID3; @@ -13,12 +15,70 @@ import com.cappielloantonio.tempo.subsonic.models.IndexID3; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class ArtistRepository { + private final AlbumRepository albumRepository; + + public ArtistRepository() { + this.albumRepository = new AlbumRepository(); + } + + public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) { + Log.d("ArtistSync", "Getting albums for artist: " + artistId); + + // Use AlbumRepository to get all albums by this artist + albumRepository.getArtistAlbums(artistId).observeForever(albums -> { + Log.d("ArtistSync", "Got albums: " + (albums != null ? albums.size() : 0)); + if (albums != null && !albums.isEmpty()) { + fetchAllAlbumSongsWithCallback(albums, callback); + } else { + Log.d("ArtistSync", "No albums found"); + callback.onSongsCollected(new ArrayList<>()); + } + }); + } + + private void fetchAllAlbumSongsWithCallback(List albums, ArtistSongsCallback callback) { + if (albums == null || albums.isEmpty()) { + Log.d("ArtistSync", "No albums to process"); + callback.onSongsCollected(new ArrayList<>()); + return; + } + + List allSongs = new ArrayList<>(); + AtomicInteger remainingAlbums = new AtomicInteger(albums.size()); + Log.d("ArtistSync", "Processing " + albums.size() + " albums"); + + for (AlbumID3 album : albums) { + Log.d("ArtistSync", "Getting tracks for album: " + album.getName()); + MutableLiveData> albumTracks = albumRepository.getAlbumTracks(album.getId()); + albumTracks.observeForever(songs -> { + Log.d("ArtistSync", "Got " + (songs != null ? songs.size() : 0) + " songs from album"); + if (songs != null) { + allSongs.addAll(songs); + } + albumTracks.removeObservers(null); + + int remaining = remainingAlbums.decrementAndGet(); + Log.d("ArtistSync", "Remaining albums: " + remaining); + + if (remaining == 0) { + Log.d("ArtistSync", "All albums processed. Total songs: " + allSongs.size()); + callback.onSongsCollected(allSongs); + } + }); + } + } + + public interface ArtistSongsCallback { + void onSongsCollected(List songs); + } + public MutableLiveData> getStarredArtists(boolean random, int size) { MutableLiveData> starredArtists = new MutableLiveData<>(new ArrayList<>()); @@ -89,7 +149,7 @@ public class ArtistRepository { } /* - * Metodo che mi restituisce le informazioni essenzionali dell'artista (cover, numero di album...) + * Method that returns essential artist information (cover, album number, etc.) */ public void getArtistInfo(List artists, MutableLiveData> list) { List liveArtists = list.getValue(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java new file mode 100644 index 00000000..448ca072 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java @@ -0,0 +1,88 @@ +package com.cappielloantonio.tempo.ui.dialog; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.widget.Button; + +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.util.UnstableApi; + +import com.cappielloantonio.tempo.R; +import com.cappielloantonio.tempo.databinding.DialogStarredArtistSyncBinding; +import com.cappielloantonio.tempo.model.Download; +import com.cappielloantonio.tempo.util.DownloadUtil; +import com.cappielloantonio.tempo.util.MappingUtil; +import com.cappielloantonio.tempo.util.Preferences; +import com.cappielloantonio.tempo.viewmodel.StarredArtistsSyncViewModel; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.stream.Collectors; + +@OptIn(markerClass = UnstableApi.class) +public class StarredArtistSyncDialog extends DialogFragment { + private StarredArtistsSyncViewModel starredArtistsSyncViewModel; + + private Runnable onCancel; + + public StarredArtistSyncDialog(Runnable onCancel) { + this.onCancel = onCancel; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + DialogStarredArtistSyncBinding bind = DialogStarredArtistSyncBinding.inflate(getLayoutInflater()); + + starredArtistsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredArtistsSyncViewModel.class); + + return new MaterialAlertDialogBuilder(getActivity()) + .setView(bind.getRoot()) + .setTitle(R.string.starred_artist_sync_dialog_title) + .setPositiveButton(R.string.starred_sync_dialog_positive_button, null) + .setNeutralButton(R.string.starred_sync_dialog_neutral_button, null) + .setNegativeButton(R.string.starred_sync_dialog_negative_button, null) + .create(); + } + + @Override + public void onResume() { + super.onResume(); + setButtonAction(requireContext()); + } + + private void setButtonAction(Context context) { + androidx.appcompat.app.AlertDialog dialog = (androidx.appcompat.app.AlertDialog) getDialog(); + + if (dialog != null) { + Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(v -> { + starredArtistsSyncViewModel.getStarredArtistSongs(requireActivity()).observe(this, allSongs -> { + if (allSongs != null && !allSongs.isEmpty()) { + DownloadUtil.getDownloadTracker(context).download( + MappingUtil.mapDownloads(allSongs), + allSongs.stream().map(Download::new).collect(Collectors.toList()) + ); + } + dialog.dismiss(); + }); + }); + + Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL); + neutralButton.setOnClickListener(v -> { + Preferences.setStarredArtistsSyncEnabled(true); + dialog.dismiss(); + }); + + Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE); + negativeButton.setOnClickListener(v -> { + Preferences.setStarredArtistsSyncEnabled(false); + if (onCancel != null) onCancel.run(); + dialog.dismiss(); + }); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java index f3c9d490..78fc943e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java @@ -66,7 +66,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement super.onStop(); } - // TODO Utilizzare il viewmodel come tramite ed evitare le chiamate dirette + // TODO Use the viewmodel as a conduit and avoid direct calls private void init(View view) { ImageView coverArtist = view.findViewById(R.id.artist_cover_image_view); CustomGlideRequest.Builder @@ -81,7 +81,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite); favoriteToggle.setChecked(artistBottomSheetViewModel.getArtist().getStarred() != null); favoriteToggle.setOnClickListener(v -> { - artistBottomSheetViewModel.setFavorite(); + artistBottomSheetViewModel.setFavorite(requireContext()); }); TextView playRadio = view.findViewById(R.id.play_radio_text_view); diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 92cb30cd..f4188719 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -37,6 +37,7 @@ object Preferences { private const val WIFI_ONLY = "wifi_only" private const val DATA_SAVING_MODE = "data_saving_mode" private const val SERVER_UNREACHABLE = "server_unreachable" + private const val SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE = "sync_starred_artists_for_offline_use" private const val SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE = "sync_starred_albums_for_offline_use" private const val SYNC_STARRED_TRACKS_FOR_OFFLINE_USE = "sync_starred_tracks_for_offline_use" private const val QUEUE_SYNCING = "queue_syncing" @@ -303,6 +304,18 @@ object Preferences { .apply() } + @JvmStatic + fun isStarredArtistsSyncEnabled(): Boolean { + return App.getInstance().preferences.getBoolean(SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, false) + } + + @JvmStatic + fun setStarredArtistsSyncEnabled(isStarredSyncEnabled: Boolean) { + App.getInstance().preferences.edit().putBoolean( + SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, isStarredSyncEnabled + ).apply() + } + @JvmStatic fun isStarredAlbumsSyncEnabled(): Boolean { return App.getInstance().preferences.getBoolean(SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, false) diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java index 08ae3681..2c008d80 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java @@ -1,17 +1,25 @@ package com.cappielloantonio.tempo.viewmodel; import android.app.Application; - +import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; +import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.FavoriteRepository; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; +import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.util.NetworkUtil; +import com.cappielloantonio.tempo.util.DownloadUtil; +import com.cappielloantonio.tempo.util.MappingUtil; +import com.cappielloantonio.tempo.util.Preferences; import java.util.Date; +import java.util.stream.Collectors; +import java.util.List; public class ArtistBottomSheetViewModel extends AndroidViewModel { private final ArtistRepository artistRepository; @@ -34,7 +42,7 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel { this.artist = artist; } - public void setFavorite() { + public void setFavorite(Context context) { if (artist.getStarred() != null) { if (NetworkUtil.isOffline()) { removeFavoriteOffline(); @@ -43,9 +51,9 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel { } } else { if (NetworkUtil.isOffline()) { - setFavoriteOffline(); + setFavoriteOffline(context); } else { - setFavoriteOnline(); + setFavoriteOnline(context); } } } @@ -59,7 +67,6 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel { favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() { @Override public void onError() { - // artist.setStarred(new Date()); favoriteRepository.starLater(null, null, artist.getId(), false); } }); @@ -67,20 +74,45 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel { artist.setStarred(null); } - private void setFavoriteOffline() { + private void setFavoriteOffline(Context context) { favoriteRepository.starLater(null, null, artist.getId(), true); artist.setStarred(new Date()); } - private void setFavoriteOnline() { + private void setFavoriteOnline(Context context) { favoriteRepository.star(null, null, artist.getId(), new StarCallback() { @Override public void onError() { - // artist.setStarred(null); favoriteRepository.starLater(null, null, artist.getId(), true); } }); artist.setStarred(new Date()); + + Log.d("ArtistSync", "Checking preference: " + Preferences.isStarredArtistsSyncEnabled()); + + if (Preferences.isStarredArtistsSyncEnabled()) { + Log.d("ArtistSync", "Starting artist sync for: " + artist.getName()); + + artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() { + @Override + public void onSongsCollected(List songs) { + Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0)); + if (songs != null && !songs.isEmpty()) { + Log.d("ArtistSync", "Starting download of " + songs.size() + " songs"); + DownloadUtil.getDownloadTracker(context).download( + MappingUtil.mapDownloads(songs), + songs.stream().map(Download::new).collect(Collectors.toList()) + ); + Log.d("ArtistSync", "Download started successfully"); + } else { + Log.d("ArtistSync", "No songs to download"); + } + } + }); + } else { + Log.d("ArtistSync", "Artist sync preference is disabled"); + } } + /// } diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java new file mode 100644 index 00000000..724a7f9c --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java @@ -0,0 +1,95 @@ +package com.cappielloantonio.tempo.viewmodel; + +import android.app.Application; +import android.app.Activity; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.MutableLiveData; + +import com.cappielloantonio.tempo.repository.ArtistRepository; +import com.cappielloantonio.tempo.subsonic.models.ArtistID3; +import com.cappielloantonio.tempo.subsonic.models.Child; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +public class StarredArtistsSyncViewModel extends AndroidViewModel { + private final ArtistRepository artistRepository; + + private final MutableLiveData> starredArtists = new MutableLiveData<>(null); + private final MutableLiveData> starredArtistSongs = new MutableLiveData<>(null); + + public StarredArtistsSyncViewModel(@NonNull Application application) { + super(application); + artistRepository = new ArtistRepository(); + } + + public LiveData> getStarredArtists(LifecycleOwner owner) { + artistRepository.getStarredArtists(false, -1).observe(owner, starredArtists::postValue); + return starredArtists; + } + + public LiveData> getAllStarredArtistSongs() { + artistRepository.getStarredArtists(false, -1).observeForever(new Observer>() { + @Override + public void onChanged(List artists) { + if (artists != null && !artists.isEmpty()) { + collectAllArtistSongs(artists, starredArtistSongs::postValue); + } else { + starredArtistSongs.postValue(new ArrayList<>()); + } + artistRepository.getStarredArtists(false, -1).removeObserver(this); + } + }); + + return starredArtistSongs; + } + + public LiveData> getStarredArtistSongs(Activity activity) { + artistRepository.getStarredArtists(false, -1).observe((LifecycleOwner) activity, artists -> { + if (artists != null && !artists.isEmpty()) { + collectAllArtistSongs(artists, starredArtistSongs::postValue); + } else { + starredArtistSongs.postValue(new ArrayList<>()); + } + }); + return starredArtistSongs; + } + + private void collectAllArtistSongs(List artists, ArtistSongsCallback callback) { + if (artists == null || artists.isEmpty()) { + callback.onSongsCollected(new ArrayList<>()); + return; + } + + List allSongs = new ArrayList<>(); + AtomicInteger remainingArtists = new AtomicInteger(artists.size()); + + for (ArtistID3 artist : artists) { + // Use the new callback-based method + artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() { + @Override + public void onSongsCollected(List songs) { + if (songs != null) { + allSongs.addAll(songs); + } + + int remaining = remainingArtists.decrementAndGet(); + if (remaining == 0) { + callback.onSongsCollected(allSongs); + } + } + }); + } + } + + private interface ArtistSongsCallback { + void onSongsCollected(List songs); + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_starred_artist_sync.xml b/app/src/main/res/layout/dialog_starred_artist_sync.xml new file mode 100644 index 00000000..ca41742e --- /dev/null +++ b/app/src/main/res/layout/dialog_starred_artist_sync.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40febf58..126f027c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,6 +346,8 @@ Priority given to the transcoding mode. If set to \"Direct play\" the bitrate of the file will not be changed. Download transcoded media. If enabled, the download endpoint will not be used, but the following settings. \n\n If \"Transcode format for donwloads\" is set to \"Direct download\" the bitrate of the file will not be changed. When the file is transcoded on the fly, the client usually does not show the track length. It is possible to request the servers that support the functionality to estimate the duration of the track being played, but the response times may take longer. + If enabled, starred artists will be downloaded for offline use. + Sync starred artists for offline use If enabled, starred albums will be downloaded for offline use. Sync starred albums for offline use If enabled, starred tracks will be downloaded for offline use. @@ -403,6 +405,8 @@ Continue and download Downloading starred tracks may require a large amount of data. Sync starred tracks + Downloading starred artists may require a large amount of data. + Sync starred artists Downloading starred albums may require a large amount of data. Sync starred albums For the changes to take effect, restart the app. @@ -410,7 +414,7 @@ Select storage option External Internal - https://buymeacoffee.com/a.cappiello + https://ko-fi.com/eddyizm Album Artist Bit depth diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index 18cf4470..e8d262d7 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -150,6 +150,12 @@ android:summary="@string/settings_sync_starred_albums_for_offline_use_summary" android:key="sync_starred_albums_for_offline_use" /> + + Date: Sat, 27 Sep 2025 21:52:04 -0700 Subject: [PATCH 18/42] chore: added dialog to starred artists sync --- .../tempo/ui/fragment/SettingsFragment.java | 18 +++++++++++++++++- .../viewmodel/StarredArtistsSyncViewModel.java | 1 - 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index aa33631c..602acc8c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -42,6 +42,7 @@ import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog; import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog; import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog; import com.cappielloantonio.tempo.ui.dialog.StarredAlbumSyncDialog; +import com.cappielloantonio.tempo.ui.dialog.StarredArtistSyncDialog; import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.Preferences; @@ -110,6 +111,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { actionScan(); actionSyncStarredAlbums(); actionSyncStarredTracks(); + actionSyncStarredArtists(); actionChangeStreamingCacheStorage(); actionChangeDownloadStorage(); actionDeleteDownloadStorage(); @@ -296,7 +298,21 @@ public class SettingsFragment extends PreferenceFragmentCompat { return true; }); } - + + private void actionSyncStarredArtists() { + findPreference("sync_starred_artists_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> { + if (newValue instanceof Boolean) { + if ((Boolean) newValue) { + StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> { + ((SwitchPreference)preference).setChecked(false); + }); + dialog.show(activity.getSupportFragmentManager(), null); + } + } + return true; + }); + } + private void actionChangeStreamingCacheStorage() { findPreference("streaming_cache_storage").setOnPreferenceClickListener(preference -> { StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java index 724a7f9c..474cbe87 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java @@ -72,7 +72,6 @@ public class StarredArtistsSyncViewModel extends AndroidViewModel { AtomicInteger remainingArtists = new AtomicInteger(artists.size()); for (ArtistID3 artist : artists) { - // Use the new callback-based method artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() { @Override public void onSongsCollected(List songs) { From a187ba1e75456525d2cc9103dbf12a064b805162 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sat, 27 Sep 2025 22:37:30 -0700 Subject: [PATCH 19/42] fix: moved api call back to artist repository after losing the thread. --- .../tempo/repository/ArtistRepository.java | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java index 71b43fa9..4e06fad7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java @@ -31,16 +31,38 @@ public class ArtistRepository { public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) { Log.d("ArtistSync", "Getting albums for artist: " + artistId); - // Use AlbumRepository to get all albums by this artist - albumRepository.getArtistAlbums(artistId).observeForever(albums -> { - Log.d("ArtistSync", "Got albums: " + (albums != null ? albums.size() : 0)); - if (albums != null && !albums.isEmpty()) { - fetchAllAlbumSongsWithCallback(albums, callback); - } else { - Log.d("ArtistSync", "No albums found"); - callback.onSongsCollected(new ArrayList<>()); - } - }); + // Get the artist info first, which contains the albums + App.getSubsonicClientInstance(false) + .getBrowsingClient() + .getArtist(artistId) + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() && response.body() != null && + response.body().getSubsonicResponse().getArtist() != null && + response.body().getSubsonicResponse().getArtist().getAlbums() != null) { + + List albums = response.body().getSubsonicResponse().getArtist().getAlbums(); + Log.d("ArtistSync", "Got albums directly: " + albums.size()); + + if (!albums.isEmpty()) { + fetchAllAlbumSongsWithCallback(albums, callback); + } else { + Log.d("ArtistSync", "No albums found in artist response"); + callback.onSongsCollected(new ArrayList<>()); + } + } else { + Log.d("ArtistSync", "Failed to get artist info"); + callback.onSongsCollected(new ArrayList<>()); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + Log.d("ArtistSync", "Error getting artist info: " + t.getMessage()); + callback.onSongsCollected(new ArrayList<>()); + } + }); } private void fetchAllAlbumSongsWithCallback(List albums, ArtistSongsCallback callback) { From 47380a79a576e9c4ccbc0d66a6efb1589116fd40 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sun, 28 Sep 2025 16:14:42 -0700 Subject: [PATCH 20/42] fix: added init on home tab and dialog, refactor and check for songs for albums/artists before displaying dialog --- .../ui/fragment/HomeTabMusicFragment.java | 168 +++++++++++++++--- .../tempo/viewmodel/HomeViewModel.java | 6 + .../res/layout/fragment_home_tab_music.xml | 92 ++++++++++ app/src/main/res/values/strings.xml | 10 ++ 4 files changed, 249 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java index 4c47f0c9..bf6b7d6c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java @@ -9,6 +9,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.PopupMenu; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -40,6 +41,7 @@ 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.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter; import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter; @@ -116,6 +118,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { initSyncStarredView(); initSyncStarredAlbumsView(); + initSyncStarredArtistsView(); initDiscoverSongSlideView(); initSimilarSongView(); initArtistRadio(); @@ -327,32 +330,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { private void initSyncStarredAlbumsView() { if (Preferences.isStarredAlbumsSyncEnabled()) { - homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observeForever(new Observer>() { + homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(List albums) { - if (albums != null) { - DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext()); - List albumsToSync = new ArrayList<>(); - int albumCount = 0; - - for (AlbumID3 album : albums) { - boolean needsSync = false; - albumCount++; - albumsToSync.add(album.getName()); - } - - if (albumCount > 0) { - bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE); - String message = getResources().getQuantityString( - R.plurals.home_sync_starred_albums_count, - albumCount, - albumCount - ); - bind.homeSyncStarredAlbumsToSync.setText(message); - } + if (albums != null && !albums.isEmpty()) { + checkIfAlbumsNeedSync(albums); } - - homeViewModel.getStarredAlbums(getViewLifecycleOwner()).removeObserver(this); } }); } @@ -362,26 +345,157 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { }); bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> { - homeViewModel.getAllStarredAlbumSongs().observeForever(new Observer>() { + homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(List allSongs) { - if (allSongs != null) { + 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++; } } - } - homeViewModel.getAllStarredAlbumSongs().removeObserver(this); + if (songsToDownload > 0) { + Toast.makeText(requireContext(), + getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload), + Toast.LENGTH_SHORT).show(); + } + } + bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE); } }); }); } + private void checkIfAlbumsNeedSync(List albums) { + homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(List allSongs) { + if (allSongs != null) { + DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext()); + int songsToDownload = 0; + List 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 (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(), + albumsNeedingSync.size() + ); + bind.homeSyncStarredAlbumsToSync.setText(message); + } else { + bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE); + } + } + } + }); + } + + private void initSyncStarredArtistsView() { + if (Preferences.isStarredArtistsSyncEnabled()) { + homeViewModel.getStarredArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(List artists) { + if (artists != null && !artists.isEmpty()) { + checkIfArtistsNeedSync(artists); + } + } + }); + } + + bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> { + bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); + }); + + bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> { + homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(List 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 (songsToDownload > 0) { + Toast.makeText(requireContext(), + getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload), + Toast.LENGTH_SHORT).show(); + } + } + + bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); + } + }); + }); + } + + private void checkIfArtistsNeedSync(List artists) { + homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(List allSongs) { + if (allSongs != null) { + DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext()); + int songsToDownload = 0; + List 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 (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(), + artistsNeedingSync.size() + ); + bind.homeSyncStarredArtistsToSync.setText(message); + } else { + bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); + } + } + } + }); + } + private void initDiscoverSongSlideView() { if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return; diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java index 6477178c..2089ce20 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java @@ -48,6 +48,7 @@ public class HomeViewModel extends AndroidViewModel { private final SharingRepository sharingRepository; private final StarredAlbumsSyncViewModel albumsSyncViewModel; + private final StarredArtistsSyncViewModel artistSyncViewModel; private final MutableLiveData> dicoverSongSample = new MutableLiveData<>(null); private final MutableLiveData> newReleasedAlbum = new MutableLiveData<>(null); @@ -85,6 +86,7 @@ public class HomeViewModel extends AndroidViewModel { sharingRepository = new SharingRepository(); albumsSyncViewModel = new StarredAlbumsSyncViewModel(application); + artistSyncViewModel = new StarredArtistsSyncViewModel(application); setOfflineFavorite(); } @@ -174,6 +176,10 @@ public class HomeViewModel extends AndroidViewModel { return albumsSyncViewModel.getAllStarredAlbumSongs(); } + public LiveData> getAllStarredArtistSongs() { + return artistSyncViewModel.getAllStarredArtistSongs(); + } + public LiveData> getStarredArtists(LifecycleOwner owner) { if (starredArtists.getValue() == null) { artistRepository.getStarredArtists(true, 20).observe(owner, starredArtists::postValue); diff --git a/app/src/main/res/layout/fragment_home_tab_music.xml b/app/src/main/res/layout/fragment_home_tab_music.xml index e9811da3..c516171c 100644 --- a/app/src/main/res/layout/fragment_home_tab_music.xml +++ b/app/src/main/res/layout/fragment_home_tab_music.xml @@ -198,6 +198,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + Looks like there are some starred tracks to sync Sync Starred Albums Albums marked with a star will be available offline + Starred Artists Sync + You have starred artists with music not downloaded Best of Discovery Shuffle all @@ -447,6 +449,14 @@ %d album to sync %d albums to sync + + %d artist to sync + %d artists to sync + + + Downloading %d song + Downloading %d songs + Equalizer Reset Enable From c2b6d7eed5aac959055b5d2087bc27ded90b1a26 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Mon, 29 Sep 2025 22:04:38 +0930 Subject: [PATCH 21/42] feat: Enable downloading of song lyrics for offline viewing --- .../12.json | 1151 +++++++++++++++++ .../tempo/database/AppDatabase.java | 10 +- .../tempo/database/dao/LyricsDao.java | 24 + .../tempo/model/LyricsCache.kt | 25 + .../tempo/repository/LyricsRepository.java | 92 ++ .../ui/fragment/PlayerLyricsFragment.java | 178 ++- .../tempo/ui/fragment/SettingsFragment.java | 16 + .../tempo/util/Preferences.kt | 19 + .../viewmodel/PlayerBottomSheetViewModel.java | 172 ++- .../layout/inner_fragment_player_lyrics.xml | 20 +- app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/global_preferences.xml | 6 + 12 files changed, 1652 insertions(+), 67 deletions(-) create mode 100644 app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json create mode 100644 app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt create mode 100644 app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json new file mode 100644 index 00000000..77974597 --- /dev/null +++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json @@ -0,0 +1,1151 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "2d26471ae15a1cdaf996261b72f81613", + "entities": [ + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackOrder", + "columnName": "track_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlay", + "columnName": "last_play", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playingChanged", + "columnName": "playing_changed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_order" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localAddress", + "columnName": "local_address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLowSecurity", + "columnName": "low_security", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_search", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, PRIMARY KEY(`search`))", + "fields": [ + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "search" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "playlistName", + "columnName": "playlist_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "downloadUri", + "columnName": "download_uri", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chronology", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "server", + "columnName": "server", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "samplingRate", + "columnName": "sampling_rate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toStar", + "columnName": "toStar", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "session_media_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)", + "fields": [ + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDir", + "columnName": "is_dir", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedContentType", + "columnName": "transcoding_content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "transcodedSuffix", + "columnName": "transcoded_suffix", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isVideo", + "columnName": "is_video", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userRating", + "columnName": "user_rating", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "averageRating", + "columnName": "average_rating", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bookmarkPosition", + "columnName": "bookmark_position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalWidth", + "columnName": "original_width", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "originalHeight", + "columnName": "original_height", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamUrl", + "columnName": "stream_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "index" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArtId", + "columnName": "coverArt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "structuredLyrics", + "columnName": "structured_lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2d26471ae15a1cdaf996261b72f81613')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java index b19e934f..3a5e98ef 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java +++ b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java @@ -12,6 +12,7 @@ import com.cappielloantonio.tempo.database.converter.DateConverters; import com.cappielloantonio.tempo.database.dao.ChronologyDao; import com.cappielloantonio.tempo.database.dao.DownloadDao; import com.cappielloantonio.tempo.database.dao.FavoriteDao; +import com.cappielloantonio.tempo.database.dao.LyricsDao; import com.cappielloantonio.tempo.database.dao.PlaylistDao; import com.cappielloantonio.tempo.database.dao.QueueDao; import com.cappielloantonio.tempo.database.dao.RecentSearchDao; @@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.database.dao.SessionMediaItemDao; import com.cappielloantonio.tempo.model.Chronology; import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Favorite; +import com.cappielloantonio.tempo.model.LyricsCache; import com.cappielloantonio.tempo.model.Queue; import com.cappielloantonio.tempo.model.RecentSearch; import com.cappielloantonio.tempo.model.Server; @@ -28,9 +30,9 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist; @UnstableApi @Database( - version = 11, - entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class}, - autoMigrations = {@AutoMigration(from = 10, to = 11)} + version = 12, + 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)} ) @TypeConverters({DateConverters.class}) public abstract class AppDatabase extends RoomDatabase { @@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase { public abstract SessionMediaItemDao sessionMediaItemDao(); public abstract PlaylistDao playlistDao(); + + public abstract LyricsDao lyricsDao(); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java new file mode 100644 index 00000000..89d0d585 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java @@ -0,0 +1,24 @@ +package com.cappielloantonio.tempo.database.dao; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; + +import com.cappielloantonio.tempo.model.LyricsCache; + +@Dao +public interface LyricsDao { + @Query("SELECT * FROM lyrics_cache WHERE song_id = :songId") + LyricsCache getOne(String songId); + + @Query("SELECT * FROM lyrics_cache WHERE song_id = :songId") + LiveData observeOne(String songId); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + void insert(LyricsCache lyricsCache); + + @Query("DELETE FROM lyrics_cache WHERE song_id = :songId") + void delete(String songId); +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt b/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt new file mode 100644 index 00000000..3c437e2c --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt @@ -0,0 +1,25 @@ +package com.cappielloantonio.tempo.model + +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlin.jvm.JvmOverloads + +@Keep +@Entity(tableName = "lyrics_cache") +data class LyricsCache @JvmOverloads constructor( + @PrimaryKey + @ColumnInfo(name = "song_id") + var songId: String, + @ColumnInfo(name = "artist") + var artist: String? = null, + @ColumnInfo(name = "title") + var title: String? = null, + @ColumnInfo(name = "lyrics") + var lyrics: String? = null, + @ColumnInfo(name = "structured_lyrics") + var structuredLyrics: String? = null, + @ColumnInfo(name = "updated_at") + var updatedAt: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java new file mode 100644 index 00000000..fb7a05a3 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java @@ -0,0 +1,92 @@ +package com.cappielloantonio.tempo.repository; + +import androidx.lifecycle.LiveData; + +import com.cappielloantonio.tempo.database.AppDatabase; +import com.cappielloantonio.tempo.database.dao.LyricsDao; +import com.cappielloantonio.tempo.model.LyricsCache; + +public class LyricsRepository { + private final LyricsDao lyricsDao = AppDatabase.getInstance().lyricsDao(); + + public LyricsCache getLyrics(String songId) { + GetLyricsThreadSafe getLyricsThreadSafe = new GetLyricsThreadSafe(lyricsDao, songId); + Thread thread = new Thread(getLyricsThreadSafe); + thread.start(); + + try { + thread.join(); + return getLyricsThreadSafe.getLyrics(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + return null; + } + + public LiveData observeLyrics(String songId) { + return lyricsDao.observeOne(songId); + } + + public void insert(LyricsCache lyricsCache) { + InsertThreadSafe insert = new InsertThreadSafe(lyricsDao, lyricsCache); + Thread thread = new Thread(insert); + thread.start(); + } + + public void delete(String songId) { + DeleteThreadSafe delete = new DeleteThreadSafe(lyricsDao, songId); + Thread thread = new Thread(delete); + thread.start(); + } + + private static class GetLyricsThreadSafe implements Runnable { + private final LyricsDao lyricsDao; + private final String songId; + private LyricsCache lyricsCache; + + public GetLyricsThreadSafe(LyricsDao lyricsDao, String songId) { + this.lyricsDao = lyricsDao; + this.songId = songId; + } + + @Override + public void run() { + lyricsCache = lyricsDao.getOne(songId); + } + + public LyricsCache getLyrics() { + return lyricsCache; + } + } + + private static class InsertThreadSafe implements Runnable { + private final LyricsDao lyricsDao; + private final LyricsCache lyricsCache; + + public InsertThreadSafe(LyricsDao lyricsDao, LyricsCache lyricsCache) { + this.lyricsDao = lyricsDao; + this.lyricsCache = lyricsCache; + } + + @Override + public void run() { + lyricsDao.insert(lyricsCache); + } + } + + private static class DeleteThreadSafe implements Runnable { + private final LyricsDao lyricsDao; + private final String songId; + + public DeleteThreadSafe(LyricsDao lyricsDao, String songId) { + this.lyricsDao = lyricsDao; + this.songId = songId; + } + + @Override + public void run() { + lyricsDao.delete(songId); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java index 7140632f..24a1abcd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java @@ -4,15 +4,16 @@ import android.annotation.SuppressLint; import android.content.ComponentName; import android.os.Bundle; import android.os.Handler; +import android.text.Layout; import android.text.Spannable; import android.text.SpannableString; -import android.text.Layout; +import android.text.TextUtils; import android.text.style.ForegroundColorSpan; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -29,10 +30,10 @@ import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.subsonic.models.Line; import com.cappielloantonio.tempo.subsonic.models.LyricsList; import com.cappielloantonio.tempo.util.MusicUtil; -import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.google.common.util.concurrent.ListenableFuture; +import com.google.android.material.button.MaterialButton; import com.google.common.util.concurrent.MoreExecutors; import java.util.List; @@ -48,6 +49,9 @@ public class PlayerLyricsFragment extends Fragment { private MediaBrowser mediaBrowser; private Handler syncLyricsHandler; private Runnable syncLyricsRunnable; + private String currentLyrics; + private LyricsList currentLyricsList; + private String currentDescription; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -66,6 +70,7 @@ public class PlayerLyricsFragment extends Fragment { super.onViewCreated(view, savedInstanceState); initPanelContent(); + observeDownloadState(); } @Override @@ -101,12 +106,26 @@ public class PlayerLyricsFragment extends Fragment { public void onDestroyView() { super.onDestroyView(); bind = null; + currentLyrics = null; + currentLyricsList = null; + currentDescription = null; } private void initOverlay() { bind.syncLyricsTapButton.setOnClickListener(view -> { playerBottomSheetViewModel.changeSyncLyricsState(); }); + + bind.downloadLyricsButton.setOnClickListener(view -> { + boolean saved = playerBottomSheetViewModel.downloadCurrentLyrics(); + if (getContext() != null) { + Toast.makeText( + requireContext(), + saved ? R.string.player_lyrics_download_success : R.string.player_lyrics_download_failure, + Toast.LENGTH_SHORT + ).show(); + } + }); } private void initializeBrowser() { @@ -136,50 +155,91 @@ public class PlayerLyricsFragment extends Fragment { } private void initPanelContent() { - if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) { - playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> { - setPanelContent(null, lyricsList); - }); - } else { - playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> { - setPanelContent(lyrics, null); - }); - } + playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> { + currentLyrics = lyrics; + updatePanelContent(); + }); + + playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> { + currentLyricsList = lyricsList; + updatePanelContent(); + }); + + playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> { + currentDescription = description; + updatePanelContent(); + }); } - private void setPanelContent(String lyrics, LyricsList lyricsList) { - playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> { + private void observeDownloadState() { + playerBottomSheetViewModel.getLyricsCachedState().observe(getViewLifecycleOwner(), cached -> { if (bind != null) { - bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0); - - if (lyrics != null && !lyrics.trim().equals("")) { - bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(lyrics)); - bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); - bind.emptyDescriptionImageView.setVisibility(View.GONE); - bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); - bind.syncLyricsTapButton.setVisibility(View.GONE); - } else if (lyricsList != null && lyricsList.getStructuredLyrics() != null) { - setSyncLirics(lyricsList); - bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); - bind.emptyDescriptionImageView.setVisibility(View.GONE); - bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); - bind.syncLyricsTapButton.setVisibility(View.VISIBLE); - } else if (description != null && !description.trim().equals("")) { - bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(description)); - bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); - bind.emptyDescriptionImageView.setVisibility(View.GONE); - bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); - bind.syncLyricsTapButton.setVisibility(View.GONE); + MaterialButton downloadButton = (MaterialButton) bind.downloadLyricsButton; + if (cached != null && cached) { + downloadButton.setIconResource(R.drawable.ic_done); + downloadButton.setContentDescription(getString(R.string.player_lyrics_downloaded_content_description)); } else { - bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE); - bind.emptyDescriptionImageView.setVisibility(View.VISIBLE); - bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE); - bind.syncLyricsTapButton.setVisibility(View.GONE); + downloadButton.setIconResource(R.drawable.ic_download); + downloadButton.setContentDescription(getString(R.string.player_lyrics_download_content_description)); } } }); } + private void updatePanelContent() { + if (bind == null) { + return; + } + + bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0); + + if (hasStructuredLyrics(currentLyricsList)) { + setSyncLirics(currentLyricsList); + bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); + bind.emptyDescriptionImageView.setVisibility(View.GONE); + bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); + bind.syncLyricsTapButton.setVisibility(View.VISIBLE); + bind.downloadLyricsButton.setVisibility(View.VISIBLE); + bind.downloadLyricsButton.setEnabled(true); + } else if (hasText(currentLyrics)) { + bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentLyrics)); + bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); + bind.emptyDescriptionImageView.setVisibility(View.GONE); + bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); + bind.syncLyricsTapButton.setVisibility(View.GONE); + bind.downloadLyricsButton.setVisibility(View.VISIBLE); + bind.downloadLyricsButton.setEnabled(true); + } else if (hasText(currentDescription)) { + bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentDescription)); + bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); + bind.emptyDescriptionImageView.setVisibility(View.GONE); + bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); + bind.syncLyricsTapButton.setVisibility(View.GONE); + bind.downloadLyricsButton.setVisibility(View.GONE); + bind.downloadLyricsButton.setEnabled(false); + } else { + bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE); + bind.emptyDescriptionImageView.setVisibility(View.VISIBLE); + bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE); + bind.syncLyricsTapButton.setVisibility(View.GONE); + bind.downloadLyricsButton.setVisibility(View.GONE); + bind.downloadLyricsButton.setEnabled(false); + } + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private boolean hasStructuredLyrics(LyricsList lyricsList) { + return lyricsList != null + && lyricsList.getStructuredLyrics() != null + && !lyricsList.getStructuredLyrics().isEmpty() + && lyricsList.getStructuredLyrics().get(0) != null + && lyricsList.getStructuredLyrics().get(0).getLine() != null + && !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty(); + } + @SuppressLint("DefaultLocale") private void setSyncLirics(LyricsList lyricsList) { if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) { @@ -198,28 +258,28 @@ public class PlayerLyricsFragment extends Fragment { private void defineProgressHandler() { playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> { - if (lyricsList != null) { - - if (lyricsList.getStructuredLyrics() != null && lyricsList.getStructuredLyrics().get(0) != null && !lyricsList.getStructuredLyrics().get(0).getSynced()) { - releaseHandler(); - return; - } - - syncLyricsHandler = new Handler(); - syncLyricsRunnable = () -> { - if (syncLyricsHandler != null) { - if (bind != null) { - displaySyncedLyrics(); - } - - syncLyricsHandler.postDelayed(syncLyricsRunnable, 250); - } - }; - - syncLyricsHandler.postDelayed(syncLyricsRunnable, 250); - } else { + if (!hasStructuredLyrics(lyricsList)) { releaseHandler(); + return; } + + if (!lyricsList.getStructuredLyrics().get(0).getSynced()) { + releaseHandler(); + return; + } + + syncLyricsHandler = new Handler(); + syncLyricsRunnable = () -> { + if (syncLyricsHandler != null) { + if (bind != null) { + displaySyncedLyrics(); + } + + syncLyricsHandler.postDelayed(syncLyricsRunnable, 250); + } + }; + + syncLyricsHandler.postDelayed(syncLyricsRunnable, 250); }); } @@ -227,7 +287,7 @@ public class PlayerLyricsFragment extends Fragment { LyricsList lyricsList = playerBottomSheetViewModel.getLiveLyricsList().getValue(); int timestamp = (int) (mediaBrowser.getCurrentPosition()); - if (lyricsList != null && lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) { + if (hasStructuredLyrics(lyricsList)) { StringBuilder lyricsBuilder = new StringBuilder(); List lines = lyricsList.getStructuredLyrics().get(0).getLine(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index 602acc8c..ef4f2134 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -116,6 +116,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { actionChangeDownloadStorage(); actionDeleteDownloadStorage(); actionKeepScreenOn(); + actionAutoDownloadLyrics(); bindMediaService(); actionAppEqualizer(); @@ -357,6 +358,21 @@ public class SettingsFragment extends PreferenceFragmentCompat { }); } + private void actionAutoDownloadLyrics() { + SwitchPreference preference = findPreference("auto_download_lyrics"); + if (preference == null) { + return; + } + + preference.setChecked(Preferences.isAutoDownloadLyricsEnabled()); + preference.setOnPreferenceChangeListener((pref, newValue) -> { + if (newValue instanceof Boolean) { + Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue); + } + return true; + }); + } + private void getScanStatus() { settingViewModel.getScanStatus(new ScanCallback() { @Override diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index f4188719..80276319 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -46,6 +46,7 @@ object Preferences { private const val ROUNDED_CORNER_SIZE = "rounded_corner_size" private const val PODCAST_SECTION_VISIBILITY = "podcast_section_visibility" private const val RADIO_SECTION_VISIBILITY = "radio_section_visibility" + private const val AUTO_DOWNLOAD_LYRICS = "auto_download_lyrics" private const val MUSIC_DIRECTORY_SECTION_VISIBILITY = "music_directory_section_visibility" private const val REPLAY_GAIN_MODE = "replay_gain_mode" private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority" @@ -163,6 +164,24 @@ object Preferences { App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply() } + @JvmStatic + fun isAutoDownloadLyricsEnabled(): Boolean { + val preferences = App.getInstance().preferences + + if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) { + return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false) + } + + return false + } + + @JvmStatic + fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) { + App.getInstance().preferences.edit() + .putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled) + .apply() + } + @JvmStatic fun getLocalAddress(): String? { return App.getInstance().preferences.getString(LOCAL_ADDRESS, null) diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java index bf90fa65..6f972add 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.viewmodel; import android.app.Application; import android.content.Context; +import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.OptIn; @@ -9,14 +10,17 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; import androidx.media3.common.util.UnstableApi; import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.model.Download; +import com.cappielloantonio.tempo.model.LyricsCache; import com.cappielloantonio.tempo.model.Queue; import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.FavoriteRepository; +import com.cappielloantonio.tempo.repository.LyricsRepository; import com.cappielloantonio.tempo.repository.OpenRepository; import com.cappielloantonio.tempo.repository.QueueRepository; import com.cappielloantonio.tempo.repository.SongRepository; @@ -31,6 +35,7 @@ import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.NetworkUtil; import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil; import com.cappielloantonio.tempo.util.Preferences; +import com.google.gson.Gson; import java.util.Collections; import java.util.Date; @@ -47,14 +52,20 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { private final QueueRepository queueRepository; private final FavoriteRepository favoriteRepository; private final OpenRepository openRepository; + private final LyricsRepository lyricsRepository; private final MutableLiveData lyricsLiveData = new MutableLiveData<>(null); private final MutableLiveData lyricsListLiveData = new MutableLiveData<>(null); + private final MutableLiveData lyricsCachedLiveData = new MutableLiveData<>(false); private final MutableLiveData descriptionLiveData = new MutableLiveData<>(null); private final MutableLiveData liveMedia = new MutableLiveData<>(null); private final MutableLiveData liveAlbum = new MutableLiveData<>(null); private final MutableLiveData liveArtist = new MutableLiveData<>(null); private final MutableLiveData> instantMix = new MutableLiveData<>(null); + private final Gson gson = new Gson(); private boolean lyricsSyncState = true; + private LiveData cachedLyricsSource; + private String currentSongId; + private final Observer cachedLyricsObserver = this::onCachedLyricsChanged; public PlayerBottomSheetViewModel(@NonNull Application application) { @@ -66,6 +77,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { queueRepository = new QueueRepository(); favoriteRepository = new FavoriteRepository(); openRepository = new OpenRepository(); + lyricsRepository = new LyricsRepository(); } public LiveData> getQueueSong() { @@ -139,12 +151,49 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { } public void refreshMediaInfo(LifecycleOwner owner, Child media) { + lyricsLiveData.postValue(null); + lyricsListLiveData.postValue(null); + lyricsCachedLiveData.postValue(false); + + clearCachedLyricsObserver(); + + String songId = media != null ? media.getId() : currentSongId; + + if (TextUtils.isEmpty(songId) || owner == null) { + return; + } + + currentSongId = songId; + + observeCachedLyrics(owner, songId); + + LyricsCache cachedLyrics = lyricsRepository.getLyrics(songId); + if (cachedLyrics != null) { + onCachedLyricsChanged(cachedLyrics); + } + + if (NetworkUtil.isOffline() || media == null) { + return; + } + if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) { - openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsListLiveData::postValue); - lyricsLiveData.postValue(null); + openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsList -> { + lyricsListLiveData.postValue(lyricsList); + lyricsLiveData.postValue(null); + + if (shouldAutoDownloadLyrics() && hasStructuredLyrics(lyricsList)) { + saveLyricsToCache(media, null, lyricsList); + } + }); } else { - songRepository.getSongLyrics(media).observe(owner, lyricsLiveData::postValue); - lyricsListLiveData.postValue(null); + songRepository.getSongLyrics(media).observe(owner, lyrics -> { + lyricsLiveData.postValue(lyrics); + lyricsListLiveData.postValue(null); + + if (shouldAutoDownloadLyrics() && !TextUtils.isEmpty(lyrics)) { + saveLyricsToCache(media, lyrics, null); + } + }); } } @@ -153,6 +202,17 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { } public void setLiveMedia(LifecycleOwner owner, String mediaType, String mediaId) { + currentSongId = mediaId; + + if (!TextUtils.isEmpty(mediaId)) { + refreshMediaInfo(owner, null); + } else { + clearCachedLyricsObserver(); + lyricsLiveData.postValue(null); + lyricsListLiveData.postValue(null); + lyricsCachedLiveData.postValue(false); + } + if (mediaType != null) { switch (mediaType) { case Constants.MEDIA_TYPE_MUSIC: @@ -162,7 +222,12 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { case Constants.MEDIA_TYPE_PODCAST: liveMedia.postValue(null); break; + default: + liveMedia.postValue(null); + break; } + } else { + liveMedia.postValue(null); } } @@ -233,6 +298,105 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { return false; } + private void observeCachedLyrics(LifecycleOwner owner, String songId) { + if (TextUtils.isEmpty(songId)) { + return; + } + + cachedLyricsSource = lyricsRepository.observeLyrics(songId); + cachedLyricsSource.observe(owner, cachedLyricsObserver); + } + + private void clearCachedLyricsObserver() { + if (cachedLyricsSource != null) { + cachedLyricsSource.removeObserver(cachedLyricsObserver); + cachedLyricsSource = null; + } + } + + private void onCachedLyricsChanged(LyricsCache lyricsCache) { + if (lyricsCache == null) { + lyricsCachedLiveData.postValue(false); + return; + } + + lyricsCachedLiveData.postValue(true); + + if (!TextUtils.isEmpty(lyricsCache.getStructuredLyrics())) { + try { + LyricsList cachedList = gson.fromJson(lyricsCache.getStructuredLyrics(), LyricsList.class); + lyricsListLiveData.postValue(cachedList); + lyricsLiveData.postValue(null); + } catch (Exception exception) { + lyricsListLiveData.postValue(null); + lyricsLiveData.postValue(lyricsCache.getLyrics()); + } + } else { + lyricsListLiveData.postValue(null); + lyricsLiveData.postValue(lyricsCache.getLyrics()); + } + } + + private void saveLyricsToCache(Child media, String lyrics, LyricsList lyricsList) { + if (media == null) { + return; + } + + if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) { + return; + } + + LyricsCache lyricsCache = new LyricsCache(media.getId()); + lyricsCache.setArtist(media.getArtist()); + lyricsCache.setTitle(media.getTitle()); + lyricsCache.setUpdatedAt(System.currentTimeMillis()); + + if (lyricsList != null && hasStructuredLyrics(lyricsList)) { + lyricsCache.setStructuredLyrics(gson.toJson(lyricsList)); + lyricsCache.setLyrics(null); + } else { + lyricsCache.setLyrics(lyrics); + lyricsCache.setStructuredLyrics(null); + } + + lyricsRepository.insert(lyricsCache); + lyricsCachedLiveData.postValue(true); + } + + private boolean hasStructuredLyrics(LyricsList lyricsList) { + return lyricsList != null + && lyricsList.getStructuredLyrics() != null + && !lyricsList.getStructuredLyrics().isEmpty() + && lyricsList.getStructuredLyrics().get(0) != null + && lyricsList.getStructuredLyrics().get(0).getLine() != null + && !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty(); + } + + private boolean shouldAutoDownloadLyrics() { + return Preferences.isAutoDownloadLyricsEnabled(); + } + + public boolean downloadCurrentLyrics() { + Child media = getLiveMedia().getValue(); + if (media == null) { + return false; + } + + LyricsList lyricsList = lyricsListLiveData.getValue(); + String lyrics = lyricsLiveData.getValue(); + + if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) { + return false; + } + + saveLyricsToCache(media, lyrics, lyricsList); + return true; + } + + public LiveData getLyricsCachedState() { + return lyricsCachedLiveData; + } + public void changeSyncLyricsState() { lyricsSyncState = !lyricsSyncState; } diff --git a/app/src/main/res/layout/inner_fragment_player_lyrics.xml b/app/src/main/res/layout/inner_fragment_player_lyrics.xml index f9b5ba48..5ece8996 100644 --- a/app/src/main/res/layout/inner_fragment_player_lyrics.xml +++ b/app/src/main/res/layout/inner_fragment_player_lyrics.xml @@ -51,7 +51,25 @@ app:layout_constraintTop_toTopOf="parent" /> -