From 922022ab032741d6dbd2e0d2621c0dec543b3ebc Mon Sep 17 00:00:00 2001 From: tzugen Date: Sun, 3 Apr 2022 23:57:50 +0200 Subject: [PATCH] Switch to Media3 --- .../org/moire/ultrasonic/domain/RepeatMode.kt | 15 - gradle/libs.versions.toml | 4 + ultrasonic/build.gradle | 3 + ultrasonic/src/main/AndroidManifest.xml | 14 +- .../receiver/A2dpIntentReceiver.java | 57 -- .../service/JukeboxMediaPlayer.java | 489 ----------- .../ultrasonic/util/ShufflePlayBuffer.java | 125 --- .../moire/ultrasonic/util/StreamProxy.java | 290 ------- .../moire/ultrasonic/view/VisualizerView.java | 2 +- .../ultrasonic/activity/NavigationActivity.kt | 8 +- .../ultrasonic/adapters/TrackViewBinder.kt | 59 +- .../ultrasonic/adapters/TrackViewHolder.kt | 2 +- .../moire/ultrasonic/di/ApplicationModule.kt | 2 - .../moire/ultrasonic/di/MediaPlayerModule.kt | 10 +- .../ultrasonic/fragment/DownloadsFragment.kt | 2 +- .../ultrasonic/fragment/NowPlayingFragment.kt | 11 +- .../ultrasonic/fragment/PlayerFragment.kt | 359 ++++---- .../ultrasonic/fragment/SearchFragment.kt | 11 +- .../fragment/ServerSelectorFragment.kt | 8 +- .../ultrasonic/fragment/SettingsFragment.kt | 22 +- .../fragment/TrackCollectionFragment.kt | 2 +- .../ultrasonic/playback/APIDataSource.kt | 398 +++++++++ .../playback/LegacyPlaylistManager.kt | 127 +++ .../ultrasonic/playback/MediaItemTree.kt | 254 ++++++ .../playback/MediaNotificationProvider.kt | 158 ++++ .../org/moire/ultrasonic/playback/Plan.md | 18 + .../ultrasonic/playback/PlaybackService.kt | 244 ++++++ .../ultrasonic/playback/UltrasonicCache.kt | 99 +++ .../ultrasonic/service/AudioFocusHandler.kt | 118 --- .../service/AutoMediaBrowserService.kt | 29 +- .../moire/ultrasonic/service/DownloadFile.kt | 271 +----- .../ultrasonic/service/DownloadService.kt | 256 ++++++ .../moire/ultrasonic/service/Downloader.kt | 526 ++++++------ .../ultrasonic/service/JukeboxMediaPlayer.kt | 337 ++++++++ .../ultrasonic/service/LocalMediaPlayer.kt | 745 ----------------- .../service/MediaPlayerController.kt | 536 +++++++----- .../service/MediaPlayerLifecycleSupport.kt | 126 +-- .../ultrasonic/service/MediaPlayerService.kt | 769 ------------------ .../service/PlaybackStateSerializer.kt | 2 +- .../org/moire/ultrasonic/service/RxBus.kt | 7 +- .../ultrasonic/subsonic/DownloadHandler.kt | 25 +- .../org/moire/ultrasonic/util/CacheCleaner.kt | 4 +- .../org/moire/ultrasonic/util/FileUtil.kt | 2 +- .../ultrasonic/util/MediaSessionHandler.kt | 332 -------- .../org/moire/ultrasonic/util/Settings.kt | 39 - .../drawable/media3_notification_pause.xml | 5 + .../res/drawable/media3_notification_play.xml | 5 + .../media3_notification_seek_to_next.xml | 5 + .../media3_notification_seek_to_previous.xml | 5 + .../media3_notification_small_icon.xml | 9 + 50 files changed, 2871 insertions(+), 4075 deletions(-) delete mode 100644 core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaItemTree.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/UltrasonicCache.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt create mode 100644 ultrasonic/src/main/res/drawable/media3_notification_pause.xml create mode 100644 ultrasonic/src/main/res/drawable/media3_notification_play.xml create mode 100644 ultrasonic/src/main/res/drawable/media3_notification_seek_to_next.xml create mode 100644 ultrasonic/src/main/res/drawable/media3_notification_seek_to_previous.xml create mode 100644 ultrasonic/src/main/res/drawable/media3_notification_small_icon.xml diff --git a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt b/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt deleted file mode 100644 index f9005eb9..00000000 --- a/core/domain/src/main/kotlin/org/moire/ultrasonic/domain/RepeatMode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.moire.ultrasonic.domain - -enum class RepeatMode { - OFF { - override operator fun next(): RepeatMode = ALL - }, - ALL { - override operator fun next(): RepeatMode = SINGLE - }, - SINGLE { - override operator fun next(): RepeatMode = OFF - }; - - abstract operator fun next(): RepeatMode -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 064dd182..ea4b36e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ detekt = "1.19.0" jacoco = "0.8.7" preferences = "1.1.1" media = "1.3.1" +media3 = "1.0.0-alpha03" androidSupport = "28.0.0" androidLegacySupport = "1.0.0" @@ -66,6 +67,9 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" } preferences = { module = "androidx.preference:preference", version.ref = "preferences" } media = { module = "androidx.media:media", version.ref = "media" } +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" } kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index f6fc5607..57836a3b 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -100,6 +100,9 @@ dependencies { implementation libs.constraintLayout implementation libs.preferences implementation libs.media + implementation libs.media3exoplayer + implementation libs.media3session + implementation libs.media3okhttp implementation libs.navigationFragment implementation libs.navigationUi diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 768e7571..757d335f 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -56,18 +56,17 @@ - + @@ -146,13 +145,6 @@ android:name=".provider.SearchSuggestionProvider" android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/> - - - - - diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java b/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java deleted file mode 100644 index e122ca31..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/receiver/A2dpIntentReceiver.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.moire.ultrasonic.receiver; - -import static org.koin.java.KoinJavaComponent.inject; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.service.MediaPlayerController; - -import kotlin.Lazy; - -public class A2dpIntentReceiver extends BroadcastReceiver -{ - private static final String PLAYSTATUS_RESPONSE = "com.android.music.playstatusresponse"; - private Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); - - @Override - public void onReceive(Context context, Intent intent) - { - if (mediaPlayerControllerLazy.getValue().getCurrentPlaying() == null) return; - - Track song = mediaPlayerControllerLazy.getValue().getCurrentPlaying().getTrack(); - if (song == null) return; - - Intent avrcpIntent = new Intent(PLAYSTATUS_RESPONSE); - - Integer duration = song.getDuration(); - int playerPosition = mediaPlayerControllerLazy.getValue().getPlayerPosition(); - int listSize = mediaPlayerControllerLazy.getValue().getPlaylistSize(); - - if (duration != null) - { - avrcpIntent.putExtra("duration", (long) duration); - } - - avrcpIntent.putExtra("position", (long) playerPosition); - avrcpIntent.putExtra("ListSize", (long) listSize); - - switch (mediaPlayerControllerLazy.getValue().getPlayerState()) - { - case STARTED: - avrcpIntent.putExtra("playing", true); - break; - case STOPPED: - case PAUSED: - case COMPLETED: - avrcpIntent.putExtra("playing", false); - break; - default: - return; - } - - context.sendBroadcast(avrcpIntent); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java deleted file mode 100644 index 46e62147..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java +++ /dev/null @@ -1,489 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.service; - -import android.content.Context; -import android.os.Handler; -import timber.log.Timber; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ProgressBar; -import android.widget.Toast; - -import org.jetbrains.annotations.NotNull; -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException; -import org.moire.ultrasonic.api.subsonic.SubsonicRESTException; -import org.moire.ultrasonic.app.UApp; -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.JukeboxStatus; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.util.Util; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; - -import kotlin.Lazy; - -import static org.koin.java.KoinJavaComponent.inject; - -/** - * Provides an asynchronous interface to the remote jukebox on the Subsonic server. - * - * @author Sindre Mehus - * @version $Id$ - */ -public class JukeboxMediaPlayer -{ - private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L; - - private final TaskQueue tasks = new TaskQueue(); - private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); - private ScheduledFuture statusUpdateFuture; - private final AtomicLong timeOfLastUpdate = new AtomicLong(); - private JukeboxStatus jukeboxStatus; - private float gain = 0.5f; - private VolumeToast volumeToast; - private final AtomicBoolean running = new AtomicBoolean(); - private Thread serviceThread; - private boolean enabled = false; - - // TODO: These create circular references, try to refactor - private final Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); - private final Downloader downloader; - - // TODO: Report warning if queue fills up. - // TODO: Create shutdown method? - // TODO: Disable repeat. - // TODO: Persist RC state? - // TODO: Minimize status updates. - - public JukeboxMediaPlayer(Downloader downloader) - { - this.downloader = downloader; - } - - public void startJukeboxService() - { - if (running.get()) - { - return; - } - - running.set(true); - startProcessTasks(); - Timber.d("Started Jukebox Service"); - } - - public void stopJukeboxService() - { - running.set(false); - Util.sleepQuietly(1000); - - if (serviceThread != null) - { - serviceThread.interrupt(); - } - Timber.d("Stopped Jukebox Service"); - } - - private void startProcessTasks() - { - serviceThread = new Thread() - { - @Override - public void run() - { - processTasks(); - } - }; - - serviceThread.start(); - } - - private synchronized void startStatusUpdate() - { - stopStatusUpdate(); - - Runnable updateTask = new Runnable() - { - @Override - public void run() - { - tasks.remove(GetStatus.class); - tasks.add(new GetStatus()); - } - }; - - statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS); - } - - private synchronized void stopStatusUpdate() - { - if (statusUpdateFuture != null) - { - statusUpdateFuture.cancel(false); - statusUpdateFuture = null; - } - } - - private void processTasks() - { - while (running.get()) - { - JukeboxTask task = null; - - try - { - if (!ActiveServerProvider.Companion.isOffline()) - { - task = tasks.take(); - JukeboxStatus status = task.execute(); - onStatusUpdate(status); - } - } - catch (InterruptedException ignored) - { - - } - catch (Throwable x) - { - onError(task, x); - } - - Util.sleepQuietly(1); - } - } - - private void onStatusUpdate(JukeboxStatus jukeboxStatus) - { - timeOfLastUpdate.set(System.currentTimeMillis()); - this.jukeboxStatus = jukeboxStatus; - - // Track change? - Integer index = jukeboxStatus.getCurrentPlayingIndex(); - - if (index != null && index != -1 && index != downloader.getCurrentPlayingIndex()) - { - mediaPlayerControllerLazy.getValue().setCurrentPlaying(index); - } - } - - private void onError(JukeboxTask task, Throwable x) - { - if (x instanceof ApiNotSupportedException && !(task instanceof Stop)) - { - disableJukeboxOnError(x, R.string.download_jukebox_server_too_old); - } - else if (x instanceof OfflineException && !(task instanceof Stop)) - { - disableJukeboxOnError(x, R.string.download_jukebox_offline); - } - else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop)) - { - disableJukeboxOnError(x, R.string.download_jukebox_not_authorized); - } - else - { - Timber.e(x, "Failed to process jukebox task"); - } - } - - private void disableJukeboxOnError(Throwable x, final int resourceId) - { - Timber.w(x.toString()); - Context context = UApp.Companion.applicationContext(); - new Handler().post(() -> Util.toast(context, resourceId, false)); - - mediaPlayerControllerLazy.getValue().setJukeboxEnabled(false); - } - - public void updatePlaylist() - { - if (!enabled) return; - - tasks.remove(Skip.class); - tasks.remove(Stop.class); - tasks.remove(Start.class); - - List ids = new ArrayList<>(); - for (DownloadFile file : downloader.getAll()) - { - ids.add(file.getTrack().getId()); - } - - tasks.add(new SetPlaylist(ids)); - } - - public void skip(final int index, final int offsetSeconds) - { - tasks.remove(Skip.class); - tasks.remove(Stop.class); - tasks.remove(Start.class); - - startStatusUpdate(); - - if (jukeboxStatus != null) - { - jukeboxStatus.setPositionSeconds(offsetSeconds); - } - - tasks.add(new Skip(index, offsetSeconds)); - mediaPlayerControllerLazy.getValue().setPlayerState(PlayerState.STARTED); - } - - public void stop() - { - tasks.remove(Stop.class); - tasks.remove(Start.class); - - stopStatusUpdate(); - - tasks.add(new Stop()); - } - - public void start() - { - tasks.remove(Stop.class); - tasks.remove(Start.class); - - startStatusUpdate(); - tasks.add(new Start()); - } - - public synchronized void adjustVolume(boolean up) - { - float delta = up ? 0.05f : -0.05f; - gain += delta; - gain = Math.max(gain, 0.0f); - gain = Math.min(gain, 1.0f); - - tasks.remove(SetGain.class); - tasks.add(new SetGain(gain)); - - Context context = UApp.Companion.applicationContext(); - if (volumeToast == null) volumeToast = new VolumeToast(context); - - volumeToast.setVolume(gain); - } - - private MusicService getMusicService() - { - return MusicServiceFactory.getMusicService(); - } - - public int getPositionSeconds() - { - if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0) - { - return 0; - } - - if (jukeboxStatus.isPlaying()) - { - int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L); - return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate; - } - - return jukeboxStatus.getPositionSeconds(); - } - - public void setEnabled(boolean enabled) - { - Timber.d("Jukebox Service setting enabled to %b", enabled); - this.enabled = enabled; - - tasks.clear(); - if (enabled) - { - updatePlaylist(); - } - - stop(); - } - - public boolean isEnabled() - { - return enabled; - } - - private static class TaskQueue - { - private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); - - void add(JukeboxTask jukeboxTask) - { - queue.add(jukeboxTask); - } - - JukeboxTask take() throws InterruptedException - { - return queue.take(); - } - - void remove(Class taskClass) - { - try - { - Iterator iterator = queue.iterator(); - - while (iterator.hasNext()) - { - JukeboxTask task = iterator.next(); - - if (taskClass.equals(task.getClass())) - { - iterator.remove(); - } - } - } - catch (Throwable x) - { - Timber.w(x, "Failed to clean-up task queue."); - } - } - - void clear() - { - queue.clear(); - } - } - - private abstract static class JukeboxTask - { - abstract JukeboxStatus execute() throws Exception; - - @NotNull - @Override - public String toString() - { - return getClass().getSimpleName(); - } - } - - private class GetStatus extends JukeboxTask - { - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().getJukeboxStatus(); - } - } - - private class SetPlaylist extends JukeboxTask - { - private final List ids; - - SetPlaylist(List ids) - { - this.ids = ids; - } - - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().updateJukeboxPlaylist(ids); - } - } - - private class Skip extends JukeboxTask - { - private final int index; - private final int offsetSeconds; - - Skip(int index, int offsetSeconds) - { - this.index = index; - this.offsetSeconds = offsetSeconds; - } - - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().skipJukebox(index, offsetSeconds); - } - } - - private class Stop extends JukeboxTask - { - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().stopJukebox(); - } - } - - private class Start extends JukeboxTask - { - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().startJukebox(); - } - } - - private class SetGain extends JukeboxTask - { - - private final float gain; - - private SetGain(float gain) - { - this.gain = gain; - } - - @Override - JukeboxStatus execute() throws Exception - { - return getMusicService().setJukeboxGain(gain); - } - } - - private static class VolumeToast extends Toast - { - - private final ProgressBar progressBar; - - public VolumeToast(Context context) - { - super(context); - setDuration(Toast.LENGTH_SHORT); - LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - View view = inflater.inflate(R.layout.jukebox_volume, null); - progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar); - setView(view); - setGravity(Gravity.TOP, 0, 0); - } - - public void setVolume(float volume) - { - progressBar.setProgress(Math.round(100 * volume)); - show(); - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java deleted file mode 100644 index 23b013af..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/ShufflePlayBuffer.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.MusicDirectory; -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.service.MusicService; -import org.moire.ultrasonic.service.MusicServiceFactory; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import timber.log.Timber; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public class ShufflePlayBuffer -{ - private static final int CAPACITY = 50; - private static final int REFILL_THRESHOLD = 40; - - private final List buffer = new ArrayList<>(); - private ScheduledExecutorService executorService; - private int currentServer; - - public boolean isEnabled = false; - - public ShufflePlayBuffer() - { - } - - public void onCreate() - { - executorService = Executors.newSingleThreadScheduledExecutor(); - Runnable runnable = this::refill; - executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS); - Timber.i("ShufflePlayBuffer created"); - } - - public void onDestroy() - { - executorService.shutdown(); - Timber.i("ShufflePlayBuffer destroyed"); - } - - public List get(int size) - { - clearBufferIfNecessary(); - - List result = new ArrayList<>(size); - synchronized (buffer) - { - while (!buffer.isEmpty() && result.size() < size) - { - result.add(buffer.remove(buffer.size() - 1)); - } - } - Timber.i("Taking %d songs from shuffle play buffer. %d remaining.", result.size(), buffer.size()); - return result; - } - - private void refill() - { - if (!isEnabled) return; - - // Check if active server has changed. - clearBufferIfNecessary(); - - if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected() && !ActiveServerProvider.Companion.isOffline())) - { - return; - } - - try - { - MusicService service = MusicServiceFactory.getMusicService(); - int n = CAPACITY - buffer.size(); - MusicDirectory songs = service.getRandomSongs(n); - - synchronized (buffer) - { - buffer.addAll(songs.getTracks()); - Timber.i("Refilled shuffle play buffer with %d songs.", songs.getTracks().size()); - } - } - catch (Exception x) - { - Timber.w(x, "Failed to refill shuffle play buffer."); - } - } - - private void clearBufferIfNecessary() - { - synchronized (buffer) - { - if (currentServer != ActiveServerProvider.Companion.getActiveServerId()) - { - currentServer = ActiveServerProvider.Companion.getActiveServerId(); - buffer.clear(); - } - } - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java deleted file mode 100644 index 227e3d2c..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java +++ /dev/null @@ -1,290 +0,0 @@ -package org.moire.ultrasonic.util; - -import org.moire.ultrasonic.domain.Track; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.Supplier; - -import java.io.BufferedOutputStream; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.UnsupportedEncodingException; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.net.URLDecoder; -import java.net.UnknownHostException; -import java.util.StringTokenizer; - -import timber.log.Timber; - -public class StreamProxy implements Runnable -{ - private Thread thread; - private boolean isRunning; - private ServerSocket socket; - private int port; - private Supplier currentPlaying; - - public StreamProxy(Supplier currentPlaying) - { - - // Create listening socket - try - { - socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); - socket.setSoTimeout(5000); - port = socket.getLocalPort(); - this.currentPlaying = currentPlaying; - } - catch (UnknownHostException e) - { // impossible - } - catch (IOException e) - { - Timber.e(e, "IOException initializing server"); - } - } - - public int getPort() - { - return port; - } - - public void start() - { - thread = new Thread(this); - thread.start(); - } - - public void stop() - { - isRunning = false; - thread.interrupt(); - } - - @Override - public void run() - { - isRunning = true; - while (isRunning) - { - try - { - Socket client = socket.accept(); - if (client == null) - { - continue; - } - Timber.i("Client connected"); - - StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client); - if (task.processRequest()) - { - new Thread(task).start(); - } - - } - catch (SocketTimeoutException e) - { - // Do nothing - } - catch (IOException e) - { - Timber.e(e, "Error connecting to client"); - } - } - Timber.i("Proxy interrupted. Shutting down."); - } - - private class StreamToMediaPlayerTask implements Runnable { - String localPath; - Socket client; - int cbSkip; - - StreamToMediaPlayerTask(Socket client) { - this.client = client; - } - - private String readRequest() { - InputStream is; - String firstLine; - try { - is = client.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192); - firstLine = reader.readLine(); - } catch (IOException e) { - Timber.e(e, "Error parsing request"); - return null; - } - - if (firstLine == null) { - Timber.i("Proxy client closed connection without a request."); - return null; - } - - StringTokenizer st = new StringTokenizer(firstLine); - st.nextToken(); // method - String uri = st.nextToken(); - String realUri = uri.substring(1); - Timber.i(realUri); - - return realUri; - } - - boolean processRequest() { - final String uri = readRequest(); - if (uri == null || uri.isEmpty()) { - return false; - } - - // Read HTTP headers - Timber.i("Processing request: %s", uri); - - try { - localPath = URLDecoder.decode(uri, Constants.UTF_8); - } catch (UnsupportedEncodingException e) { - Timber.e(e, "Unsupported encoding"); - return false; - } - - Timber.i("Processing request for file %s", localPath); - if (Storage.INSTANCE.isPathExists(localPath)) return true; - - // Usually the .partial file will be requested here, but sometimes it has already - // been renamed, so check if it is completed since - String saveFileName = FileUtil.INSTANCE.getSaveFile(localPath); - String completeFileName = FileUtil.INSTANCE.getCompleteFile(saveFileName); - - if (Storage.INSTANCE.isPathExists(saveFileName)) { - localPath = saveFileName; - return true; - } - - if (Storage.INSTANCE.isPathExists(completeFileName)) { - localPath = completeFileName; - return true; - } - - Timber.e("File %s does not exist", localPath); - return false; - - } - - @Override - public void run() - { - Timber.i("Streaming song in background"); - DownloadFile downloadFile = currentPlaying == null? null : currentPlaying.get(); - Track song = downloadFile.getTrack(); - long fileSize = downloadFile.getBitRate() * ((song.getDuration() != null) ? song.getDuration() : 0) * 1000 / 8; - Timber.i("Streaming fileSize: %d", fileSize); - - // Create HTTP header - String headers = "HTTP/1.0 200 OK\r\n"; - headers += "Content-Type: application/octet-stream\r\n"; - headers += "Connection: close\r\n"; - headers += "\r\n"; - - long cbToSend = fileSize - cbSkip; - OutputStream output = null; - byte[] buff = new byte[64 * 1024]; - - try - { - output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024); - output.write(headers.getBytes()); - - if (!downloadFile.isWorkDone()) - { - // Loop as long as there's stuff to send - while (isRunning && !client.isClosed()) - { - // See if there's more to send - String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile(); - int cbSentThisBatch = 0; - - AbstractFile storageFile = Storage.INSTANCE.getFromPath(file); - if (storageFile != null) - { - InputStream input = storageFile.getFileInputStream(); - - try - { - long skip = input.skip(cbSkip); - int cbToSendThisBatch = input.available(); - - while (cbToSendThisBatch > 0) - { - int cbToRead = Math.min(cbToSendThisBatch, buff.length); - int cbRead = input.read(buff, 0, cbToRead); - - if (cbRead == -1) - { - break; - } - - cbToSendThisBatch -= cbRead; - cbToSend -= cbRead; - output.write(buff, 0, cbRead); - output.flush(); - cbSkip += cbRead; - cbSentThisBatch += cbRead; - } - } - finally - { - input.close(); - } - - // Done regardless of whether or not it thinks it is - if (downloadFile.isWorkDone() && cbSkip >= file.length()) - { - break; - } - } - - // If we did nothing this batch, block for a second - if (cbSentThisBatch == 0) - { - Timber.d("Blocking until more data appears (%d)", cbToSend); - Util.sleepQuietly(1000L); - } - } - } - else - { - Timber.w("Requesting data for completely downloaded file"); - } - } - catch (SocketException socketException) - { - Timber.e("SocketException() thrown, proxy client has probably closed. This can exit harmlessly"); - } - catch (Exception e) - { - Timber.e("Exception thrown from streaming task:"); - Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage()); - } - - // Cleanup - try - { - if (output != null) - { - output.close(); - } - client.close(); - } - catch (IOException e) - { - Timber.e("IOException while cleaning up streaming task:"); - Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage()); - } - } - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java b/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java index 1de45b44..3a567399 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/view/VisualizerView.java @@ -130,7 +130,7 @@ public class VisualizerView extends View return; } - if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED) + if (mediaPlayerControllerLazy.getValue().getLegacyPlayerState() != PlayerState.STARTED) { return; } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index a82d93d1..fac3e504 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -27,6 +27,8 @@ import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.FragmentContainerView +import androidx.media3.common.Player.STATE_BUFFERING +import androidx.media3.common.Player.STATE_READY import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment @@ -414,9 +416,9 @@ class NavigationActivity : AppCompatActivity() { } if (nowPlayingView != null) { - val playerState: PlayerState = mediaPlayerController.playerState - if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) { - val file: DownloadFile? = mediaPlayerController.currentPlaying + val playerState: Int = mediaPlayerController.playbackState + if (playerState == STATE_BUFFERING || playerState == STATE_READY) { + val file: DownloadFile? = mediaPlayerController.currentPlayingLegacy if (file != null) { nowPlayingView?.visibility = View.VISIBLE } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt index 0c9028f0..4efe0338 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -17,7 +17,7 @@ import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.Downloader class TrackViewBinder( - val onItemClick: (DownloadFile) -> Unit, + val onItemClick: (DownloadFile, Int) -> Unit, val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null, val checkable: Boolean, val draggable: Boolean, @@ -29,7 +29,7 @@ class TrackViewBinder( // Set our layout files val layout = R.layout.list_item_track - val contextMenuLayout = R.menu.context_menu_track + private val contextMenuLayout = R.menu.context_menu_track private val downloader: Downloader by inject() private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context) @@ -41,15 +41,14 @@ class TrackViewBinder( @SuppressLint("ClickableViewAccessibility") @Suppress("LongMethod") override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) { - val downloadFile: DownloadFile? val diffAdapter = adapter as BaseAdapter<*> - when (item) { + val downloadFile: DownloadFile = when (item) { is Track -> { - downloadFile = downloader.getDownloadFileForSong(item) + downloader.getDownloadFileForSong(item) } is DownloadFile -> { - downloadFile = item + item } else -> { return @@ -90,7 +89,7 @@ class TrackViewBinder( val nowChecked = !holder.check.isChecked holder.isChecked = nowChecked } else { - onItemClick(downloadFile) + onItemClick(downloadFile, holder.bindingAdapterPosition) } } @@ -103,41 +102,37 @@ class TrackViewBinder( // Notify the adapter of selection changes holder.observableChecked.observe( - lifecycleOwner, - { isCheckedNow -> - if (isCheckedNow) { - diffAdapter.notifySelected(holder.entry!!.longId) - } else { - diffAdapter.notifyUnselected(holder.entry!!.longId) - } + lifecycleOwner + ) { isCheckedNow -> + if (isCheckedNow) { + diffAdapter.notifySelected(holder.entry!!.longId) + } else { + diffAdapter.notifyUnselected(holder.entry!!.longId) } - ) + } // Listen to changes in selection status and update ourselves diffAdapter.selectionRevision.observe( - lifecycleOwner, - { - val newStatus = diffAdapter.isSelected(item.longId) + lifecycleOwner + ) { + val newStatus = diffAdapter.isSelected(item.longId) - if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus - } - ) + if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus + } // Observe download status downloadFile.status.observe( - lifecycleOwner, - { - holder.updateStatus(it) - diffAdapter.notifyChanged() - } - ) + lifecycleOwner + ) { + holder.updateStatus(it) + diffAdapter.notifyChanged() + } downloadFile.progress.observe( - lifecycleOwner, - { - holder.updateProgress(it) - } - ) + lifecycleOwner + ) { + holder.updateProgress(it) + } } override fun onViewRecycled(holder: TrackViewHolder) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 23a885d2..2b9c2ec5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable } rxSubscription = RxBus.playerStateObservable.subscribe { - setPlayIcon(it.track == downloadFile) + setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index 3d0298e5..9bffd3f8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -4,7 +4,6 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.MediaSessionHandler /** * This Koin module contains the registration of general classes needed for Ultrasonic @@ -12,5 +11,4 @@ import org.moire.ultrasonic.util.MediaSessionHandler val applicationModule = module { single { ActiveServerProvider(get()) } single { ImageLoaderProvider(androidContext()) } - single { MediaSessionHandler() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index d08c8f20..658893ea 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -1,15 +1,13 @@ package org.moire.ultrasonic.di import org.koin.dsl.module -import org.moire.ultrasonic.service.AudioFocusHandler +import org.moire.ultrasonic.playback.LegacyPlaylistManager import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.ExternalStorageMonitor import org.moire.ultrasonic.service.JukeboxMediaPlayer -import org.moire.ultrasonic.service.LocalMediaPlayer import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.PlaybackStateSerializer -import org.moire.ultrasonic.util.ShufflePlayBuffer /** * This Koin module contains the registration of classes related to the media player @@ -19,10 +17,8 @@ val mediaPlayerModule = module { single { MediaPlayerLifecycleSupport() } single { PlaybackStateSerializer() } single { ExternalStorageMonitor() } - single { ShufflePlayBuffer() } - single { Downloader(get(), get(), get()) } - single { LocalMediaPlayer() } - single { AudioFocusHandler(get()) } + single { LegacyPlaylistManager() } + single { Downloader(get(), get()) } // TODO Ideally this can be cleaned up when all circular references are removed. single { MediaPlayerController(get(), get(), get(), get(), get()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index 86847435..913154b4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -54,7 +54,7 @@ class DownloadsFragment : MultiListFragment() { viewAdapter.register( TrackViewBinder( - { }, + { _, _ -> }, { _, _ -> true }, checkable = false, draggable = false, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 95724b59..9a1f2489 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -47,7 +47,7 @@ class NowPlayingFragment : Fragment() { private var nowPlayingTrack: TextView? = null private var nowPlayingArtist: TextView? = null - private var playerStateSubscription: Disposable? = null + private var rxBusSubscription: Disposable? = null private val mediaPlayerController: MediaPlayerController by inject() private val imageLoader: ImageLoaderProvider by inject() @@ -69,8 +69,7 @@ class NowPlayingFragment : Fragment() { nowPlayingAlbumArtImage = view.findViewById(R.id.now_playing_image) nowPlayingTrack = view.findViewById(R.id.now_playing_trackname) nowPlayingArtist = view.findViewById(R.id.now_playing_artist) - playerStateSubscription = - RxBus.playerStateObservable.subscribe { update() } + rxBusSubscription = RxBus.playerStateObservable.subscribe { update() } } override fun onResume() { @@ -80,13 +79,13 @@ class NowPlayingFragment : Fragment() { override fun onDestroy() { super.onDestroy() - playerStateSubscription!!.dispose() + rxBusSubscription!!.dispose() } @SuppressLint("ClickableViewAccessibility") private fun update() { try { - val playerState = mediaPlayerController.playerState + val playerState = mediaPlayerController.legacyPlayerState if (playerState === PlayerState.PAUSED) { playButton!!.setImageDrawable( @@ -102,7 +101,7 @@ class NowPlayingFragment : Fragment() { ) } - val file = mediaPlayerController.currentPlaying + val file = mediaPlayerController.currentPlayingLegacy if (file != null) { val song = file.track diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 53913818..f5b266e2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -13,6 +13,7 @@ import android.graphics.Point import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Handler +import android.os.Looper import android.view.ContextMenu import android.view.ContextMenu.ContextMenuInfo import android.view.GestureDetector @@ -35,24 +36,15 @@ import android.widget.TextView import android.widget.ViewFlipper import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.navigation.Navigation import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView -import io.reactivex.rxjava3.disposables.Disposable -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.ArrayList -import java.util.Date -import java.util.Locale -import java.util.concurrent.CancellationException -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit -import kotlin.math.abs -import kotlin.math.max +import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -66,15 +58,13 @@ import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.audiofx.VisualizerController import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.Identifiable -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.service.DownloadFile -import org.moire.ultrasonic.service.LocalMediaPlayer import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.ShareHandler @@ -86,9 +76,20 @@ import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.view.AutoRepeatButton import org.moire.ultrasonic.view.VisualizerView import timber.log.Timber +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.abs +import kotlin.math.max /** * Contains the Music Player screen of Ultrasonic with playback controls and the playlist + * TODO: Add timeline lister -> updateProgressBar(). */ @Suppress("LargeClass", "TooManyFunctions", "MagicNumber") class PlayerFragment : @@ -113,14 +114,13 @@ class PlayerFragment : // Data & Services private val networkAndStorageChecker: NetworkAndStorageChecker by inject() private val mediaPlayerController: MediaPlayerController by inject() - private val localMediaPlayer: LocalMediaPlayer by inject() private val shareHandler: ShareHandler by inject() private val imageLoaderProvider: ImageLoaderProvider by inject() - private lateinit var executorService: ScheduledExecutorService private var currentPlaying: DownloadFile? = null private var currentSong: Track? = null private lateinit var viewManager: LinearLayoutManager - private var rxBusSubscription: Disposable? = null + private var rxBusSubscription: CompositeDisposable = CompositeDisposable() + private lateinit var executorService: ScheduledExecutorService private var ioScope = CoroutineScope(Dispatchers.IO) // Views and UI Elements @@ -148,7 +148,7 @@ class PlayerFragment : private lateinit var durationTextView: TextView private lateinit var pauseButton: View private lateinit var stopButton: View - private lateinit var startButton: View + private lateinit var playButton: View private lateinit var repeatButton: ImageView private lateinit var hollowStar: Drawable private lateinit var fullStar: Drawable @@ -189,7 +189,7 @@ class PlayerFragment : pauseButton = view.findViewById(R.id.button_pause) stopButton = view.findViewById(R.id.button_stop) - startButton = view.findViewById(R.id.button_start) + playButton = view.findViewById(R.id.button_start) repeatButton = view.findViewById(R.id.button_repeat) visualizerViewLayout = view.findViewById(R.id.current_playing_visualizer_layout) fiveStar1ImageView = view.findViewById(R.id.song_five_star_1) @@ -216,13 +216,6 @@ class PlayerFragment : swipeVelocity = swipeDistance gestureScanner = GestureDetector(context, this) - // The secondary progress is an indicator of how far the song is cached. - localMediaPlayer.secondaryProgress.observe( - viewLifecycleOwner, - { - progressBar.secondaryProgress = it - } - ) findViews(view) val previousButton: AutoRepeatButton = view.findViewById(R.id.button_previous) @@ -291,34 +284,40 @@ class PlayerFragment : } } - startButton.setOnClickListener { + playButton.setOnClickListener { networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() launch(CommunicationError.getHandler(context)) { - start() + mediaPlayerController.play() onCurrentChanged() onSliderProgressChanged() } } shuffleButton.setOnClickListener { - mediaPlayerController.shuffle() + mediaPlayerController.toggleShuffle() Util.toast(activity, R.string.download_menu_shuffle_notification) } repeatButton.setOnClickListener { - val repeatMode = mediaPlayerController.repeatMode.next() - mediaPlayerController.repeatMode = repeatMode + var newRepeat = mediaPlayerController.repeatMode + 1 + if (newRepeat == 3) { + newRepeat = 0 + } + + mediaPlayerController.repeatMode = newRepeat + onPlaylistChanged() - when (repeatMode) { - RepeatMode.OFF -> Util.toast( + + when (newRepeat) { + 0 -> Util.toast( context, R.string.download_repeat_off ) - RepeatMode.ALL -> Util.toast( - context, R.string.download_repeat_all - ) - RepeatMode.SINGLE -> Util.toast( + 1 -> Util.toast( context, R.string.download_repeat_single ) + 2 -> Util.toast( + context, R.string.download_repeat_all + ) else -> { } } @@ -351,53 +350,62 @@ class PlayerFragment : visualizerViewLayout.isVisible = false VisualizerController.get().observe( - requireActivity(), - { visualizerController -> - if (visualizerController != null) { - Timber.d("VisualizerController Observer.onChanged received controller") - visualizerView = VisualizerView(context) - visualizerViewLayout.addView( - visualizerView, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) + requireActivity() + ) { visualizerController -> + if (visualizerController != null) { + Timber.d("VisualizerController Observer.onChanged received controller") + visualizerView = VisualizerView(context) + visualizerViewLayout.addView( + visualizerView, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT ) + ) - visualizerViewLayout.isVisible = visualizerView.isActive + visualizerViewLayout.isVisible = visualizerView.isActive - visualizerView.setOnTouchListener { _, _ -> - visualizerView.isActive = !visualizerView.isActive - mediaPlayerController.showVisualization = visualizerView.isActive - true - } - isVisualizerAvailable = true - } else { - Timber.d("VisualizerController Observer.onChanged has no controller") - visualizerViewLayout.isVisible = false - isVisualizerAvailable = false + visualizerView.setOnTouchListener { _, _ -> + visualizerView.isActive = !visualizerView.isActive + mediaPlayerController.showVisualization = visualizerView.isActive + true } + isVisualizerAvailable = true + } else { + Timber.d("VisualizerController Observer.onChanged has no controller") + visualizerViewLayout.isVisible = false + isVisualizerAvailable = false } - ) + } EqualizerController.get().observe( - requireActivity(), - { equalizerController -> - isEqualizerAvailable = if (equalizerController != null) { - Timber.d("EqualizerController Observer.onChanged received controller") - true - } else { - Timber.d("EqualizerController Observer.onChanged has no controller") - false - } + requireActivity() + ) { equalizerController -> + isEqualizerAvailable = if (equalizerController != null) { + Timber.d("EqualizerController Observer.onChanged received controller") + true + } else { + Timber.d("EqualizerController Observer.onChanged has no controller") + false } - ) + } // Observe playlist changes and update the UI - rxBusSubscription = RxBus.playlistObservable.subscribe { + // FIXME + rxBusSubscription += RxBus.playlistObservable.subscribe { onPlaylistChanged() } + rxBusSubscription += RxBus.playerStateObservable.subscribe { + update() + } + + mediaPlayerController.controller?.addListener(object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + onSliderProgressChanged() + } + }) + // Query the Jukebox state in an IO Context ioScope.launch(CommunicationError.getHandler(context)) { try { @@ -412,16 +420,15 @@ class PlayerFragment : override fun onResume() { super.onResume() - if (mediaPlayerController.currentPlaying == null) { + if (mediaPlayerController.currentPlayingLegacy == null) { playlistFlipper.displayedChild = 1 } else { - // Download list and Album art must be updated when Resumed + // Download list and Album art must be updated when resumed onPlaylistChanged() onCurrentChanged() } - val handler = Handler() - // TODO Use Rx for Update instead of polling! + val handler = Handler(Looper.getMainLooper()) val runnable = Runnable { handler.post { update(cancellationToken) } } executorService = Executors.newSingleThreadScheduledExecutor() executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS) @@ -441,7 +448,7 @@ class PlayerFragment : // Scroll to current playing. private fun scrollToCurrent() { - val index = mediaPlayerController.playList.indexOf(currentPlaying) + val index = mediaPlayerController.currentMediaItemIndex if (index != -1) { val smoothScroller = LinearSmoothScroller(context) @@ -459,7 +466,7 @@ class PlayerFragment : } override fun onDestroyView() { - rxBusSubscription?.dispose() + rxBusSubscription.dispose() cancel("CoroutineScope cancelled because the view was destroyed") cancellationToken.cancel() super.onDestroyView() @@ -504,7 +511,7 @@ class PlayerFragment : visualizerMenuItem.isVisible = isVisualizerAvailable } val mediaPlayerController = mediaPlayerController - val downloadFile = mediaPlayerController.currentPlaying + val downloadFile = mediaPlayerController.currentPlayingLegacy if (downloadFile != null) { currentSong = downloadFile.track @@ -631,7 +638,7 @@ class PlayerFragment : return true } R.id.menu_shuffle -> { - mediaPlayerController.shuffle() + mediaPlayerController.toggleShuffle() Util.toast(context, R.string.download_menu_shuffle_notification) return true } @@ -768,10 +775,10 @@ class PlayerFragment : } } - private fun update(cancel: CancellationToken?) { - if (cancel!!.isCancellationRequested) return + private fun update(cancel: CancellationToken? = null) { + if (cancel?.isCancellationRequested == true) return val mediaPlayerController = mediaPlayerController - if (currentPlaying != mediaPlayerController.currentPlaying) { + if (currentPlaying != mediaPlayerController.currentPlayingLegacy) { onCurrentChanged() } onSliderProgressChanged() @@ -822,23 +829,6 @@ class PlayerFragment : scrollToCurrent() } - private fun start() { - val service = mediaPlayerController - val state = service.playerState - if (state === PlayerState.PAUSED || - state === PlayerState.COMPLETED || state === PlayerState.STOPPED - ) { - service.start() - } else if (state === PlayerState.IDLE) { - networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() - val current = mediaPlayerController.currentPlayingNumberOnPlaylist - if (current == -1) { - service.play(0) - } else { - service.play(current) - } - } - } private fun initPlaylistDisplay() { // Create a View Manager @@ -852,17 +842,17 @@ class PlayerFragment : } // Create listener - val listener: ((DownloadFile) -> Unit) = { file -> - val list = mediaPlayerController.playList - val index = list.indexOf(file) - mediaPlayerController.play(index) + val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos -> + mediaPlayerController.seekTo(pos, 0) + mediaPlayerController.prepare() + mediaPlayerController.play() onCurrentChanged() onSliderProgressChanged() } viewAdapter.register( TrackViewBinder( - onItemClick = listener, + onItemClick = clickHandler, checkable = false, draggable = true, context = requireContext(), @@ -879,62 +869,63 @@ class PlayerFragment : ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT ) { - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { - val from = viewHolder.bindingAdapterPosition - val to = target.bindingAdapterPosition + val from = viewHolder.bindingAdapterPosition + val to = target.bindingAdapterPosition - // Move it in the data set - mediaPlayerController.moveItemInPlaylist(from, to) - viewAdapter.submitList(mediaPlayerController.playList) + // Move it in the data set + mediaPlayerController.moveItemInPlaylist(from, to) + viewAdapter.submitList(mediaPlayerController.playList) - return true - } + return true + } - // Swipe to delete from playlist - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val pos = viewHolder.bindingAdapterPosition - val file = mediaPlayerController.playList[pos] - mediaPlayerController.removeFromPlaylist(file) + // Swipe to delete from playlist + @SuppressLint("NotifyDataSetChanged") + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val pos = viewHolder.bindingAdapterPosition + val file = mediaPlayerController.playList[pos] + mediaPlayerController.removeFromPlaylist(file) - val songRemoved = String.format( - resources.getString(R.string.download_song_removed), - file.track.title - ) - Util.toast(context, songRemoved) + val songRemoved = String.format( + resources.getString(R.string.download_song_removed), + file.track.title + ) + Util.toast(context, songRemoved) - viewAdapter.submitList(mediaPlayerController.playList) - viewAdapter.notifyDataSetChanged() - } + viewAdapter.submitList(mediaPlayerController.playList) + viewAdapter.notifyDataSetChanged() + } - override fun onSelectedChanged( - viewHolder: RecyclerView.ViewHolder?, - actionState: Int - ) { - super.onSelectedChanged(viewHolder, actionState) + override fun onSelectedChanged( + viewHolder: RecyclerView.ViewHolder?, + actionState: Int + ) { + super.onSelectedChanged(viewHolder, actionState) - if (actionState == ACTION_STATE_DRAG) { - viewHolder?.itemView?.alpha = 0.6f - } - } - - override fun clearView( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ) { - super.clearView(recyclerView, viewHolder) - - viewHolder.itemView.alpha = 1.0f - } - - override fun isLongPressDragEnabled(): Boolean { - return false + if (actionState == ACTION_STATE_DRAG) { + viewHolder?.itemView?.alpha = 0.6f } } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) { + super.clearView(recyclerView, viewHolder) + + viewHolder.itemView.alpha = 1.0f + } + + override fun isLongPressDragEnabled(): Boolean { + return false + } + } ) dragTouchHelper.attachToRecyclerView(playlistView) @@ -950,32 +941,33 @@ class PlayerFragment : emptyTextView.isVisible = list.isEmpty() when (mediaPlayerController.repeatMode) { - RepeatMode.OFF -> repeatButton.setImageDrawable( + 0 -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( requireContext(), R.attr.media_repeat_off ) ) - RepeatMode.ALL -> repeatButton.setImageDrawable( - Util.getDrawableFromAttribute( - requireContext(), R.attr.media_repeat_all - ) - ) - RepeatMode.SINGLE -> repeatButton.setImageDrawable( + 1 -> repeatButton.setImageDrawable( Util.getDrawableFromAttribute( requireContext(), R.attr.media_repeat_single ) ) + 2 -> repeatButton.setImageDrawable( + Util.getDrawableFromAttribute( + requireContext(), R.attr.media_repeat_all + ) + ) else -> { } } } private fun onCurrentChanged() { - currentPlaying = mediaPlayerController.currentPlaying + currentPlaying = mediaPlayerController.currentPlayingLegacy + scrollToCurrent() val totalDuration = mediaPlayerController.playListDuration val totalSongs = mediaPlayerController.playlistSize.toLong() - val currentSongIndex = mediaPlayerController.currentPlayingNumberOnPlaylist + 1 + val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1 val duration = Util.formatTotalDuration(totalDuration) val trackFormat = String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs) @@ -992,7 +984,7 @@ class PlayerFragment : genreTextView.isVisible = (currentSong!!.genre != null && currentSong!!.genre!!.isNotBlank()) - var bitRate: String = "" + var bitRate = "" if (currentSong!!.bitRate != null && currentSong!!.bitRate!! > 0) bitRate = String.format( Util.appContext().getString(R.string.song_details_kbps), @@ -1027,14 +1019,14 @@ class PlayerFragment : } } - @Suppress("LongMethod", "ComplexMethod") @Synchronized private fun onSliderProgressChanged() { val isJukeboxEnabled: Boolean = mediaPlayerController.isJukeboxEnabled val millisPlayed: Int = max(0, mediaPlayerController.playerPosition) val duration: Int = mediaPlayerController.playerDuration - val playerState: PlayerState = mediaPlayerController.playerState + val playbackState: Int = mediaPlayerController.playbackState + val isPlaying = mediaPlayerController.isPlaying if (cancellationToken.isCancellationRequested) return if (currentPlaying != null) { @@ -1043,7 +1035,7 @@ class PlayerFragment : progressBar.max = if (duration == 0) 100 else duration // Work-around for apparent bug. progressBar.progress = millisPlayed - progressBar.isEnabled = currentPlaying!!.isWorkDone || isJukeboxEnabled + progressBar.isEnabled = mediaPlayerController.isPlaying || isJukeboxEnabled } else { positionTextView.setText(R.string.util_zero_time) durationTextView.setText(R.string.util_no_time) @@ -1052,21 +1044,20 @@ class PlayerFragment : progressBar.isEnabled = false } - when (playerState) { - PlayerState.DOWNLOADING -> { - val progress = - if (currentPlaying != null) currentPlaying!!.progress.value!! else 0 + val progress = mediaPlayerController.bufferedPercentage + + when (playbackState) { + Player.STATE_BUFFERING -> { + val downloadStatus = resources.getString( R.string.download_playerstate_downloading, Util.formatPercentage(progress) ) + progressBar.secondaryProgress = progress setTitle(this@PlayerFragment, downloadStatus) } - PlayerState.PREPARING -> setTitle( - this@PlayerFragment, - R.string.download_playerstate_buffering - ) - PlayerState.STARTED -> { + Player.STATE_READY -> { + progressBar.secondaryProgress = progress if (mediaPlayerController.isShufflePlayEnabled) { setTitle( this@PlayerFragment, @@ -1076,30 +1067,28 @@ class PlayerFragment : setTitle(this@PlayerFragment, R.string.common_appname) } } - PlayerState.IDLE, - PlayerState.PREPARED, - PlayerState.STOPPED, - PlayerState.PAUSED, - PlayerState.COMPLETED -> { + Player.STATE_IDLE, + Player.STATE_ENDED, + -> { } else -> setTitle(this@PlayerFragment, R.string.common_appname) } - when (playerState) { - PlayerState.STARTED -> { - pauseButton.isVisible = true + when (playbackState) { + Player.STATE_READY -> { + pauseButton.isVisible = isPlaying stopButton.isVisible = false - startButton.isVisible = false + playButton.isVisible = !isPlaying } - PlayerState.DOWNLOADING, PlayerState.PREPARING -> { + Player.STATE_BUFFERING -> { pauseButton.isVisible = false stopButton.isVisible = true - startButton.isVisible = false + playButton.isVisible = false } else -> { pauseButton.isVisible = false stopButton.isVisible = false - startButton.isVisible = true + playButton.isVisible = true } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index bfca7917..72baa959 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -109,7 +109,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { viewAdapter.register( TrackViewBinder( - onItemClick = ::onItemClick, + onItemClick = { file, _ -> onItemClick(file) }, onContextMenuClick = ::onContextMenuItemSelected, checkable = false, draggable = false, @@ -151,7 +151,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { val arguments = arguments val autoPlay = arguments != null && - arguments.getBoolean(Constants.INTENT_AUTOPLAY, false) + arguments.getBoolean(Constants.INTENT_AUTOPLAY, false) val query = arguments?.getString(Constants.INTENT_QUERY) // If started with a query, enter it to the searchView @@ -303,13 +303,12 @@ class SearchFragment : MultiListFragment(), KoinComponent { } mediaPlayerController.addToPlaylist( listOf(song), - save = false, + cachePermanently = false, autoPlay = false, - playNext = false, shuffle = false, - newPlaylist = false + insertionMode = MediaPlayerController.InsertionMode.APPEND ) - mediaPlayerController.play(mediaPlayerController.playlistSize - 1) + mediaPlayerController.play(mediaPlayerController.mediaItemCount - 1) toast(context, resources.getQuantityString(R.plurals.select_album_n_songs_added, 1, 1)) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index d676e0ee..730d8bd1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -34,7 +34,7 @@ class ServerSelectorFragment : Fragment() { private var listView: ListView? = null private val serverSettingsModel: ServerSettingsModel by viewModel() - private val service: MediaPlayerController by inject() + private val controller: MediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() private var serverRowAdapter: ServerRowAdapter? = null @@ -117,14 +117,14 @@ class ServerSelectorFragment : Fragment() { // TODO this is still a blocking call - we shouldn't leave this activity before the active server is updated. // Maybe this can be refactored by using LiveData, or this can be made more user friendly with a ProgressDialog runBlocking { + controller.clearIncomplete() withContext(Dispatchers.IO) { if (activeServerProvider.getActiveServer().index != index) { - service.clearIncomplete() activeServerProvider.setActiveServerByIndex(index) - service.isJukeboxEnabled = - activeServerProvider.getActiveServer().jukeboxByDefault } } + controller.isJukeboxEnabled = + activeServerProvider.getActiveServer().jukeboxByDefault } Timber.i("Active server was set to: $index") } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index b140e701..061d6f13 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -23,7 +23,7 @@ import androidx.preference.PreferenceFragmentCompat import java.io.File import kotlin.math.ceil import org.koin.core.component.KoinComponent -import org.koin.java.KoinJavaComponent.inject +import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle @@ -40,7 +40,6 @@ import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.ErrorDialog import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory import org.moire.ultrasonic.util.InfoDialog -import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings.preferences import org.moire.ultrasonic.util.Settings.shareGreeting @@ -89,12 +88,7 @@ class SettingsFragment : private var debugLogToFile: CheckBoxPreference? = null private var customCacheLocation: CheckBoxPreference? = null - private val mediaPlayerControllerLazy = inject( - MediaPlayerController::class.java - ) - private val mediaSessionHandler = inject( - MediaSessionHandler::class.java - ) + private val mediaPlayerController: MediaPlayerController by inject() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings, rootKey) @@ -221,9 +215,6 @@ class SettingsFragment : Constants.PREFERENCES_KEY_HIDE_MEDIA -> { setHideMedia(sharedPreferences.getBoolean(key, false)) } - Constants.PREFERENCES_KEY_MEDIA_BUTTONS -> { - setMediaButtonsEnabled(sharedPreferences.getBoolean(key, true)) - } Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS -> { setBluetoothPreferences(sharedPreferences.getBoolean(key, true)) } @@ -433,11 +424,6 @@ class SettingsFragment : toast(activity, R.string.settings_hide_media_toast, false) } - private fun setMediaButtonsEnabled(enabled: Boolean) { - lockScreenEnabled!!.isEnabled = enabled - mediaSessionHandler.value.updateMediaButtonReceiver() - } - private fun setBluetoothPreferences(enabled: Boolean) { sendBluetoothAlbumArt!!.isEnabled = enabled } @@ -451,8 +437,8 @@ class SettingsFragment : Settings.cacheLocationUri = path // Clear download queue. - mediaPlayerControllerLazy.value.clear() - mediaPlayerControllerLazy.value.clearCaches() + mediaPlayerController.clear() + mediaPlayerController.clearCaches() Storage.reset() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index e0e990a9..3c9fcb39 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -122,7 +122,7 @@ open class TrackCollectionFragment : MultiListFragment() { viewAdapter.register( TrackViewBinder( - onItemClick = { onItemClick(it.track) }, + onItemClick = { file, _ -> onItemClick(file.track) }, onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) }, checkable = true, draggable = false, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt new file mode 100644 index 00000000..92f3244a --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt @@ -0,0 +1,398 @@ +/* + * APIDataSource.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.MediaLibraryInfo +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSourceException +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.HttpDataSource.HttpDataSourceException +import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.datasource.HttpDataSource.RequestProperties +import androidx.media3.datasource.HttpUtil +import androidx.media3.datasource.TransferListener +import com.google.common.net.HttpHeaders +import okhttp3.CacheControl +import okhttp3.Call +import okhttp3.ResponseBody +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.api.subsonic.response.StreamResponse +import org.moire.ultrasonic.api.subsonic.throwOnFailure +import org.moire.ultrasonic.api.subsonic.toStreamResponse +import org.moire.ultrasonic.util.AbstractFile +import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.Storage +import timber.log.Timber +import java.io.IOException +import java.io.InputStream +import java.io.InterruptedIOException + +/** + * An [HttpDataSource] that delegates to Square's [Call.Factory]. + * + * + * Note: HTTP request headers will be set using all parameters passed via (in order of decreasing + * priority) the `dataSpec`, [.setRequestProperty] and the default parameters used to + * construct the instance. + */ +@UnstableApi +open class OkHttpDataSource private constructor( + subsonicAPIClient: SubsonicAPIClient, + userAgent: String?, + cacheControl: CacheControl?, + defaultRequestProperties: RequestProperties? +) : BaseDataSource(true), + HttpDataSource { + companion object { + init { + MediaLibraryInfo.registerModule("media3.datasource.okhttp") + } + } + + /** [DataSource.Factory] for [OkHttpDataSource] instances. */ + class Factory(private val subsonicAPIClient: SubsonicAPIClient) : HttpDataSource.Factory { + private val defaultRequestProperties: RequestProperties = RequestProperties() + private var userAgent: String? = null + private var transferListener: TransferListener? = null + private var cacheControl: CacheControl? = null + + override fun setDefaultRequestProperties(defaultRequestProperties: Map): Factory { + this.defaultRequestProperties.clearAndSet(defaultRequestProperties) + return this + } + + + /** + * Sets the [TransferListener] that will be used. + * + * + * The default is `null`. + * + * + * See [DataSource.addTransferListener]. + * + * @param transferListener The listener that will be used. + * @return This factory. + */ + fun setTransferListener(transferListener: TransferListener?): Factory { + this.transferListener = transferListener + return this + } + + override fun createDataSource(): OkHttpDataSource { + val dataSource = OkHttpDataSource( + subsonicAPIClient, + userAgent, + cacheControl, + defaultRequestProperties + ) + if (transferListener != null) { + dataSource.addTransferListener(transferListener!!) + } + return dataSource + } + + } + + + private val subsonicAPIClient: SubsonicAPIClient = Assertions.checkNotNull(subsonicAPIClient) + private val requestProperties: RequestProperties + private val userAgent: String? + private val cacheControl: CacheControl? + private val defaultRequestProperties: RequestProperties? + private var dataSpec: DataSpec? = null + private var response: retrofit2.Response? = null + private var responseByteStream: InputStream? = null + private var openedNetwork = false + private var openedFile = false + private var cachePath: String? = null + private var cacheFile: AbstractFile? = null + private var bytesToRead: Long = 0 + private var bytesRead: Long = 0 + + override fun getUri(): Uri? { + return when { + cachePath != null -> cachePath!!.toUri() + response == null -> null + else -> response!!.raw().request.url.toString().toUri() + } + } + + override fun getResponseCode(): Int { + return if (response == null) -1 else response!!.code() + } + + override fun getResponseHeaders(): Map> { + return if (response == null) emptyMap() else response!!.headers().toMultimap() + } + + override fun setRequestProperty(name: String, value: String) { + Assertions.checkNotNull(name) + Assertions.checkNotNull(value) + requestProperties[name] = value + } + + override fun clearRequestProperty(name: String) { + Assertions.checkNotNull(name) + requestProperties.remove(name) + } + + override fun clearAllRequestProperties() { + requestProperties.clear() + } + + @Throws(HttpDataSourceException::class) + override fun open(dataSpec: DataSpec): Long { + this.dataSpec = dataSpec + bytesRead = 0 + bytesToRead = 0 + + transferInitializing(dataSpec) + val components = dataSpec.uri.toString().split('|') + val id = components[0] + val bitrate = components[1].toInt() + val path = components[2] + + val cacheLength = checkCache(path) + + // We have found an item in the cache, return early + if (cacheLength > 0) { + bytesToRead = cacheLength + return bytesToRead + } + + Timber.i("DATASOURCE: %s", "Start") + val request = subsonicAPIClient.api.stream(id, bitrate, offset = 0) + val response: retrofit2.Response? + val streamResponse: StreamResponse + Timber.i("DATASOURCE: %s", "Start2") + try { + this.response = request.execute() + Timber.i("DATASOURCE: %s", "Start3") + response = this.response + streamResponse = response!!.toStreamResponse() + Timber.i("DATASOURCE: %s", "Start4") + responseByteStream = streamResponse.stream + Timber.i("DATASOURCE: %s", "Start5") + } catch (e: IOException) { + throw HttpDataSourceException.createForIOException( + e, dataSpec, HttpDataSourceException.TYPE_OPEN + ) + } + + streamResponse.throwOnFailure() + + val responseCode = response.code() + + // Check for a valid response code. + if (!response.isSuccessful) { + if (responseCode == 416) { + val documentSize = + HttpUtil.getDocumentSize(response.headers()[HttpHeaders.CONTENT_RANGE]) + if (dataSpec.position == documentSize) { + openedNetwork = true + transferStarted(dataSpec) + return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0 + } + } + val errorResponseBody: ByteArray = try { + Util.toByteArray(Assertions.checkNotNull(responseByteStream)) + } catch (e: IOException) { + Util.EMPTY_BYTE_ARRAY + } + val headers = response.headers().toMultimap() + closeConnectionQuietly() + val cause: IOException? = + if (responseCode == 416) DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) else null + throw InvalidResponseCodeException( + responseCode, response.message(), cause, headers, dataSpec, errorResponseBody + ) + } + + Timber.i("DATASOURCE: %s", "Start6") + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + val bytesToSkip = + if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0 + + // Determine the length of the data to be read, after skipping. + bytesToRead = if (dataSpec.length != C.LENGTH_UNSET.toLong()) { + dataSpec.length + } else { + val contentLength = response.body()!!.contentLength() + if (contentLength != -1L) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong() + } + openedNetwork = true + transferStarted(dataSpec) + try { + skipFully(bytesToSkip, dataSpec) + } catch (e: HttpDataSourceException) { + closeConnectionQuietly() + throw e + } + Timber.i("DATASOURCE: %s", "Start7") + + return bytesToRead + } + + @Throws(HttpDataSourceException::class) + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return try { + readInternal(buffer, offset, length) + } catch (e: IOException) { + throw HttpDataSourceException.createForIOException( + e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ + ) + } + } + + override fun close() { + if (openedNetwork) { + openedNetwork = false + transferEnded() + closeConnectionQuietly() + } else if (openedFile) { + openedFile = false + responseByteStream?.close() + responseByteStream = null + } + } + + /** + * Checks our cache for a matching media file + */ + private fun checkCache(path: String): Long { + var filePath: String = path + var found = Storage.isPathExists(path) + + if (!found) { + filePath = FileUtil.getCompleteFile(path) + found = Storage.isPathExists(filePath) + } + + if (!found) return -1 + + cachePath = filePath + + cacheFile = Storage.getFromPath(filePath)!! + responseByteStream = cacheFile!!.getFileInputStream() + + return cacheFile!!.getDocumentFileDescriptor("r")!!.length + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpec The [DataSpec]. + * @throws HttpDataSourceException If the thread is interrupted during the operation, or an error + * occurs while reading from the source, or if the data ended before skipping the specified + * number of bytes. + */ + @Throws(HttpDataSourceException::class) + private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) { + var bytesToSkip = bytesToSkip + if (bytesToSkip == 0L) { + return + } + val skipBuffer = ByteArray(4096) + try { + while (bytesToSkip > 0) { + val readLength = + bytesToSkip.coerceAtMost(skipBuffer.size.toLong()).toInt() + val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength) + if (Thread.currentThread().isInterrupted) { + throw InterruptedIOException() + } + if (read == -1) { + throw HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN + ) + } + bytesToSkip -= read.toLong() + bytesTransferred(read) + } + return + } catch (e: IOException) { + if (e is HttpDataSourceException) { + throw e + } else { + throw HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN + ) + } + } + } + + /** + * Reads up to `length` bytes of data and stores them into `buffer`, starting at index + * `offset`. + * + * + * This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into `buffer` at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or [C.RESULT_END_OF_INPUT] if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + @Throws(IOException::class) + private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { + var readLength = readLength + if (readLength == 0) { + return 0 + } + if (bytesToRead != C.LENGTH_UNSET.toLong()) { + val bytesRemaining = bytesToRead - bytesRead + if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + readLength = readLength.toLong().coerceAtMost(bytesRemaining).toInt() + } + val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLength) + if (read == -1) { + return C.RESULT_END_OF_INPUT + } + bytesRead += read.toLong() + // bytesTransferred(read) + return read + } + + /** Closes the current connection quietly, if there is one. */ + private fun closeConnectionQuietly() { + if (response != null) { + Assertions.checkNotNull(response!!.body()).close() + response = null + } + responseByteStream = null + } + + init { + this.userAgent = userAgent + this.cacheControl = cacheControl + this.defaultRequestProperties = defaultRequestProperties + requestProperties = RequestProperties() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt new file mode 100644 index 00000000..ebe17602 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt @@ -0,0 +1,127 @@ +/* + * LegacyPlaylist.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import androidx.media3.common.MediaItem +import androidx.media3.session.MediaController +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.DownloadFile +import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.service.JukeboxMediaPlayer +import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.util.LRUCache +import timber.log.Timber + +/** + * This class keeps a legacy playlist maintained which + * reflects the internal timeline of the Media3.Player + */ +class LegacyPlaylistManager : KoinComponent { + + private val _playlist = mutableListOf() + + @JvmField + var currentPlaying: DownloadFile? = null + + private val mediaItemCache = LRUCache(1000) + + val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() + val downloader: Downloader by inject() + + private var playlistUpdateRevision: Long = 0 + private set(value) { + field = value + RxBus.playlistPublisher.onNext(_playlist) + } + + fun rebuildPlaylist(controller: MediaController) { + _playlist.clear() + + val n = controller.mediaItemCount + + for (i in 0 until n) { + val item = controller.getMediaItemAt(i) + val file = mediaItemCache[item.mediaMetadata.mediaUri.toString()] + if (file != null) + _playlist.add(file) + } + + playlistUpdateRevision++ + } + + fun addToCache(item: MediaItem, file: DownloadFile) { + mediaItemCache.put(item.mediaMetadata.mediaUri.toString(), file) + } + + fun updateCurrentPlaying(item: MediaItem?) { + currentPlaying = mediaItemCache[item?.mediaMetadata?.mediaUri.toString()] + } + + @Synchronized + fun clearIncomplete() { + val iterator = _playlist.iterator() + var changedPlaylist = false + while (iterator.hasNext()) { + val downloadFile = iterator.next() + if (!downloadFile.isCompleteFileAvailable) { + iterator.remove() + changedPlaylist = true + } + } + if (changedPlaylist) playlistUpdateRevision++ + } + + @Synchronized + fun clearPlaylist() { + _playlist.clear() + playlistUpdateRevision++ + } + + fun onDestroy() { + clearPlaylist() + Timber.i("PlaylistManager destroyed") + } + + // Public facing playlist (immutable) + val playlist: List + get() = _playlist + + // FIXME: Returns wrong count if item is twice in queue + @get:Synchronized + val currentPlayingIndex: Int + get() = _playlist.indexOf(currentPlaying) + + @get:Synchronized + val playlistDuration: Long + get() { + var totalDuration: Long = 0 + for (downloadFile in _playlist) { + val song = downloadFile.track + if (!song.isDirectory) { + if (song.artist != null) { + if (song.duration != null) { + totalDuration += song.duration!!.toLong() + } + } + } + } + return totalDuration + } + + /** + * Extension function + * Gathers the download file for a given song, and modifies shouldSave if provided. + */ + fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { + return downloader.getDownloadFileForSong(this).apply { + if (save != null) this.shouldSave = save + } + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaItemTree.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaItemTree.kt new file mode 100644 index 00000000..f3232bb8 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaItemTree.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2021 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.playback + +import android.content.res.AssetManager +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE +import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS +import androidx.media3.common.util.Util +import com.google.common.collect.ImmutableList +import org.json.JSONObject + +/** + * A sample media catalog that represents media items as a tree. + * + * It fetched the data from {@code catalog.json}. The root's children are folders containing media + * items from the same album/artist/genre. + * + * Each app should have their own way of representing the tree. MediaItemTree is used for + * demonstration purpose only. + */ +object MediaItemTree { + private var treeNodes: MutableMap = mutableMapOf() + private var titleMap: MutableMap = mutableMapOf() + private var isInitialized = false + private const val ROOT_ID = "[rootID]" + private const val ALBUM_ID = "[albumID]" + private const val GENRE_ID = "[genreID]" + private const val ARTIST_ID = "[artistID]" + private const val ALBUM_PREFIX = "[album]" + private const val GENRE_PREFIX = "[genre]" + private const val ARTIST_PREFIX = "[artist]" + private const val ITEM_PREFIX = "[item]" + + private class MediaItemNode(val item: MediaItem) { + private val children: MutableList = ArrayList() + + fun addChild(childID: String) { + this.children.add(treeNodes[childID]!!.item) + } + + fun getChildren(): List { + return ImmutableList.copyOf(children) + } + } + + private fun buildMediaItem( + title: String, + mediaId: String, + isPlayable: Boolean, + @MediaMetadata.FolderType folderType: Int, + album: String? = null, + artist: String? = null, + genre: String? = null, + sourceUri: Uri? = null, + imageUri: Uri? = null, + ): MediaItem { + // TODO(b/194280027): add artwork + val metadata = + MediaMetadata.Builder() + .setAlbumTitle(album) + .setTitle(title) + .setArtist(artist) + .setGenre(genre) + .setFolderType(folderType) + .setIsPlayable(isPlayable) + .setArtworkUri(imageUri) + .build() + return MediaItem.Builder() + .setMediaId(mediaId) + .setMediaMetadata(metadata) + .setUri(sourceUri) + .build() + } + + @androidx.media3.common.util.UnstableApi + private fun loadJSONFromAsset(assets: AssetManager): String { + val buffer = assets.open("catalog.json").use { Util.toByteArray(it) } + return String(buffer, Charsets.UTF_8) + } + + fun initialize(assets: AssetManager) { + if (isInitialized) return + isInitialized = true + // create root and folders for album/artist/genre. + treeNodes[ROOT_ID] = + MediaItemNode( + buildMediaItem( + title = "Root Folder", + mediaId = ROOT_ID, + isPlayable = false, + folderType = FOLDER_TYPE_MIXED + ) + ) + treeNodes[ALBUM_ID] = + MediaItemNode( + buildMediaItem( + title = "Album Folder", + mediaId = ALBUM_ID, + isPlayable = false, + folderType = FOLDER_TYPE_MIXED + ) + ) + treeNodes[ARTIST_ID] = + MediaItemNode( + buildMediaItem( + title = "Artist Folder", + mediaId = ARTIST_ID, + isPlayable = false, + folderType = FOLDER_TYPE_MIXED + ) + ) + treeNodes[GENRE_ID] = + MediaItemNode( + buildMediaItem( + title = "Genre Folder", + mediaId = GENRE_ID, + isPlayable = false, + folderType = FOLDER_TYPE_MIXED + ) + ) + treeNodes[ROOT_ID]!!.addChild(ALBUM_ID) + treeNodes[ROOT_ID]!!.addChild(ARTIST_ID) + treeNodes[ROOT_ID]!!.addChild(GENRE_ID) + + // Here, parse the json file in asset for media list. + // We use a file in asset for demo purpose +// val jsonObject = JSONObject(loadJSONFromAsset(assets)) +// val mediaList = jsonObject.getJSONArray("media") +// +// // create subfolder with same artist, album, etc. +// for (i in 0 until mediaList.length()) { +// addNodeToTree(mediaList.getJSONObject(i)) +// } + } + + private fun addNodeToTree(mediaObject: JSONObject) { + + val id = mediaObject.getString("id") + val album = mediaObject.getString("album") + val title = mediaObject.getString("title") + val artist = mediaObject.getString("artist") + val genre = mediaObject.getString("genre") + val sourceUri = Uri.parse(mediaObject.getString("source")) + val imageUri = Uri.parse(mediaObject.getString("image")) + // key of such items in tree + val idInTree = ITEM_PREFIX + id + val albumFolderIdInTree = ALBUM_PREFIX + album + val artistFolderIdInTree = ARTIST_PREFIX + artist + val genreFolderIdInTree = GENRE_PREFIX + genre + + treeNodes[idInTree] = + MediaItemNode( + buildMediaItem( + title = title, + mediaId = idInTree, + isPlayable = true, + album = album, + artist = artist, + genre = genre, + sourceUri = sourceUri, + imageUri = imageUri, + folderType = FOLDER_TYPE_NONE + ) + ) + + titleMap[title.lowercase()] = treeNodes[idInTree]!! + + if (!treeNodes.containsKey(albumFolderIdInTree)) { + treeNodes[albumFolderIdInTree] = + MediaItemNode( + buildMediaItem( + title = album, + mediaId = albumFolderIdInTree, + isPlayable = true, + folderType = FOLDER_TYPE_PLAYLISTS + ) + ) + treeNodes[ALBUM_ID]!!.addChild(albumFolderIdInTree) + } + treeNodes[albumFolderIdInTree]!!.addChild(idInTree) + + // add into artist folder + if (!treeNodes.containsKey(artistFolderIdInTree)) { + treeNodes[artistFolderIdInTree] = + MediaItemNode( + buildMediaItem( + title = artist, + mediaId = artistFolderIdInTree, + isPlayable = true, + folderType = FOLDER_TYPE_PLAYLISTS + ) + ) + treeNodes[ARTIST_ID]!!.addChild(artistFolderIdInTree) + } + treeNodes[artistFolderIdInTree]!!.addChild(idInTree) + + // add into genre folder + if (!treeNodes.containsKey(genreFolderIdInTree)) { + treeNodes[genreFolderIdInTree] = + MediaItemNode( + buildMediaItem( + title = genre, + mediaId = genreFolderIdInTree, + isPlayable = true, + folderType = FOLDER_TYPE_PLAYLISTS + ) + ) + treeNodes[GENRE_ID]!!.addChild(genreFolderIdInTree) + } + treeNodes[genreFolderIdInTree]!!.addChild(idInTree) + } + + fun getItem(id: String): MediaItem? { + return treeNodes[id]?.item + } + + fun getRootItem(): MediaItem { + return treeNodes[ROOT_ID]!!.item + } + + fun getChildren(id: String): List? { + return treeNodes[id]?.getChildren() + } + + fun getRandomItem(): MediaItem { + var curRoot = getRootItem() + while (curRoot.mediaMetadata.folderType != FOLDER_TYPE_NONE) { + val children = getChildren(curRoot.mediaId)!! + curRoot = children.random() + } + return curRoot + } + + fun getItemFromTitle(title: String): MediaItem? { + return titleMap[title]?.item + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt new file mode 100644 index 00000000..4afd316d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt @@ -0,0 +1,158 @@ +/* + * MediaNotificationProvider.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Bundle +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.media3.common.Player +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.session.MediaController +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaNotification.ActionFactory +import org.moire.ultrasonic.R + +/* +* This is a copy of DefaultMediaNotificationProvider.java with some small changes +* I have opened a bug https://github.com/androidx/media/issues/65 to make it easier to customize +* the icons and actions without creating our own copy of this class.. + */ +@UnstableApi +/* package */ +internal class MediaNotificationProvider(context: Context) : + MediaNotification.Provider { + private val context: Context = context.applicationContext + private val notificationManager: NotificationManager = Assertions.checkStateNotNull( + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ) + + override fun createNotification( + mediaController: MediaController, + actionFactory: ActionFactory, + onNotificationChangedCallback: MediaNotification.Provider.Callback + ): MediaNotification { + ensureNotificationChannel() + val builder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID + ) + // TODO(b/193193926): Filter actions depending on the player's available commands. + // Skip to previous action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource( + context, + R.drawable.media3_notification_seek_to_previous + ), + context.getString(R.string.media3_controls_seek_to_previous_description), + ActionFactory.COMMAND_SKIP_TO_PREVIOUS + ) + ) + if (mediaController.playbackState == Player.STATE_ENDED + || !mediaController.playWhenReady + ) { + // Play action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_play), + context.getString(R.string.media3_controls_play_description), + ActionFactory.COMMAND_PLAY + ) + ) + } else { + // Pause action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_pause), + context.getString(R.string.media3_controls_pause_description), + ActionFactory.COMMAND_PAUSE + ) + ) + } + // Skip to next action. + builder.addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next), + context.getString(R.string.media3_controls_seek_to_next_description), + ActionFactory.COMMAND_SKIP_TO_NEXT + ) + ) + + // Set metadata info in the notification. + val metadata = mediaController.mediaMetadata + builder.setContentTitle(metadata.title).setContentText(metadata.artist) + if (metadata.artworkData != null) { + val artworkBitmap = + BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData!!.size) + builder.setLargeIcon(artworkBitmap) + } + val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() + .setCancelButtonIntent( + actionFactory.createMediaActionPendingIntent( + ActionFactory.COMMAND_STOP + ) + ) + .setShowActionsInCompactView(0, 1, 2) + val notification: Notification = builder + .setContentIntent(mediaController.sessionActivity) + .setDeleteIntent( + actionFactory.createMediaActionPendingIntent( + ActionFactory.COMMAND_STOP + ) + ) + .setOnlyAlertOnce(true) + .setSmallIcon(getSmallIconResId()) + .setStyle(mediaStyle) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(false) + .build() + return MediaNotification( + NOTIFICATION_ID, + notification + ) + } + + override fun handleCustomAction( + mediaController: MediaController, + action: String, + extras: Bundle + ) { + // We don't handle custom commands. + } + + private fun ensureNotificationChannel() { + if (Util.SDK_INT < 26 + || notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null + ) { + return + } + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + + companion object { + private const val NOTIFICATION_ID = 1001 + private const val NOTIFICATION_CHANNEL_ID = "default_channel_id" + private const val NOTIFICATION_CHANNEL_NAME = "Now playing" + private fun getSmallIconResId(): Int { + return R.drawable.ic_stat_ultrasonic + } + } + +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md new file mode 100644 index 00000000..7f9a4100 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/Plan.md @@ -0,0 +1,18 @@ + + + +UI: +[x] Display tracks +[x] On selection: Translate Tracks to MediaItems +[x] Move playlist val to Controller: Keep it around for easier migration!! +[x] Also make a LRU Cache to help with translation between MediaItem and DownloadFile +[x] Hand MediaItems to Service +[] If wanted also hand them to Downloader.kt +[x] Service plays MediaItem through OkHttp +[x] UI needs to receive info from service +[] Create a Cache Layer +[] Translate AutoMediaBrowserService +[] Add new shuffle icon.... + +DownloadNotificationHelper +convertToPlaybackStateCompatState() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt new file mode 100644 index 00000000..bd98fb84 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -0,0 +1,244 @@ +/* + * Copyright 2021 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.playback + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.C.CONTENT_TYPE_MUSIC +import androidx.media3.common.C.USAGE_MEDIA +import androidx.media3.common.MediaItem +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.activity.NavigationActivity +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.app.UApp +import org.moire.ultrasonic.util.Constants + + +class PlaybackService : MediaLibraryService(), KoinComponent { + private lateinit var player: ExoPlayer + private lateinit var mediaLibrarySession: MediaLibrarySession + private lateinit var dataSourceFactory: DataSource.Factory + + private val librarySessionCallback = CustomMediaLibrarySessionCallback() + + companion object { + private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch" + private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri" + } + + private inner class CustomMediaLibrarySessionCallback : + MediaLibrarySession.MediaLibrarySessionCallback { + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> { + return Futures.immediateFuture( + LibraryResult.ofItem( + MediaItemTree.getRootItem(), + params + ) + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val item = + MediaItemTree.getItem(mediaId) + ?: return Futures.immediateFuture( + LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + ) + return Futures.immediateFuture(LibraryResult.ofItem(item, /* params= */ null)) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val children = + MediaItemTree.getChildren(parentId) + ?: return Futures.immediateFuture( + LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + ) + + return Futures.immediateFuture(LibraryResult.ofItemList(children, params)) + } + + private fun setMediaItemFromSearchQuery(query: String) { + // Only accept query with pattern "play [Title]" or "[Title]" + // Where [Title]: must be exactly matched + // If no media with exact name found, play a random media instead + val mediaTitle = + if (query.startsWith("play ", ignoreCase = true)) { + query.drop(5) + } else { + query + } + + val item = MediaItemTree.getItemFromTitle(mediaTitle) ?: MediaItemTree.getRandomItem() + player.setMediaItem(item) + } + + override fun onSetMediaUri( + session: MediaSession, + controller: MediaSession.ControllerInfo, + uri: Uri, + extras: Bundle + ): Int { + + if (uri.toString().startsWith(SEARCH_QUERY_PREFIX) || + uri.toString().startsWith(SEARCH_QUERY_PREFIX_COMPAT) + ) { + val searchQuery = + uri.getQueryParameter("query") + ?: return SessionResult.RESULT_ERROR_NOT_SUPPORTED + setMediaItemFromSearchQuery(searchQuery) + + return SessionResult.RESULT_SUCCESS + } else { + return SessionResult.RESULT_ERROR_NOT_SUPPORTED + } + } + } + + /* + * For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer, + * and thereby customarily it is required to rebuild it.. + */ + private class CustomMediaItemFiller : MediaSession.MediaItemFiller { + override fun fillInLocalConfiguration( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItem: MediaItem + ): MediaItem { + // Again, set the Uri, so that it will get a LocalConfiguration + val item = mediaItem.buildUpon() + .setUri(mediaItem.mediaMetadata.mediaUri) + .build() + + return item + + } + } + + override fun onCreate() { + super.onCreate() + initializeSessionAndPlayer() + } + + override fun onDestroy() { + player.release() + mediaLibrarySession.release() + super.onDestroy() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession { + return mediaLibrarySession + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun initializeSessionAndPlayer() { + /* + * TODO: + * * Could be refined to use WAKE_MODE_LOCAL when offline.... + */ + + + setMediaNotificationProvider(MediaNotificationProvider(UApp.applicationContext())) + + + val subsonicAPIClient: SubsonicAPIClient by inject() + + // Create a MediaSource which passes calls through our OkHttp Stack + dataSourceFactory = OkHttpDataSource.Factory(subsonicAPIClient) + + + // A download cache should not evict media, so should use a NoopCacheEvictor. + // A download cache should not evict media, so should use a NoopCacheEvictor. + // TODO: Add cache: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer +// var cache = UltrasonicCache() +// +// val cacheDataSourceFactory: DataSource.Factory = CacheDataSource.Factory() +// .setCache(cache) +// .setUpstreamDataSourceFactory(dataSourceFactory) +// .setCacheWriteDataSinkFactory(null) // Disable writing. + + + // Create a renderer with HW rendering support + val renderer = DefaultRenderersFactory(this) + renderer.setEnableAudioOffload(true) + + // Create the player + player = ExoPlayer.Builder(this) + .setAudioAttributes(getAudioAttributes(), true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setHandleAudioBecomingNoisy(true) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + //.setRenderersFactory(renderer) + .build() + + // Enable audio offload + //player.experimentalSetOffloadSchedulingEnabled(true) + + MediaItemTree.initialize(assets) + + // THIS Will need to use the AutoCalls + mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) + .setMediaItemFiller(CustomMediaItemFiller()) + .setSessionActivity(getPendingIntentForContent()) + .build() + } + + @SuppressLint("UnspecifiedImmutableFlag") + private fun getPendingIntentForContent(): PendingIntent { + val intent = Intent(this, NavigationActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val flags = PendingIntent.FLAG_UPDATE_CURRENT + intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) + return PendingIntent.getActivity(this, 0, intent, flags) + } + + private fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.Builder() + .setUsage(USAGE_MEDIA) + .setContentType(CONTENT_TYPE_MUSIC) + .build() + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/UltrasonicCache.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/UltrasonicCache.kt new file mode 100644 index 00000000..f3b59499 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/UltrasonicCache.kt @@ -0,0 +1,99 @@ +/* + * UltrasonicCache.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.playback + +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheSpan +import androidx.media3.datasource.cache.ContentMetadata +import androidx.media3.datasource.cache.ContentMetadataMutations +import java.io.File +import java.util.NavigableSet + +@UnstableApi + +class UltrasonicCache : Cache { + override fun getUid(): Long { + TODO("Not yet implemented") + } + + override fun release() { + // R/O Cache Implementation + } + + override fun addListener(key: String, listener: Cache.Listener): NavigableSet { + // Not (yet?) implemented + return emptySet() as NavigableSet + } + + override fun removeListener(key: String, listener: Cache.Listener) { + // Not (yet?) implemented + } + + override fun getCachedSpans(key: String): NavigableSet { + TODO("Not yet implemented") + } + + override fun getKeys(): MutableSet { + TODO("Not yet implemented") + } + + override fun getCacheSpace(): Long { + TODO("Not yet implemented") + } + + override fun startReadWrite(key: String, position: Long, length: Long): CacheSpan { + TODO("Not yet implemented") + } + + override fun startReadWriteNonBlocking(key: String, position: Long, length: Long): CacheSpan? { + TODO("Not yet implemented") + } + + override fun startFile(key: String, position: Long, length: Long): File { + // R/O Cache Implementation + return File("NONE") + } + + override fun commitFile(file: File, length: Long) { + // R/O Cache Implementation + } + + override fun releaseHoleSpan(holeSpan: CacheSpan) { + TODO("Not yet implemented") + } + + override fun removeResource(key: String) { + // R/O Cache Implementation + } + + override fun removeSpan(span: CacheSpan) { + // R/O Cache Implementation + } + + override fun isCached(key: String, position: Long, length: Long): Boolean { + TODO("Not yet implemented") + } + + override fun getCachedLength(key: String, position: Long, length: Long): Long { + TODO("Not yet implemented") + } + + override fun getCachedBytes(key: String, position: Long, length: Long): Long { + TODO("Not yet implemented") + } + + override fun applyContentMetadataMutations(key: String, mutations: ContentMetadataMutations) { + TODO("Not yet implemented") + } + + override fun getContentMetadata(key: String): ContentMetadata { + TODO("Not yet implemented") + } + +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt deleted file mode 100644 index 34ad87cc..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AudioFocusHandler.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.moire.ultrasonic.service - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioManager -import android.media.AudioManager.OnAudioFocusChangeListener -import androidx.media.AudioAttributesCompat -import androidx.media.AudioFocusRequestCompat -import androidx.media.AudioManagerCompat -import org.koin.java.KoinJavaComponent.inject -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.util.Settings -import timber.log.Timber - -class AudioFocusHandler(private val context: Context) { - // TODO: This is a circular reference, try to remove it - // This should be doable by using the native MediaController framework - private val mediaPlayerControllerLazy = - inject(MediaPlayerController::class.java) - - private val audioManager by lazy { - context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - } - - private val lossPref: Int - get() = Settings.tempLoss - - private val audioAttributesCompat by lazy { - AudioAttributesCompat.Builder() - .setUsage(AudioAttributesCompat.USAGE_MEDIA) - .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) - .setLegacyStreamType(AudioManager.STREAM_MUSIC) - .build() - } - - fun requestAudioFocus() { - if (!hasFocus) { - hasFocus = true - AudioManagerCompat.requestAudioFocus(audioManager, focusRequest) - } - } - - private val listener = OnAudioFocusChangeListener { focusChange -> - - val mediaPlayerController = mediaPlayerControllerLazy.value - - when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN -> { - Timber.v("Regained Audio Focus") - if (pauseFocus) { - pauseFocus = false - mediaPlayerController.start() - } else if (lowerFocus) { - lowerFocus = false - mediaPlayerController.setVolume(1.0f) - } - } - AudioManager.AUDIOFOCUS_LOSS -> { - if (!mediaPlayerController.isJukeboxEnabled) { - hasFocus = false - mediaPlayerController.pause() - AudioManagerCompat.abandonAudioFocusRequest(audioManager, focusRequest) - Timber.v("Abandoned Audio Focus") - } - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - if (!mediaPlayerController.isJukeboxEnabled) { - Timber.v("Lost Audio Focus") - - if (mediaPlayerController.playerState === PlayerState.STARTED) { - if (lossPref == 0 || lossPref == 1) { - pauseFocus = true - mediaPlayerController.pause() - } - } - } - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - if (!mediaPlayerController.isJukeboxEnabled) { - Timber.v("Lost Audio Focus") - - if (mediaPlayerController.playerState === PlayerState.STARTED) { - if (lossPref == 2 || lossPref == 1) { - lowerFocus = true - mediaPlayerController.setVolume(0.1f) - } else if (lossPref == 0 || lossPref == 1) { - pauseFocus = true - mediaPlayerController.pause() - } - } - } - } - } - } - - private val focusRequest: AudioFocusRequestCompat by lazy { - AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) - .setAudioAttributes(audioAttributesCompat) - .setWillPauseWhenDucked(true) - .setOnAudioFocusChangeListener(listener) - .build() - } - - companion object { - private var hasFocus = false - private var pauseFocus = false - private var lowerFocus = false - - // TODO: This can be removed if we switch to androidx.media2.player - fun getAudioAttributes(): AudioAttributes { - return AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setLegacyStreamType(AudioManager.STREAM_MUSIC) - .build() - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index b0c5c6e1..f700ee8a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -9,6 +9,7 @@ package org.moire.ultrasonic.service import android.os.Bundle import android.os.Handler +import android.os.Looper import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat import androidx.media.MediaBrowserServiceCompat @@ -26,7 +27,6 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -73,7 +73,6 @@ private const val SEARCH_LIMIT = 10 class AutoMediaBrowserService : MediaBrowserServiceCompat() { private val lifecycleSupport by inject() - private val mediaSessionHandler by inject() private val mediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() private val musicService = MusicServiceFactory.getMusicService() @@ -108,9 +107,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { playFromSearchCommand(it.first) } - mediaSessionHandler.initialize() - val handler = Handler() + val handler = Handler(Looper.getMainLooper()) handler.postDelayed( { // Ultrasonic may be started from Android Auto. This boots up the necessary components. @@ -118,7 +116,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { "AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..." ) lifecycleSupport.onCreate() - MediaPlayerService.getInstance() + DownloadService.getInstance() }, 100 ) @@ -186,7 +184,6 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { override fun onDestroy() { super.onDestroy() rxBusSubscription.dispose() - mediaSessionHandler.release() serviceJob.cancel() Timber.i("AutoMediaBrowserService onDestroy finished") @@ -662,7 +659,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = callWithErrorHandling { musicService.getPlaylist(id, name) } playlistCache = content?.getTracks() } - if (playlistCache != null) playSongs(playlistCache) + if (playlistCache != null) playSongs(playlistCache!!) } } @@ -905,7 +902,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = listStarredSongsInMusicService() starredSongsCache = content?.songs } - if (starredSongsCache != null) playSongs(starredSongsCache) + if (starredSongsCache != null) playSongs(starredSongsCache!!) } } @@ -959,7 +956,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } randomSongsCache = content?.getTracks() } - if (randomSongsCache != null) playSongs(randomSongsCache) + if (randomSongsCache != null) playSongs(randomSongsCache!!) } } @@ -1071,27 +1068,25 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { return section.toString() } - private fun playSongs(songs: List?) { + private fun playSongs(songs: List) { mediaPlayerController.addToPlaylist( songs, - save = false, + cachePermanently = false, autoPlay = true, - playNext = false, shuffle = false, - newPlaylist = true + insertionMode = MediaPlayerController.InsertionMode.CLEAR ) } private fun playSong(song: Track) { mediaPlayerController.addToPlaylist( listOf(song), - save = false, + cachePermanently = false, autoPlay = false, - playNext = true, shuffle = false, - newPlaylist = false + insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT ) - if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next() + if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next() else mediaPlayerController.play() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt index 9b99eb9b..95985926 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -7,27 +7,18 @@ package org.moire.ultrasonic.service -import android.text.TextUtils import androidx.lifecycle.MutableLiveData +import androidx.media3.common.MediaItem import java.io.IOException -import java.io.InputStream -import java.io.OutputStream import java.util.Locale import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Util -import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber /** @@ -44,29 +35,22 @@ class DownloadFile( ) : KoinComponent, Identifiable { val partialFile: String lateinit var completeFile: String - val saveFile: String = FileUtil.getSongFile(track) + val pinnedFile: String = FileUtil.getSongFile(track) var shouldSave = save - private var downloadTask: CancellableTask? = null + internal var downloadTask: CancellableTask? = null var isFailed = false - private var retryCount = MAX_RETRIES + internal var retryCount = MAX_RETRIES - private val desiredBitRate: Int = Settings.maxBitRate + val desiredBitRate: Int = Settings.maxBitRate var priority = 100 var downloadPrepared = false @Volatile - private var isPlaying = false + internal var saveWhenDone = false @Volatile - private var saveWhenDone = false - - @Volatile - private var completeWhenDone = false - - private val downloader: Downloader by inject() - private val imageLoaderProvider: ImageLoaderProvider by inject() - private val activeServerProvider: ActiveServerProvider by inject() + var completeWhenDone = false val progress: MutableLiveData = MutableLiveData(0) @@ -78,7 +62,7 @@ class DownloadFile( private val lazyInitialStatus: Lazy = lazy { when { - Storage.isPathExists(saveFile) -> { + Storage.isPathExists(pinnedFile) -> { DownloadStatus.PINNED } Storage.isPathExists(completeFile) -> { @@ -95,10 +79,10 @@ class DownloadFile( } init { - partialFile = FileUtil.getParentPath(saveFile) + "/" + - FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile)) - completeFile = FileUtil.getParentPath(saveFile) + "/" + - FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile)) + partialFile = FileUtil.getParentPath(pinnedFile) + "/" + + FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile)) + completeFile = FileUtil.getParentPath(pinnedFile) + "/" + + FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile)) } /** @@ -115,13 +99,6 @@ class DownloadFile( downloadPrepared = true } - @Synchronized - fun download() { - FileUtil.createDirectoryForParent(saveFile) - isFailed = false - downloadTask = DownloadTask() - downloadTask!!.start() - } @Synchronized fun cancelDownload() { @@ -129,30 +106,23 @@ class DownloadFile( } val completeOrSaveFile: String - get() = if (Storage.isPathExists(saveFile)) { - saveFile + get() = if (Storage.isPathExists(pinnedFile)) { + pinnedFile } else { completeFile } - val completeOrPartialFile: String - get() = if (isCompleteFileAvailable) { - completeOrSaveFile - } else { - partialFile - } - val isSaved: Boolean - get() = Storage.isPathExists(saveFile) + get() = Storage.isPathExists(pinnedFile) @get:Synchronized val isCompleteFileAvailable: Boolean - get() = Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile) + get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile) @get:Synchronized val isWorkDone: Boolean get() = Storage.isPathExists(completeFile) && !shouldSave || - Storage.isPathExists(saveFile) || saveWhenDone || completeWhenDone + Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone @get:Synchronized val isDownloading: Boolean @@ -170,54 +140,66 @@ class DownloadFile( cancelDownload() Storage.delete(partialFile) Storage.delete(completeFile) - Storage.delete(saveFile) + Storage.delete(pinnedFile) status.postValue(DownloadStatus.IDLE) - Util.scanMedia(saveFile) + Util.scanMedia(pinnedFile) } fun unpin() { - val file = Storage.getFromPath(saveFile) ?: return + Timber.e("CLEANING") + val file = Storage.getFromPath(pinnedFile) ?: return Storage.rename(file, completeFile) status.postValue(DownloadStatus.DONE) } fun cleanup(): Boolean { + Timber.e("CLEANING") var ok = true - if (Storage.isPathExists(completeFile) || Storage.isPathExists(saveFile)) { + if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) { ok = Storage.delete(partialFile) } - if (Storage.isPathExists(saveFile)) { + if (Storage.isPathExists(pinnedFile)) { ok = ok and Storage.delete(completeFile) } return ok } - fun setPlaying(isPlaying: Boolean) { - if (!isPlaying) doPendingRename() - this.isPlaying = isPlaying + /** + * Create a MediaItem instance representing the data inside this DownloadFile + */ + val mediaItem: MediaItem by lazy { + track.toMediaItem() } + var isPlaying: Boolean = false + get() = field + set(isPlaying) { + if (!isPlaying) doPendingRename() + field = isPlaying + } + // Do a pending rename after the song has stopped playing private fun doPendingRename() { try { + Timber.e("CLEANING") if (saveWhenDone) { - Storage.rename(completeFile, saveFile) + Storage.rename(completeFile, pinnedFile) saveWhenDone = false } else if (completeWhenDone) { if (shouldSave) { - Storage.rename(partialFile, saveFile) - Util.scanMedia(saveFile) + Storage.rename(partialFile, pinnedFile) + Util.scanMedia(pinnedFile) } else { Storage.rename(partialFile, completeFile) } completeWhenDone = false } } catch (e: IOException) { - Timber.w(e, "Failed to rename file %s to %s", completeFile, saveFile) + Timber.w(e, "Failed to rename file %s to %s", completeFile, pinnedFile) } } @@ -225,176 +207,7 @@ class DownloadFile( return String.format(Locale.ROOT, "DownloadFile (%s)", track) } - private inner class DownloadTask : CancellableTask() { - val musicService = getMusicService() - - override fun execute() { - - downloadPrepared = false - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - if (Storage.isPathExists(saveFile)) { - Timber.i("%s already exists. Skipping.", saveFile) - status.postValue(DownloadStatus.PINNED) - return - } - - if (Storage.isPathExists(completeFile)) { - var newStatus: DownloadStatus = DownloadStatus.DONE - if (shouldSave) { - if (isPlaying) { - saveWhenDone = true - } else { - Storage.rename(completeFile, saveFile) - newStatus = DownloadStatus.PINNED - } - } else { - Timber.i("%s already exists. Skipping.", completeFile) - } - status.postValue(newStatus) - return - } - - status.postValue(DownloadStatus.DOWNLOADING) - - // Some devices seem to throw error on partial file which doesn't exist - val needsDownloading: Boolean - val duration = track.duration - val fileLength = Storage.getFromPath(partialFile)?.length ?: 0 - - needsDownloading = ( - desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L - ) - - if (needsDownloading) { - // Attempt partial HTTP GET, appending to the file if it exists. - val (inStream, isPartial) = musicService.getDownloadInputStream( - track, fileLength, desiredBitRate, shouldSave - ) - - inputStream = inStream - - if (isPartial) { - Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) - } - - outputStream = Storage.getOrCreateFileFromPath(partialFile) - .getFileOutputStream(isPartial) - - val len = inputStream.copyTo(outputStream) { totalBytesCopied -> - setProgress(totalBytesCopied) - } - - Timber.i("Downloaded %d bytes to %s", len, partialFile) - - inputStream.close() - outputStream.flush() - outputStream.close() - - if (isCancelled) { - status.postValue(DownloadStatus.CANCELLED) - throw RuntimeException( - String.format(Locale.ROOT, "Download of '%s' was cancelled", track) - ) - } - - if (track.artistId != null) { - cacheMetadata(track.artistId!!) - } - - downloadAndSaveCoverArt() - } - - if (isPlaying) { - completeWhenDone = true - } else { - if (shouldSave) { - Storage.rename(partialFile, saveFile) - status.postValue(DownloadStatus.PINNED) - Util.scanMedia(saveFile) - } else { - Storage.rename(partialFile, completeFile) - status.postValue(DownloadStatus.DONE) - } - } - } catch (all: Exception) { - outputStream.safeClose() - Storage.delete(completeFile) - Storage.delete(saveFile) - if (!isCancelled) { - isFailed = true - if (retryCount > 1) { - status.postValue(DownloadStatus.RETRYING) - --retryCount - } else if (retryCount == 1) { - status.postValue(DownloadStatus.FAILED) - --retryCount - } - Timber.w(all, "Failed to download '%s'.", track) - } - } finally { - inputStream.safeClose() - outputStream.safeClose() - CacheCleaner().cleanSpace() - downloader.checkDownloads() - } - } - - override fun toString(): String { - return String.format(Locale.ROOT, "DownloadTask (%s)", track) - } - - private fun cacheMetadata(artistId: String) { - // TODO: Right now it's caching the track artist. - // Once the albums are cached in db, we should retrieve the album, - // and then cache the album artist. - if (artistId.isEmpty()) return - var artist: Artist? = - activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId) - - // If we are downloading a new album, and the user has not visited the Artists list - // recently, then the artist won't be in the database. - if (artist == null) { - val artists: List = musicService.getArtists(true) - artist = artists.find { - it.id == artistId - } - } - - // If we have found an artist, catch it. - if (artist != null) { - activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist) - } - } - - private fun downloadAndSaveCoverArt() { - try { - if (!TextUtils.isEmpty(track.coverArt)) { - // Download the largest size that we can display in the UI - imageLoaderProvider.getImageLoader().cacheCoverArt(track) - } - } catch (all: Exception) { - Timber.e(all, "Failed to get cover art.") - } - } - - @Throws(IOException::class) - fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = read(buffer) - while (!isCancelled && bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - onCopy(bytesCopied) - bytes = read(buffer) - } - return bytesCopied - } - } - - private fun setProgress(totalBytesCopied: Long) { + internal fun setProgress(totalBytesCopied: Long) { if (track.size != null) { progress.postValue((totalBytesCopied * 100 / track.size!!).toInt()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt new file mode 100644 index 00000000..1f1dddda --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -0,0 +1,256 @@ +/* + * MediaPlayerService.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.koin.android.ext.android.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.activity.NavigationActivity +import org.moire.ultrasonic.app.UApp +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.SimpleServiceBinder +import org.moire.ultrasonic.util.Util +import timber.log.Timber + +/** + * Android Foreground service which is used to download tracks even when the app is not visible + * + * "A foreground service is a service that the user is + * actively aware of and isn’t a candidate for the system to kill when low on memory." + * + * TODO: Migrate this to use the Media3 DownloadHelper + */ +class DownloadService : Service() { + private val binder: IBinder = SimpleServiceBinder(this) + + private val downloader by inject() + + private var mediaSession: MediaSessionCompat? = null + + private var isInForeground = false + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onCreate() { + super.onCreate() + + + // Create Notification Channel + createNotificationChannel() + updateNotification() + + instance = this + Timber.i("DownloadService created") + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + instance = null + try { + downloader.stop() + + mediaSession?.release() + mediaSession = null + } catch (ignored: Throwable) { + } + Timber.i("DownloadService stopped") + } + + fun notifyDownloaderStopped() { + isInForeground = false + stopForeground(true) + stopSelf() + } + + private fun setupOnSongCompletedHandler() { +// localMediaPlayer.onSongCompleted = { currentPlaying: DownloadFile? -> +// val index = downloader.currentPlayingIndex +// +// if (currentPlaying != null) { +// val song = currentPlaying.track +// if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) { +// val musicService = getMusicService() +// try { +// musicService.deleteBookmark(song.id) +// } catch (ignored: Exception) { +// } +// } +// } +// if (index != -1) { +// +// if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) { +// if (Settings.shouldClearPlaylist) { +// clear(true) +// jukeboxMediaPlayer.updatePlaylist() +// } +// resetPlayback() +// } else { +// play(index + 1) +// } +// } +// null +// } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + + // The suggested importance of a startForeground service notification is IMPORTANCE_LOW + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + + channel.lightColor = android.R.color.holo_blue_dark + channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + channel.setShowBadge(false) + + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + } + + // We should use a single notification builder, otherwise the notification may not be updated + // Set some values that never change + private val notificationBuilder: NotificationCompat.Builder by lazy { + NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ultrasonic) + .setAutoCancel(false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setWhen(System.currentTimeMillis()) + .setShowWhen(false) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(getPendingIntentForContent()) + .setPriority(NotificationCompat.PRIORITY_LOW) + } + + private fun updateNotification() { + + val notification = buildForegroundNotification() + + if (isInForeground) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, notification) + } else { + val manager = NotificationManagerCompat.from(this) + manager.notify(NOTIFICATION_ID, notification) + } + Timber.v("Updated notification") + } else { + startForeground(NOTIFICATION_ID, notification) + isInForeground = true + Timber.v("Created Foreground notification") + } + } + + /** + * This method builds a notification, reusing the Notification Builder if possible + */ + @Suppress("SpreadOperator") + private fun buildForegroundNotification(): Notification { + + if (downloader.started) { + // No song is playing, but Ultrasonic is downloading files + notificationBuilder.setContentTitle( + getString(R.string.notification_downloading_title) + ) + } + + return notificationBuilder.build() + } + + @SuppressLint("UnspecifiedImmutableFlag") + private fun getPendingIntentForContent(): PendingIntent { + val intent = Intent(this, NavigationActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val flags = PendingIntent.FLAG_UPDATE_CURRENT + intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) + return PendingIntent.getActivity(this, 0, intent, flags) + } + + @Suppress("MagicNumber") + companion object { + + private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" + private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" + private const val NOTIFICATION_ID = 3033 + + @Volatile + private var instance: DownloadService? = null + private val instanceLock = Any() + + @JvmStatic + fun getInstance(): DownloadService? { + val context = UApp.applicationContext() + // Try for twenty times to retrieve a running service, + // sleep 100 millis between each try, + // and run the block that creates a service only synchronized. + for (i in 0..19) { + if (instance != null) return instance + synchronized(instanceLock) { + if (instance != null) return instance + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService( + Intent(context, DownloadService::class.java) + ) + } else { + context.startService(Intent(context, DownloadService::class.java)) + } + } + Util.sleepQuietly(100L) + } + return instance + } + + @JvmStatic + val runningInstance: DownloadService? + get() { + synchronized(instanceLock) { return instance } + } + + @JvmStatic + fun executeOnStartedMediaPlayerService( + taskToExecute: (DownloadService) -> Unit + ) { + + val t: Thread = object : Thread() { + override fun run() { + val instance = getInstance() + if (instance == null) { + Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!") + return + } else { + taskToExecute(instance) + } + } + } + t.start() + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index a0a33c50..9679f03b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -1,119 +1,130 @@ package org.moire.ultrasonic.service import android.net.wifi.WifiManager +import android.os.Handler +import android.os.Looper +import android.text.TextUtils import androidx.lifecycle.MutableLiveData -import java.util.ArrayList -import java.util.PriorityQueue -import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit +import io.reactivex.rxjava3.disposables.CompositeDisposable import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.playback.LegacyPlaylistManager +import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.CacheCleaner +import org.moire.ultrasonic.util.CancellableTask +import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.LRUCache import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.ShufflePlayBuffer +import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.Locale +import java.util.PriorityQueue /** * This class is responsible for maintaining the playlist and downloading * its items from the network to the filesystem. * - * TODO: Move away from managing the queue with scheduled checks, instead use callbacks when - * Downloads are finished + * TODO: Move entirely to subclass the Media3.DownloadService */ class Downloader( - private val shufflePlayBuffer: ShufflePlayBuffer, - private val externalStorageMonitor: ExternalStorageMonitor, - private val localMediaPlayer: LocalMediaPlayer + private val storageMonitor: ExternalStorageMonitor, + private val legacyPlaylistManager: LegacyPlaylistManager, ) : KoinComponent { - private val playlist = mutableListOf() + // Dependencies + private val imageLoaderProvider: ImageLoaderProvider by inject() + private val activeServerProvider: ActiveServerProvider by inject() + private val mediaController: MediaPlayerController by inject() var started: Boolean = false + var shouldStop: Boolean = false private val downloadQueue = PriorityQueue() private val activelyDownloading = mutableListOf() - // TODO: The playlist is now published with RX, while the observableDownloads is using LiveData. - // Use the same for both + // 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. val observableDownloads = MutableLiveData>() - private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() - // This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries - private val downloadFileCache = LRUCache(100) + private val downloadFileCache = LRUCache(500) - private var executorService: ScheduledExecutorService? = null + private var handler: Handler = Handler(Looper.getMainLooper()) private var wifiLock: WifiManager.WifiLock? = null - private var playlistUpdateRevision: Long = 0 - private set(value) { - field = value - RxBus.playlistPublisher.onNext(playlist) - } + private var backgroundPriorityCounter = 100 - var backgroundPriorityCounter = 100 + private val rxBusSubscription: CompositeDisposable = CompositeDisposable() - val downloadChecker = Runnable { - try { - Timber.w("Checking Downloads") - checkDownloadsInternal() - } catch (all: Exception) { - Timber.e(all, "checkDownloads() failed.") + var downloadChecker = object : Runnable { + override fun run() { + try { + Timber.w("Checking Downloads") + checkDownloadsInternal() + } catch (all: Exception) { + Timber.e(all, "checkDownloads() failed.") + } finally { + if (!shouldStop) { + Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL) + } else { + shouldStop = false + } + } } } fun onDestroy() { stop() - clearPlaylist() + rxBusSubscription.dispose() clearBackground() observableDownloads.value = listOf() Timber.i("Downloader destroyed") } + @Synchronized fun start() { started = true - if (executorService == null) { - executorService = Executors.newSingleThreadScheduledExecutor() - executorService!!.scheduleWithFixedDelay( - downloadChecker, 0L, CHECK_INTERVAL, TimeUnit.SECONDS - ) - Timber.i("Downloader started") - } + + // Start our loop + handler.postDelayed(downloadChecker, 100) if (wifiLock == null) { wifiLock = Util.createWifiLock(toString()) wifiLock?.acquire() } + + // Check downloads if the playlist changed + rxBusSubscription += RxBus.playlistObservable.subscribe { + checkDownloads() + } } fun stop() { started = false - executorService?.shutdown() - executorService = null + shouldStop = true wifiLock?.release() wifiLock = null - MediaPlayerService.runningInstance?.notifyDownloaderStopped() + DownloadService.runningInstance?.notifyDownloaderStopped() Timber.i("Downloader stopped") } fun checkDownloads() { - if ( - executorService == null || - executorService!!.isTerminated || - executorService!!.isShutdown - ) { + if (!started) { start() } else { try { - executorService?.execute(downloadChecker) - } catch (exception: RejectedExecutionException) { + handler.postDelayed(downloadChecker, 100) + } catch (all: Exception) { Timber.w( - exception, + all, "checkDownloads() can't run, maybe the Downloader is shutting down..." ) } @@ -121,22 +132,17 @@ class Downloader( } @Synchronized - @Suppress("ComplexMethod", "ComplexCondition") fun checkDownloadsInternal() { - if ( - !Util.isExternalStoragePresent() || - !externalStorageMonitor.isExternalStorageAvailable - ) { + if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) { return } - if (shufflePlayBuffer.isEnabled) { - checkShufflePlay() - } - if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { + + if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { return } Timber.v("Downloader checkDownloadsInternal checking downloads") + // Check the active downloads for failures or completions and remove them // Store the result in a flag to know if changes have occurred var listChanged = cleanupActiveDownloads() @@ -145,13 +151,14 @@ class Downloader( val preloadCount = Settings.preloadCount // Start preloading at the current playing song - var start = currentPlayingIndex + var start = mediaController.currentMediaItemIndex + if (start == -1) start = 0 - val end = (start + preloadCount).coerceAtMost(playlist.size) + val end = (start + preloadCount).coerceAtMost(mediaController.mediaItemCount) for (i in start until end) { - val download = playlist[i] + val download = legacyPlaylistManager.playlist[i] // Set correct priority (the lower the number, the higher the priority) download.priority = i @@ -173,10 +180,6 @@ class Downloader( activelyDownloading.add(task) startDownloadOnService(task) - // The next file on the playlist is currently downloading - if (playlist.indexOf(task) == 1) { - localMediaPlayer.setNextPlayerState(PlayerState.DOWNLOADING) - } listChanged = true } @@ -194,10 +197,14 @@ class Downloader( observableDownloads.postValue(downloads) } - private fun startDownloadOnService(task: DownloadFile) { - task.prepare() - MediaPlayerService.executeOnStartedMediaPlayerService { - task.download() + private fun startDownloadOnService(file: DownloadFile) { + if (file.isDownloading) return + file.prepare() + DownloadService.executeOnStartedMediaPlayerService { + FileUtil.createDirectoryForParent(file.pinnedFile) + file.isFailed = false + file.downloadTask = DownloadTask(file) + file.downloadTask!!.start() } } @@ -225,26 +232,6 @@ class Downloader( return (oldSize != activelyDownloading.size) } - @get:Synchronized - val currentPlayingIndex: Int - get() = playlist.indexOf(localMediaPlayer.currentPlaying) - - @get:Synchronized - val downloadListDuration: Long - get() { - var totalDuration: Long = 0 - for (downloadFile in playlist) { - val song = downloadFile.track - if (!song.isDirectory) { - if (song.artist != null) { - if (song.duration != null) { - totalDuration += song.duration!!.toLong() - } - } - } - } - return totalDuration - } @get:Synchronized val all: List @@ -252,7 +239,7 @@ class Downloader( val temp: MutableList = ArrayList() temp.addAll(activelyDownloading) temp.addAll(downloadQueue) - temp.addAll(playlist) + temp.addAll(legacyPlaylistManager.playlist) return temp.distinct().sorted() } @@ -267,7 +254,7 @@ class Downloader( temp.addAll(activelyDownloading) temp.addAll(downloadQueue) temp.addAll( - playlist.filter { + legacyPlaylistManager.playlist.filter { if (!it.isStatusInitialized) false else when (it.status.value) { DownloadStatus.DOWNLOADING -> true @@ -278,37 +265,13 @@ class Downloader( return temp.distinct().sorted() } - // Public facing playlist (immutable) - @Synchronized - fun getPlaylist(): List = playlist - @Synchronized fun clearDownloadFileCache() { downloadFileCache.clear() } @Synchronized - fun clearPlaylist() { - playlist.clear() - - val toRemove = mutableListOf() - - // Cancel all active downloads with a high priority - for (download in activelyDownloading) { - if (download.priority < 100) { - download.cancelDownload() - toRemove.add(download) - } - } - - activelyDownloading.removeAll(toRemove) - - playlistUpdateRevision++ - updateLiveData() - } - - @Synchronized - private fun clearBackground() { + fun clearBackground() { // Clear the pending queue downloadQueue.clear() @@ -333,78 +296,6 @@ class Downloader( updateLiveData() } - @Synchronized - fun removeFromPlaylist(downloadFile: DownloadFile) { - if (activelyDownloading.contains(downloadFile)) { - downloadFile.cancelDownload() - } - playlist.remove(downloadFile) - playlistUpdateRevision++ - checkDownloads() - } - - @Synchronized - fun addToPlaylist( - songs: List, - save: Boolean, - autoPlay: Boolean, - playNext: Boolean, - newPlaylist: Boolean - ) { - shufflePlayBuffer.isEnabled = false - var offset = 1 - if (songs.isEmpty()) { - return - } - if (newPlaylist) { - playlist.clear() - } - if (playNext) { - if (autoPlay && currentPlayingIndex >= 0) { - offset = 0 - } - for (song in songs) { - val downloadFile = song.getDownloadFile(save) - playlist.add(currentPlayingIndex + offset, downloadFile) - offset++ - } - } else { - for (song in songs) { - val downloadFile = song.getDownloadFile(save) - playlist.add(downloadFile) - } - } - playlistUpdateRevision++ - checkDownloads() - } - - fun moveItemInPlaylist(oldPos: Int, newPos: Int) { - val item = playlist[oldPos] - playlist.remove(item) - - if (newPos < oldPos) { - playlist.add(newPos + 1, item) - } else { - playlist.add(newPos - 1, item) - } - - playlistUpdateRevision++ - checkDownloads() - } - - @Synchronized - fun clearIncomplete() { - val iterator = playlist.iterator() - var changedPlaylist = false - while (iterator.hasNext()) { - val downloadFile = iterator.next() - if (!downloadFile.isCompleteFileAvailable) { - iterator.remove() - changedPlaylist = true - } - } - if (changedPlaylist) playlistUpdateRevision++ - } @Synchronized fun downloadBackground(songs: List, save: Boolean) { @@ -413,30 +304,19 @@ class Downloader( for (song in songs) { val file = song.getDownloadFile() file.shouldSave = save - file.priority = backgroundPriorityCounter++ - downloadQueue.add(file) + if (!file.isDownloading) { + file.priority = backgroundPriorityCounter++ + downloadQueue.add(file) + } } checkDownloads() } - @Synchronized - fun shuffle() { - playlist.shuffle() - - // Move the current song to the top.. - if (localMediaPlayer.currentPlaying != null) { - playlist.remove(localMediaPlayer.currentPlaying) - playlist.add(0, localMediaPlayer.currentPlaying!!) - } - - playlistUpdateRevision++ - } - @Synchronized @Suppress("ReturnCount") fun getDownloadFileForSong(song: Track): DownloadFile { - for (downloadFile in playlist) { + for (downloadFile in legacyPlaylistManager.playlist) { if (downloadFile.track == song) { return downloadFile } @@ -459,63 +339,205 @@ class Downloader( return downloadFile } - @Synchronized - private fun checkShufflePlay() { - // Get users desired random playlist size - val listSize = Settings.maxSongs - val wasEmpty = playlist.isEmpty() - val revisionBefore = playlistUpdateRevision - - // First, ensure that list is at least 20 songs long. - val size = playlist.size - if (size < listSize) { - for (song in shufflePlayBuffer[listSize - size]) { - val downloadFile = song.getDownloadFile(false) - playlist.add(downloadFile) - playlistUpdateRevision++ - } - } - - val currIndex = if (localMediaPlayer.currentPlaying == null) 0 else currentPlayingIndex - - // Only shift playlist if playing song #5 or later. - if (currIndex > SHUFFLE_BUFFER_LIMIT) { - val songsToShift = currIndex - 2 - for (song in shufflePlayBuffer[songsToShift]) { - playlist.add(song.getDownloadFile(false)) - playlist[0].cancelDownload() - playlist.removeAt(0) - playlistUpdateRevision++ - } - } - - if (revisionBefore != playlistUpdateRevision) { - jukeboxMediaPlayer.updatePlaylist() - } - - if (wasEmpty && playlist.isNotEmpty()) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip(0, 0) - localMediaPlayer.setPlayerState(PlayerState.STARTED, playlist[0]) - } else { - localMediaPlayer.play(playlist[0]) - } - } - } companion object { const val PARALLEL_DOWNLOADS = 3 - const val CHECK_INTERVAL = 5L - const val SHUFFLE_BUFFER_LIMIT = 4 + const val CHECK_INTERVAL = 5000L } /** * Extension function * Gathers the download file for a given song, and modifies shouldSave if provided. */ - fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { + private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { return getDownloadFileForSong(this).apply { if (save != null) this.shouldSave = save } } + + private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() { + val musicService = MusicServiceFactory.getMusicService() + + override fun execute() { + + downloadFile.downloadPrepared = false + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + try { + if (Storage.isPathExists(downloadFile.pinnedFile)) { + Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile) + downloadFile.status.postValue(DownloadStatus.PINNED) + return + } + + if (Storage.isPathExists(downloadFile.completeFile)) { + var newStatus: DownloadStatus = DownloadStatus.DONE + if (downloadFile.shouldSave) { + if (downloadFile.isPlaying) { + downloadFile.saveWhenDone = true + } else { + Storage.rename( + downloadFile.completeFile, + downloadFile.pinnedFile + ) + newStatus = DownloadStatus.PINNED + } + } else { + Timber.i( + "%s already exists. Skipping.", + downloadFile.completeFile + ) + } + downloadFile.status.postValue(newStatus) + return + } + + downloadFile.status.postValue(DownloadStatus.DOWNLOADING) + + // Some devices seem to throw error on partial file which doesn't exist + val needsDownloading: Boolean + val duration = downloadFile.track.duration + val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0 + + needsDownloading = ( + downloadFile.desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L + ) + + if (needsDownloading) { + // Attempt partial HTTP GET, appending to the file if it exists. + val (inStream, isPartial) = musicService.getDownloadInputStream( + downloadFile.track, fileLength, + downloadFile.desiredBitRate, + downloadFile.shouldSave + ) + + inputStream = inStream + + if (isPartial) { + Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) + } + + outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile) + .getFileOutputStream(isPartial) + + val len = inputStream.copyTo(outputStream) { totalBytesCopied -> + downloadFile.setProgress(totalBytesCopied) + } + + Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile) + + inputStream.close() + outputStream.flush() + outputStream.close() + + if (isCancelled) { + downloadFile.status.postValue(DownloadStatus.CANCELLED) + throw RuntimeException( + String.format( + Locale.ROOT, "Download of '%s' was cancelled", + downloadFile.track + ) + ) + } + + if (downloadFile.track.artistId != null) { + cacheMetadata(downloadFile.track.artistId!!) + } + + downloadAndSaveCoverArt() + } + + if (downloadFile.isPlaying) { + downloadFile.completeWhenDone = true + } else { + if (downloadFile.shouldSave) { + Storage.rename( + downloadFile.partialFile, + downloadFile.pinnedFile + ) + downloadFile.status.postValue(DownloadStatus.PINNED) + Util.scanMedia(downloadFile.pinnedFile) + } else { + Storage.rename( + downloadFile.partialFile, + downloadFile.completeFile + ) + downloadFile.status.postValue(DownloadStatus.DONE) + } + } + } catch (all: Exception) { + outputStream.safeClose() + Storage.delete(downloadFile.completeFile) + Storage.delete(downloadFile.pinnedFile) + if (!isCancelled) { + downloadFile.isFailed = true + if (downloadFile.retryCount > 1) { + downloadFile.status.postValue(DownloadStatus.RETRYING) + --downloadFile.retryCount + } else if (downloadFile.retryCount == 1) { + downloadFile.status.postValue(DownloadStatus.FAILED) + --downloadFile.retryCount + } + Timber.w(all, "Failed to download '%s'.", downloadFile.track) + } + } finally { + inputStream.safeClose() + outputStream.safeClose() + CacheCleaner().cleanSpace() + checkDownloads() + } + } + + override fun toString(): String { + return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track) + } + + private fun cacheMetadata(artistId: String) { + // TODO: Right now it's caching the track artist. + // Once the albums are cached in db, we should retrieve the album, + // and then cache the album artist. + if (artistId.isEmpty()) return + var artist: Artist? = + activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId) + + // If we are downloading a new album, and the user has not visited the Artists list + // recently, then the artist won't be in the database. + if (artist == null) { + val artists: List = musicService.getArtists(true) + artist = artists.find { + it.id == artistId + } + } + + // If we have found an artist, catch it. + if (artist != null) { + activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist) + } + } + + private fun downloadAndSaveCoverArt() { + try { + if (!TextUtils.isEmpty(downloadFile.track.coverArt)) { + // Download the largest size that we can display in the UI + imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track) + } + } catch (all: Exception) { + Timber.e(all, "Failed to get cover art.") + } + } + + @Throws(IOException::class) + fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = read(buffer) + while (!isCancelled && bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + onCopy(bytesCopied) + bytes = read(buffer) + } + return bytesCopied + } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt new file mode 100644 index 00000000..af14e15d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -0,0 +1,337 @@ +/* + * JukeboxMediaPlayer.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ +package org.moire.ultrasonic.service + +import android.content.Context +import android.os.Handler +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.ProgressBar +import android.widget.Toast +import org.koin.java.KoinJavaComponent.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException +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.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.Util.sleepQuietly +import org.moire.ultrasonic.util.Util.toast +import timber.log.Timber +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.roundToInt + +/** + * Provides an asynchronous interface to the remote jukebox on the Subsonic server. + * + * TODO: Report warning if queue fills up. + * TODO: Create shutdown method? + * TODO: Disable repeat. + * TODO: Persist RC state? + * TODO: Minimize status updates. + */ +class JukeboxMediaPlayer(private val downloader: Downloader) { + private val tasks = TaskQueue() + private val executorService = Executors.newSingleThreadScheduledExecutor() + private var statusUpdateFuture: ScheduledFuture<*>? = null + private val timeOfLastUpdate = AtomicLong() + private var jukeboxStatus: JukeboxStatus? = null + private var gain = 0.5f + private var volumeToast: VolumeToast? = null + private val running = AtomicBoolean() + private var serviceThread: Thread? = null + private var enabled = false + + // TODO: These create circular references, try to refactor + private val mediaPlayerControllerLazy = inject( + MediaPlayerController::class.java + ) + + fun startJukeboxService() { + if (running.get()) { + return + } + running.set(true) + startProcessTasks() + Timber.d("Started Jukebox Service") + } + + fun stopJukeboxService() { + running.set(false) + sleepQuietly(1000) + if (serviceThread != null) { + serviceThread!!.interrupt() + } + Timber.d("Stopped Jukebox Service") + } + + private fun startProcessTasks() { + serviceThread = object : Thread() { + override fun run() { + processTasks() + } + } + (serviceThread as Thread).start() + } + + @Synchronized + private fun startStatusUpdate() { + stopStatusUpdate() + val updateTask = Runnable { + tasks.remove(GetStatus::class.java) + tasks.add(GetStatus()) + } + statusUpdateFuture = executorService.scheduleWithFixedDelay( + updateTask, + STATUS_UPDATE_INTERVAL_SECONDS, + STATUS_UPDATE_INTERVAL_SECONDS, + TimeUnit.SECONDS + ) + } + + @Synchronized + private fun stopStatusUpdate() { + if (statusUpdateFuture != null) { + statusUpdateFuture!!.cancel(false) + statusUpdateFuture = null + } + } + + private fun processTasks() { + while (running.get()) { + var task: JukeboxTask? = null + try { + if (!isOffline()) { + task = tasks.take() + val status = task.execute() + onStatusUpdate(status) + } + } catch (ignored: InterruptedException) { + } catch (x: Throwable) { + onError(task, x) + } + sleepQuietly(1) + } + } + + private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) { + timeOfLastUpdate.set(System.currentTimeMillis()) + this.jukeboxStatus = jukeboxStatus + } + + private fun onError(task: JukeboxTask?, x: Throwable) { + if (x is ApiNotSupportedException && task !is Stop) { + disableJukeboxOnError(x, R.string.download_jukebox_server_too_old) + } else if (x is OfflineException && task !is Stop) { + disableJukeboxOnError(x, R.string.download_jukebox_offline) + } else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) { + disableJukeboxOnError(x, R.string.download_jukebox_not_authorized) + } else { + Timber.e(x, "Failed to process jukebox task") + } + } + + private fun disableJukeboxOnError(x: Throwable, resourceId: Int) { + Timber.w(x.toString()) + val context = applicationContext() + Handler().post { toast(context, resourceId, false) } + mediaPlayerControllerLazy.value.isJukeboxEnabled = false + } + + fun updatePlaylist() { + if (!enabled) return + tasks.remove(Skip::class.java) + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + val ids: MutableList = ArrayList() + for (file in downloader.all) { + ids.add(file.track.id) + } + tasks.add(SetPlaylist(ids)) + } + + fun skip(index: Int, offsetSeconds: Int) { + tasks.remove(Skip::class.java) + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + startStatusUpdate() + if (jukeboxStatus != null) { + jukeboxStatus!!.positionSeconds = offsetSeconds + } + tasks.add(Skip(index, offsetSeconds)) + } + + fun stop() { + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + stopStatusUpdate() + tasks.add(Stop()) + } + + fun start() { + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + startStatusUpdate() + tasks.add(Start()) + } + + @Synchronized + fun adjustVolume(up: Boolean) { + val delta = if (up) 0.05f else -0.05f + gain += delta + gain = gain.coerceAtLeast(0.0f) + gain = gain.coerceAtMost(1.0f) + tasks.remove(SetGain::class.java) + tasks.add(SetGain(gain)) + val context = applicationContext() + if (volumeToast == null) volumeToast = VolumeToast(context) + volumeToast!!.setVolume(gain) + } + + private val musicService: MusicService + get() = getMusicService() + + val positionSeconds: Int + get() { + if (jukeboxStatus == null || jukeboxStatus!!.positionSeconds == null || timeOfLastUpdate.get() == 0L) { + return 0 + } + if (jukeboxStatus!!.isPlaying) { + val secondsSinceLastUpdate = + ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L).toInt() + return jukeboxStatus!!.positionSeconds!! + secondsSinceLastUpdate + } + return jukeboxStatus!!.positionSeconds!! + } + + var isEnabled: Boolean + set(enabled) { + Timber.d("Jukebox Service setting enabled to %b", enabled) + this.enabled = enabled + tasks.clear() + if (enabled) { + updatePlaylist() + } + stop() + } + get() { + return enabled + } + + private class TaskQueue { + private val queue = LinkedBlockingQueue() + fun add(jukeboxTask: JukeboxTask) { + queue.add(jukeboxTask) + } + + @Throws(InterruptedException::class) + fun take(): JukeboxTask { + return queue.take() + } + + fun remove(taskClass: Class) { + try { + val iterator = queue.iterator() + while (iterator.hasNext()) { + val task = iterator.next() + if (taskClass == task.javaClass) { + iterator.remove() + } + } + } catch (x: Throwable) { + Timber.w(x, "Failed to clean-up task queue.") + } + } + + fun clear() { + queue.clear() + } + } + + private abstract class JukeboxTask { + @Throws(Exception::class) + abstract fun execute(): JukeboxStatus + override fun toString(): String { + return javaClass.simpleName + } + } + + private inner class GetStatus : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.getJukeboxStatus() + } + } + + private inner class SetPlaylist(private val ids: List) : + JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.updateJukeboxPlaylist(ids) + } + } + + private inner class Skip( + private val index: Int, + private val offsetSeconds: Int + ) : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.skipJukebox(index, offsetSeconds) + } + } + + private inner class Stop : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.stopJukebox() + } + } + + private inner class Start : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.startJukebox() + } + } + + private inner class SetGain(private val gain: Float) : JukeboxTask() { + @Throws(Exception::class) + override fun execute(): JukeboxStatus { + return musicService.setJukeboxGain(gain) + } + } + + private class VolumeToast(context: Context) : Toast(context) { + private val progressBar: ProgressBar + fun setVolume(volume: Float) { + progressBar.progress = (100 * volume).roundToInt() + show() + } + + init { + duration = LENGTH_SHORT + val inflater = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val view = inflater.inflate(R.layout.jukebox_volume, null) + progressBar = view.findViewById(R.id.jukebox_volume_progress_bar) as ProgressBar + setView(view) + setGravity(Gravity.TOP, 0, 0) + } + } + + companion object { + private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt deleted file mode 100644 index 0f69e247..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ /dev/null @@ -1,745 +0,0 @@ -/* - * LocalMediaPlayer.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.service - -import android.content.Context -import android.content.Context.POWER_SERVICE -import android.content.Intent -import android.media.MediaPlayer -import android.media.MediaPlayer.OnCompletionListener -import android.media.audiofx.AudioEffect -import android.os.Handler -import android.os.Looper -import android.os.PowerManager -import android.os.PowerManager.PARTIAL_WAKE_LOCK -import android.os.PowerManager.WakeLock -import androidx.lifecycle.MutableLiveData -import java.net.URLEncoder -import java.util.Locale -import kotlin.math.abs -import kotlin.math.max -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.audiofx.EqualizerController -import org.moire.ultrasonic.audiofx.VisualizerController -import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.util.CancellableTask -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.Storage -import org.moire.ultrasonic.util.StreamProxy -import org.moire.ultrasonic.util.Util -import timber.log.Timber - -/** - * Represents a Media Player which uses the mobile's resources for playback - */ -@Suppress("TooManyFunctions") -class LocalMediaPlayer : KoinComponent { - - private val audioFocusHandler by inject() - private val context by inject() - - @JvmField - var onSongCompleted: ((DownloadFile?) -> Unit?)? = null - - @JvmField - var onPrepared: (() -> Any?)? = null - - @JvmField - var onNextSongRequested: Runnable? = null - - @JvmField - @Volatile - var playerState = PlayerState.IDLE - - @JvmField - var currentPlaying: DownloadFile? = null - - @JvmField - var nextPlaying: DownloadFile? = null - - private var nextPlayerState = PlayerState.IDLE - private var nextSetup = false - private var nextPlayingTask: CancellableTask? = null - private var mediaPlayer: MediaPlayer = MediaPlayer() - private var nextMediaPlayer: MediaPlayer? = null - private var mediaPlayerLooper: Looper? = null - private var mediaPlayerHandler: Handler? = null - private var cachedPosition = 0 - private var proxy: StreamProxy? = null - private var bufferTask: CancellableTask? = null - private var positionCache: PositionCache? = null - - private val pm = context.getSystemService(POWER_SERVICE) as PowerManager - private val wakeLock: WakeLock = pm.newWakeLock(PARTIAL_WAKE_LOCK, this.javaClass.name) - - val secondaryProgress: MutableLiveData = MutableLiveData(0) - - fun init() { - Thread { - Thread.currentThread().name = "MediaPlayerThread" - Looper.prepare() - mediaPlayer.setWakeMode(context, PARTIAL_WAKE_LOCK) - mediaPlayer.setOnErrorListener { _, what, more -> - handleError( - Exception( - String.format( - Locale.getDefault(), - "MediaPlayer error: %d (%d)", what, more - ) - ) - ) - false - } - try { - val i = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId) - i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - context.sendBroadcast(i) - } catch (ignored: Throwable) { - // Froyo or lower - } - mediaPlayerLooper = Looper.myLooper() - mediaPlayerHandler = Handler(mediaPlayerLooper!!) - Looper.loop() - }.start() - - // Create Equalizer and Visualizer on a new thread as this can potentially take some time - Thread { - EqualizerController.create(context, mediaPlayer) - VisualizerController.create(mediaPlayer) - }.start() - - wakeLock.setReferenceCounted(false) - Timber.i("LocalMediaPlayer created") - } - - fun release() { - // Calling reset() will result in changing this player's state. If we allow - // the onPlayerStateChanged callback, then the state change will cause this - // to resurrect the media session which has just been destroyed. - reset() - try { - val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mediaPlayer.audioSessionId) - i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - context.sendBroadcast(i) - EqualizerController.release() - VisualizerController.release() - mediaPlayer.release() - - mediaPlayer = MediaPlayer() - - if (nextMediaPlayer != null) { - nextMediaPlayer!!.release() - } - mediaPlayerLooper!!.quit() - if (bufferTask != null) { - bufferTask!!.cancel() - } - if (nextPlayingTask != null) { - nextPlayingTask!!.cancel() - } - - wakeLock.release() - } catch (exception: Throwable) { - Timber.w(exception, "LocalMediaPlayer onDestroy exception: ") - } - Timber.i("LocalMediaPlayer destroyed") - } - - @Synchronized - fun setPlayerState(playerState: PlayerState, track: DownloadFile?) { - Timber.i("%s -> %s (%s)", this.playerState.name, playerState.name, track) - synchronized(playerState) { - this.playerState = playerState - } - if (playerState === PlayerState.STARTED) { - audioFocusHandler.requestAudioFocus() - } - - RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, track)) - - if (playerState === PlayerState.STARTED && positionCache == null) { - positionCache = PositionCache() - val thread = Thread(positionCache) - thread.start() - } else if (playerState !== PlayerState.STARTED && positionCache != null) { - positionCache!!.stop() - positionCache = null - } - } - - /* - * Set the current playing file. It's called with null to reset the player. - */ - @Synchronized - fun setCurrentPlaying(currentPlaying: DownloadFile?) { - // In some cases this function is called twice - if (this.currentPlaying == currentPlaying) return - this.currentPlaying = currentPlaying - RxBus.playerStatePublisher.onNext(RxBus.StateWithTrack(playerState, currentPlaying)) - } - - /* - * Set the next playing file. nextToPlay cannot be null - */ - @Synchronized - fun setNextPlaying(nextToPlay: DownloadFile) { - nextPlaying = nextToPlay - nextPlayingTask = CheckCompletionTask(nextPlaying) - nextPlayingTask?.start() - } - - /* - * Clear the next playing file. setIdle controls whether the playerState is affected as well - */ - @Synchronized - fun clearNextPlaying(setIdle: Boolean) { - nextSetup = false - nextPlaying = null - if (nextPlayingTask != null) { - nextPlayingTask!!.cancel() - nextPlayingTask = null - } - - if (setIdle) { - setNextPlayerState(PlayerState.IDLE) - } - } - - @Synchronized - fun setNextPlayerState(playerState: PlayerState) { - Timber.i("Next: %s -> %s (%s)", nextPlayerState.name, playerState.name, nextPlaying) - nextPlayerState = playerState - } - - /* - * Public method to play a given file. - * Optionally specify a position to start at. - */ - @Synchronized - @JvmOverloads - fun play(fileToPlay: DownloadFile?, position: Int = 0, autoStart: Boolean = true) { - if (nextPlayingTask != null) { - nextPlayingTask!!.cancel() - nextPlayingTask = null - } - setCurrentPlaying(fileToPlay) - - if (fileToPlay != null) { - bufferAndPlay(fileToPlay, position, autoStart) - } - } - - @Synchronized - fun playNext() { - if (nextMediaPlayer == null || nextPlaying == null) return - - mediaPlayer = nextMediaPlayer!! - - setCurrentPlaying(nextPlaying) - setPlayerState(PlayerState.STARTED, currentPlaying) - - attachHandlersToPlayer(mediaPlayer, nextPlaying!!, false) - - postRunnable(onNextSongRequested) - - // Proxy should not be being used here since the next player was already setup to play - proxy?.stop() - proxy = null - } - - @Synchronized - fun pause() { - try { - mediaPlayer.pause() - } catch (x: Exception) { - handleError(x) - } - } - - @Synchronized - fun start() { - try { - mediaPlayer.start() - } catch (x: Exception) { - handleError(x) - } - } - - @Synchronized - fun seekTo(position: Int) { - try { - mediaPlayer.seekTo(position) - cachedPosition = position - } catch (x: Exception) { - handleError(x) - } - } - - @get:Synchronized - val playerPosition: Int - get() = try { - when (playerState) { - PlayerState.IDLE -> 0 - PlayerState.DOWNLOADING -> 0 - PlayerState.PREPARING -> 0 - else -> cachedPosition - } - } catch (x: Exception) { - handleError(x) - 0 - } - - @get:Synchronized - val playerDuration: Int - get() { - if (currentPlaying != null) { - val duration = currentPlaying!!.track.duration - if (duration != null) { - return duration * 1000 - } - } - if (playerState !== PlayerState.IDLE && - playerState !== PlayerState.DOWNLOADING && - playerState !== PlayerState.PREPARING - ) { - try { - return mediaPlayer.duration - } catch (x: Exception) { - handleError(x) - } - } - return 0 - } - - fun setVolume(volume: Float) { - mediaPlayer.setVolume(volume, volume) - } - - @Synchronized - private fun bufferAndPlay(fileToPlay: DownloadFile, position: Int, autoStart: Boolean) { - if (playerState !== PlayerState.PREPARED && !fileToPlay.isWorkDone) { - reset() - bufferTask = BufferTask(fileToPlay, position, autoStart) - bufferTask!!.start() - } else { - doPlay(fileToPlay, position, autoStart) - } - } - - @Synchronized - private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) { - setPlayerState(PlayerState.IDLE, downloadFile) - - // In many cases we will be resetting the mediaPlayer a second time here. - // figure out if we can remove this call... - resetMediaPlayer() - - try { - downloadFile.setPlaying(false) - - val file = Storage.getFromPath(downloadFile.completeOrPartialFile) - val partial = !downloadFile.isCompleteFileAvailable - - // TODO this won't work with SAF, we should use something else, e.g. a recent list - // downloadFile.updateModificationDate() - mediaPlayer.setOnCompletionListener(null) - - setAudioAttributes(mediaPlayer) - - var streamUrl: String? = null - if (partial) { - if (proxy == null) { - proxy = StreamProxy(object : Supplier() { - override fun get(): DownloadFile { - return currentPlaying!! - } - }) - proxy!!.start() - } - streamUrl = String.format( - Locale.getDefault(), "http://127.0.0.1:%d/%s", - proxy!!.port, URLEncoder.encode(file!!.path, Constants.UTF_8) - ) - Timber.i("Data Source: %s", streamUrl) - } else if (proxy != null) { - proxy?.stop() - proxy = null - } - - Timber.i("Preparing media player") - - if (streamUrl != null) { - Timber.v("LocalMediaPlayer doPlay dataSource: %s", streamUrl) - mediaPlayer.setDataSource(streamUrl) - } else { - Timber.v("LocalMediaPlayer doPlay Path: %s", file!!.path) - val descriptor = file.getDocumentFileDescriptor("r")!! - mediaPlayer.setDataSource(descriptor.fileDescriptor) - descriptor.close() - } - - setPlayerState(PlayerState.PREPARING, downloadFile) - - mediaPlayer.setOnBufferingUpdateListener { mp, percent -> - val song = downloadFile.track - - if (percent == 100) { - mp.setOnBufferingUpdateListener(null) - } - - // The secondary progress is an indicator of how far the song is cached. - if (song.transcodedContentType == null && Settings.maxBitRate == 0) { - val progress = (percent.toDouble() / 100.toDouble() * playerDuration).toInt() - secondaryProgress.postValue(progress) - } - } - - mediaPlayer.setOnPreparedListener { - Timber.i("Media player prepared") - setPlayerState(PlayerState.PREPARED, downloadFile) - - // Populate seek bar secondary progress if we have a complete file for consistency - if (downloadFile.isWorkDone) { - secondaryProgress.postValue(playerDuration) - } - - synchronized(this@LocalMediaPlayer) { - if (position != 0) { - Timber.i("Restarting player from position %d", position) - seekTo(position) - } - cachedPosition = position - if (start) { - mediaPlayer.start() - setPlayerState(PlayerState.STARTED, downloadFile) - } else { - setPlayerState(PlayerState.PAUSED, downloadFile) - } - } - - postRunnable { - onPrepared - } - } - - attachHandlersToPlayer(mediaPlayer, downloadFile, partial) - mediaPlayer.prepareAsync() - } catch (x: Exception) { - handleError(x) - } - } - - private fun setAudioAttributes(player: MediaPlayer) { - player.setAudioAttributes(AudioFocusHandler.getAudioAttributes()) - } - - @Suppress("ComplexCondition") - @Synchronized - private fun setupNext(downloadFile: DownloadFile) { - try { - val file = Storage.getFromPath(downloadFile.completeOrPartialFile) - - // Release the media player if it is not our active player - if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) { - nextMediaPlayer!!.setOnCompletionListener(null) - nextMediaPlayer!!.release() - nextMediaPlayer = null - } - nextMediaPlayer = MediaPlayer() - nextMediaPlayer!!.setWakeMode(context, PARTIAL_WAKE_LOCK) - - setAudioAttributes(nextMediaPlayer!!) - - // This has nothing to do with the MediaSession, it is used to associate - // the equalizer or visualizer with the player - try { - nextMediaPlayer!!.audioSessionId = mediaPlayer.audioSessionId - } catch (ignored: Throwable) { - } - - Timber.v("LocalMediaPlayer setupNext Path: %s", file!!.path) - val descriptor = file.getDocumentFileDescriptor("r")!! - nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor) - descriptor.close() - - setNextPlayerState(PlayerState.PREPARING) - nextMediaPlayer!!.setOnPreparedListener { - try { - setNextPlayerState(PlayerState.PREPARED) - if (Settings.gaplessPlayback && - (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) - ) { - mediaPlayer.setNextMediaPlayer(nextMediaPlayer) - nextSetup = true - } - } catch (x: Exception) { - handleErrorNext(x) - } - } - nextMediaPlayer!!.setOnErrorListener { _, what, extra -> - Timber.w("Error on playing next (%d, %d): %s", what, extra, downloadFile) - true - } - nextMediaPlayer!!.prepareAsync() - } catch (x: Exception) { - handleErrorNext(x) - } - } - - private fun attachHandlersToPlayer( - mediaPlayer: MediaPlayer, - downloadFile: DownloadFile, - isPartial: Boolean - ) { - mediaPlayer.setOnErrorListener { _, what, extra -> - Timber.w("Error on playing file (%d, %d): %s", what, extra, downloadFile) - val pos = cachedPosition - reset() - downloadFile.setPlaying(false) - doPlay(downloadFile, pos, true) - downloadFile.setPlaying(true) - true - } - - var duration = 0 - if (downloadFile.track.duration != null) { - duration = downloadFile.track.duration!! * 1000 - } - - mediaPlayer.setOnCompletionListener(object : OnCompletionListener { - override fun onCompletion(mediaPlayer: MediaPlayer) { - // Acquire a temporary wakelock, since when we return from - // this callback the MediaPlayer will release its wakelock - // and allow the device to go to sleep. - wakeLock.acquire(60000) - val pos = cachedPosition - Timber.i("Ending position %d of %d", pos, duration) - - if (!isPartial || downloadFile.isWorkDone && abs(duration - pos) < 1000) { - setPlayerState(PlayerState.COMPLETED, downloadFile) - if (Settings.gaplessPlayback && - nextPlaying != null && - nextPlayerState === PlayerState.PREPARED - ) { - if (nextSetup) { - nextSetup = false - } - playNext() - } else { - if (onSongCompleted != null) { - val mainHandler = Handler(context.mainLooper) - val myRunnable = Runnable { onSongCompleted!!(currentPlaying) } - mainHandler.post(myRunnable) - } - } - return - } - - synchronized(this) { - if (downloadFile.isWorkDone) { - // Complete was called early even though file is fully buffered - Timber.i("Requesting restart from %d of %d", pos, duration) - reset() - downloadFile.setPlaying(false) - doPlay(downloadFile, pos, true) - downloadFile.setPlaying(true) - } else { - Timber.i("Requesting restart from %d of %d", pos, duration) - reset() - bufferTask = BufferTask(downloadFile, pos) - bufferTask!!.start() - } - } - } - }) - } - - @Synchronized - fun reset() { - if (bufferTask != null) { - bufferTask!!.cancel() - } - - resetMediaPlayer() - - try { - setPlayerState(PlayerState.IDLE, currentPlaying) - mediaPlayer.setOnErrorListener(null) - mediaPlayer.setOnCompletionListener(null) - } catch (x: Exception) { - handleError(x) - } - } - - @Synchronized - fun resetMediaPlayer() { - try { - mediaPlayer.reset() - } catch (x: Exception) { - Timber.w(x, "MediaPlayer was released but LocalMediaPlayer was not destroyed") - - // Recreate MediaPlayer - mediaPlayer = MediaPlayer() - } - } - - private inner class BufferTask( - private val downloadFile: DownloadFile, - private val position: Int, - private val autoStart: Boolean = true - ) : CancellableTask() { - private val expectedFileSize: Long - private val partialFile: String = downloadFile.partialFile - - override fun execute() { - setPlayerState(PlayerState.DOWNLOADING, downloadFile) - while (!bufferComplete() && !isOffline()) { - Util.sleepQuietly(1000L) - if (isCancelled) { - return - } - } - - doPlay(downloadFile, position, autoStart) - } - - private fun bufferComplete(): Boolean { - val completeFileAvailable = downloadFile.isWorkDone - val size = Storage.getFromPath(partialFile)?.length ?: 0 - - Timber.i( - "Buffering %s (%d/%d, %s)", - partialFile, size, expectedFileSize, completeFileAvailable - ) - - return completeFileAvailable || size >= expectedFileSize - } - - override fun toString(): String { - return String.format("BufferTask (%s)", downloadFile) - } - - init { - var bufferLength = Settings.bufferLength.toLong() - if (bufferLength == 0L) { - // Set to seconds in a day, basically infinity - bufferLength = 86400L - } - - // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to. - val bitRate = downloadFile.getBitRate() - val byteCount = max(100000, bitRate * 1024L / 8L * bufferLength) - - // Find out how large the file should grow before resuming playback. - Timber.i("Buffering from position %d and bitrate %d", position, bitRate) - expectedFileSize = position * bitRate / 8 + byteCount - } - } - - private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() { - private val downloadFile: DownloadFile? - private val partialFile: String? - override fun execute() { - Thread.currentThread().name = "CheckCompletionTask" - if (downloadFile == null) { - return - } - - // Do an initial sleep so this prepare can't compete with main prepare - Util.sleepQuietly(5000L) - while (!bufferComplete()) { - Util.sleepQuietly(5000L) - if (isCancelled) { - return - } - } - - // Start the setup of the next media player - mediaPlayerHandler!!.post { setupNext(downloadFile) } - } - - private fun bufferComplete(): Boolean { - val completeFileAvailable = downloadFile!!.isWorkDone - val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) - - val length = if (partialFile == null) 0 - else Storage.getFromPath(partialFile)?.length ?: 0 - - Timber.i("Buffering next %s (%d)", partialFile, length) - - return completeFileAvailable && state - } - - override fun toString(): String { - return String.format("CheckCompletionTask (%s)", downloadFile) - } - - init { - setNextPlayerState(PlayerState.IDLE) - this.downloadFile = downloadFile - partialFile = downloadFile?.partialFile - } - } - - private inner class PositionCache : Runnable { - var isRunning = true - fun stop() { - isRunning = false - } - - override fun run() { - Thread.currentThread().name = "PositionCache" - - // Stop checking position before the song reaches completion - while (isRunning) { - try { - if (playerState === PlayerState.STARTED) { - synchronized(playerState) { - if (playerState === PlayerState.STARTED) { - cachedPosition = mediaPlayer.currentPosition - } - } - RxBus.playbackPositionPublisher.onNext(cachedPosition) - } - Util.sleepQuietly(100L) - } catch (e: Exception) { - Timber.w(e, "Crashed getting current position") - isRunning = false - positionCache = null - } - } - } - } - - private fun handleError(x: Exception) { - Timber.w(x, "Media player error") - try { - mediaPlayer.reset() - } catch (ex: Exception) { - Timber.w(ex, "Exception encountered when resetting media player") - } - } - - private fun handleErrorNext(x: Exception) { - Timber.w(x, "Next Media player error") - nextMediaPlayer!!.reset() - } - - private fun postRunnable(runnable: Runnable?) { - if (runnable != null) { - val mainHandler = Handler(context.mainLooper) - val myRunnable = Runnable { runnable.run() } - mainHandler.post(myRunnable) - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 9e17fde5..2f89e21b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -6,20 +6,34 @@ */ package org.moire.ultrasonic.service +import android.content.ComponentName +import android.content.Context import android.content.Intent +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.Player.STATE_BUFFERING +import androidx.media3.common.Timeline +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.MediaPlayerService.Companion.executeOnStartedMediaPlayerService -import org.moire.ultrasonic.service.MediaPlayerService.Companion.getInstance -import org.moire.ultrasonic.service.MediaPlayerService.Companion.runningInstance +import org.moire.ultrasonic.playback.LegacyPlaylistManager +import org.moire.ultrasonic.playback.PlaybackService +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 +import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 +import org.moire.ultrasonic.service.DownloadService.Companion.getInstance import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.ShufflePlayBuffer import timber.log.Timber /** @@ -32,8 +46,8 @@ class MediaPlayerController( private val playbackStateSerializer: PlaybackStateSerializer, private val externalStorageMonitor: ExternalStorageMonitor, private val downloader: Downloader, - private val shufflePlayBuffer: ShufflePlayBuffer, - private val localMediaPlayer: LocalMediaPlayer + private val legacyPlaylistManager: LegacyPlaylistManager, + val context: Context ) : KoinComponent { private var created = false @@ -42,22 +56,142 @@ class MediaPlayerController( var showVisualization = false private var autoPlayStart = false + private val scrobbler = Scrobbler() + private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() private val activeServerProvider: ActiveServerProvider by inject() + private var sessionToken = + SessionToken(context, ComponentName(context, PlaybackService::class.java)) + + private var mediaControllerFuture = MediaController.Builder( + context, + sessionToken + ).buildAsync() + + var controller: MediaController? = null + fun onCreate() { if (created) return externalStorageMonitor.onCreate { reset() } isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault + + mediaControllerFuture.addListener({ + controller = mediaControllerFuture.get() + + controller?.addListener(object : Player.Listener { + /* + * This will be called everytime the playlist has changed. + */ + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + legacyPlaylistManager.rebuildPlaylist(controller!!) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + translatePlaybackState(playbackState = playbackState) + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + translatePlaybackState(isPlaying = isPlaying) + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + legacyPlaylistManager.updateCurrentPlaying(mediaItem) + publishPlaybackState() + } + }) + + //controller?.play() + }, MoreExecutors.directExecutor()) + created = true Timber.i("MediaPlayerController created") } + @Suppress("DEPRECATION") + fun translatePlaybackState( + playbackState: Int = controller?.playbackState ?: 0, + isPlaying: Boolean = controller?.isPlaying ?: false + ) { + legacyPlayerState = when (playbackState) { + STATE_BUFFERING -> PlayerState.DOWNLOADING + Player.STATE_ENDED -> { + PlayerState.COMPLETED + } + Player.STATE_IDLE -> { + PlayerState.IDLE + } + Player.STATE_READY -> { + if (isPlaying) { + PlayerState.STARTED + } else { + PlayerState.PAUSED + } + } + else -> { + PlayerState.IDLE + } + } + } + + private fun playerStateChangedHandler() { + + val playerState = legacyPlayerState + val currentPlaying = legacyPlaylistManager.currentPlaying + + when { + playerState === PlayerState.PAUSED -> { + // TODO: Save playlist +// playbackStateSerializer.serialize( +// downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition +// ) + } + playerState === PlayerState.STARTED -> { + scrobbler.scrobble(currentPlaying, false) + } + playerState === PlayerState.COMPLETED -> { + scrobbler.scrobble(currentPlaying, true) + } + } + + //Update widget + if (currentPlaying != null) { + updateWidget(playerState, currentPlaying.track) + } + + Timber.d("Processed player state change") + } + + private fun publishPlaybackState() { + RxBus.playerStatePublisher.onNext( + RxBus.StateWithTrack( + state = legacyPlayerState, + track = legacyPlaylistManager.currentPlaying, + index = currentMediaItemIndex + ) + ) + } + + private fun updateWidget(playerState: PlayerState, song: Track?) { + val started = playerState === PlayerState.STARTED + val context = UApp.applicationContext() + + UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false) + UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true) + UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false) + UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false) + } + fun onDestroy() { if (!created) return val context = UApp.applicationContext() externalStorageMonitor.onDestroy() - context.stopService(Intent(context, MediaPlayerService::class.java)) + context.stopService(Intent(context, DownloadService::class.java)) + legacyPlaylistManager.onDestroy() downloader.onDestroy() created = false Timber.i("MediaPlayerController destroyed") @@ -73,33 +207,30 @@ class MediaPlayerController( ) { addToPlaylist( songs, - save = false, + cachePermanently = false, autoPlay = false, playNext = false, shuffle = false, newPlaylist = newPlaylist ) + if (currentPlayingIndex != -1) { - executeOnStartedMediaPlayerService { mediaPlayerService: MediaPlayerService -> - mediaPlayerService.play(currentPlayingIndex, autoPlayStart) - if (localMediaPlayer.currentPlaying != null) { - if (autoPlay && jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip( - downloader.currentPlayingIndex, - currentPlayingPosition / 1000 - ) - } else { - if (localMediaPlayer.currentPlaying!!.isCompleteFileAvailable) { - localMediaPlayer.play( - localMediaPlayer.currentPlaying, - currentPlayingPosition, - autoPlay - ) - } - } - } - autoPlayStart = false + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.skip( + currentPlayingIndex, + currentPlayingPosition / 1000 + ) + } else { + seekTo(currentPlayingIndex, currentPlayingPosition) } + + if (autoPlay) { + prepare() + play() + } + + autoPlayStart = false + } } @@ -110,93 +241,139 @@ class MediaPlayerController( @Synchronized fun play(index: Int) { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.play(index, true) - } + controller?.seekTo(index, 0L) + controller?.play() } @Synchronized fun play() { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.play() + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.start() + } else { + controller?.play() } } + @Synchronized + fun prepare() { + controller?.prepare() + } + @Synchronized fun resumeOrPlay() { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.resumeOrPlay() - } + controller?.play() } @Synchronized fun togglePlayPause() { - if (localMediaPlayer.playerState === PlayerState.IDLE) autoPlayStart = true - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.togglePlayPause() + if (playbackState == Player.STATE_IDLE) autoPlayStart = true + if (controller?.isPlaying == false) { + controller?.pause() + } else { + controller?.play() } } - @Synchronized - fun start() { - executeOnStartedMediaPlayerService { service: MediaPlayerService -> - service.start() - } - } @Synchronized fun seekTo(position: Int) { - val mediaPlayerService = runningInstance - mediaPlayerService?.seekTo(position) + controller?.seekTo(position.toLong()) + } + + @Synchronized + fun seekTo(index: Int, position: Int) { + controller?.seekTo(index, position.toLong()) } @Synchronized fun pause() { - val mediaPlayerService = runningInstance - mediaPlayerService?.pause() + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.stop() + } else { + controller?.pause() + } } @Synchronized fun stop() { - val mediaPlayerService = runningInstance - mediaPlayerService?.stop() + if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.stop() + } else { + controller?.stop() + } } + @Synchronized + @Deprecated("Use InsertionMode Syntax") @Suppress("LongParameterList") fun addToPlaylist( songs: List?, - save: Boolean, + cachePermanently: Boolean, autoPlay: Boolean, playNext: Boolean, shuffle: Boolean, newPlaylist: Boolean ) { if (songs == null) return - val filteredSongs = songs.filterNotNull() - downloader.addToPlaylist(filteredSongs, save, autoPlay, playNext, newPlaylist) - jukeboxMediaPlayer.updatePlaylist() - if (shuffle) shuffle() - val isLastTrack = (downloader.getPlaylist().size - 1 == downloader.currentPlayingIndex) - if (!playNext && !autoPlay && isLastTrack) { - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() + val insertionMode = when { + newPlaylist -> InsertionMode.CLEAR + playNext -> InsertionMode.AFTER_CURRENT + else -> InsertionMode.APPEND } + val filteredSongs = songs.filterNotNull() + + addToPlaylist( + filteredSongs, cachePermanently, autoPlay, shuffle, insertionMode + ) + } + + @Synchronized + fun addToPlaylist( + songs: List, + cachePermanently: Boolean, + autoPlay: Boolean, + shuffle: Boolean, + insertionMode: InsertionMode + ) { + var insertAt = 0 + + if (insertionMode == InsertionMode.CLEAR) { + clear() + } + + when (insertionMode) { + InsertionMode.CLEAR -> clear() + InsertionMode.APPEND -> insertAt = mediaItemCount + InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex + } + + val mediaItems: List = songs.map { + val downloadFile = downloader.getDownloadFileForSong(it) + if (cachePermanently) downloadFile.shouldSave = true + val result = it.toMediaItem() + legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it)) + result + } + + controller?.addMediaItems(insertAt, mediaItems) + + jukeboxMediaPlayer.updatePlaylist() + + if (shuffle) isShufflePlayEnabled = true + if (autoPlay) { + prepare() play(0) } else { - if (localMediaPlayer.currentPlaying == null && downloader.getPlaylist().isNotEmpty()) { - localMediaPlayer.currentPlaying = downloader.getPlaylist()[0] - downloader.getPlaylist()[0].setPlaying(true) - } downloader.checkDownloads() } playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, + legacyPlaylistManager.playlist, + currentMediaItemIndex, playerPosition ) } @@ -206,77 +383,60 @@ class MediaPlayerController( if (songs == null) return val filteredSongs = songs.filterNotNull() downloader.downloadBackground(filteredSongs, save) + playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, + legacyPlaylistManager.playlist, + currentMediaItemIndex, playerPosition ) } - @Synchronized - fun setCurrentPlaying(index: Int) { - val mediaPlayerService = runningInstance - mediaPlayerService?.setCurrentPlaying(index) - } - fun stopJukeboxService() { jukeboxMediaPlayer.stopJukeboxService() } @set:Synchronized var isShufflePlayEnabled: Boolean - get() = shufflePlayBuffer.isEnabled + get() = controller?.shuffleModeEnabled == true set(enabled) { - shufflePlayBuffer.isEnabled = enabled + controller?.shuffleModeEnabled = enabled if (enabled) { - clear() downloader.checkDownloads() } } @Synchronized - fun shuffle() { - downloader.shuffle() - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) - jukeboxMediaPlayer.updatePlaylist() - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() + fun toggleShuffle() { + isShufflePlayEnabled = !isShufflePlayEnabled } + val bufferedPercentage: Int + get() = controller?.bufferedPercentage ?: 0 + @Synchronized fun moveItemInPlaylist(oldPos: Int, newPos: Int) { - downloader.moveItemInPlaylist(oldPos, newPos) + controller?.moveMediaItem(oldPos, newPos) } @set:Synchronized - var repeatMode: RepeatMode - get() = Settings.repeatMode - set(repeatMode) { - Settings.repeatMode = repeatMode - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() + var repeatMode: Int + get() = controller?.repeatMode ?: 0 + set(newMode) { + controller?.repeatMode = newMode } @Synchronized @JvmOverloads fun clear(serialize: Boolean = true) { - val mediaPlayerService = runningInstance - if (mediaPlayerService != null) { - mediaPlayerService.clear(serialize) - } else { - // If no MediaPlayerService is available, just empty the playlist - downloader.clearPlaylist() - if (serialize) { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, playerPosition - ) - } + + controller?.clearMediaItems() + + if (controller != null && serialize) { + playbackStateSerializer.serialize( + listOf(), -1, 0 + ) } + jukeboxMediaPlayer.updatePlaylist() } @@ -289,11 +449,12 @@ class MediaPlayerController( fun clearIncomplete() { reset() - downloader.clearIncomplete() + downloader.clearActiveDownloads() + downloader.clearBackground() playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, + legacyPlaylistManager.playlist, + currentMediaItemIndex, playerPosition ) @@ -301,26 +462,17 @@ class MediaPlayerController( } @Synchronized - // TODO: If a playlist contains an item twice, this call will wrongly remove all + // FIXME + // With the new API we can only remove by index!! fun removeFromPlaylist(downloadFile: DownloadFile) { - if (downloadFile == localMediaPlayer.currentPlaying) { - reset() - currentPlaying = null - } - downloader.removeFromPlaylist(downloadFile) playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, + legacyPlaylistManager.playlist, + legacyPlaylistManager.currentPlayingIndex, playerPosition ) jukeboxMediaPlayer.updatePlaylist() - - if (downloadFile == localMediaPlayer.nextPlaying) { - val mediaPlayerService = runningInstance - mediaPlayerService?.setNextPlaying() - } } @Synchronized @@ -341,80 +493,56 @@ class MediaPlayerController( @Synchronized fun previous() { - val index = downloader.currentPlayingIndex - if (index == -1) { - return - } - - // Restart song if played more than five seconds. - @Suppress("MagicNumber") - if (playerPosition > 5000 || index == 0) { - play(index) - } else { - play(index - 1) - } + controller?.seekToPrevious() } @Synchronized operator fun next() { - val index = downloader.currentPlayingIndex - if (index != -1) { - when (repeatMode) { - RepeatMode.SINGLE, RepeatMode.OFF -> { - // Play next if exists - if (index + 1 >= 0 && index + 1 < downloader.getPlaylist().size) { - play(index + 1) - } - } - RepeatMode.ALL -> { - play((index + 1) % downloader.getPlaylist().size) - } - else -> { - } - } - } + controller?.seekToNext() } @Synchronized fun reset() { - val mediaPlayerService = runningInstance - if (mediaPlayerService != null) localMediaPlayer.reset() + controller?.clearMediaItems() } @get:Synchronized val playerPosition: Int get() { - val mediaPlayerService = runningInstance ?: return 0 - return mediaPlayerService.playerPosition + return if (jukeboxMediaPlayer.isEnabled) { + jukeboxMediaPlayer.positionSeconds * 1000 + } else { + controller?.currentPosition?.toInt() ?: 0 + } } @get:Synchronized val playerDuration: Int get() { - val mediaPlayerService = runningInstance ?: return 0 - return mediaPlayerService.playerDuration + return controller?.duration?.toInt() ?: return 0 } + @Deprecated("Use Controller.playbackState and Controller.isPlaying") @set:Synchronized - var playerState: PlayerState - get() = localMediaPlayer.playerState - set(state) { - val mediaPlayerService = runningInstance - if (mediaPlayerService != null) - localMediaPlayer.setPlayerState(state, localMediaPlayer.currentPlaying) - } + var legacyPlayerState: PlayerState = PlayerState.IDLE + + val playbackState: Int + get() = controller?.playbackState ?: 0 + + val isPlaying: Boolean + get() = controller?.isPlaying ?: false @set:Synchronized var isJukeboxEnabled: Boolean get() = jukeboxMediaPlayer.isEnabled set(jukeboxEnabled) { jukeboxMediaPlayer.isEnabled = jukeboxEnabled - playerState = PlayerState.IDLE + if (jukeboxEnabled) { jukeboxMediaPlayer.startJukeboxService() reset() - // Cancel current download, if necessary. + // Cancel current downloads downloader.clearActiveDownloads() } else { jukeboxMediaPlayer.stopJukeboxService() @@ -441,19 +569,12 @@ class MediaPlayerController( } fun setVolume(volume: Float) { - if (runningInstance != null) localMediaPlayer.setVolume(volume) - } - - private fun updateNotification() { - runningInstance?.updateNotification( - localMediaPlayer.playerState, - localMediaPlayer.currentPlaying - ) + controller?.volume = volume } fun toggleSongStarred() { - if (localMediaPlayer.currentPlaying == null) return - val song = localMediaPlayer.currentPlaying!!.track + if (legacyPlaylistManager.currentPlaying == null) return + val song = legacyPlaylistManager.currentPlaying!!.track Thread { val musicService = getMusicService() @@ -469,15 +590,16 @@ class MediaPlayerController( }.start() // Trigger an update - localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) + // TODO Update Metadata of MediaItem... + //localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) song.starred = !song.starred } @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions fun setSongRating(rating: Int) { if (!Settings.useFiveStarRating) return - if (localMediaPlayer.currentPlaying == null) return - val song = localMediaPlayer.currentPlaying!!.track + if (legacyPlaylistManager.currentPlaying == null) return + val song = legacyPlaylistManager.currentPlaying!!.track song.userRating = rating Thread { try { @@ -487,27 +609,33 @@ class MediaPlayerController( } }.start() // TODO this would be better handled with a Rx command - updateNotification() + //updateNotification() } - @set:Synchronized - var currentPlaying: DownloadFile? - get() = localMediaPlayer.currentPlaying - set(currentPlaying) { - if (runningInstance != null) localMediaPlayer.setCurrentPlaying(currentPlaying) - } + val currentMediaItem: MediaItem? + get() = controller?.currentMediaItem + val currentMediaItemIndex: Int + get() = controller?.currentMediaItemIndex ?: -1 + + @Deprecated("Use currentMediaItem") + val currentPlayingLegacy: DownloadFile? + get() = legacyPlaylistManager.currentPlaying + + val mediaItemCount: Int + get() = controller?.mediaItemCount ?: 0 + + @Deprecated("Use mediaItemCount") val playlistSize: Int - get() = downloader.getPlaylist().size - - val currentPlayingNumberOnPlaylist: Int - get() = downloader.currentPlayingIndex + get() = legacyPlaylistManager.playlist.size + @Deprecated("Use native APIs") val playList: List - get() = downloader.getPlaylist() + get() = legacyPlaylistManager.playlist + @Deprecated("Use timeline") val playListDuration: Long - get() = downloader.downloadListDuration + get() = legacyPlaylistManager.playlistDuration fun getDownloadFileForSong(song: Track): DownloadFile { return downloader.getDownloadFileForSong(song) @@ -516,4 +644,30 @@ class MediaPlayerController( init { Timber.i("MediaPlayerController constructed") } + + enum class InsertionMode { + CLEAR, APPEND, AFTER_CURRENT + } } + + +fun Track.toMediaItem(): MediaItem { + + val filePath = FileUtil.getSongFile(this) + val bitrate = Settings.maxBitRate + val uri = "$id|$bitrate|$filePath" + + val metadata = MediaMetadata.Builder() + metadata.setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setMediaUri(uri.toUri()) + .setAlbumArtist(artist) + + val mediaItem = MediaItem.Builder() + .setUri(uri) + .setMediaId(id) + .setMediaMetadata(metadata.build()) + + return mediaItem.build() +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index 87288df8..a12e83c0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -8,32 +8,23 @@ package org.moire.ultrasonic.service import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.media.AudioManager import android.view.KeyEvent import io.reactivex.rxjava3.disposables.Disposable import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp.Companion.applicationContext -import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util.ifNotNull import timber.log.Timber /** * This class is responsible for handling received events for the Media Player implementation - * - * @author Sindre Mehus */ class MediaPlayerLifecycleSupport : KoinComponent { private val playbackStateSerializer by inject() private val mediaPlayerController by inject() - private val downloader by inject() private var created = false private var headsetEventReceiver: BroadcastReceiver? = null @@ -50,11 +41,6 @@ class MediaPlayerLifecycleSupport : KoinComponent { return } - mediaButtonEventSubscription = RxBus.mediaButtonEventObservable.subscribe { - handleKeyEvent(it) - } - - registerHeadsetReceiver() mediaPlayerController.onCreate() if (autoPlay) mediaPlayerController.preload() @@ -68,13 +54,6 @@ class MediaPlayerLifecycleSupport : KoinComponent { false ) - // Work-around: Serialize again, as the restore() method creates a - // serialization without current playing info. - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - mediaPlayerController.playerPosition - ) afterCreated?.run() } @@ -87,11 +66,12 @@ class MediaPlayerLifecycleSupport : KoinComponent { if (!created) return - playbackStateSerializer.serializeNow( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - mediaPlayerController.playerPosition - ) + // TODO +// playbackStateSerializer.serializeNow( +// downloader.getPlaylist(), +// downloader.currentPlayingIndex, +// mediaPlayerController.playerPosition +// ) mediaPlayerController.clear(false) mediaButtonEventSubscription?.dispose() @@ -121,73 +101,19 @@ class MediaPlayerLifecycleSupport : KoinComponent { } } - /** - * The Headset Intent Receiver is responsible for resuming playback when a headset is inserted - * and pausing it when it is removed. - * Unfortunately this Intent can't be registered in the AndroidManifest, so it works only - * while Ultrasonic is running. - */ - private fun registerHeadsetReceiver() { - - val sp = Settings.preferences - val context = applicationContext() - val spKey = context - .getString(R.string.settings_playback_resume_play_on_headphones_plug) - - headsetEventReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val extras = intent.extras ?: return - - Timber.i("Headset event for: %s", extras["name"]) - - val state = extras.getInt("state") - - if (state == 0) { - if (!mediaPlayerController.isJukeboxEnabled) { - mediaPlayerController.pause() - } - } else if (state == 1) { - if (!mediaPlayerController.isJukeboxEnabled && - sp.getBoolean( - spKey, - false - ) && mediaPlayerController.playerState === PlayerState.PAUSED - ) { - mediaPlayerController.start() - } - } - } - } - - val headsetIntentFilter = IntentFilter(AudioManager.ACTION_HEADSET_PLUG) - - applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter) - } - - @Suppress("MagicNumber", "ComplexMethod") + @Suppress("MagicNumber") private fun handleKeyEvent(event: KeyEvent) { if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return - val keyCode: Int - val receivedKeyCode = event.keyCode - - // Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices - keyCode = if (Settings.singleButtonPlayPause && ( - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE - ) - ) { - Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE") - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - } else receivedKeyCode + val keyCode: Int = event.keyCode val autoStart = keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || - keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - keyCode == KeyEvent.KEYCODE_HEADSETHOOK || - keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS || - keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || + keyCode == KeyEvent.KEYCODE_HEADSETHOOK || + keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS || + keyCode == KeyEvent.KEYCODE_MEDIA_NEXT // We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start onCreate(autoStart) { @@ -197,14 +123,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous() KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next() KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop() - - KeyEvent.KEYCODE_MEDIA_PLAY -> - if (mediaPlayerController.playerState === PlayerState.IDLE) { - mediaPlayerController.play() - } else if (mediaPlayerController.playerState !== PlayerState.STARTED) { - mediaPlayerController.start() - } - + KeyEvent.KEYCODE_MEDIA_PLAY -> mediaPlayerController.play() KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause() KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1) KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2) @@ -221,21 +140,15 @@ class MediaPlayerLifecycleSupport : KoinComponent { /** * This function processes the intent that could come from other applications. */ - @Suppress("ComplexMethod") private fun handleUltrasonicIntent(intentAction: String) { val isRunning = created // If Ultrasonic is not running, do nothing to stop or pause - if ( - !isRunning && ( - intentAction == Constants.CMD_PAUSE || - intentAction == Constants.CMD_STOP - ) - ) return + if (!isRunning && (intentAction == Constants.CMD_PAUSE || intentAction == Constants.CMD_STOP)) + return - val autoStart = - intentAction == Constants.CMD_PLAY || + val autoStart = intentAction == Constants.CMD_PLAY || intentAction == Constants.CMD_RESUME_OR_PLAY || intentAction == Constants.CMD_TOGGLEPAUSE || intentAction == Constants.CMD_PREVIOUS || @@ -253,12 +166,7 @@ class MediaPlayerLifecycleSupport : KoinComponent { Constants.CMD_NEXT -> mediaPlayerController.next() Constants.CMD_PREVIOUS -> mediaPlayerController.previous() Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause() - - Constants.CMD_STOP -> { - // TODO: There is a stop() function, shouldn't we use that? - mediaPlayerController.pause() - mediaPlayerController.seekTo(0) - } + Constants.CMD_STOP -> mediaPlayerController.stop() Constants.CMD_PAUSE -> mediaPlayerController.pause() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt deleted file mode 100644 index 55951d8e..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ /dev/null @@ -1,769 +0,0 @@ -/* - * MediaPlayerService.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.support.v4.media.session.MediaSessionCompat -import android.view.KeyEvent -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import io.reactivex.rxjava3.disposables.CompositeDisposable -import kotlin.collections.ArrayList -import org.koin.android.ext.android.inject -import org.moire.ultrasonic.R -import org.moire.ultrasonic.activity.NavigationActivity -import org.moire.ultrasonic.app.UApp -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode -import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.imageloader.BitmapUtils -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 -import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 -import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.MediaSessionHandler -import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.ShufflePlayBuffer -import org.moire.ultrasonic.util.SimpleServiceBinder -import org.moire.ultrasonic.util.Util -import timber.log.Timber - -/** - * Android Foreground Service for playing music - * while the rest of the Ultrasonic App is in the background. - * - * "A foreground service is a service that the user is - * actively aware of and isn’t a candidate for the system to kill when low on memory." - */ -@Suppress("LargeClass") -class MediaPlayerService : Service() { - private val binder: IBinder = SimpleServiceBinder(this) - private val scrobbler = Scrobbler() - - private val jukeboxMediaPlayer by inject() - private val playbackStateSerializer by inject() - private val shufflePlayBuffer by inject() - private val downloader by inject() - private val localMediaPlayer by inject() - private val mediaSessionHandler by inject() - - private var mediaSession: MediaSessionCompat? = null - private var mediaSessionToken: MediaSessionCompat.Token? = null - private var isInForeground = false - private var notificationBuilder: NotificationCompat.Builder? = null - private var rxBusSubscription: CompositeDisposable = CompositeDisposable() - - private var currentPlayerState: PlayerState? = null - private var currentTrack: DownloadFile? = null - - override fun onBind(intent: Intent): IBinder { - return binder - } - - override fun onCreate() { - super.onCreate() - - shufflePlayBuffer.onCreate() - localMediaPlayer.init() - - setupOnSongCompletedHandler() - - localMediaPlayer.onPrepared = { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, - playerPosition - ) - null - } - - localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } - - // Create Notification Channel - createNotificationChannel() - - // Update notification early. It is better to show an empty one temporarily - // than waiting too long and letting Android kill the app - updateNotification(PlayerState.IDLE, null) - - // Subscribing should be after updateNotification to avoid concurrency - rxBusSubscription += RxBus.playerStateObservable.subscribe { - playerStateChangedHandler(it.state, it.track) - } - - rxBusSubscription += RxBus.mediaSessionTokenObservable.subscribe { - mediaSessionToken = it - } - - rxBusSubscription += RxBus.skipToQueueItemCommandObservable.subscribe { - play(it.toInt()) - } - - mediaSessionHandler.initialize() - - instance = this - Timber.i("MediaPlayerService created") - } - - override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - return START_NOT_STICKY - } - - override fun onDestroy() { - super.onDestroy() - instance = null - try { - mediaSessionHandler.release() - rxBusSubscription.dispose() - - localMediaPlayer.release() - downloader.stop() - shufflePlayBuffer.onDestroy() - - mediaSession?.release() - mediaSession = null - } catch (ignored: Throwable) { - } - Timber.i("MediaPlayerService stopped") - } - - private fun stopIfIdle() { - synchronized(instanceLock) { - // currentPlaying could be changed from another thread in the meantime, - // so check again before stopping for good - if (localMediaPlayer.currentPlaying == null || - localMediaPlayer.playerState === PlayerState.STOPPED - ) { - stopSelf() - } - } - } - - fun notifyDownloaderStopped() { - // TODO It would be nice to know if the service really can be stopped instead of just - // checking if it is idle once... - val handler = Handler(Looper.getMainLooper()) - handler.postDelayed({ stopIfIdle() }, 1000) - } - - @Synchronized - fun seekTo(position: Int) { - if (jukeboxMediaPlayer.isEnabled) { - // TODO These APIs should be more aligned - val seconds = position / 1000 - jukeboxMediaPlayer.skip(downloader.currentPlayingIndex, seconds) - } else { - localMediaPlayer.seekTo(position) - } - } - - @get:Synchronized - val playerPosition: Int - get() { - if (localMediaPlayer.playerState === PlayerState.IDLE || - localMediaPlayer.playerState === PlayerState.DOWNLOADING || - localMediaPlayer.playerState === PlayerState.PREPARING - ) { - return 0 - } - return if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.positionSeconds * 1000 - } else { - localMediaPlayer.playerPosition - } - } - - @get:Synchronized - val playerDuration: Int - get() = localMediaPlayer.playerDuration - - @Synchronized - fun setCurrentPlaying(currentPlayingIndex: Int) { - try { - localMediaPlayer.setCurrentPlaying(downloader.getPlaylist()[currentPlayingIndex]) - } catch (ignored: IndexOutOfBoundsException) { - } - } - - @Synchronized - fun setNextPlaying() { - // Download the next few songs if necessary - downloader.checkDownloads() - - if (!Settings.gaplessPlayback) { - localMediaPlayer.clearNextPlaying(true) - return - } - - var index = downloader.currentPlayingIndex - - if (index != -1) { - when (Settings.repeatMode) { - RepeatMode.OFF -> index += 1 - RepeatMode.ALL -> index = (index + 1) % downloader.getPlaylist().size - RepeatMode.SINGLE -> { - } - else -> { - } - } - } - - localMediaPlayer.clearNextPlaying(false) - if (index < downloader.getPlaylist().size && index != -1) { - localMediaPlayer.setNextPlaying(downloader.getPlaylist()[index]) - } else { - localMediaPlayer.clearNextPlaying(true) - } - } - - @Synchronized - fun togglePlayPause() { - if (localMediaPlayer.playerState === PlayerState.PAUSED || - localMediaPlayer.playerState === PlayerState.COMPLETED || - localMediaPlayer.playerState === PlayerState.STOPPED - ) { - start() - } else if (localMediaPlayer.playerState === PlayerState.IDLE) { - play() - } else if (localMediaPlayer.playerState === PlayerState.STARTED) { - pause() - } - } - - @Synchronized - fun resumeOrPlay() { - if (localMediaPlayer.playerState === PlayerState.PAUSED || - localMediaPlayer.playerState === PlayerState.COMPLETED || - localMediaPlayer.playerState === PlayerState.STOPPED - ) { - start() - } else if (localMediaPlayer.playerState === PlayerState.IDLE) { - play() - } - } - - /** - * Plays either the current song (resume) or the first/next one in queue. - */ - @Synchronized - fun play() { - val current = downloader.currentPlayingIndex - if (current == -1) { - play(0) - } else { - play(current) - } - } - - @Synchronized - fun play(index: Int) { - play(index, true) - } - - @Synchronized - fun play(index: Int, start: Boolean) { - Timber.v("play requested for %d", index) - if (index < 0 || index >= downloader.getPlaylist().size) { - resetPlayback() - } else { - setCurrentPlaying(index) - if (start) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip(index, 0) - } else { - localMediaPlayer.play(downloader.getPlaylist()[index]) - } - } - setNextPlaying() - } - } - - @Synchronized - private fun resetPlayback() { - localMediaPlayer.reset() - localMediaPlayer.setCurrentPlaying(null) - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, playerPosition - ) - } - - @Synchronized - fun pause() { - if (localMediaPlayer.playerState === PlayerState.STARTED) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.stop() - } else { - localMediaPlayer.pause() - } - localMediaPlayer.setPlayerState(PlayerState.PAUSED, localMediaPlayer.currentPlaying) - } - } - - @Synchronized - fun stop() { - if (localMediaPlayer.playerState === PlayerState.STARTED) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.stop() - } else { - localMediaPlayer.pause() - } - } - localMediaPlayer.setPlayerState(PlayerState.STOPPED, null) - } - - @Synchronized - fun start() { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.start() - } else { - localMediaPlayer.start() - } - localMediaPlayer.setPlayerState(PlayerState.STARTED, localMediaPlayer.currentPlaying) - } - - private fun updateWidget(playerState: PlayerState, song: Track?) { - val started = playerState === PlayerState.STARTED - val context = this@MediaPlayerService - - UltrasonicAppWidgetProvider4X1.getInstance().notifyChange(context, song, started, false) - UltrasonicAppWidgetProvider4X2.getInstance().notifyChange(context, song, started, true) - UltrasonicAppWidgetProvider4X3.getInstance().notifyChange(context, song, started, false) - UltrasonicAppWidgetProvider4X4.getInstance().notifyChange(context, song, started, false) - } - - private fun playerStateChangedHandler( - playerState: PlayerState, - currentPlaying: DownloadFile? - ) { - val context = this@MediaPlayerService - // AVRCP handles these separately so we must differentiate between the cases - val isStateChanged = playerState != currentPlayerState - val isTrackChanged = currentPlaying != currentTrack - if (!isStateChanged && !isTrackChanged) return - - val showWhenPaused = playerState !== PlayerState.STOPPED && - Settings.isNotificationAlwaysEnabled - - val show = playerState === PlayerState.STARTED || showWhenPaused - val song = currentPlaying?.track - - if (isStateChanged) { - when { - playerState === PlayerState.PAUSED -> { - playbackStateSerializer.serialize( - downloader.getPlaylist(), downloader.currentPlayingIndex, playerPosition - ) - } - playerState === PlayerState.STARTED -> { - scrobbler.scrobble(currentPlaying, false) - } - playerState === PlayerState.COMPLETED -> { - scrobbler.scrobble(currentPlaying, true) - } - } - - Util.broadcastPlaybackStatusChange(context, playerState) - Util.broadcastA2dpPlayStatusChange( - context, playerState, song, - downloader.getPlaylist().size, - downloader.getPlaylist().indexOf(currentPlaying) + 1, playerPosition - ) - } else { - // State didn't change, only the track - Util.broadcastA2dpMetaDataChange( - this@MediaPlayerService, playerPosition, currentPlaying, - downloader.all.size, downloader.currentPlayingIndex + 1 - ) - } - - if (isTrackChanged) { - Util.broadcastNewTrackInfo(this@MediaPlayerService, currentPlaying?.track) - } - - // Update widget - updateWidget(playerState, song) - - if (show) { - // Only update notification if player state is one that will change the icon - if (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) { - updateNotification(playerState, currentPlaying) - } - } else { - stopForeground(true) - isInForeground = false - stopIfIdle() - } - - currentPlayerState = playerState - currentTrack = currentPlaying - - Timber.d("Processed player state change") - } - - private fun setupOnSongCompletedHandler() { - localMediaPlayer.onSongCompleted = { currentPlaying: DownloadFile? -> - val index = downloader.currentPlayingIndex - - if (currentPlaying != null) { - val song = currentPlaying.track - if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) { - val musicService = getMusicService() - try { - musicService.deleteBookmark(song.id) - } catch (ignored: Exception) { - } - } - } - if (index != -1) { - when (Settings.repeatMode) { - RepeatMode.OFF -> { - if (index + 1 < 0 || index + 1 >= downloader.getPlaylist().size) { - if (Settings.shouldClearPlaylist) { - clear(true) - jukeboxMediaPlayer.updatePlaylist() - } - resetPlayback() - } else { - play(index + 1) - } - } - RepeatMode.ALL -> { - play((index + 1) % downloader.getPlaylist().size) - } - RepeatMode.SINGLE -> play(index) - else -> { - } - } - } - null - } - } - - @Synchronized - fun clear(serialize: Boolean) { - localMediaPlayer.reset() - downloader.clearPlaylist() - localMediaPlayer.setCurrentPlaying(null) - setNextPlaying() - if (serialize) { - playbackStateSerializer.serialize( - downloader.getPlaylist(), - downloader.currentPlayingIndex, playerPosition - ) - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - - // The suggested importance of a startForeground service notification is IMPORTANCE_LOW - val channel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - NOTIFICATION_CHANNEL_NAME, - NotificationManager.IMPORTANCE_LOW - ) - - channel.lightColor = android.R.color.holo_blue_dark - channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC - channel.setShowBadge(false) - - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } - } - - fun updateNotification(playerState: PlayerState, currentPlaying: DownloadFile?) { - val notification = buildForegroundNotification(playerState, currentPlaying) - - if (Settings.isNotificationEnabled) { - if (isInForeground) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - manager.notify(NOTIFICATION_ID, notification) - } else { - val manager = NotificationManagerCompat.from(this) - manager.notify(NOTIFICATION_ID, notification) - } - Timber.v("Updated notification") - } else { - startForeground(NOTIFICATION_ID, notification) - isInForeground = true - Timber.v("Created Foreground notification") - } - } - } - - /** - * This method builds a notification, reusing the Notification Builder if possible - */ - @Suppress("SpreadOperator") - private fun buildForegroundNotification( - playerState: PlayerState, - currentPlaying: DownloadFile? - ): Notification { - - // Init - val context = applicationContext - val song = currentPlaying?.track - val stopIntent = Util.getPendingIntentForMediaAction( - context, - KeyEvent.KEYCODE_MEDIA_STOP, - 100 - ) - - // We should use a single notification builder, otherwise the notification may not be updated - if (notificationBuilder == null) { - notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - - // Set some values that never change - notificationBuilder!!.setSmallIcon(R.drawable.ic_stat_ultrasonic) - notificationBuilder!!.setAutoCancel(false) - notificationBuilder!!.setOngoing(true) - notificationBuilder!!.setOnlyAlertOnce(true) - notificationBuilder!!.setWhen(System.currentTimeMillis()) - notificationBuilder!!.setShowWhen(false) - notificationBuilder!!.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - notificationBuilder!!.priority = NotificationCompat.PRIORITY_LOW - - // Add content intent (when user taps on notification) - notificationBuilder!!.setContentIntent(getPendingIntentForContent()) - - // This intent is executed when the user closes the notification - notificationBuilder!!.setDeleteIntent(stopIntent) - } - - // Use the Media Style, to enable native Android support for playback notification - val style = androidx.media.app.NotificationCompat.MediaStyle() - - if (mediaSessionToken != null) { - style.setMediaSession(mediaSessionToken) - } - - // Clear old actions - notificationBuilder!!.clearActions() - - if (song != null) { - // Add actions - val compactActions = addActions(context, notificationBuilder!!, playerState, song) - // Configure shortcut actions - style.setShowActionsInCompactView(*compactActions) - notificationBuilder!!.setStyle(style) - - // Set song title, artist and cover - val iconSize = (256 * context.resources.displayMetrics.density).toInt() - val bitmap = BitmapUtils.getAlbumArtBitmapFromDisk(song, iconSize) - notificationBuilder!!.setContentTitle(song.title) - notificationBuilder!!.setContentText(song.artist) - notificationBuilder!!.setLargeIcon(bitmap) - notificationBuilder!!.setSubText(song.album) - } else if (downloader.started) { - // No song is playing, but Ultrasonic is downloading files - notificationBuilder!!.setContentTitle( - getString(R.string.notification_downloading_title) - ) - } - - return notificationBuilder!!.build() - } - - private fun addActions( - context: Context, - notificationBuilder: NotificationCompat.Builder, - playerState: PlayerState, - song: Track? - ): IntArray { - // Init - val compactActionList = ArrayList() - var numActions = 0 // we start and 0 and then increment by 1 for each call to generateAction - - // Star - if (song != null) { - notificationBuilder.addAction(generateStarAction(context, numActions, song.starred)) - } - numActions++ - - // Next - notificationBuilder.addAction(generateAction(context, numActions)) - compactActionList.add(numActions) - numActions++ - - // Play/Pause button - notificationBuilder.addAction(generatePlayPauseAction(context, numActions, playerState)) - compactActionList.add(numActions) - numActions++ - - // Previous - notificationBuilder.addAction(generateAction(context, numActions)) - compactActionList.add(numActions) - numActions++ - - // Close - notificationBuilder.addAction(generateAction(context, numActions)) - val actionArray = IntArray(compactActionList.size) - for (i in actionArray.indices) { - actionArray[i] = compactActionList[i] - } - return actionArray - // notificationBuilder.setShowActionsInCompactView()) - } - - private fun generateAction(context: Context, requestCode: Int): NotificationCompat.Action? { - val keycode: Int - val icon: Int - val label: String - - when (requestCode) { - 1 -> { - keycode = KeyEvent.KEYCODE_MEDIA_PREVIOUS - label = getString(R.string.common_play_previous) - icon = R.drawable.media_backward_medium_dark - } - 2 -> // Is handled in generatePlayPauseAction() - return null - 3 -> { - keycode = KeyEvent.KEYCODE_MEDIA_NEXT - label = getString(R.string.common_play_next) - icon = R.drawable.media_forward_medium_dark - } - 4 -> { - keycode = KeyEvent.KEYCODE_MEDIA_STOP - label = getString(R.string.buttons_stop) - icon = R.drawable.ic_baseline_close - } - else -> return null - } - - val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode) - return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() - } - - private fun generatePlayPauseAction( - context: Context, - requestCode: Int, - playerState: PlayerState - ): NotificationCompat.Action { - val isPlaying = playerState === PlayerState.STARTED - val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode) - val label: String - val icon: Int - - if (isPlaying) { - label = getString(R.string.common_pause) - icon = R.drawable.media_pause_large_dark - } else { - label = getString(R.string.common_play) - icon = R.drawable.media_start_large_dark - } - - return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() - } - - private fun generateStarAction( - context: Context, - requestCode: Int, - isStarred: Boolean - ): NotificationCompat.Action { - - val label: String - val icon: Int - val keyCode: Int = KeyEvent.KEYCODE_STAR - - if (isStarred) { - label = getString(R.string.download_menu_star) - icon = R.drawable.ic_star_full_dark - } else { - label = getString(R.string.download_menu_star) - icon = R.drawable.ic_star_hollow_dark - } - - val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode) - return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() - } - - private fun getPendingIntentForContent(): PendingIntent { - val intent = Intent(this, NavigationActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - val flags = PendingIntent.FLAG_UPDATE_CURRENT - intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) - return PendingIntent.getActivity(this, 0, intent, flags) - } - - @Suppress("MagicNumber") - companion object { - private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" - private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" - private const val NOTIFICATION_ID = 3033 - - @Volatile - private var instance: MediaPlayerService? = null - private val instanceLock = Any() - - @JvmStatic - fun getInstance(): MediaPlayerService? { - val context = UApp.applicationContext() - // Try for twenty times to retrieve a running service, - // sleep 100 millis between each try, - // and run the block that creates a service only synchronized. - for (i in 0..19) { - if (instance != null) return instance - synchronized(instanceLock) { - if (instance != null) return instance - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService( - Intent(context, MediaPlayerService::class.java) - ) - } else { - context.startService(Intent(context, MediaPlayerService::class.java)) - } - } - Util.sleepQuietly(100L) - } - return instance - } - - @JvmStatic - val runningInstance: MediaPlayerService? - get() { - synchronized(instanceLock) { return instance } - } - - @JvmStatic - fun executeOnStartedMediaPlayerService( - taskToExecute: (MediaPlayerService) -> Unit - ) { - - val t: Thread = object : Thread() { - override fun run() { - val instance = getInstance() - if (instance == null) { - Timber.e("ExecuteOnStarted.. failed to get a MediaPlayerService instance!") - return - } else { - taskToExecute(instance) - } - } - } - t.start() - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt index 7115140a..edb1cea5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt @@ -53,7 +53,7 @@ class PlaybackStateSerializer : KoinComponent { } } - fun serializeNow( + private fun serializeNow( songs: Iterable, currentPlayingIndex: Int, currentPlayingPosition: Int diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index f5686372..469b50e9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -20,11 +20,6 @@ class RxBus { .replay(1) .autoConnect(0) - val mediaButtonEventPublisher: PublishSubject = - PublishSubject.create() - val mediaButtonEventObservable: Observable = - mediaButtonEventPublisher.observeOn(AndroidSchedulers.mainThread()) - val themeChangedEventPublisher: PublishSubject = PublishSubject.create() val themeChangedEventObservable: Observable = @@ -83,7 +78,7 @@ class RxBus { } } - data class StateWithTrack(val state: PlayerState, val track: DownloadFile?) + data class StateWithTrack(val state: PlayerState, val track: DownloadFile?, val index: Int = -1) } operator fun CompositeDisposable.plusAssign(disposable: Disposable) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt index 3dca314c..e02b134c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/DownloadHandler.kt @@ -34,20 +34,23 @@ class DownloadHandler( autoPlay: Boolean, playNext: Boolean, shuffle: Boolean, - songs: List + songs: List, ) { val onValid = Runnable { - if (!append && !playNext) { - mediaPlayerController.clear() + // TODO: The logic here is different than in the controller... + val insertionMode = when { + append -> MediaPlayerController.InsertionMode.APPEND + playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT + else -> MediaPlayerController.InsertionMode.CLEAR } + networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() mediaPlayerController.addToPlaylist( songs, save, autoPlay, - playNext, shuffle, - false + insertionMode ) val playlistName: String? = fragment.arguments?.getString( Constants.INTENT_PLAYLIST_NAME @@ -281,26 +284,28 @@ class DownloadHandler( } } + // Called when we have collected the tracks override fun done(songs: List) { if (Settings.shouldSortByDisc) { Collections.sort(songs, EntryByDiscAndTrackComparator()) } if (songs.isNotEmpty()) { - if (!append && !playNext && !unpin && !background) { - mediaPlayerController.clear() - } networkAndStorageChecker.warnIfNetworkOrStorageUnavailable() if (!background) { if (unpin) { mediaPlayerController.unpin(songs) } else { + val insertionMode = when { + append -> MediaPlayerController.InsertionMode.APPEND + playNext -> MediaPlayerController.InsertionMode.AFTER_CURRENT + else -> MediaPlayerController.InsertionMode.CLEAR + } mediaPlayerController.addToPlaylist( songs, save, autoPlay, - playNext, shuffle, - false + insertionMode ) if ( !append && diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index 9990ca7c..e585ea66 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -233,7 +233,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { ) { if (file.isFile && (isPartial(file) || isComplete(file))) { files.add(file) - } else { + } else if (file.isDirectory) { // Depth-first for (child in listFiles(file)) { findCandidatesForDeletion(child, files, dirs) @@ -257,7 +257,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { for (downloadFile in downloader.value.all) { filesToNotDelete.add(downloadFile.partialFile) filesToNotDelete.add(downloadFile.completeFile) - filesToNotDelete.add(downloadFile.saveFile) + filesToNotDelete.add(downloadFile.pinnedFile) } filesToNotDelete.add(musicDirectory.path) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index 8511d9c1..8e7fe064 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -406,7 +406,7 @@ object FileUtil { return path.substringBeforeLast('/') } - fun getSaveFile(name: String): String { + fun getPinnedFile(name: String): String { val baseName = getBaseName(name) if (baseName.endsWith(".partial") || baseName.endsWith(".complete")) { return "${getBaseName(baseName)}.${getExtension(name)}" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt deleted file mode 100644 index ae3def28..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ /dev/null @@ -1,332 +0,0 @@ -/* - * MediaSessionHandler.kt - * Copyright (C) 2009-2021 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.util - -import android.app.PendingIntent -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN -import android.view.KeyEvent -import io.reactivex.rxjava3.disposables.CompositeDisposable -import kotlin.Pair -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.R -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.imageloader.BitmapUtils -import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver -import org.moire.ultrasonic.service.DownloadFile -import org.moire.ultrasonic.service.RxBus -import org.moire.ultrasonic.service.plusAssign -import org.moire.ultrasonic.util.Util.ifNotNull -import timber.log.Timber - -private const val INTENT_CODE_MEDIA_BUTTON = 161 -/** - * Central place to handle the state of the MediaSession - */ -class MediaSessionHandler : KoinComponent { - - private var mediaSession: MediaSessionCompat? = null - private var playbackState: Int? = null - private var playbackActions: Long? = null - private var cachedPlayingIndex: Long? = null - - private val applicationContext by inject() - - private var referenceCount: Int = 0 - private var cachedPlaylist: List? = null - private var cachedPosition: Long = 0 - - private val rxBusSubscription: CompositeDisposable = CompositeDisposable() - - fun release() { - - if (referenceCount > 0) referenceCount-- - if (referenceCount > 0) return - - mediaSession?.isActive = false - RxBus.releaseMediaSessionToken() - rxBusSubscription.dispose() - mediaSession?.release() - mediaSession = null - - Timber.i("MediaSessionHandler.release Media Session released") - } - - fun initialize() { - - referenceCount++ - if (referenceCount > 1) return - - @Suppress("MagicNumber") - val keycode = 110 - - Timber.d("MediaSessionHandler.initialize Creating Media Session") - - mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") - val mediaSessionToken = mediaSession?.sessionToken ?: return - RxBus.mediaSessionTokenPublisher.onNext(mediaSessionToken) - - updateMediaButtonReceiver() - - mediaSession?.setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { - super.onPlay() - - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PLAY, - keycode - ).send() - - Timber.v("Media Session Callback: onPlay") - } - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - super.onPlayFromMediaId(mediaId, extras) - - Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId) - RxBus.playFromMediaIdCommandPublisher.onNext(Pair(mediaId, extras)) - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - super.onPlayFromSearch(query, extras) - - Timber.d("Media Session Callback: onPlayFromSearch %s", query) - RxBus.playFromSearchCommandPublisher.onNext(Pair(query, extras)) - } - - override fun onPause() { - super.onPause() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PAUSE, - keycode - ).send() - Timber.v("Media Session Callback: onPause") - } - - override fun onStop() { - super.onStop() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_STOP, - keycode - ).send() - Timber.v("Media Session Callback: onStop") - } - - override fun onSkipToNext() { - super.onSkipToNext() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_NEXT, - keycode - ).send() - Timber.v("Media Session Callback: onSkipToNext") - } - - override fun onSkipToPrevious() { - super.onSkipToPrevious() - Util.getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PREVIOUS, - keycode - ).send() - Timber.v("Media Session Callback: onSkipToPrevious") - } - - override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // This probably won't be necessary once we implement more - // of the modern media APIs, like the MediaController etc. - val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent? - event.ifNotNull { RxBus.mediaButtonEventPublisher.onNext(it) } - return true - } - - override fun onSkipToQueueItem(id: Long) { - super.onSkipToQueueItem(id) - RxBus.skipToQueueItemCommandPublisher.onNext(id) - } - } - ) - - // It seems to be the best practice to set this to true for the lifetime of the session - mediaSession?.isActive = true - rxBusSubscription += RxBus.playbackPositionObservable.subscribe { - updateMediaSessionPlaybackPosition(it) - } - rxBusSubscription += RxBus.playlistObservable.subscribe { - updateMediaSessionQueue(it) - } - rxBusSubscription += RxBus.playerStateObservable.subscribe { - updateMediaSession(it.state, it.track) - } - - Timber.i("MediaSessionHandler.initialize Media Session created") - } - - @Suppress("LongMethod", "ComplexMethod") - private fun updateMediaSession( - playerState: PlayerState, - currentPlaying: DownloadFile? - ) { - Timber.d("Updating the MediaSession") - - // Set Metadata - val metadata = MediaMetadataCompat.Builder() - if (currentPlaying != null) { - try { - val song = currentPlaying.track - val cover = BitmapUtils.getAlbumArtBitmapFromDisk( - song, Util.getMinDisplayMetric() - ) - val duration = song.duration?.times(1000) ?: -1 - metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong()) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) - metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) - metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) - } catch (all: Exception) { - Timber.e(all, "Error setting the metadata") - } - } - - // Save the metadata - mediaSession?.setMetadata(metadata.build()) - - playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or - PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM - - // Map our playerState to native PlaybackState - // TODO: Synchronize these APIs - when (playerState) { - PlayerState.STARTED -> { - playbackState = PlaybackStateCompat.STATE_PLAYING - playbackActions = playbackActions!! or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_STOP - } - PlayerState.COMPLETED, - PlayerState.STOPPED -> { - playbackState = PlaybackStateCompat.STATE_STOPPED - cachedPosition = PLAYBACK_POSITION_UNKNOWN - } - PlayerState.IDLE -> { - // IDLE state usually just means the playback is stopped - // STATE_NONE means that there is no track to play (playlist is empty) - playbackState = if (currentPlaying == null) - PlaybackStateCompat.STATE_NONE - else - PlaybackStateCompat.STATE_STOPPED - playbackActions = 0L - cachedPosition = PLAYBACK_POSITION_UNKNOWN - } - PlayerState.PAUSED -> { - playbackState = PlaybackStateCompat.STATE_PAUSED - playbackActions = playbackActions!! or - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_STOP - } - else -> { - // These are the states PREPARING, PREPARED & DOWNLOADING - playbackState = PlaybackStateCompat.STATE_PAUSED - } - } - - val playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f) - - // Set actions - playbackStateBuilder.setActions(playbackActions!!) - - val index = cachedPlaylist?.indexOf(currentPlaying) - cachedPlayingIndex = if (index == null || index < 0) null else index.toLong() - cachedPlaylist.ifNotNull { setMediaSessionQueue(it) } - - if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending) - cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) } - - // Save the playback state - mediaSession?.setPlaybackState(playbackStateBuilder.build()) - } - - private fun updateMediaSessionQueue(playlist: List) { - cachedPlaylist = playlist - setMediaSessionQueue(playlist) - } - - private fun setMediaSessionQueue(playlist: List) { - if (mediaSession == null) return - if (Settings.shouldDisableNowPlayingListSending) return - - val queue = playlist.mapIndexed { id, file -> - MediaSessionCompat.QueueItem( - Util.getMediaDescriptionForEntry(file.track), - id.toLong() - ) - } - mediaSession?.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) - mediaSession?.setQueue(queue) - } - - private fun updateMediaSessionPlaybackPosition(playbackPosition: Int) { - cachedPosition = playbackPosition.toLong() - if (playbackState == null || playbackActions == null) return - - val playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f) - playbackStateBuilder.setActions(playbackActions!!) - - if (cachedPlaylist != null && !Settings.shouldDisableNowPlayingListSending) - cachedPlayingIndex.ifNotNull { playbackStateBuilder.setActiveQueueItemId(it) } - - mediaSession?.setPlaybackState(playbackStateBuilder.build()) - } - - fun updateMediaButtonReceiver() { - if (Settings.mediaButtonsEnabled) { - registerMediaButtonEventReceiver() - } else { - unregisterMediaButtonEventReceiver() - } - } - - private fun registerMediaButtonEventReceiver() { - val component = ComponentName( - applicationContext.packageName, - MediaButtonIntentReceiver::class.java.name - ) - val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) - mediaButtonIntent.component = component - - val pendingIntent = PendingIntent.getBroadcast( - applicationContext, - INTENT_CODE_MEDIA_BUTTON, - mediaButtonIntent, - PendingIntent.FLAG_CANCEL_CURRENT - ) - - mediaSession?.setMediaButtonReceiver(pendingIntent) - } - - private fun unregisterMediaButtonEventReceiver() { - mediaSession?.setMediaButtonReceiver(null) - } -} 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 c286bd69..2b29f506 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -9,13 +9,11 @@ package org.moire.ultrasonic.util import android.content.Context import android.content.SharedPreferences -import android.os.Build import androidx.preference.PreferenceManager import java.util.regex.Pattern import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.domain.RepeatMode /** * Contains convenience functions for reading and writing preferences @@ -23,43 +21,6 @@ import org.moire.ultrasonic.domain.RepeatMode object Settings { private val PATTERN = Pattern.compile(":") - var repeatMode: RepeatMode - get() { - val preferences = preferences - return RepeatMode.valueOf( - preferences.getString( - Constants.PREFERENCES_KEY_REPEAT_MODE, - RepeatMode.OFF.name - )!! - ) - } - set(repeatMode) { - val preferences = preferences - val editor = preferences.edit() - editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name) - editor.apply() - } - - // After API26 foreground services must be used for music playback, - // and they must have a notification - val isNotificationEnabled: Boolean - get() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true - val preferences = preferences - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false) - } - - // After API26 foreground services must be used for music playback, - // and they must have a notification - val isNotificationAlwaysEnabled: Boolean - get() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true - val preferences = preferences - return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false) - } - - var isLockScreenEnabled by BooleanSetting(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS) - @JvmStatic var theme by StringSetting( Constants.PREFERENCES_KEY_THEME, diff --git a/ultrasonic/src/main/res/drawable/media3_notification_pause.xml b/ultrasonic/src/main/res/drawable/media3_notification_pause.xml new file mode 100644 index 00000000..64164940 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/media3_notification_pause.xml @@ -0,0 +1,5 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/media3_notification_play.xml b/ultrasonic/src/main/res/drawable/media3_notification_play.xml new file mode 100644 index 00000000..0ebbb01e --- /dev/null +++ b/ultrasonic/src/main/res/drawable/media3_notification_play.xml @@ -0,0 +1,5 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/media3_notification_seek_to_next.xml b/ultrasonic/src/main/res/drawable/media3_notification_seek_to_next.xml new file mode 100644 index 00000000..dc96d2dc --- /dev/null +++ b/ultrasonic/src/main/res/drawable/media3_notification_seek_to_next.xml @@ -0,0 +1,5 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/media3_notification_seek_to_previous.xml b/ultrasonic/src/main/res/drawable/media3_notification_seek_to_previous.xml new file mode 100644 index 00000000..79c6bfd3 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/media3_notification_seek_to_previous.xml @@ -0,0 +1,5 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/media3_notification_small_icon.xml b/ultrasonic/src/main/res/drawable/media3_notification_small_icon.xml new file mode 100644 index 00000000..e2943794 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/media3_notification_small_icon.xml @@ -0,0 +1,9 @@ + + +