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:
mucahit-kaya 2025-09-16 18:00:51 +02:00
parent 2e29e9537a
commit cc0e264a17
20 changed files with 746 additions and 2 deletions

View file

@ -73,5 +73,20 @@
android:name="autoStoreLocales" android:name="autoStoreLocales"
android:value="true" /> android:value="true" />
</service> </service>
<receiver
android:name=".widget.WidgetProvider4x1"
android:exported="false"
android:label="@string/widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info"/>
</receiver>
</application> </application>
</manifest> </manifest>

View file

@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.glide; package com.cappielloantonio.tempo.glide;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.util.Log; 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.bitmap.RoundedCorners;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.signature.ObjectKey; import com.bumptech.glide.signature.ObjectKey;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
@ -109,6 +111,18 @@ public class CustomGlideRequest {
return uri.toString(); 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 { public static class Builder {
private final RequestManager requestManager; private final RequestManager requestManager;
private Object item; private Object item;

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="10dp" />
<solid android:color="@color/widget_bg" />
</shape>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="64dp"
android:padding="8dp"
android:background="@drawable/widget_bg">
<ImageView
android:id="@+id/album_art"
android:layout_width="56dp"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@string/widget_content_desc_album_art"/>
<LinearLayout
android:id="@+id/texts"
android:orientation="vertical"
android:layout_toRightOf="@id/album_art"
android:layout_toEndOf="@id/album_art"
android:layout_toLeftOf="@id/controls"
android:layout_toStartOf="@id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="8dp">
<TextView
android:id="@+id/title"
android:maxLines="1"
android:ellipsize="end"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="@color/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/subtitle"
android:maxLines="1"
android:ellipsize="end"
android:textSize="12sp"
android:textColor="@color/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
<LinearLayout
android:id="@+id/controls"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ImageButton android:id="@+id/btn_prev"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_previous"
android:contentDescription="@string/widget_content_desc_prev"
android:tint="@color/widget_icon_tint"/>
<ImageButton android:id="@+id/btn_play_pause"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_play"
android:contentDescription="@string/widget_content_desc_play_pause"
android:tint="@color/widget_icon_tint"/>
<ImageButton android:id="@+id/btn_next"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_next"
android:contentDescription="@string/widget_content_desc_next"
android:tint="@color/widget_icon_tint"/>
</LinearLayout>
</RelativeLayout>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="112dp"
android:padding="12dp"
android:background="@drawable/widget_bg">
<ImageView
android:id="@+id/album_art"
android:layout_width="96dp"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@string/widget_content_desc_album_art"/>
<LinearLayout
android:id="@+id/texts"
android:orientation="vertical"
android:layout_toRightOf="@id/album_art"
android:layout_toEndOf="@id/album_art"
android:layout_toLeftOf="@id/controls"
android:layout_toStartOf="@id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/title"
android:maxLines="2"
android:ellipsize="end"
android:textStyle="bold"
android:textSize="16sp"
android:textColor="@color/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/subtitle"
android:maxLines="1"
android:ellipsize="end"
android:textSize="13sp"
android:textColor="@color/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
<LinearLayout
android:id="@+id/controls"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ImageButton android:id="@+id/btn_prev"
android:layout_width="44dp" android:layout_height="44dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_previous"
android:contentDescription="@string/widget_content_desc_prev"
android:tint="@color/widget_icon_tint"/>
<ImageButton android:id="@+id/btn_play_pause"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_play"
android:contentDescription="@string/widget_content_desc_play_pause"
android:tint="@color/widget_icon_tint"/>
<ImageButton android:id="@+id/btn_next"
android:layout_width="44dp" android:layout_height="44dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_next"
android:contentDescription="@string/widget_content_desc_next"
android:tint="@color/widget_icon_tint"/>
</LinearLayout>
</RelativeLayout>

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingLeft="8dp"
android:paddingTop="0dp"
android:paddingRight="0dp"
android:paddingBottom="8dp"
android:background="@drawable/widget_bg">
<ImageView
android:id="@+id/album_art"
android:layout_width="56dp"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@drawable/ic_splash_logo"
android:contentDescription="@string/widget_content_desc_album_art"/>
<LinearLayout
android:id="@+id/texts"
android:orientation="vertical"
android:layout_toEndOf="@id/album_art"
android:layout_toStartOf="@id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="8dp">
<TextView
android:id="@+id/title"
android:maxLines="1"
android:ellipsize="end"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="@color/widget_title"
android:text="@string/widget_not_playing"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/subtitle"
android:maxLines="1"
android:ellipsize="end"
android:textSize="12sp"
android:textColor="@color/widget_subtitle"
android:text="@string/widget_placeholder_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
<LinearLayout
android:id="@+id/controls"
android:layout_alignParentEnd="true"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ImageButton android:id="@+id/btn_prev"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_previous"
android:tint="@color/widget_icon_tint"
android:contentDescription="@string/widget_content_desc_prev"/>
<ImageButton android:id="@+id/btn_play_pause"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_play"
android:tint="@color/widget_icon_tint"
android:contentDescription="@string/widget_content_desc_play_pause"/>
<ImageButton android:id="@+id/btn_next"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_next"
android:tint="@color/widget_icon_tint"
android:contentDescription="@string/widget_content_desc_next"/>
</LinearLayout>
</RelativeLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_bg">#CC000000</color>
<color name="widget_title">#FFFFFFFF</color>
<color name="widget_subtitle">#B3FFFFFF</color>
<color name="widget_icon_tint">#FFFFFFFF</color>
</resources>

View file

@ -90,6 +90,7 @@
<string name="exo_download_notification_channel_name">İndirilenler</string> <string name="exo_download_notification_channel_name">İndirilenler</string>
<string name="filter_info_selection">İki veya daha fazla filtre seçin</string> <string name="filter_info_selection">İki veya daha fazla filtre seçin</string>
<string name="filter_title">Filtre</string> <string name="filter_title">Filtre</string>
<string name="filter_artist">Sanatçıları filtrele</string>
<string name="filter_title_expanded">Türleri filtrele</string> <string name="filter_title_expanded">Türleri filtrele</string>
<string name="generic_list_page_count">(%1$d)</string> <string name="generic_list_page_count">(%1$d)</string>
<string name="generic_list_page_count_unknown">(+%1$d)</string> <string name="generic_list_page_count_unknown">(+%1$d)</string>
@ -116,6 +117,7 @@
<string name="home_sync_starred_download">İndir</string> <string name="home_sync_starred_download">İndir</string>
<string name="home_sync_starred_subtitle">Bu parçaların indirilmesi önemli miktarda veri kullanabilir</string> <string name="home_sync_starred_subtitle">Bu parçaların indirilmesi önemli miktarda veri kullanabilir</string>
<string name="home_sync_starred_title">Eşitlenecek bazı yıldızlı parçalar var gibi görünüyor</string> <string name="home_sync_starred_title">Eşitlenecek bazı yıldızlı parçalar var gibi görünüyor</string>
<string name="home_sync_starred_albums_subtitle">Yıldız ile işaretlenen albümler çevrimdışı kullanılabilir olacak</string>
<string name="home_title_best_of">En iyiler</string> <string name="home_title_best_of">En iyiler</string>
<string name="home_title_discovery">Keşfet</string> <string name="home_title_discovery">Keşfet</string>
<string name="home_title_discovery_shuffle_all_button">Tümünü karıştır</string> <string name="home_title_discovery_shuffle_all_button">Tümünü karıştır</string>
@ -164,6 +166,7 @@
<string name="menu_add_button">Ekle</string> <string name="menu_add_button">Ekle</string>
<string name="menu_add_to_playlist_button">Çalma listesine ekle</string> <string name="menu_add_to_playlist_button">Çalma listesine ekle</string>
<string name="menu_download_all_button">Tümünü indir</string> <string name="menu_download_all_button">Tümünü indir</string>
<string name="menu_rate_album">Albümü oyla</string>
<string name="menu_download_label">İndir</string> <string name="menu_download_label">İndir</string>
<string name="menu_filter_all">Tümü</string> <string name="menu_filter_all">Tümü</string>
<string name="menu_filter_download">İndirilenler</string> <string name="menu_filter_download">İndirilenler</string>
@ -192,6 +195,7 @@
<string name="menu_sort_year">Yıl</string> <string name="menu_sort_year">Yıl</string>
<string name="player_playback_speed">%1$.2fx</string> <string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">Çalma sırasını temizle</string> <string name="player_queue_clean_all_button">Çalma sırasını temizle</string>
<string name="player_queue_save_queue_success">Kayıtlı oynatma sırası</string>
<string name="player_server_priority">Sunucu önceliği</string> <string name="player_server_priority">Sunucu önceliği</string>
<string name="player_unknown_format">Bilinmeyen format</string> <string name="player_unknown_format">Bilinmeyen format</string>
<string name="player_transcoding">Dönüştürme</string> <string name="player_transcoding">Dönüştürme</string>
@ -311,6 +315,7 @@
<string name="settings_podcast_summary">Etkinleştirildiğinde podcast bölümü görüntülenir. Tam etkili olması için uygulamayı yeniden başlatın.</string> <string name="settings_podcast_summary">Etkinleştirildiğinde podcast bölümü görüntülenir. Tam etkili olması için uygulamayı yeniden başlatın.</string>
<string name="settings_audio_quality">Ses kalitesini göster</string> <string name="settings_audio_quality">Ses kalitesini göster</string>
<string name="settings_audio_quality_summary">Her ses parçası için bit hızı ve ses formatı gösterilecektir.</string> <string name="settings_audio_quality_summary">Her ses parçası için bit hızı ve ses formatı gösterilecektir.</string>
<string name="settings_song_rating_summary">" "</string>
<string name="settings_item_rating">Öğe değerlemesini göster</string> <string name="settings_item_rating">Öğe değerlemesini göster</string>
<string name="settings_item_rating_summary">Etkinleştirildiğinde, öğenin puanı ve favori olarak işaretlenip işaretlenmediği görüntülenir.</string> <string name="settings_item_rating_summary">Etkinleştirildiğinde, öğenin puanı ve favori olarak işaretlenip işaretlenmediği görüntülenir.</string>
<string name="settings_queue_syncing_countdown">Eşitleme zamanlayıcısı</string> <string name="settings_queue_syncing_countdown">Eşitleme zamanlayıcısı</string>
@ -340,6 +345,7 @@
<string name="settings_summary_transcoding_download">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.</string> <string name="settings_summary_transcoding_download">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.</string>
<string name="settings_summary_transcoding_estimate_content_length">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, <string name="settings_summary_transcoding_estimate_content_length">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.</string> ancak yanıt süreleri daha uzun olabilir.</string>
<string name="settings_sync_starred_albums_for_offline_use_title">Çevrimdışı kullanım için yıldızlı albümleri senkronize et</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">Etkinleştirildiğinde, yıldızlı parçalar çevrimdışı kullanım için indirilecektir.</string> <string name="settings_sync_starred_tracks_for_offline_use_summary">Etkinleştirildiğinde, yıldızlı parçalar çevrimdışı kullanım için indirilecektir.</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">Çevrimdışı kullanım için yıldızlı parçaları eşitle</string> <string name="settings_sync_starred_tracks_for_offline_use_title">Çevrimdışı kullanım için yıldızlı parçaları eşitle</string>
<string name="settings_theme">Tema</string> <string name="settings_theme">Tema</string>
@ -395,6 +401,8 @@
<string name="starred_sync_dialog_positive_button">Devam et ve indir</string> <string name="starred_sync_dialog_positive_button">Devam et ve indir</string>
<string name="starred_sync_dialog_summary">Yıldızlı parçaların indirilmesi yüksek miktarda veri gerektirebilir.</string> <string name="starred_sync_dialog_summary">Yıldızlı parçaların indirilmesi yüksek miktarda veri gerektirebilir.</string>
<string name="starred_sync_dialog_title">Yıldızlı parçaları eşitle</string> <string name="starred_sync_dialog_title">Yıldızlı parçaları eşitle</string>
<string name="starred_album_sync_dialog_summary">Yıldızlı albümleri indirmek yüksek miktarda veri kullanımı gerektirebilir.</string>
<string name="starred_album_sync_dialog_title">Yıldızlı albümleri senkronize et</string>
<string name="streaming_cache_storage_dialog_sub_summary">Değişikliklerin geçerli olması için uygulamayı yeniden başlatın.</string> <string name="streaming_cache_storage_dialog_sub_summary">Değişikliklerin geçerli olması için uygulamayı yeniden başlatın.</string>
<string name="streaming_cache_storage_dialog_summary">Önbelleğe alınmış dosyaların hedefini bir depolamadan diğerine değiştirmek, önceki depolamadaki önbellek dosyalarının silinmesine yol açabilir.</string> <string name="streaming_cache_storage_dialog_summary">Önbelleğe alınmış dosyaların hedefini bir depolamadan diğerine değiştirmek, önceki depolamadaki önbellek dosyalarının silinmesine yol açabilir.</string>
<string name="streaming_cache_storage_dialog_title">Depolama seçeneğini seç</string> <string name="streaming_cache_storage_dialog_title">Depolama seçeneğini seç</string>
@ -433,4 +441,14 @@
<string name="undraw_page">unDraw</string> <string name="undraw_page">unDraw</string>
<string name="undraw_thanks">İllüstrasyonlarıyla bu uygulamayı daha güzel hale getirmemize yardımcı olan unDrawa özel teşekkürler.</string> <string name="undraw_thanks">İllüstrasyonlarıyla bu uygulamayı daha güzel hale getirmemize yardımcı olan unDrawa özel teşekkürler.</string>
<string name="undraw_url">https://undraw.co/</string> <string name="undraw_url">https://undraw.co/</string>
<string name="home_sync_starred_albums_title">Yıldızlı Albümleri Senkronize Et</string>
<string name="widget_label">Tempo Widget</string>
<string name="widget_not_playing">Şu an oynatılmıyor</string>
<string name="widget_placeholder_subtitle">Tempoyu aç</string>
<string name="widget_content_desc_album_art">Albüm kapağı</string>
<string name="widget_content_desc_play_pause">Çal/Duraklat</string>
<string name="widget_content_desc_next">Sonraki parça</string>
<string name="widget_content_desc_prev">Önceki parça</string>
<string name="settings_song_rating">Şarkının yıldız derecelendirmesini göster</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">"Etkinleştirildiğinde yıldızlı albümler çevrimdışı kullanım için indirilecek. "</string>
</resources> </resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Light theme: bright card with dark content -->
<color name="widget_bg">#CCFFFFFF</color>
<color name="widget_title">#DE000000</color>
<color name="widget_subtitle">#99000000</color>
<color name="widget_icon_tint">#DE000000</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="widget_large_min_height_dp">100</integer>
</resources>

View file

@ -439,6 +439,13 @@
<string name="undraw_page">unDraw</string> <string name="undraw_page">unDraw</string>
<string name="undraw_thanks">A special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful.</string> <string name="undraw_thanks">A special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful.</string>
<string name="undraw_url">https://undraw.co/</string> <string name="undraw_url">https://undraw.co/</string>
<string name="widget_label">Tempo Widget</string>
<string name="widget_not_playing">Not playing</string>
<string name="widget_placeholder_subtitle">Open Tempo</string>
<string name="widget_content_desc_album_art">Album artwork</string>
<string name="widget_content_desc_play_pause">Play or pause</string>
<string name="widget_content_desc_next">Next track</string>
<string name="widget_content_desc_prev">Previous track</string>
<plurals name="home_sync_starred_albums_count"> <plurals name="home_sync_starred_albums_count">
<item quantity="one">%d album to sync</item> <item quantity="one">%d album to sync</item>
<item quantity="other">%d albums to sync</item> <item quantity="other">%d albums to sync</item>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="64dp"
android:updatePeriodMillis="0"
android:resizeMode="horizontal|vertical"
android:initialLayout="@layout/widget_layout_compact"
android:previewImage="@drawable/ic_splash_logo"
android:previewLayout="@layout/widget_preview_compact"
android:widgetCategory="home_screen|keyguard" />

View file

@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture 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) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem) MediaManager.setLastPlayedTimestamp(mediaItem)
} }
updateWidget()
} }
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
@ -279,6 +281,7 @@ class MediaService : MediaLibraryService() {
} else { } else {
MediaManager.scrobble(player.currentMediaItem, false) MediaManager.scrobble(player.currentMediaItem, false)
} }
updateWidget()
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -290,6 +293,7 @@ class MediaService : MediaLibraryService() {
MediaManager.scrobble(player.currentMediaItem, true) MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem) MediaManager.saveChronology(player.currentMediaItem)
} }
updateWidget()
} }
override fun onPositionDiscontinuity( override fun onPositionDiscontinuity(
@ -383,5 +387,25 @@ class MediaService : MediaLibraryService() {
.build() .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) private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory() =
DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
} }

View file

@ -25,6 +25,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil 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.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability 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) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem) MediaManager.setLastPlayedTimestamp(mediaItem)
} }
updateWidget()
} }
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
@ -180,6 +182,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
} else { } else {
MediaManager.scrobble(player.currentMediaItem, false) MediaManager.scrobble(player.currentMediaItem, false)
} }
updateWidget()
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -192,6 +195,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
MediaManager.scrobble(player.currentMediaItem, true) MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem) MediaManager.saveChronology(player.currentMediaItem)
} }
updateWidget()
} }
override fun onPositionDiscontinuity( 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 { private fun initializeLoadControl(): DefaultLoadControl {
return DefaultLoadControl.Builder() return DefaultLoadControl.Builder()
.setBufferDurationsMs( .setBufferDurationsMs(

View file

@ -25,6 +25,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil 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.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability 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) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem) MediaManager.setLastPlayedTimestamp(mediaItem)
} }
updateWidget()
} }
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
@ -180,6 +182,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
} else { } else {
MediaManager.scrobble(player.currentMediaItem, false) MediaManager.scrobble(player.currentMediaItem, false)
} }
updateWidget()
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -192,6 +195,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
MediaManager.scrobble(player.currentMediaItem, true) MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem) MediaManager.saveChronology(player.currentMediaItem)
} }
updateWidget()
} }
override fun onPositionDiscontinuity( 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 { private fun initializeLoadControl(): DefaultLoadControl {
return DefaultLoadControl.Builder() return DefaultLoadControl.Builder()
.setBufferDurationsMs( .setBufferDurationsMs(