From e18abbc856d20b865b576bfc4ba64a5cc3388f3c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 28 Nov 2022 11:45:34 +0000 Subject: [PATCH] Update media3 to v1.0.0-beta03 --- .../org/moire/ultrasonic/domain/Track.kt | 8 +- .../VersionAwareJacksonConverterFactory.kt | 2 +- .../api/subsonic/response/SubsonicResponse.kt | 4 +- gradle/libs.versions.toml | 2 +- ultrasonic/minify/proguard-kotlin-reflect.pro | 6 +- .../DefaultMediaNotificationProvider2.java | 639 ++++++++++++++++++ .../ultrasonic/adapters/ArtistRowBinder.kt | 6 +- .../ultrasonic/model/ServerSettingsModel.kt | 2 +- .../ultrasonic/model/TrackCollectionModel.kt | 10 +- ...vider.kt => CustomNotificationProvider.kt} | 17 +- .../ultrasonic/playback/PlaybackService.kt | 4 +- .../ultrasonic/service/JukeboxMediaPlayer.kt | 11 +- .../ultrasonic/service/PlaylistTimeline.kt | 2 +- .../org/moire/ultrasonic/util/Settings.kt | 2 +- ultrasonic/src/main/res/layout/primary.xml | 1 - .../src/main/res/values-zh-rTW/strings.xml | 2 +- ultrasonic/src/main/res/values/themes.xml | 2 +- 17 files changed, 680 insertions(+), 40 deletions(-) create mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/{MediaNotificationProvider.kt => CustomNotificationProvider.kt} (87%) diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Track.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Track.kt index c0e6afd8..6236a074 100644 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Track.kt +++ b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/Track.kt @@ -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 } } } diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/VersionAwareJacksonConverterFactory.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/VersionAwareJacksonConverterFactory.kt index 7863d7a1..b5c73e64 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/VersionAwareJacksonConverterFactory.kt +++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/VersionAwareJacksonConverterFactory.kt @@ -35,7 +35,7 @@ class VersionAwareJacksonConverterFactory( type: Type, annotations: Array, retrofit: Retrofit - ): Converter? { + ): Converter { val javaType: JavaType = mapper!!.typeFactory.constructType(type) val reader: ObjectReader? = mapper!!.readerFor(javaType) return VersionAwareResponseBodyConverter(notifier, reader!!) diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/SubsonicResponse.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/SubsonicResponse.kt index 041955fe..866b8e0f 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/SubsonicResponse.kt +++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/SubsonicResponse.kt @@ -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() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bd6c005..7fefc18f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/ultrasonic/minify/proguard-kotlin-reflect.pro b/ultrasonic/minify/proguard-kotlin-reflect.pro index b41d2485..1edad85d 100644 --- a/ultrasonic/minify/proguard-kotlin-reflect.pro +++ b/ultrasonic/minify/proguard-kotlin-reflect.pro @@ -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 ; } -keep class org.jetbrains.kotlin.** { *; } -keep class org.jetbrains.annotations.** { *; } --keepclassmembers class ** { - @org.jetbrains.annotations.ReadOnly public *; -} + diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java new file mode 100644 index 00000000..405d4fb2 --- /dev/null +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DefaultMediaNotificationProvider2.java @@ -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}. + * + *

Actions

+ * + * The following actions are included in the provided notifications: + * + *
    + *
  • {@link MediaController#COMMAND_PLAY_PAUSE} to start or pause playback. + *
  • {@link MediaController#COMMAND_SEEK_TO_PREVIOUS} to seek to the previous item. + *
  • {@link MediaController#COMMAND_SEEK_TO_NEXT} to seek to the next item. + *
+ * + *

Custom commands

+ * + * 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. + * + *

Drawables

+ * + * The drawables used can be overridden by drawables with the same names defined the application. + * The drawables are: + * + *
    + *
  • {@code media3_notification_play} - The play icon. + *
  • {@code media3_notification_pause} - The pause icon. + *
  • {@code media3_notification_seek_to_previous} - The previous icon. + *
  • {@code media3_notification_seek_to_next} - The next icon. + *
  • {@code media3_notification_small_icon} - The {@link + * NotificationCompat.Builder#setSmallIcon(int) small icon}. A different icon can be set with + * {@link #setSmallIcon(int)}. + *
+ * + *

String resources

+ * + * String resources used can be overridden by resources with the same names defined the application. + * These are: + * + *
    + *
  • {@code media3_controls_play_description} - The description of the play icon. + *
  • {@code media3_controls_pause_description} - The description of the pause icon. + *
  • {@code media3_controls_seek_to_previous_description} - The description of the + * previous icon. + *
  • {@code media3_controls_seek_to_next_description} - The description of the next icon. + *
  • {@code default_notification_channel_name} 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)}. + *
+ */ +@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}. + * + *

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}. + * + *

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 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 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. + * + *

This method is called each time a new notification is built. + * + *

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()}. + * + *

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}. + * + *

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 getMediaButtons( + Player.Commands playerCommands, List customLayout, boolean showPauseButton) { + // Skip to previous action. + List 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. + * + *

The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons( + * Player.Commands, List, boolean)}. + * + *

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. + * + *

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 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. + * + *

This method is called each time a new notification is built. + * + *

Override this method to customize which field of {@link MediaMetadata} is used for content + * title of the notification. + * + *

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. + * + *

This method is called each time a new notification is built. + * + *

Override this method to customize which field of {@link MediaMetadata} is used for content + * text of the notification. + * + *

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 { + 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); + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt index a0b77260..2013dfeb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt @@ -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 ?: "") diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt index 182ecb66..54100ee4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/ServerSettingsModel.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt index 140d9a65..8c2e65f9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt @@ -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, - 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, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt similarity index 87% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt index 88dd6946..066e4c07 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CustomNotificationProvider.kt @@ -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 } -} +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index bbe91de7..1635b18f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index f43297db..4cc9f635 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -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 = mutableListOf() private val playlist: MutableList = 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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt index fb32dd84..387367d3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index e6c0ca23..d98379ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -289,5 +289,5 @@ object Settings { private val appContext: Context get() = UApp.applicationContext() - val COLON_PATTERN = Pattern.compile(":") + val COLON_PATTERN: Pattern = Pattern.compile(":") } diff --git a/ultrasonic/src/main/res/layout/primary.xml b/ultrasonic/src/main/res/layout/primary.xml index 69dc85a3..af450a28 100644 --- a/ultrasonic/src/main/res/layout/primary.xml +++ b/ultrasonic/src/main/res/layout/primary.xml @@ -1,6 +1,5 @@ - + 載入中… 書籤 diff --git a/ultrasonic/src/main/res/values/themes.xml b/ultrasonic/src/main/res/values/themes.xml index 0fb42d58..bb470b01 100644 --- a/ultrasonic/src/main/res/values/themes.xml +++ b/ultrasonic/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ - +