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 1/4] 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 2/4] 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 3/4] 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 4/4] 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