Fix some crashes

This commit is contained in:
birdbird 2022-12-18 10:55:13 +00:00
parent 0fe41fe498
commit ff824d19aa
15 changed files with 70 additions and 723 deletions

View File

@ -11,6 +11,7 @@ buildscript {
google()
mavenCentral()
maven { url "https://plugins.gradle.org/m2/" }
maven { url 'https://jitpack.io' }
}
dependencies {
classpath libs.gradle

View File

@ -9,7 +9,7 @@ ktlint = "0.43.2"
ktlintGradle = "11.0.0"
detekt = "1.22.0"
preferences = "1.2.0"
media3 = "1.0.0-beta03"
media3 = "f3e450e783"
androidSupport = "1.5.0"
materialDesign = "1.6.1"
@ -64,9 +64,11 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"}
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3session = { module = "androidx.media3:media3-session", version.ref = "media3" }
media3common = { module = "com.github.androidx.media:media3-common", version.ref = "media3" }
media3exoplayer = { module = "com.github.androidx.media:media3-exoplayer", version.ref = "media3" }
media3datasource = { module = "com.github.androidx.media:media3-datasource", version.ref = "media3" }
media3okhttp = { module = "com.github.androidx.media:media3-datasource-okhttp", version.ref = "media3" }
media3session = { module = "com.github.androidx.media:media3-session", version.ref = "media3" }
swipeRefresh = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swipeRefresh" }
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }

View File

@ -104,8 +104,10 @@ dependencies {
implementation libs.viewModelKtx
implementation libs.constraintLayout
implementation libs.preferences
implementation libs.media3common
implementation libs.media3exoplayer
implementation libs.media3session
implementation libs.media3datasource
implementation libs.media3okhttp
implementation libs.swipeRefresh

View File

@ -1,639 +0,0 @@
/*
* 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);
}
}
}

View File

@ -6,16 +6,11 @@ import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.SearchRecentSuggestions
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.View
import androidx.annotation.StringRes
import androidx.preference.CheckBoxPreference
@ -89,34 +84,11 @@ class SettingsFragment :
customCacheLocation = findPreference(getString(R.string.setting_key_custom_cache_location))
cacheLocation = findPreference(getString(R.string.setting_key_cache_location))
setupTextColors()
setupClearSearchPreference()
setupCacheLocationPreference()
setupBluetoothDevicePreferences()
}
private fun setupTextColors(enabled: Boolean = shouldUseId3Tags) {
val firstPart = getString(R.string.settings_use_id3_offline_warning)
var secondPart = getString(R.string.settings_use_id3_offline_summary)
// Little hack to circumvent a bug in Android. If we just change the color,
// the text is not refreshed. If we also change the string, it is refreshed.
if (enabled) secondPart += " "
val color = if (enabled) "#bd5164" else "#813b48"
Timber.i(color)
val warning = SpannableString(firstPart + "\n" + secondPart)
warning.setSpan(
ForegroundColorSpan(Color.parseColor(color)), 0, firstPart.length, 0
)
warning.setSpan(
StyleSpan(Typeface.BOLD), 0, firstPart.length, 0
)
useId3TagsOffline?.summary = warning
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -205,7 +177,6 @@ class SettingsFragment :
val enabled = sharedPreferences.getBoolean(key, false)
showArtistPicture?.isEnabled = enabled
useId3TagsOffline?.isEnabled = enabled
setupTextColors(enabled)
}
getString(R.string.setting_key_theme) -> {
RxBus.themeChangedEventPublisher.onNext(Unit)

View File

@ -12,19 +12,20 @@ import androidx.media3.common.HeartRating
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
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.service.DefaultMediaNotificationProvider2
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.toTrack
@UnstableApi
class CustomNotificationProvider(ctx: Context?) :
DefaultMediaNotificationProvider2(Builder(ctx)), KoinComponent {
class CustomNotificationProvider(ctx: Context) :
DefaultMediaNotificationProvider(ctx),
KoinComponent {
/*
* It is currently not possible to edit a MediaItem after creation so the isRated value

View File

@ -23,7 +23,8 @@ import androidx.lifecycle.MutableLiveData
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import java.util.PriorityQueue
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.PriorityBlockingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -135,19 +136,19 @@ class DownloadService : Service(), KoinComponent {
var listChanged = false
// Fill up active List with waiting tasks
while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) {
while (activeDownloads.size < Settings.parallelDownloads && downloadQueue.peek() != null) {
// Use poll() instead of remove() which throws an Exception if there is no element.
val track: DownloadableTrack = downloadQueue.poll() ?: continue
val downloadTask = DownloadTask(track, scope!!, ::downloadStateChangedCallback)
activelyDownloading[track] = downloadTask
activeDownloads[track.id] = downloadTask
FileUtil.createDirectoryForParent(track.pinnedFile)
downloadTask.start()
listChanged = true
}
// Stop Executor service when done downloading
if (activelyDownloading.isEmpty()) {
if (activeDownloads.isEmpty()) {
CacheCleaner().cleanSpace()
stopSelf()
}
@ -175,14 +176,14 @@ class DownloadService : Service(), KoinComponent {
postState(item.track, downloadState, progress)
if (downloadState.isFinalState()) {
activelyDownloading.remove(item)
activeDownloads.remove(item.id)
processNextTracks()
}
when (downloadState) {
DownloadState.FAILED -> {
downloadQueue.remove(item)
failedList.add(item)
failedList[item.id] = item
}
DownloadState.RETRYING -> {
item.tryCount++
@ -194,7 +195,7 @@ class DownloadService : Service(), KoinComponent {
private fun updateLiveData() {
val temp: MutableList<Track> = ArrayList()
temp.addAll(activelyDownloading.keys.map { x -> x.track })
temp.addAll(activeDownloads.values.map { it.track.track })
temp.addAll(downloadQueue.map { x -> x.track })
observableDownloads.postValue(temp.distinct().sorted())
}
@ -205,10 +206,10 @@ class DownloadService : Service(), KoinComponent {
postState(downloadQueue.remove().track, DownloadState.IDLE)
}
// Cancel all active downloads
for (download in activelyDownloading) {
for (download in activeDownloads) {
download.value.cancel()
}
activelyDownloading.clear()
activeDownloads.clear()
updateLiveData()
}
@ -280,9 +281,9 @@ class DownloadService : Service(), KoinComponent {
private var startFuture: SettableFuture<DownloadService>? = null
private val downloadQueue = PriorityQueue<DownloadableTrack>()
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
private val failedList = mutableListOf<DownloadableTrack>()
private val downloadQueue = PriorityBlockingQueue<DownloadableTrack>()
private val activeDownloads = ConcurrentHashMap<String, DownloadTask>()
private val failedList = ConcurrentHashMap<String, DownloadableTrack>()
// The generic list models expect a LiveData, so even though we are using Rx for many events
// surrounding playback the list of Downloads is published as LiveData.
@ -319,17 +320,15 @@ class DownloadService : Service(), KoinComponent {
// Update Pinned flag of items in progress
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = save }
activelyDownloading.filter { item -> tracks.any { it.id == item.key.id } }
.forEach { it.key.pinned = save }
failedList.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = save }
tracks.forEach {
activeDownloads[it.id]?.track?.pinned = save
}
tracks.forEach {
failedList[it.id]?.pinned = save
}
filteredTracks = filteredTracks.filter {
!downloadQueue.any { t ->
t.track.id == it.id
} && !activelyDownloading.any { t ->
t.key.track.id == it.id
}
!downloadQueue.contains(it.id) && !activeDownloads.contains(it.id)
}
// The remainder tracks should be added to the download queue
@ -361,8 +360,8 @@ class DownloadService : Service(), KoinComponent {
fun delete(track: Track) {
downloadQueue.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) }
failedList.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) }
downloadQueue.get(track.id)?.let { downloadQueue.remove(it) }
failedList[track.id]?.let { downloadQueue.remove(it) }
cancelDownload(track)
Storage.delete(track.getPartialFile())
@ -375,9 +374,9 @@ class DownloadService : Service(), KoinComponent {
fun unpin(track: Track) {
// Update Pinned flag of items in progress
downloadQueue.singleOrNull { it.id == track.id }?.pinned = false
activelyDownloading.keys.singleOrNull { it.id == track.id }?.pinned = false
failedList.singleOrNull { it.id == track.id }?.pinned = false
downloadQueue.get(track.id)?.pinned = false
activeDownloads[track.id]?.track?.pinned = false
failedList[track.id]?.pinned = false
val pinnedFile = track.getPinnedFile()
if (!Storage.isPathExists(pinnedFile)) return
@ -393,17 +392,17 @@ class DownloadService : Service(), KoinComponent {
@Suppress("ReturnCount")
fun getDownloadState(track: Track): DownloadState {
if (Storage.isPathExists(track.getCompleteFile())) return DownloadState.DONE
if (Storage.isPathExists(track.getPinnedFile())) return DownloadState.PINNED
if (activelyDownloading.any { it.key.id == track.id }) return DownloadState.QUEUED
if (downloadQueue.any { it.id == track.id }) return DownloadState.QUEUED
if (activeDownloads.contains(track.id)) return DownloadState.QUEUED
if (downloadQueue.contains(track.id)) return DownloadState.QUEUED
val key = activelyDownloading.keys.firstOrNull { it.track.id == track.id }
if (key != null) {
if (key.tryCount > 0) return DownloadState.RETRYING
val downloadableTrack = activeDownloads[track.id]?.track
if (downloadableTrack != null) {
if (downloadableTrack.tryCount > 0) return DownloadState.RETRYING
return DownloadState.DOWNLOADING
}
if (failedList.any { it.track.id == track.id }) return DownloadState.FAILED
if (failedList[track.id] != null) return DownloadState.FAILED
if (Storage.isPathExists(track.getCompleteFile())) return DownloadState.DONE
if (Storage.isPathExists(track.getPinnedFile())) return DownloadState.PINNED
return DownloadState.IDLE
}
@ -417,8 +416,7 @@ class DownloadService : Service(), KoinComponent {
}
private fun cancelDownload(track: Track) {
val key = activelyDownloading.keys.singleOrNull { it.track.id == track.id } ?: return
activelyDownloading[key]?.cancel()
activeDownloads[track.id]?.cancel()
}
private fun postState(track: Track, state: DownloadState, progress: Int? = null) {
@ -447,5 +445,16 @@ class DownloadService : Service(), KoinComponent {
context.startService(intent)
}
}
fun PriorityBlockingQueue<DownloadableTrack>.get(id: String): DownloadableTrack? {
for (el in this) {
if (el.id == id) return el
}
return null
}
fun PriorityBlockingQueue<DownloadableTrack>.contains(id: String): Boolean {
return (this.get(id) != null)
}
}
}

View File

@ -49,6 +49,9 @@ class DownloadTask(
private var outputStream: OutputStream? = null
private var lastPostTime: Long = 0
val track: DownloadableTrack
get() = item
private fun checkIfExists(): Boolean {
if (Storage.isPathExists(item.pinnedFile)) {
Timber.i("%s already exists. Skipping.", item.pinnedFile)

View File

@ -172,11 +172,11 @@ object Settings {
// Normally you don't need to use these Settings directly,
// use ActiveServerProvider.isID3Enabled() instead
@JvmStatic
var shouldUseId3Tags by BooleanSetting(getKey(R.string.setting_key_id3_tags), false)
var shouldUseId3Tags by BooleanSetting(getKey(R.string.setting_key_id3_tags), true)
// See comment above.
@JvmStatic
var useId3TagsOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), false)
var useId3TagsOffline by BooleanSetting(getKey(R.string.setting_key_id3_tags_offline), true)
var activeServer by IntSetting(getKey(R.string.setting_key_server_instance), -1)

View File

@ -70,6 +70,8 @@ object Storage {
} catch (ignored: FileAlreadyExistsException) {
// Play console has revealed a crash when for some reason both files exist
delete(pathFrom.path)
} catch (ignored: Exception) {
// Ignore any other exceptions, such as NoSuchFileException etc.
}
}

View File

@ -313,8 +313,7 @@
<string name="settings.use_id3">Durchsuchen von ID3-Tags</string>
<string name="settings.use_id3_summary">Nutze ID3 Tag Methode anstatt Dateisystem-Methode</string>
<string name="settings.use_id3_offline">ID3 Methode auch offline nutzen</string>
<string name="settings.use_id3_offline_warning">Experimentell: Falls aktiviert, werden nur Lieder angezeigt die mit Ultrasonic 4.0 oder höher heruntergeladen wurden.</string>
<string name="settings.use_id3_offline_summary">Ältere Downloads haben nicht unbedingt die notwendigen Metadaten heruntergeladen. Du kannst zwischen dem Anheft- und Downloadmodus wechseln um die fehlenden Metadaten herunter zu laden.</string>
<string name="settings.use_id3_offline_summary">Falls aktiviert, werden nur Lieder angezeigt die mit Ultrasonic 4.0 oder höher heruntergeladen wurden. Ältere Downloads haben nicht die notwendigen Metadaten heruntergeladen. Du kannst zwischen dem Anheft- und Downloadmodus wechseln um die fehlenden Metadaten herunter zu laden.</string>
<string name="settings.show_artist_picture">Zeige Bilder in der Liste der Künstler*innen an.</string>
<string name="settings.show_artist_picture_summary">Wenn Künstler*innen Bilder zugeordnet sind werden diese in der Liste angezeigt</string>
<string name="main.video" tools:ignore="UnusedResources">Film</string>

View File

@ -316,8 +316,7 @@
<string name="settings.use_id3">Navegar usando las etiquetas ID3</string>
<string name="settings.use_id3_summary">Usar el método de etiquetas ID3 en lugar del método basado en el sistema de ficheros</string>
<string name="settings.use_id3_offline">Utilizar etiquetas ID3 también cuando se está fuera de línea</string>
<string name="settings.use_id3_offline_warning">Experimental: Si activas este ajuste sólo se mostrará la música que hayas descargado con Ultrasonic 4.0 o posterior.</string>
<string name="settings.use_id3_offline_summary">Las descargas anteriores no tienen los metadatos necesarios descargados. Puedes alternar entre el modo anclar y guardar para activar la descarga de los metadatos que faltan.</string>
<string name="settings.use_id3_offline_summary">Si activas este ajuste sólo se mostrará la música que hayas descargado con Ultrasonic 4.0 o posterior. Las descargas anteriores no tienen los metadatos necesarios descargados. Puedes alternar entre el modo anclar y guardar para activar la descarga de los metadatos que faltan.</string>
<string name="settings.show_artist_picture">Mostrar la imagen del artista en la lista de artistas</string>
<string name="settings.show_artist_picture_summary">Muestra la imagen del artista en la lista de artistas si está disponible</string>
<string name="main.video" tools:ignore="UnusedResources">Vídeo</string>

View File

@ -316,8 +316,7 @@
<string name="settings.use_id3">Navigeren met behulp van ID3-labels</string>
<string name="settings.use_id3_summary">ID3-labels gebruiken in plaats van systeemlabels</string>
<string name="settings.use_id3_offline">ID3-methoe ook in offline-modus gebruiken</string>
<string name="settings.use_id3_offline_warning">Experimenteel: als je deze instelling inschakelt, dan wordt er alleen muziek getoond die gedownload is met versie 4.0 of hoger.</string>
<string name="settings.use_id3_offline_summary">Oudere downloads beschikken niet over de benodigde metagegevens. Je kunt tussen modi schakelen om de ontbrekende metagegevens op te halen.</string>
<string name="settings.use_id3_offline_summary">Als je deze instelling inschakelt, dan wordt er alleen muziek getoond die gedownload is met versie 4.0 of hoger. Oudere downloads beschikken niet over de benodigde metagegevens. Je kunt tussen modi schakelen om de ontbrekende metagegevens op te halen.</string>
<string name="settings.show_artist_picture">Artiestfoto tonen op artiestenlijst</string>
<string name="settings.show_artist_picture_summary">Toont de artiestfoto op de artiestenlijst (indien beschikbaar)</string>
<string name="main.video" tools:ignore="UnusedResources">Video</string>

View File

@ -314,8 +314,7 @@
<string name="settings.use_id3">Navegar Usando Etiquetas ID3</string>
<string name="settings.use_id3_summary">Usar as etiquetas ID3 ao invés do sistema de arquivos</string>
<string name="settings.use_id3_offline">Usar método ID3 quando offline também</string>
<string name="settings.use_id3_offline_warning">Experimental: Se você ativar esta configuração, ela mostrará apenas a música que você baixou com o Ultrasonic 4.0 ou posterior.</string>
<string name="settings.use_id3_offline_summary">Os downloads anteriores não têm os metadados necessários baixados. Você pode alternar entre os modos Pin e Salvar para acionar o download dos metadados ausentes.</string>
<string name="settings.use_id3_offline_summary">Se você ativar esta configuração, ela mostrará apenas a música que você baixou com o Ultrasonic 4.0 ou posterior. Os downloads anteriores não têm os metadados necessários baixados. Você pode alternar entre os modos Pin e Salvar para acionar o download dos metadados ausentes.</string>
<string name="settings.show_artist_picture">Mostrar Foto do Artista na Lista</string>
<string name="settings.show_artist_picture_summary">Mostrar a imagem do artista na lista de artistas, se disponível</string>
<string name="main.video" tools:ignore="UnusedResources">Vídeo</string>

View File

@ -316,8 +316,7 @@
<string name="settings.use_id3">Browse Using ID3 Tags</string>
<string name="settings.use_id3_summary">Use ID3 tag methods instead of file system based methods</string>
<string name="settings.use_id3_offline">Use ID3 method also when offline</string>
<string name="settings.use_id3_offline_warning">Experimental: If you enable this Setting it will only show the music that you have downloaded with Ultrasonic 4.0 or later.</string>
<string name="settings.use_id3_offline_summary">Earlier downloads don\'t have the necessary metadata downloaded. You can toggle between Pin and Save mode to trigger the download of the missing metadata.</string>
<string name="settings.use_id3_offline_summary">If you enable this Setting it will only show the music that you have downloaded with Ultrasonic 4.0 or later. Earlier downloads don\'t have the necessary metadata downloaded. You can toggle between Pin and Save mode to trigger the download of the missing metadata.</string>
<string name="settings.show_artist_picture">Show artist picture in artist list</string>
<string name="settings.show_artist_picture_summary">Displays the artist picture in the artist list if available</string>
<string name="main.video" tools:ignore="UnusedResources">Video</string>