Merge branch 'renovate/media3' into 'develop'

Update media3 to v1.0.0-beta03

See merge request 
This commit is contained in:
birdbird 2022-11-28 11:45:34 +00:00
commit 1467a38370
17 changed files with 680 additions and 40 deletions
core
domain/src/main/kotlin/org/moire/ultrasonic/domain
subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic
gradle
ultrasonic

@ -57,15 +57,15 @@ data class Track(
}
fun compareTo(other: Track): Int {
when {
return when {
this.closeness == other.closeness -> {
return 0
0
}
this.closeness > other.closeness -> {
return -1
-1
}
else -> {
return 1
1
}
}
}

@ -35,7 +35,7 @@ class VersionAwareJacksonConverterFactory(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
): Converter<ResponseBody, *> {
val javaType: JavaType = mapper!!.typeFactory.constructType(type)
val reader: ObjectReader? = mapper!!.readerFor(javaType)
return VersionAwareResponseBodyConverter<Any>(notifier, reader!!)

@ -23,8 +23,8 @@ open class SubsonicResponse(
OK("ok"), ERROR("failed");
companion object {
fun getStatusFromJson(jsonValue: String) = values()
.filter { it.jsonValue == jsonValue }.firstOrNull()
fun getStatusFromJson(jsonValue: String) =
values().firstOrNull { it.jsonValue == jsonValue }
?: throw IllegalArgumentException("Unknown status value: $jsonValue")
class StatusJsonDeserializer : JsonDeserializer<Status>() {

@ -9,7 +9,7 @@ ktlint = "0.43.2"
ktlintGradle = "11.0.0"
detekt = "1.22.0"
preferences = "1.2.0"
media3 = "1.0.0-beta02"
media3 = "1.0.0-beta03"
androidSupport = "1.5.0"
materialDesign = "1.6.1"

@ -1,12 +1,10 @@
-keep class kotlin.Metadata { *; }
-keep class kotlin.reflect.** { *; }
-keepclassmembers public class com.company[obfuscated].domain.api.models.** {
-keepclassmembers public class org.ultrasonic.domain.api.models.** {
public synthetic <methods>;
}
-keep class org.jetbrains.kotlin.** { *; }
-keep class org.jetbrains.annotations.** { *; }
-keepclassmembers class ** {
@org.jetbrains.annotations.ReadOnly public *;
}

@ -0,0 +1,639 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package org.moire.ultrasonic.service;
import static androidx.media3.common.C.INDEX_UNSET;
import static androidx.media3.common.Player.COMMAND_INVALID;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_STOP;
import static androidx.media3.common.Player.STATE_ENDED;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.DoNotInline;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.C;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.session.CommandButton;
import androidx.media3.session.MediaController;
import androidx.media3.session.MediaNotification;
import androidx.media3.session.MediaSession;
import androidx.media3.session.MediaStyleNotificationHelper;
import androidx.media3.session.SessionCommand;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.moire.ultrasonic.R;
/**
* The default {@link MediaNotification.Provider}.
*
* <h2>Actions</h2>
*
* The following actions are included in the provided notifications:
*
* <ul>
* <li>{@link MediaController#COMMAND_PLAY_PAUSE} to start or pause playback.
* <li>{@link MediaController#COMMAND_SEEK_TO_PREVIOUS} to seek to the previous item.
* <li>{@link MediaController#COMMAND_SEEK_TO_NEXT} to seek to the next item.
* </ul>
*
* <h2>Custom commands</h2>
*
* Custom actions are sent to the session under the hood. You can receive them by overriding the
* session callback method {@link MediaSession.Callback#onCustomCommand(MediaSession,
* MediaSession.ControllerInfo, SessionCommand, Bundle)}. This is useful because starting with
* Android 13, the System UI notification sends commands directly to the session. So handling the
* custom commands on the session level allows you to handle them at the same callback for all API
* levels.
*
* <h2>Drawables</h2>
*
* The drawables used can be overridden by drawables with the same names defined the application.
* The drawables are:
*
* <ul>
* <li><b>{@code media3_notification_play}</b> - The play icon.
* <li><b>{@code media3_notification_pause}</b> - The pause icon.
* <li><b>{@code media3_notification_seek_to_previous}</b> - The previous icon.
* <li><b>{@code media3_notification_seek_to_next}</b> - The next icon.
* <li><b>{@code media3_notification_small_icon}</b> - The {@link
* NotificationCompat.Builder#setSmallIcon(int) small icon}. A different icon can be set with
* {@link #setSmallIcon(int)}.
* </ul>
*
* <h2>String resources</h2>
*
* String resources used can be overridden by resources with the same names defined the application.
* These are:
*
* <ul>
* <li><b>{@code media3_controls_play_description}</b> - The description of the play icon.
* <li><b>{@code media3_controls_pause_description}</b> - The description of the pause icon.
* <li><b>{@code media3_controls_seek_to_previous_description}</b> - The description of the
* previous icon.
* <li><b>{@code media3_controls_seek_to_next_description}</b> - The description of the next icon.
* <li><b>{@code default_notification_channel_name}</b> The name of the {@link
* NotificationChannel} on which created notifications are posted. A different string resource
* can be set when constructing the provider with {@link
* DefaultMediaNotificationProvider2.Builder#setChannelName(int)}.
* </ul>
*/
@UnstableApi
public class DefaultMediaNotificationProvider2 implements MediaNotification.Provider {
/** A builder for {@link DefaultMediaNotificationProvider2} instances. */
public static final class Builder {
private final Context context;
private NotificationIdProvider notificationIdProvider;
private String channelId;
@StringRes private int channelNameResourceId;
private boolean built;
/**
* Creates a builder.
*
* @param context Any {@link Context}.
*/
public Builder(Context context) {
this.context = context;
notificationIdProvider = session -> DEFAULT_NOTIFICATION_ID;
channelId = DEFAULT_CHANNEL_ID;
channelNameResourceId = DEFAULT_CHANNEL_NAME_RESOURCE_ID;
}
/**
* Sets the {@link MediaNotification#notificationId} used for the created notifications. By
* default, this is set to {@link #DEFAULT_NOTIFICATION_ID}.
*
* <p>Overwrites anything set in {@link #setNotificationIdProvider(NotificationIdProvider)}.
*
* @param notificationId The notification ID.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setNotificationId(int notificationId) {
this.notificationIdProvider = session -> notificationId;
return this;
}
/**
* Sets the provider for the {@link MediaNotification#notificationId} used for the created
* notifications. By default, this is set to a provider that always returns {@link
* #DEFAULT_NOTIFICATION_ID}.
*
* <p>Overwrites anything set in {@link #setNotificationId(int)}.
*
* @param notificationIdProvider The notification ID provider.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setNotificationIdProvider(NotificationIdProvider notificationIdProvider) {
this.notificationIdProvider = notificationIdProvider;
return this;
}
/**
* Sets the ID of the {@link NotificationChannel} on which created notifications are posted on.
* By default, this is set to {@link #DEFAULT_CHANNEL_ID}.
*
* @param channelId The channel ID.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setChannelId(String channelId) {
this.channelId = channelId;
return this;
}
/**
* Sets the name of the {@link NotificationChannel} on which created notifications are posted
* on. By default, this is set to {@link #DEFAULT_CHANNEL_NAME_RESOURCE_ID}.
*
* @param channelNameResourceId The string resource ID with the channel name.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setChannelName(@StringRes int channelNameResourceId) {
this.channelNameResourceId = channelNameResourceId;
return this;
}
/**
* Builds the {@link DefaultMediaNotificationProvider2}. The method can be called at most once.
*/
public DefaultMediaNotificationProvider2 build() {
checkState(!built);
DefaultMediaNotificationProvider2 provider = new DefaultMediaNotificationProvider2(this);
built = true;
return provider;
}
}
/**
* Provides notification IDs for posting media notifications for given media sessions.
*
* @see Builder#setNotificationIdProvider(NotificationIdProvider)
*/
public interface NotificationIdProvider {
/** Returns the notification ID for the media notification of the given session. */
int getNotificationId(MediaSession mediaSession);
}
/**
* An extras key that can be used to define the index of a {@link CommandButton} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}.
*/
public static final String COMMAND_KEY_COMPACT_VIEW_INDEX =
"androidx.media3.session.command.COMPACT_VIEW_INDEX";
/** The default ID used for the {@link MediaNotification#notificationId}. */
public static final int DEFAULT_NOTIFICATION_ID = 1001;
/**
* The default ID used for the {@link NotificationChannel} on which created notifications are
* posted on.
*/
public static final String DEFAULT_CHANNEL_ID = "default_channel_id";
/**
* The default name used for the {@link NotificationChannel} on which created notifications are
* posted on.
*/
@StringRes
public static final int DEFAULT_CHANNEL_NAME_RESOURCE_ID =
R.string.default_notification_channel_name;
private static final String TAG = "NotificationProvider";
private final Context context;
private final NotificationIdProvider notificationIdProvider;
private final String channelId;
@StringRes private final int channelNameResourceId;
private final NotificationManager notificationManager;
// Cache the last bitmap load request to avoid reloading the bitmap again, particularly useful
// when showing a notification for the same item (e.g. when switching from playing to paused).
private final Handler mainHandler;
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
@DrawableRes private int smallIconResourceId;
public DefaultMediaNotificationProvider2(Builder builder) {
this.context = builder.context;
this.notificationIdProvider = builder.notificationIdProvider;
this.channelId = builder.channelId;
this.channelNameResourceId = builder.channelNameResourceId;
notificationManager =
checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
mainHandler = new Handler(Looper.getMainLooper());
smallIconResourceId = R.drawable.media3_notification_small_icon;
}
// MediaNotification.Provider implementation
@Override
public final MediaNotification createNotification(
MediaSession mediaSession,
ImmutableList<CommandButton> customLayout,
MediaNotification.ActionFactory actionFactory,
Callback onNotificationChangedCallback) {
ensureNotificationChannel();
Player player = mediaSession.getPlayer();
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);
int notificationId = notificationIdProvider.getNotificationId(mediaSession);
MediaStyleNotificationHelper.MediaStyle mediaStyle = new MediaStyleNotificationHelper.MediaStyle(mediaSession);
int[] compactViewIndices =
addNotificationActions(
mediaSession,
getMediaButtons(
player.getAvailableCommands(),
customLayout,
/* showPauseButton= */ player.getPlayWhenReady()
&& player.getPlaybackState() != STATE_ENDED),
builder,
actionFactory);
mediaStyle.setShowActionsInCompactView(compactViewIndices);
// Set metadata info in the notification.
MediaMetadata metadata = player.getMediaMetadata();
builder
.setContentTitle(getNotificationContentTitle(metadata))
.setContentText(getNotificationContentText(metadata));
@Nullable
ListenableFuture<Bitmap> bitmapFuture =
mediaSession.getBitmapLoader().loadBitmapFromMetadata(metadata);
if (bitmapFuture != null) {
if (pendingOnBitmapLoadedFutureCallback != null) {
pendingOnBitmapLoadedFutureCallback.discardIfPending();
}
if (bitmapFuture.isDone()) {
try {
builder.setLargeIcon(Futures.getDone(bitmapFuture));
} catch (ExecutionException e) {
Log.w(TAG, "Failed to load bitmap", e);
}
} else {
pendingOnBitmapLoadedFutureCallback =
new OnBitmapLoadedFutureCallback(
notificationId, builder, onNotificationChangedCallback);
Futures.addCallback(
bitmapFuture,
pendingOnBitmapLoadedFutureCallback,
// This callback must be executed on the next looper iteration, after this method has
// returned a media notification.
mainHandler::post);
}
}
if (player.isCommandAvailable(COMMAND_STOP) || Util.SDK_INT < 21) {
// We must include a cancel intent for pre-L devices.
mediaStyle.setCancelButtonIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP));
}
long playbackStartTimeMs = getPlaybackStartTimeEpochMs(player);
boolean displayElapsedTimeWithChronometer = playbackStartTimeMs != C.TIME_UNSET;
builder
.setWhen(playbackStartTimeMs)
.setShowWhen(displayElapsedTimeWithChronometer)
.setUsesChronometer(displayElapsedTimeWithChronometer);
Notification notification =
builder
.setContentIntent(mediaSession.getSessionActivity())
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP))
.setOnlyAlertOnce(true)
.setSmallIcon(smallIconResourceId)
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(false)
.build();
return new MediaNotification(notificationId, notification);
}
@Override
public final boolean handleCustomCommand(MediaSession session, String action, Bundle extras) {
// Make the custom action being delegated to the session as a custom session command.
return false;
}
// Other methods
/**
* Sets the small icon of the notification which is also shown in the system status bar.
*
* @see NotificationCompat.Builder#setSmallIcon(int)
* @param smallIconResourceId The resource id of the small icon.
*/
public final void setSmallIcon(@DrawableRes int smallIconResourceId) {
this.smallIconResourceId = smallIconResourceId;
}
/**
* Returns the ordered list of {@linkplain CommandButton command buttons} to be used to build the
* notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize the buttons on the notification. Commands of the buttons
* returned by this method must be contained in {@link MediaController#getAvailableCommands()}.
*
* <p>By default, the notification shows {@link Player#COMMAND_PLAY_PAUSE} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}. This can be
* customized by defining the index of the command in compact view of up to 3 commands in their
* extras with key {@link DefaultMediaNotificationProvider2#COMMAND_KEY_COMPACT_VIEW_INDEX}.
*
* <p>To make the custom layout and commands work, you need to {@linkplain
* MediaSession#setCustomLayout(List) set the custom layout of commands} and add the custom
* commands to the available commands when a controller {@linkplain
* MediaSession.Callback#onConnect(MediaSession, MediaSession.ControllerInfo) connects to the
* session}. Controllers that connect after you called {@link MediaSession#setCustomLayout(List)}
* need the custom command set in {@link MediaSession.Callback#onPostConnect(MediaSession,
* MediaSession.ControllerInfo)} also.
*
* @param playerCommands The available player commands.
* @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of
* commands}.
* @param showPauseButton Whether the notification should show a pause button (e.g., because the
* player is currently playing content), otherwise show a play button to start playback.
* @return The ordered list of command buttons to be placed on the notification.
*/
protected List<CommandButton> getMediaButtons(
Player.Commands playerCommands, List<CommandButton> customLayout, boolean showPauseButton) {
// Skip to previous action.
List<CommandButton> commandButtons = new ArrayList<>();
if (playerCommands.containsAny(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.setIconResId(R.drawable.media3_notification_seek_to_previous)
.setDisplayName(
context.getString(R.string.media3_controls_seek_to_previous_description))
.setExtras(commandButtonExtras)
.build());
}
if (playerCommands.contains(COMMAND_PLAY_PAUSE)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_PLAY_PAUSE)
.setIconResId(
showPauseButton
? R.drawable.media3_notification_pause
: R.drawable.media3_notification_play)
.setExtras(commandButtonExtras)
.setDisplayName(
showPauseButton
? context.getString(R.string.media3_controls_pause_description)
: context.getString(R.string.media3_controls_play_description))
.build());
}
// Skip to next action.
if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.setIconResId(R.drawable.media3_notification_seek_to_next)
.setExtras(commandButtonExtras)
.setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
.build());
}
for (int i = 0; i < customLayout.size(); i++) {
CommandButton button = customLayout.get(i);
if (button.sessionCommand != null
&& button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) {
commandButtons.add(button);
}
}
return commandButtons;
}
/**
* Adds the media buttons to the notification builder for the given action factory.
*
* <p>The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons(
* Player.Commands, List, boolean)}.
*
* <p>Override this method to customize how the media buttons {@linkplain
* NotificationCompat.Builder#addAction(NotificationCompat.Action) are added} to the notification
* and define which actions are shown in compact view by returning the indices of the buttons to
* be shown in compact view.
*
* <p>By default, {@link Player#COMMAND_PLAY_PAUSE} is shown in compact view, unless some of the
* buttons are marked with {@link DefaultMediaNotificationProvider2#COMMAND_KEY_COMPACT_VIEW_INDEX}
* to declare the index in compact view of the given command button in the button extras.
*
* @param mediaSession The media session to which the actions will be sent.
* @param mediaButtons The command buttons to be included in the notification.
* @param builder The builder to add the actions to.
* @param actionFactory The actions factory to be used to build notifications.
* @return The indices of the buttons to be {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) used in compact view of the
* notification}.
*/
protected int[] addNotificationActions(
MediaSession mediaSession,
List<CommandButton> mediaButtons,
NotificationCompat.Builder builder,
MediaNotification.ActionFactory actionFactory) {
int[] compactViewIndices = new int[3];
Arrays.fill(compactViewIndices, INDEX_UNSET);
int compactViewCommandCount = 0;
for (int i = 0; i < mediaButtons.size(); i++) {
CommandButton commandButton = mediaButtons.get(i);
if (commandButton.sessionCommand != null) {
builder.addAction(
actionFactory.createCustomActionFromCustomCommandButton(mediaSession, commandButton));
} else {
checkState(commandButton.playerCommand != COMMAND_INVALID);
builder.addAction(
actionFactory.createMediaAction(
mediaSession,
IconCompat.createWithResource(context, commandButton.iconResId),
commandButton.displayName,
commandButton.playerCommand));
}
if (compactViewCommandCount == 3) {
continue;
}
int compactViewIndex =
commandButton.extras.getInt(
COMMAND_KEY_COMPACT_VIEW_INDEX, /* defaultValue= */ INDEX_UNSET);
if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.length) {
compactViewCommandCount++;
compactViewIndices[compactViewIndex] = i;
} else if (commandButton.playerCommand == COMMAND_PLAY_PAUSE
&& compactViewCommandCount == 0) {
// If there is no custom configuration we use the play/pause action in compact view.
compactViewIndices[0] = i;
}
}
for (int i = 0; i < compactViewIndices.length; i++) {
if (compactViewIndices[i] == INDEX_UNSET) {
compactViewIndices = Arrays.copyOf(compactViewIndices, i);
break;
}
}
return compactViewIndices;
}
/**
* Returns the content title to be used to build the notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize which field of {@link MediaMetadata} is used for content
* title of the notification.
*
* <p>By default, the notification shows {@link MediaMetadata#title} as content title.
*
* @param metadata The media metadata from which content title is fetched.
* @return Notification content title.
*/
@Nullable
protected CharSequence getNotificationContentTitle(MediaMetadata metadata) {
return metadata.title;
}
/**
* Returns the content text to be used to build the notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize which field of {@link MediaMetadata} is used for content
* text of the notification.
*
* <p>By default, the notification shows {@link MediaMetadata#artist} as content text.
*
* @param metadata The media metadata from which content text is fetched.
* @return Notification content text.
*/
@Nullable
protected CharSequence getNotificationContentText(MediaMetadata metadata) {
return metadata.artist;
}
private void ensureNotificationChannel() {
if (Util.SDK_INT < 26 || notificationManager.getNotificationChannel(channelId) != null) {
return;
}
Api26.createNotificationChannel(
notificationManager, channelId, context.getString(channelNameResourceId));
}
private static long getPlaybackStartTimeEpochMs(Player player) {
// Changing "showWhen" causes notification flicker if SDK_INT < 21.
if (Util.SDK_INT >= 21
&& player.isPlaying()
&& !player.isPlayingAd()
&& !player.isCurrentMediaItemDynamic()
&& player.getPlaybackParameters().speed == 1f) {
return System.currentTimeMillis() - player.getContentPosition();
} else {
return C.TIME_UNSET;
}
}
private static class OnBitmapLoadedFutureCallback implements FutureCallback<Bitmap> {
private final int notificationId;
private final NotificationCompat.Builder builder;
private final Callback onNotificationChangedCallback;
private boolean discarded;
public OnBitmapLoadedFutureCallback(
int notificationId,
NotificationCompat.Builder builder,
Callback onNotificationChangedCallback) {
this.notificationId = notificationId;
this.builder = builder;
this.onNotificationChangedCallback = onNotificationChangedCallback;
}
public void discardIfPending() {
discarded = true;
}
@Override
public void onSuccess(Bitmap result) {
if (!discarded) {
builder.setLargeIcon(result);
onNotificationChangedCallback.onNotificationChanged(
new MediaNotification(notificationId, builder.build()));
}
}
@Override
public void onFailure(Throwable t) {
if (!discarded) {
Log.d(TAG, "Failed to load bitmap", t);
}
}
}
@RequiresApi(26)
private static class Api26 {
@DoNotInline
public static void createNotificationChannel(
NotificationManager notificationManager, String channelId, String channelName) {
NotificationChannel channel =
new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW);
if (Util.SDK_INT <= 27) {
// API 28+ will automatically hide the app icon 'badge' for notifications using
// Notification.MediaStyle, but we have to manually hide it for APIs 26 (when badges were
// added) and 27.
channel.setShowBadge(false);
}
notificationManager.createNotificationChannel(channel);
}
}
}

@ -92,10 +92,10 @@ class ArtistRowBinder(
val previousItem = adapter.items[index - 1]
val previousSectionKey: String
if (previousItem is ArtistOrIndex) {
previousSectionKey = getSectionFromName(previousItem.name ?: " ")
previousSectionKey = if (previousItem is ArtistOrIndex) {
getSectionFromName(previousItem.name ?: " ")
} else {
previousSectionKey = " "
" "
}
val currentSectionKey = getSectionFromName(item.name ?: "")

@ -77,7 +77,7 @@ class ServerSettingsModel(
*/
fun moveItemDown(index: Int) {
viewModelScope.launch {
if (index < repository.getMaxIndex() ?: 0) {
if (index < (repository.getMaxIndex() ?: 0)) {
val itemToBeMoved = repository.findByIndex(index)
val nextItem = repository.findByIndex(index + 1)

@ -71,10 +71,10 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
val service = MusicServiceFactory.getMusicService()
val musicDirectory: MusicDirectory
if (Settings.shouldUseId3Tags) {
musicDirectory = Util.getSongsFromSearchResult(service.getStarred2())
musicDirectory = if (Settings.shouldUseId3Tags) {
Util.getSongsFromSearchResult(service.getStarred2())
} else {
musicDirectory = Util.getSongsFromSearchResult(service.getStarred())
Util.getSongsFromSearchResult(service.getStarred())
}
updateList(musicDirectory)
@ -171,7 +171,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
@Synchronized
fun calculateButtonState(
selection: List<Track>,
onComplete: (TrackCollectionModel.Companion.ButtonStates) -> Unit
onComplete: (ButtonStates) -> Unit
) {
val enabled = selection.isNotEmpty()
var unpinEnabled = false
@ -200,7 +200,7 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat
val pinEnabled = selection.size > pinnedCount
onComplete(
TrackCollectionModel.Companion.ButtonStates(
ButtonStates(
all = enabled,
pin = pinEnabled,
unpin = unpinEnabled,

@ -1,32 +1,29 @@
/*
* MediaNotificationProvider.kt
* CustomNotificationProvider.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.playback
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.media3.common.HeartRating
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.Player
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.service.DefaultMediaNotificationProvider2
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.toTrack
@SuppressLint("UnsafeOptInUsageError")
class MediaNotificationProvider(context: Context) :
DefaultMediaNotificationProvider(context, ArtworkBitmapLoader()), KoinComponent {
@UnstableApi
class CustomNotificationProvider(ctx: Context?) : DefaultMediaNotificationProvider2(Builder(ctx)), KoinComponent {
/*
* It is currently not possible to edit a MediaItem after creation so the isRated value
@ -88,9 +85,9 @@ class MediaNotificationProvider(context: Context) :
val commands = super.getMediaButtons(playerCommands, customLayout, playWhenReady)
commands.forEachIndexed { index, command ->
command.extras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, index)
command.extras.putInt(androidx.media3.session.DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, index)
}
return commands
}
}
}

@ -34,6 +34,7 @@ import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
@ -112,7 +113,7 @@ class PlaybackService :
private fun initializeSessionAndPlayer() {
if (isStarted) return
setMediaNotificationProvider(MediaNotificationProvider(UApp.applicationContext()))
setMediaNotificationProvider(CustomNotificationProvider(UApp.applicationContext()))
// Create a new plain OkHttpClient
val builder = OkHttpClient.Builder()
@ -156,6 +157,7 @@ class PlaybackService :
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getPendingIntentForContent())
.setBitmapLoader(ArtworkBitmapLoader())
.build()
// Set a listener to update the API client when the active server has changed

@ -39,6 +39,7 @@ import androidx.media3.common.Timeline
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.Size
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.ListenableFuture
@ -56,7 +57,7 @@ import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.playback.MediaNotificationProvider
import org.moire.ultrasonic.playback.CustomNotificationProvider
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Util.getPendingIntentToShowPlayer
import org.moire.ultrasonic.util.Util.sleepQuietly
@ -92,7 +93,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
private var listeners: MutableList<Player.Listener> = mutableListOf()
private val playlist: MutableList<MediaItem> = mutableListOf()
private var currentIndex: Int = 0
private val notificationProvider = MediaNotificationProvider(applicationContext())
private val notificationProvider = CustomNotificationProvider(applicationContext())
private lateinit var mediaSession: MediaSession
private lateinit var notificationManagerCompat: NotificationManagerCompat
@ -767,7 +768,7 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
}
override fun getCurrentCues(): CueGroup {
return CueGroup.EMPTY
return CueGroup.EMPTY_TIME_ZERO
}
override fun getAudioAttributes(): AudioAttributes {
@ -778,6 +779,10 @@ class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player {
return VideoSize(0, 0)
}
override fun getSurfaceSize(): Size {
return Size(0, 0)
}
override fun getContentBufferedPosition(): Long {
return bufferedPosition
}

@ -129,7 +129,7 @@ class PlaylistTimeline @JvmOverloads constructor(
init {
Assertions.checkState(mediaItems.size == shuffledIndices.size)
this.mediaItems = ImmutableList.copyOf(mediaItems)
this.shuffledIndices = Arrays.copyOf(shuffledIndices, shuffledIndices.size)
this.shuffledIndices = shuffledIndices.copyOf(shuffledIndices.size)
indicesInShuffled = IntArray(shuffledIndices.size)
for (i in shuffledIndices.indices) {
indicesInShuffled[shuffledIndices[i]] = i

@ -289,5 +289,5 @@ object Settings {
private val appContext: Context
get() = UApp.applicationContext()
val COLON_PATTERN = Pattern.compile(":")
val COLON_PATTERN: Pattern = Pattern.compile(":")
}

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
a:layout_width="match_parent"
a:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<string name="background_task.loading">載入中…</string>
<string name="button_bar.bookmarks">書籤</string>

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<style name="CleanSearchStyle" parent="Widget.AppCompat.SearchView">
<item name="queryBackground">@null</item>