mirror of
https://github.com/antebudimir/tempus.git
synced 2026-01-01 09:53:33 +00:00
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.
This commit is contained in:
parent
2e29e9537a
commit
cc0e264a17
20 changed files with 746 additions and 2 deletions
|
|
@ -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<Bitmap> 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;
|
||||
|
|
|
|||
|
|
@ -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<MediaController> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
||||
|
|
@ -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<Bitmap>() {
|
||||
@Override public void onResourceReady(Bitmap resource, Transition<? super Bitmap> 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<MediaController> 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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue