mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-24 12:50:58 +03:00
Merge branch 'renovate/media3' into 'develop'
Update media3 to v1.0.0-beta03 See merge request ultrasonic/ultrasonic!878
This commit is contained in:
commit
1467a38370
core
domain/src/main/kotlin/org/moire/ultrasonic/domain
subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic
gradle
ultrasonic
minify
src/main
java/org/moire/ultrasonic/service
kotlin/org/moire/ultrasonic
adapters
model
playback
service
util
res
@ -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 *;
|
||||
}
|
||||
|
||||
|
639
ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java
Normal file
639
ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java
Normal file
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user