diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e807194f3..ba2a499c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,7 +82,7 @@ Service handling music playback, system components, and state saving. --> . + */ + +package org.oxycblt.auxio + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.database.ContentObserver +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.provider.MediaStore +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import coil.ImageLoader +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.guava.asListenableFuture +import org.oxycblt.auxio.image.service.NeoBitmapLoader +import org.oxycblt.auxio.music.IndexingProgress +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.fs.contentResolverSafe +import org.oxycblt.auxio.music.service.IndexingNotification +import org.oxycblt.auxio.music.service.MusicMediaItemBrowser +import org.oxycblt.auxio.music.service.ObservingNotification +import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.service.ExoPlaybackStateHolder +import org.oxycblt.auxio.playback.service.SystemPlaybackReceiver +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +// TODO: Android Auto Hookup +// TODO: Custom notif + +@AndroidEntryPoint +class AuxioService : + MediaLibraryService(), + MediaLibrarySession.Callback, + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener, + PlaybackStateManager.Listener, + PlaybackSettings.Listener { + private val serviceJob = Job() + private var inPlayback = false + + @Inject lateinit var musicRepository: MusicRepository + @Inject lateinit var musicSettings: MusicSettings + private lateinit var indexingNotification: IndexingNotification + private lateinit var observingNotification: ObservingNotification + private lateinit var wakeLock: PowerManager.WakeLock + private lateinit var indexerContentObserver: SystemContentObserver + private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) + private var currentIndexJob: Job? = null + + @Inject lateinit var playbackManager: PlaybackStateManager + @Inject lateinit var playbackSettings: PlaybackSettings + @Inject lateinit var systemReceiver: SystemPlaybackReceiver + @Inject lateinit var exoHolderFactory: ExoPlaybackStateHolder.Factory + private lateinit var exoHolder: ExoPlaybackStateHolder + + @Inject lateinit var bitmapLoader: NeoBitmapLoader + @Inject lateinit var imageLoader: ImageLoader + + @Inject lateinit var musicMediaItemBrowser: MusicMediaItemBrowser + private val waitScope = CoroutineScope(serviceJob + Dispatchers.Default) + private lateinit var mediaSession: MediaLibrarySession + + @SuppressLint("WrongConstant") + override fun onCreate() { + super.onCreate() + + indexingNotification = IndexingNotification(this) + observingNotification = ObservingNotification(this) + wakeLock = + getSystemServiceCompat(PowerManager::class) + .newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService") + + exoHolder = exoHolderFactory.create() + + mediaSession = + MediaLibrarySession.Builder(this, exoHolder.mediaSessionPlayer, this) + .setBitmapLoader(bitmapLoader) + .build() + + // Initialize any listener-dependent components last as we wouldn't want a listener race + // condition to cause us to load music before we were fully initialize. + indexerContentObserver = SystemContentObserver() + + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(this) + .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) + .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") + .setChannelName(R.string.lbl_playback) + .build() + .also { it.setSmallIcon(R.drawable.ic_auxio_24) }) + addSession(mediaSession) + updateCustomButtons() + + // Initialize any listener-dependent components last as we wouldn't want a listener race + // condition to cause us to load music before we were fully initialize. + exoHolder.attach() + playbackManager.addListener(this) + playbackSettings.registerListener(this) + + ContextCompat.registerReceiver( + this, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + + musicMediaItemBrowser.attach() + musicSettings.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!playbackManager.progression.isPlaying) { + // Stop the service if not playing, continue playing in the background + // otherwise. + endSession() + } + } + + override fun onDestroy() { + super.onDestroy() + // De-initialize core service components first. + serviceJob.cancel() + wakeLock.releaseSafe() + // Then cancel the listener-dependent components to ensure that stray reloading + // events will not occur. + indexerContentObserver.release() + exoHolder.release() + musicSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + musicRepository.removeIndexingListener(this) + musicRepository.unregisterWorker(this) + + // Pause just in case this destruction was unexpected. + playbackManager.playing(false) + playbackManager.unregisterStateHolder(exoHolder) + playbackSettings.unregisterListener(this) + + removeSession(mediaSession) + mediaSession.release() + unregisterReceiver(systemReceiver) + exoHolder.release() + } + + // --- INDEXER OVERRIDES --- + + override fun requestIndex(withCache: Boolean) { + logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") + // Cancel the previous music loading job. + currentIndexJob?.cancel() + // Start a new music loading job on a co-routine. + currentIndexJob = musicRepository.index(this, withCache) + } + + override val workerContext: Context + get() = this + + override val scope = indexScope + + override fun onIndexingStateChanged() { + updateForeground(forMusic = true) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + logD("Music changed, updating shared objects") + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + savedState.copy( + heap = + savedState.heap.map { song -> + song?.let { deviceLibrary.findSong(it.uid) } + }), + true) + } + } + + // --- INTERNAL --- + + private fun updateForeground(forMusic: Boolean) { + if (playbackManager.progression.isPlaying) { + inPlayback = true + } + + if (inPlayback) { + if (!forMusic) { + val notification = + mediaNotificationProvider.createNotification( + mediaSession, + mediaSession.customLayout, + mediaNotificationManager.actionFactory) { notification -> + postMediaNotification(notification, mediaSession) + } + postMediaNotification(notification, mediaSession) + } + return + } + + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + updateLoadingForeground(state.progress) + } else { + updateIdleForeground() + } + } + + private fun updateLoadingForeground(progress: IndexingProgress) { + // When loading, we want to enter the foreground state so that android does + // not shut off the loading process. Note that while we will always post the + // notification when initially starting, we will not update the notification + // unless it indicates that it has changed. + val changed = indexingNotification.updateIndexingState(progress) + if (changed) { + logD("Notification changed, re-posting notification") + startForeground(indexingNotification.code, indexingNotification.build()) + } + // Make sure we can keep the CPU on while loading music + wakeLock.acquireSafe() + } + + private fun updateIdleForeground() { + if (musicSettings.shouldBeObserving) { + // There are a few reasons why we stay in the foreground with automatic rescanning: + // 1. Newer versions of Android have become more and more restrictive regarding + // how a foreground service starts. Thus, it's best to go foreground now so that + // we can go foreground later. + // 2. If a non-foreground service is killed, the app will probably still be alive, + // and thus the music library will not be updated at all. + // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need + // this anymore, or at least I only have to use it when the app task is not removed. + logD("Need to observe, staying in foreground") + startForeground(observingNotification.code, observingNotification.build()) + } else { + // Not observing and done loading, exit foreground. + logD("Exiting foreground") + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + } + // Release our wake lock (if we were using it) + wakeLock.releaseSafe() + } + + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.acquireSafe() { + // Avoid unnecessary acquire calls. + if (!wakeLock.isHeld) { + logD("Acquiring wake lock") + // Time out after a minute, which is the average music loading time for a medium-sized + // library. If this runs out, we will re-request the lock, and if music loading is + // shorter than the timeout, it will be released early. + acquire(WAKELOCK_TIMEOUT_MS) + } + } + + /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.releaseSafe() { + // Avoid unnecessary release calls. + if (wakeLock.isHeld) { + logD("Releasing wake lock") + release() + } + } + + // --- SETTING CALLBACKS --- + + override fun onIndexingSettingChanged() { + // Music loading configuration changed, need to reload music. + requestIndex(true) + } + + override fun onObservingChanged() { + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (currentIndexJob == null) { + logD("Not loading, updating idle session") + updateForeground(forMusic = false) + } + } + + /** + * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior + * known to the user as automatic rescanning. The active (and not passive) nature of observing + * the database is what requires [IndexerService] to stay foreground when this is enabled. + */ + private inner class SystemContentObserver : + ContentObserver(Handler(Looper.getMainLooper())), Runnable { + private val handler = Handler(Looper.getMainLooper()) + + init { + contentResolverSafe.registerContentObserver( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this) + } + + /** + * Release this instance, preventing it from further observing the database and cancelling + * any pending update events. + */ + fun release() { + handler.removeCallbacks(this) + contentResolverSafe.unregisterContentObserver(this) + } + + override fun onChange(selfChange: Boolean) { + // Batch rapid-fire updates to the library into a single call to run after 500ms + handler.removeCallbacks(this) + handler.postDelayed(this, REINDEX_DELAY_MS) + } + + override fun run() { + // Check here if we should even start a reindex. This is much less bug-prone than + // registering and de-registering this component as this setting changes. + if (musicSettings.shouldBeObserving) { + logD("MediaStore changed, starting re-index") + requestIndex(true) + } + } + } + + // --- SERVICE MANAGEMENT --- + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = + mediaSession + + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + logD("Notification update requested") + updateForeground(forMusic = false) + } + + private fun postMediaNotification(notification: MediaNotification, session: MediaSession) { + // Pulled from MediaNotificationManager: Need to specify MediaSession token manually + // in notification + val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token + notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) + startForeground(notification.notificationId, notification.notification) + } + + // --- MEDIASESSION CALLBACKS --- + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): ConnectionResult { + val sessionCommands = + ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() + .add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) + .add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) + .add(SessionCommand(ACTION_EXIT, Bundle.EMPTY)) + .build() + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture = + when (customCommand.customAction) { + ACTION_INC_REPEAT_MODE -> { + logD(playbackManager.repeatMode.increment()) + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + ACTION_INVERT_SHUFFLE -> { + playbackManager.shuffled(!playbackManager.isShuffled) + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + ACTION_EXIT -> { + endSession() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + else -> super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> = + Futures.immediateFuture(LibraryResult.ofItem(musicMediaItemBrowser.root, params)) + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val result = + musicMediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(result) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val children = + musicMediaItemBrowser.getChildren(parentId, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + return Futures.immediateFuture(children) + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: LibraryParams? + ): ListenableFuture> = + waitScope + .async { + musicMediaItemBrowser.prepareSearch(query) + LibraryResult.ofVoid() + } + .asListenableFuture() + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ) = + waitScope + .async { + musicMediaItemBrowser.getSearchResult(query, page, pageSize)?.let { + LibraryResult.ofItemList(it, params) + } + ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + } + .asListenableFuture() + + // --- BUTTON MANAGEMENT --- + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + updateCustomButtons() + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + super.onQueueReordered(queue, index, isShuffled) + updateCustomButtons() + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + super.onRepeatModeChanged(repeatMode) + updateCustomButtons() + } + + override fun onNotificationActionChanged() { + super.onNotificationActionChanged() + updateCustomButtons() + } + + private fun updateCustomButtons() { + val actions = mutableListOf() + + when (playbackSettings.notificationAction) { + ActionMode.REPEAT -> { + actions.add( + CommandButton.Builder() + .setIconResId(playbackManager.repeatMode.icon) + .setDisplayName(getString(R.string.desc_change_repeat)) + .setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle())) + .build()) + } + ActionMode.SHUFFLE -> { + actions.add( + CommandButton.Builder() + .setIconResId( + if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24 + else R.drawable.ic_shuffle_off_24) + .setDisplayName(getString(R.string.lbl_shuffle)) + .setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle())) + .build()) + } + else -> {} + } + + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_close_24) + .setDisplayName(getString(R.string.desc_exit)) + .setSessionCommand(SessionCommand(ACTION_EXIT, Bundle())) + .build()) + + mediaSession.setCustomLayout(actions) + } + + private fun endSession() { + // This session has ended, so we need to reset this flag for when the next + // session starts. + exoHolder.save { + // User could feasibly start playing again if they were fast enough, so + // we need to avoid stopping the foreground state if that's the case. + if (playbackManager.progression.isPlaying) { + playbackManager.playing(false) + } + inPlayback = false + updateForeground(forMusic = false) + } + } + + companion object { + const val WAKELOCK_TIMEOUT_MS = 60 * 1000L + const val REINDEX_DELAY_MS = 500L + const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" + const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" + const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" + const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" + const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" + const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index f997f3b0c..e727316fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -31,7 +31,6 @@ import javax.inject.Inject import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt new file mode 100644 index 000000000..050166483 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2024 Auxio Project + * CoilBitmapLoader.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.image.service + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.BitmapLoader +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import javax.inject.Inject +import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.service.MediaSessionUID + +class NeoBitmapLoader +@Inject +constructor( + private val musicRepository: MusicRepository, + private val bitmapProvider: BitmapProvider +) : BitmapLoader { + override fun decodeBitmap(data: ByteArray): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmap(uri: Uri): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { + throw NotImplementedError() + } + + override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { + val deviceLibrary = musicRepository.deviceLibrary ?: return null + val future = SettableFuture.create() + val song = + when (val uid = + metadata.extras?.getString("uid")?.let { MediaSessionUID.fromString(it) }) { + is MediaSessionUID.Single -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.Joined -> deviceLibrary.findSong(uid.childUid) + else -> return null + } + ?: return null + bitmapProvider.load( + song, + object : BitmapProvider.Target { + override fun onCompleted(bitmap: Bitmap?) { + if (bitmap == null) { + future.setException(IllegalStateException("Bitmap is null")) + } else { + future.set(bitmap) + } + } + }) + return future + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index 402c3703e..2b1524fdf 100644 --- a/app/src/main/java/org/oxycblt/auxio/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.service +package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock @@ -25,6 +25,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress +import org.oxycblt.auxio.ui.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt new file mode 100644 index 000000000..fcf65715a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaItemTranslation.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.getPlural + +fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { + val metadata = + MediaMetadata.Builder() + .setTitle(context.getString(nameRes)) + .setIsPlayable(false) + .setIsBrowsable(true) + .setMediaType(mediaType) + .build() + return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() +} + +fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.Single(uid) + } else { + MediaSessionUID.Joined(parent.uid, uid) + } + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setArtist(artists.resolveNames(context)) + .setAlbumTitle(album.name.resolve(context)) + .setAlbumArtist(album.artists.resolveNames(context)) + .setTrackNumber(track) + .setDiscNumber(disc?.number) + .setGenre(genres.resolveNames(context)) + .setDisplayTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setRecordingYear(album.dates?.min?.year) + .setRecordingMonth(album.dates?.min?.month) + .setRecordingDay(album.dates?.min?.day) + .setReleaseYear(album.dates?.min?.year) + .setReleaseMonth(album.dates?.min?.month) + .setReleaseDay(album.dates?.min?.day) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .setIsPlayable(true) + .setIsBrowsable(false) + .setArtworkUri(album.coverUri.mediaStore) + .setExtras( + Bundle().apply { + putString("uid", mediaSessionUID.toString()) + putLong("durationMs", durationMs) + }) + .build() + return MediaItem.Builder() + .setUri(uri) + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.Single(uid) + } else { + MediaSessionUID.Joined(parent.uid, uid) + } + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setArtist(artists.resolveNames(context)) + .setAlbumTitle(name.resolve(context)) + .setAlbumArtist(artists.resolveNames(context)) + .setRecordingYear(dates?.min?.year) + .setRecordingMonth(dates?.min?.month) + .setRecordingDay(dates?.min?.day) + .setReleaseYear(dates?.min?.year) + .setReleaseMonth(dates?.min?.month) + .setReleaseDay(dates?.min?.day) + .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) + .setIsPlayable(true) + .setIsBrowsable(true) + .setArtworkUri(coverUri.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.Single(uid) + } else { + MediaSessionUID.Joined(parent.uid, uid) + } + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setSubtitle( + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + })) + .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) + .setIsPlayable(true) + .setIsBrowsable(true) + .setGenre(genres.resolveNames(context)) + .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Genre.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.Single(uid) + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setSubtitle( + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) + .setIsPlayable(true) + .setIsBrowsable(true) + .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun Playlist.toMediaItem(context: Context): MediaItem { + val mediaSessionUID = MediaSessionUID.Single(uid) + val metadata = + MediaMetadata.Builder() + .setTitle(name.resolve(context)) + .setSubtitle( + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) + .setIsPlayable(true) + .setIsBrowsable(true) + .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) + .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) + .build() + return MediaItem.Builder() + .setMediaId(mediaSessionUID.toString()) + .setMediaMetadata(metadata) + .build() +} + +fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { + val uid = MediaSessionUID.fromString(mediaId) ?: return null + return when (uid) { + is MediaSessionUID.Single -> { + deviceLibrary.findSong(uid.uid) + } + is MediaSessionUID.Joined -> { + deviceLibrary.findSong(uid.childUid) + } + is MediaSessionUID.Category -> null + } +} + +sealed interface MediaSessionUID { + enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : + MediaSessionUID { + ROOT("root", R.string.info_app_name, null), + SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), + ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), + ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), + GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), + PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); + + override fun toString() = "$ID_CATEGORY:$id" + } + + data class Single(val uid: Music.UID) : MediaSessionUID { + override fun toString() = "$ID_ITEM:$uid" + } + + data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { + override fun toString() = "$ID_ITEM:$parentUid>$childUid" + } + + companion object { + const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" + const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" + + fun fromString(str: String): MediaSessionUID? { + val parts = str.split(":", limit = 2) + if (parts.size != 2) { + return null + } + return when (parts[0]) { + ID_CATEGORY -> + when (parts[1]) { + Category.ROOT.id -> Category.ROOT + Category.SONGS.id -> Category.SONGS + Category.ALBUMS.id -> Category.ALBUMS + Category.ARTISTS.id -> Category.ARTISTS + Category.GENRES.id -> Category.GENRES + Category.PLAYLISTS.id -> Category.PLAYLISTS + else -> null + } + ID_ITEM -> { + val uids = parts[1].split(">", limit = 2) + if (uids.size == 1) { + Music.UID.fromString(uids[0])?.let { Single(it) } + } else { + Music.UID.fromString(uids[0])?.let { parent -> + Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } + } + } + } + else -> return null + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt new file mode 100644 index 000000000..00876951f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicMediaItemBrowser.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicMediaItemBrowser.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import androidx.media3.common.MediaItem +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine + +class MusicMediaItemBrowser +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine +) : MusicRepository.UpdateListener { + private val browserJob = Job() + private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) + private val searchResults = mutableMapOf>() + + fun attach() { + musicRepository.addUpdateListener(this) + } + + fun release() { + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary) { + for (entry in searchResults.entries) { + entry.value.cancel() + } + searchResults.clear() + } + } + + val root: MediaItem + get() = MediaSessionUID.Category.ROOT.toMediaItem(context) + + fun getItem(mediaId: String): MediaItem? { + val music = + when (val uid = MediaSessionUID.fromString(mediaId)) { + is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.Single -> + musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } + is MediaSessionUID.Joined -> + musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } + null -> null + } + ?: return null + + return when (music) { + is Album -> music.toMediaItem(context, null) + is Artist -> music.toMediaItem(context, null) + is Genre -> music.toMediaItem(context) + is Playlist -> music.toMediaItem(context) + is Song -> music.toMediaItem(context, null) + } + } + + fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null + return items.paginate(page, pageSize) + } + + private fun getMediaItemList( + id: String, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): List? { + return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { + is MediaSessionUID.Category -> { + when (mediaSessionUID) { + MediaSessionUID.Category.ROOT -> + listOf( + MediaSessionUID.Category.SONGS, + MediaSessionUID.Category.ALBUMS, + MediaSessionUID.Category.ARTISTS, + MediaSessionUID.Category.GENRES, + MediaSessionUID.Category.PLAYLISTS) + .map { it.toMediaItem(context) } + MediaSessionUID.Category.SONGS -> + deviceLibrary.songs.map { it.toMediaItem(context, null) } + MediaSessionUID.Category.ALBUMS -> + deviceLibrary.albums.map { it.toMediaItem(context, null) } + MediaSessionUID.Category.ARTISTS -> + deviceLibrary.artists.map { it.toMediaItem(context, null) } + MediaSessionUID.Category.GENRES -> + deviceLibrary.genres.map { it.toMediaItem(context) } + MediaSessionUID.Category.PLAYLISTS -> + userLibrary.playlists.map { it.toMediaItem(context) } + } + } + is MediaSessionUID.Single -> { + getChildMediaItems(mediaSessionUID.uid) ?: return null + } + is MediaSessionUID.Joined -> { + getChildMediaItems(mediaSessionUID.childUid) ?: return null + } + null -> return null + } + } + + private fun getChildMediaItems(uid: Music.UID): List? { + return when (val item = musicRepository.find(uid)) { + is Album -> { + item.songs.map { it.toMediaItem(context, item) } + } + is Artist -> { + (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(context, item) } + + item.songs.map { it.toMediaItem(context, item) } + } + is Genre -> { + item.songs.map { it.toMediaItem(context, item) } + } + is Playlist -> { + item.songs.map { it.toMediaItem(context, item) } + } + is Song, + null -> return null + } + } + + suspend fun prepareSearch(query: String) { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return + } + + if (query.isEmpty()) { + return + } + + searchTo(query, deviceLibrary, userLibrary).await() + } + + suspend fun getSearchResult( + query: String, + page: Int, + pageSize: Int, + ): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + if (query.isEmpty()) { + return listOf() + } + + val existing = searchResults[query] + if (existing != null) { + return existing.await().concat().paginate(page, pageSize) + } + + return searchTo(query, deviceLibrary, userLibrary).await().concat().paginate(page, pageSize) + } + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context, null) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context, null) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } + + private fun searchTo(query: String, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + searchScope.async { + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists) + searchEngine.search(items, query) + } + + private fun List.paginate(page: Int, pageSize: Int): List? { + if (page == Int.MAX_VALUE) { + // I think if someone requests this page it more or less implies that I should + // return all of the pages. + return this + } + val start = page * pageSize + val end = (page + 1) * pageSize + if (pageSize == 0 || start !in indices || end - 1 !in indices) { + // These pages are probably invalid. Hopefully this won't backfire. + return null + } + return subList(page * pageSize, (page + 1) * pageSize).toMutableList() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt new file mode 100644 index 000000000..3ae7b7edb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -0,0 +1,551 @@ +/* + * Copyright (c) 2024 Auxio Project + * ExoPlaybackStateHolder.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.content.Context +import android.content.Intent +import android.media.audiofx.AudioEffect +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioCapabilities +import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.source.MediaSource +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.service.toMediaItem +import org.oxycblt.auxio.music.service.toSong +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.persist.PersistenceRepository +import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateHolder +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.RawQueue +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.playback.state.StateAck +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE + +class ExoPlaybackStateHolder( + private val context: Context, + private val player: ExoPlayer, + private val playbackManager: PlaybackStateManager, + private val persistenceRepository: PersistenceRepository, + private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository +) : + PlaybackStateHolder, + Player.Listener, + MusicRepository.UpdateListener, + PlaybackSettings.Listener { + private val saveJob = Job() + private val saveScope = CoroutineScope(Dispatchers.IO + saveJob) + private val restoreScope = CoroutineScope(Dispatchers.IO + saveJob) + private var currentSaveJob: Job? = null + private var openAudioEffectSession = false + + fun attach() { + player.addListener(this) + playbackManager.registerStateHolder(this) + playbackSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + fun release() { + saveJob.cancel() + player.removeListener(this) + playbackManager.unregisterStateHolder(this) + musicRepository.removeUpdateListener(this) + player.release() + } + + override var parent: MusicParent? = null + private set + + val mediaSessionPlayer: Player + get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository) + + override val progression: Progression + get() { + val mediaItem = player.currentMediaItem ?: return Progression.nil() + val duration = mediaItem.mediaMetadata.extras?.getLong("durationMs") ?: Long.MAX_VALUE + val clampedPosition = player.currentPosition.coerceAtLeast(0).coerceAtMost(duration) + return Progression.from(player.playWhenReady, player.isPlaying, clampedPosition) + } + + override val repeatMode + get() = + when (val repeatMode = player.repeatMode) { + Player.REPEAT_MODE_OFF -> RepeatMode.NONE + Player.REPEAT_MODE_ONE -> RepeatMode.TRACK + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") + } + + override val audioSessionId: Int + get() = player.audioSessionId + + override fun resolveQueue(): RawQueue { + val deviceLibrary = + musicRepository.deviceLibrary + // No library, cannot do anything. + ?: return RawQueue(emptyList(), emptyList(), 0) + val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } + val shuffledMapping = + if (player.shuffleModeEnabled) { + player.unscrambleQueueIndices() + } else { + emptyList() + } + return RawQueue( + heap.mapNotNull { it.toSong(deviceLibrary) }, + shuffledMapping, + player.currentMediaItemIndex) + } + + override fun handleDeferred(action: DeferredPlayback): Boolean { + val deviceLibrary = + musicRepository.deviceLibrary + // No library, cannot do anything. + ?: return false + + when (action) { + // Restore state -> Start a new restoreState job + is DeferredPlayback.RestoreState -> { + logD("Restoring playback state") + restoreScope.launch { + persistenceRepository.readState()?.let { + // Apply the saved state on the main thread to prevent code expecting + // state updates on the main thread from crashing. + withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } + } + } + } + // Shuffle all -> Start new playback from all songs + is DeferredPlayback.ShuffleAll -> { + logD("Shuffling all tracks") + playbackManager.play( + requireNotNull(commandFactory.all(ShuffleMode.ON)) { + "Invalid playback parameters" + }) + } + // Open -> Try to find the Song for the given file and then play it from all songs + is DeferredPlayback.Open -> { + logD("Opening specified file") + deviceLibrary.findSongForUri(context, action.uri)?.let { song -> + playbackManager.play( + requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) { + "Invalid playback parameters" + }) + } + } + } + + return true + } + + override fun playing(playing: Boolean) { + player.playWhenReady = playing + } + + override fun seekTo(positionMs: Long) { + player.seekTo(positionMs) + // Ack/state save handled on discontinuity + } + + override fun repeatMode(repeatMode: RepeatMode) { + player.repeatMode = + when (repeatMode) { + RepeatMode.NONE -> Player.REPEAT_MODE_OFF + RepeatMode.ALL -> Player.REPEAT_MODE_ALL + RepeatMode.TRACK -> Player.REPEAT_MODE_ONE + } + updatePauseOnRepeat() + playbackManager.ack(this, StateAck.RepeatModeChanged) + deferSave() + } + + override fun newPlayback(command: PlaybackCommand) { + parent = command.parent + player.shuffleModeEnabled = command.shuffled + player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) + val startIndex = + command.song + ?.let { command.queue.indexOf(it) } + .also { check(it != -1) { "Start song not in queue" } } + if (command.shuffled) { + player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1)) + } + val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(command.shuffled) + player.seekTo(target, C.TIME_UNSET) + player.prepare() + player.play() + playbackManager.ack(this, StateAck.NewPlayback) + deferSave() + } + + override fun shuffled(shuffled: Boolean) { + player.setShuffleModeEnabled(shuffled) + if (player.shuffleModeEnabled) { + // Have to manually refresh the shuffle seed and anchor it to the new current songs + player.setShuffleOrder( + BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) + } + playbackManager.ack(this, StateAck.QueueReordered) + deferSave() + } + + override fun next() { + // Replicate the old pseudo-circular queue behavior when no repeat option is implemented. + // Basically, you can't skip back and wrap around the queue, but you can skip forward and + // wrap around the queue, albeit playback will be paused. + if (player.repeatMode != Player.REPEAT_MODE_OFF || player.hasNextMediaItem()) { + player.seekToNext() + if (!playbackSettings.rememberPause) { + player.play() + } + } else { + player.seekTo( + player.currentTimeline.getFirstWindowIndex(player.shuffleModeEnabled), C.TIME_UNSET) + // TODO: Dislike the UX implications of this, I feel should I bite the bullet + // and switch to dynamic skip enable/disable? + if (!playbackSettings.rememberPause) { + player.pause() + } + } + // Ack/state save is handled in timeline change + } + + override fun prev() { + if (playbackSettings.rewindWithPrev) { + player.seekToPrevious() + } else { + player.seekToPreviousMediaItem() + } + if (!playbackSettings.rememberPause) { + player.play() + } + // Ack/state save is handled in timeline change + } + + override fun goto(index: Int) { + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[index] + player.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic + if (!playbackSettings.rememberPause) { + player.play() + } + // Ack/state save is handled in timeline change + } + + override fun playNext(songs: List, ack: StateAck.PlayNext) { + val currTimeline = player.currentTimeline + val nextIndex = + if (currTimeline.isEmpty) { + C.INDEX_UNSET + } else { + currTimeline.getNextWindowIndex( + player.currentMediaItemIndex, Player.REPEAT_MODE_OFF, player.shuffleModeEnabled) + } + + if (nextIndex == C.INDEX_UNSET) { + player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + } else { + player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) + } + playbackManager.ack(this, ack) + deferSave() + } + + override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { + player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + playbackManager.ack(this, ack) + deferSave() + } + + override fun move(from: Int, to: Int, ack: StateAck.Move) { + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueFrom = indices[from] + val trueTo = indices[to] + when { + trueFrom > trueTo -> { + player.moveMediaItem(trueFrom, trueTo) + } + trueTo > trueFrom -> { + player.moveMediaItem(trueFrom, trueTo) + } + } + playbackManager.ack(this, ack) + deferSave() + } + + override fun remove(at: Int, ack: StateAck.Remove) { + val indices = player.unscrambleQueueIndices() + if (indices.isEmpty()) { + return + } + + val trueIndex = indices[at] + val songWillChange = player.currentMediaItemIndex == trueIndex + player.removeMediaItem(trueIndex) + if (songWillChange && !playbackSettings.rememberPause) { + player.play() + } + playbackManager.ack(this, ack) + deferSave() + } + + override fun applySavedState( + parent: MusicParent?, + rawQueue: RawQueue, + ack: StateAck.NewPlayback? + ) { + this.parent = parent + player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + if (rawQueue.isShuffled) { + player.shuffleModeEnabled = true + player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + player.shuffleModeEnabled = false + } + player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) + player.prepare() + ack?.let { playbackManager.ack(this, it) } + } + + override fun reset(ack: StateAck.NewPlayback) { + player.setMediaItems(listOf()) + playbackManager.ack(this, ack) + deferSave() + } + + // --- PLAYER OVERRIDES --- + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + super.onPlayWhenReadyChanged(playWhenReady, reason) + + if (player.playWhenReady) { + // Mark that we have started playing so that the notification can now be posted. + logD("Player has started playing") + if (!openAudioEffectSession) { + // Convention to start an audioeffect session on play/pause rather than + // start/stop + logD("Opening audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = true + } + } else if (openAudioEffectSession) { + // Make sure to close the audio session when we stop playback. + logD("Closing audio effect session") + broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) + openAudioEffectSession = false + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || + reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + playbackManager.ack(this, StateAck.IndexMoved) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + + if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) { + goto(0) + player.pause() + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + // TODO: Once position also naturally drifts by some threshold, save + deferSave() + } + } + + override fun onEvents(player: Player, events: Player.Events) { + super.onEvents(player, events) + + if (events.containsAny( + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_IS_PLAYING_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY)) { + logD("Player state changed, must synchronize state") + playbackManager.ack(this, StateAck.ProgressionChanged) + } + } + + override fun onPlayerError(error: PlaybackException) { + // TODO: Replace with no skipping and a notification instead + // If there's any issue, just go to the next song. + logE("Player error occurred") + logE(error.stackTraceToString()) + playbackManager.next() + } + + private fun broadcastAudioEffectAction(event: String) { + logD("Broadcasting AudioEffect event: $event") + context.sendBroadcast( + Intent(event) + .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) + .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)) + } + + // --- MUSICREPOSITORY METHODS --- + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary && musicRepository.deviceLibrary != null) { + // We now have a library, see if we have anything we need to do. + logD("Library obtained, requesting action") + playbackManager.requestAction(this) + } + } + + // --- PLAYBACKSETTINGS OVERRIDES --- + + override fun onPauseOnRepeatChanged() { + super.onPauseOnRepeatChanged() + updatePauseOnRepeat() + } + + private fun updatePauseOnRepeat() { + player.pauseAtEndOfMediaItems = + player.repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat + } + + fun save(cb: () -> Unit) { + saveJob { + persistenceRepository.saveState(playbackManager.toSavedState()) + withContext(Dispatchers.Main) { cb() } + } + } + + private fun deferSave() { + saveJob { + logD("Waiting for save buffer") + delay(SAVE_BUFFER) + yield() + logD("Committing saved state") + persistenceRepository.saveState(playbackManager.toSavedState()) + } + } + + private fun saveJob(block: suspend () -> Unit) { + currentSaveJob?.let { + logD("Discarding prior save job") + it.cancel() + } + currentSaveJob = saveScope.launch { block() } + } + + class Factory + @Inject + constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val persistenceRepository: PersistenceRepository, + private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, + private val mediaSourceFactory: MediaSource.Factory, + private val replayGainProcessor: ReplayGainAudioProcessor + ) { + fun create(): ExoPlaybackStateHolder { + // Since Auxio is a music player, only specify an audio renderer to save + // battery/apk size/cache size + val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> + arrayOf( + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), + MediaCodecAudioRenderer( + context, + MediaCodecSelector.DEFAULT, + handler, + audioListener, + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + replayGainProcessor)) + } + + val exoPlayer = + ExoPlayer.Builder(context, audioRenderer) + .setMediaSourceFactory(mediaSourceFactory) + // Enable automatic WakeLock support + .setWakeMode(C.WAKE_MODE_LOCAL) + .setAudioAttributes( + // Signal that we are a music player. + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true) + .build() + + return ExoPlaybackStateHolder( + context, + exoPlayer, + playbackManager, + persistenceRepository, + playbackSettings, + commandFactory, + musicRepository) + } + } + + private companion object { + const val SAVE_BUFFER = 5000L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt index e2fe690ec..9ea0300b3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaButtonReceiver.kt @@ -25,8 +25,8 @@ import android.content.Intent import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.util.logD /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt new file mode 100644 index 000000000..1ea54f92a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionPlayer.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.ForwardingPlayer +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.toSong +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.util.logD + +/** + * A thin wrapper around the player instance that takes all the events I know MediaSession will send + * and routes them to PlaybackStateManager so I know that they will work the way I want it to. + * @author Alexander Capehart + */ +class MediaSessionPlayer( + player: Player, + private val playbackManager: PlaybackStateManager, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository +) : ForwardingPlayer(player) { + override fun getAvailableCommands(): Player.Commands { + return super.getAvailableCommands() + .buildUpon() + .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) + .build() + } + + override fun isCommandAvailable(command: Int): Boolean { + // We can always skip forward and backward (this is to retain parity with the old behavior) + return super.isCommandAvailable(command) || + command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) + } + + override fun setMediaItems( + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ) { + // We assume the only people calling this method are going to be the MediaSession callbacks, + // since anything else (like newPlayback) will be calling directly on the player. As part + // of this, we expand the given MediaItems into the command that should be sent to the + // player. + val command = + if (mediaItems.size > 1) { + this.playMediaItemSelection(mediaItems, startIndex) + } else { + this.playSingleMediaItem(mediaItems.first()) + } + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) + if (startPositionMs != C.TIME_UNSET) { + playbackManager.seekTo(startPositionMs) + } + } + + private fun playMediaItemSelection( + mediaItems: List, + startIndex: Int + ): PlaybackCommand? { + val deviceLibrary = musicRepository.deviceLibrary ?: return null + val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary) + val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } + var index = startIndex + if (targetSong != null) { + while (songs.getOrNull(index)?.uid != targetSong.uid) { + index-- + } + } + return commandFactory.songs(songs, ShuffleMode.OFF) + } + + private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? { + val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.Single -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.Joined -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParentCommand(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + playbackManager.playing(playWhenReady) + } + + override fun setRepeatMode(repeatMode: Int) { + val appRepeatMode = + when (repeatMode) { + Player.REPEAT_MODE_OFF -> RepeatMode.NONE + Player.REPEAT_MODE_ONE -> RepeatMode.TRACK + Player.REPEAT_MODE_ALL -> RepeatMode.ALL + else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") + } + playbackManager.repeatMode(appRepeatMode) + } + + override fun seekToNext() = playbackManager.next() + + override fun seekToNextMediaItem() = playbackManager.next() + + override fun seekToPrevious() = playbackManager.prev() + + override fun seekToPreviousMediaItem() = playbackManager.prev() + + override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) + + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + val indices = unscrambleQueueIndices() + val fakeIndex = indices.indexOf(mediaItemIndex) + if (fakeIndex < 0) { + return + } + playbackManager.goto(fakeIndex) + if (positionMs == C.TIME_UNSET) { + return + } + playbackManager.seekTo(positionMs) + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } + when { + index == + currentTimeline.getNextWindowIndex( + currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> { + playbackManager.playNext(songs) + } + index >= mediaItemCount -> playbackManager.addToQueue(songs) + else -> error("Unsupported index $index") + } + } + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + playbackManager.shuffled(shuffleModeEnabled) + } + + override fun moveMediaItem(currentIndex: Int, newIndex: Int) { + val indices = unscrambleQueueIndices() + val fakeFrom = indices.indexOf(currentIndex) + if (fakeFrom < 0) { + return + } + val fakeTo = + if (newIndex >= mediaItemCount) { + currentTimeline.getLastWindowIndex(shuffleModeEnabled) + } else { + indices.indexOf(newIndex) + } + if (fakeTo < 0) { + return + } + playbackManager.moveQueueItem(fakeFrom, fakeTo) + } + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = + error("Multi-item queue moves are unsupported") + + override fun removeMediaItem(index: Int) { + val indices = unscrambleQueueIndices() + val fakeAt = indices.indexOf(index) + if (fakeAt < 0) { + return + } + playbackManager.removeQueueItem(fakeAt) + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) = + error("Any multi-item queue removal is unsupported") + + // These methods I don't want MediaSession calling in any way since they'll do insane things + // that I'm not tracking. If they do call them, I will know. + + override fun setMediaItem(mediaItem: MediaItem) = notAllowed() + + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed() + + override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed() + + override fun setMediaItems(mediaItems: MutableList) = notAllowed() + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) = + notAllowed() + + override fun addMediaItem(mediaItem: MediaItem) = notAllowed() + + override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() + + override fun addMediaItems(mediaItems: MutableList) = notAllowed() + + override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() + + override fun replaceMediaItems( + fromIndex: Int, + toIndex: Int, + mediaItems: MutableList + ) = notAllowed() + + override fun clearMediaItems() = notAllowed() + + override fun setPlaybackSpeed(speed: Float) = notAllowed() + + override fun seekToDefaultPosition() = notAllowed() + + override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed() + + override fun seekForward() = notAllowed() + + override fun seekBack() = notAllowed() + + @Deprecated("Deprecated in Java") override fun next() = notAllowed() + + @Deprecated("Deprecated in Java") override fun previous() = notAllowed() + + @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed() + + @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() + + override fun play() = playbackManager.playing(true) + + override fun pause() = playbackManager.playing(false) + + override fun prepare() = notAllowed() + + override fun release() = notAllowed() + + override fun stop() = notAllowed() + + override fun hasNextMediaItem() = notAllowed() + + override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = + notAllowed() + + override fun setVolume(volume: Float) = notAllowed() + + override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed() + + override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed() + + override fun increaseDeviceVolume(flags: Int) = notAllowed() + + override fun decreaseDeviceVolume(flags: Int) = notAllowed() + + @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed() + + @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed() + + @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed() + + @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed() + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed() + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed() + + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed() + + override fun setVideoSurface(surface: Surface?) = notAllowed() + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() + + override fun setVideoTextureView(textureView: TextureView?) = notAllowed() + + override fun clearVideoSurface() = notAllowed() + + override fun clearVideoSurface(surface: Surface?) = notAllowed() + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() + + override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() + + private fun notAllowed(): Nothing = error("MediaSession unexpectedly called this method") +} + +fun Player.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt new file mode 100644 index 000000000..2d73d5897 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemPlaybackReciever.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import javax.inject.Inject +import org.oxycblt.auxio.AuxioService +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent +import org.oxycblt.auxio.widgets.WidgetProvider + +/** + * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an + * active [IntentFilter] to be registered. + */ +class SystemPlaybackReceiver +@Inject +constructor( + val playbackManager: PlaybackStateManager, + val playbackSettings: PlaybackSettings, + val widgetComponent: WidgetComponent +) : BroadcastReceiver() { + private var initialHeadsetPlugEventHandled = false + + val intentFilter = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(AuxioService.ACTION_INC_REPEAT_MODE) + addAction(AuxioService.ACTION_INVERT_SHUFFLE) + addAction(AuxioService.ACTION_SKIP_PREV) + addAction(AuxioService.ACTION_PLAY_PAUSE) + addAction(AuxioService.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // --- SYSTEM EVENTS --- + + // Android has three different ways of handling audio plug events for some reason: + // 1. ACTION_HEADSET_PLUG, which only works with wired headsets + // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires + // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less + // a non-starter since both require me to display a permission prompt + // 3. Some internal framework thing that also handles bluetooth headsets + // Just use ACTION_HEADSET_PLUG. + AudioManager.ACTION_HEADSET_PLUG -> { + logD("Received headset plug event") + when (intent.getIntExtra("state", -1)) { + 0 -> pauseFromHeadsetPlug() + 1 -> playFromHeadsetPlug() + } + + initialHeadsetPlugEventHandled = true + } + AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { + logD("Received Headset noise event") + pauseFromHeadsetPlug() + } + + // --- AUXIO EVENTS --- + AuxioService.ACTION_PLAY_PAUSE -> { + logD("Received play event") + playbackManager.playing(!playbackManager.progression.isPlaying) + } + AuxioService.ACTION_INC_REPEAT_MODE -> { + logD("Received repeat mode event") + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + } + AuxioService.ACTION_INVERT_SHUFFLE -> { + logD("Received shuffle event") + playbackManager.shuffled(!playbackManager.isShuffled) + } + AuxioService.ACTION_SKIP_PREV -> { + logD("Received skip previous event") + playbackManager.prev() + } + AuxioService.ACTION_SKIP_NEXT -> { + logD("Received skip next event") + playbackManager.next() + } + WidgetProvider.ACTION_WIDGET_UPDATE -> { + logD("Received widget update event") + widgetComponent.update() + } + } + } + + private fun playFromHeadsetPlug() { + // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached, + // which would result in unexpected playback. Work around it by dropping the first + // call to this function, which should come from that Intent. + if (playbackSettings.headsetAutoplay && + playbackManager.currentSong != null && + initialHeadsetPlugEventHandled) { + logD("Device connected, resuming") + playbackManager.playing(true) + } + } + + private fun pauseFromHeadsetPlug() { + if (playbackManager.currentSong != null) { + logD("Device disconnected, pausing") + playbackManager.playing(false) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index cfeef8b04..8780dcdbb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -51,9 +51,7 @@ interface PlaybackStateHolder { /** The current audio session ID of the audio player. */ val audioSessionId: Int - /** - * Applies a completely new playback state to the holder. - */ + /** Applies a completely new playback state to the holder. */ fun newPlayback(command: PlaybackCommand) /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index fc4d5fc7c..c5231e0b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -492,7 +492,7 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } } - private class QueueCommand(override val queue: List) : PlaybackCommand { + private class QueueCommand(override val queue: List) : PlaybackCommand { override val song: Song? = null override val parent: MusicParent? = null override val shuffled = false diff --git a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt deleted file mode 100644 index 8b1872f77..000000000 --- a/app/src/main/java/org/oxycblt/auxio/service/AuxioService.kt +++ /dev/null @@ -1,1915 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * AuxioService.kt is part of Auxio. - * - * This program 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. - * - * This program 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 this program. If not, see . - */ - -package org.oxycblt.auxio.service - -import android.app.Notification -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.database.ContentObserver -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.AudioManager -import android.media.audiofx.AudioEffect -import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.PowerManager -import android.provider.MediaStore -import androidx.annotation.StringRes -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.ForwardingPlayer -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.BitmapLoader -import androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.audio.AudioCapabilities -import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import androidx.media3.session.MediaNotification -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.SessionCommand -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 com.google.common.util.concurrent.SettableFuture -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.image.BitmapProvider -import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.IndexingProgress -import org.oxycblt.auxio.music.IndexingState -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.fs.contentResolverSafe -import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.persist.PersistenceRepository -import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor -import org.oxycblt.auxio.playback.service.BetterShuffleOrder -import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.playback.state.PlaybackCommand -import org.oxycblt.auxio.playback.state.PlaybackStateHolder -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Progression -import org.oxycblt.auxio.playback.state.RawQueue -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.playback.state.StateAck -import org.oxycblt.auxio.search.SearchEngine -import org.oxycblt.auxio.util.getPlural -import org.oxycblt.auxio.util.getSystemServiceCompat -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE -import org.oxycblt.auxio.widgets.WidgetComponent -import org.oxycblt.auxio.widgets.WidgetProvider -import javax.inject.Inject - -// TODO: Android Auto Hookup -// TODO: Have to clobber shuffle and repeat mode handlers - -@AndroidEntryPoint -class AuxioService : - MediaLibraryService(), - MediaLibrarySession.Callback, - MusicRepository.IndexingWorker, - MusicRepository.IndexingListener, - MusicRepository.UpdateListener, - MusicSettings.Listener, - PlaybackStateHolder, - Player.Listener, - PlaybackSettings.Listener { - @Inject - lateinit var musicRepository: MusicRepository - @Inject - lateinit var musicSettings: MusicSettings - - private lateinit var indexingNotification: IndexingNotification - private lateinit var observingNotification: ObservingNotification - private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var indexerContentObserver: SystemContentObserver - private val serviceJob = Job() - private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) - private var currentIndexJob: Job? = null - - @Inject - lateinit var playbackManager: PlaybackStateManager - @Inject - lateinit var commandFactory: PlaybackCommand.Factory - @Inject - lateinit var playbackSettings: PlaybackSettings - @Inject - lateinit var persistenceRepository: PersistenceRepository - @Inject - lateinit var mediaSourceFactory: MediaSource.Factory - @Inject - lateinit var replayGainProcessor: ReplayGainAudioProcessor - private lateinit var player: NeoPlayer - private lateinit var mediaSession: MediaLibrarySession - private val systemReceiver = PlaybackReceiver() - private val restoreScope = CoroutineScope(serviceJob + Dispatchers.IO) - private val saveScope = CoroutineScope(serviceJob + Dispatchers.IO) - private var currentSaveJob: Job? = null - private var inPlayback = false - private var openAudioEffectSession = false - - @Inject - lateinit var listSettings: ListSettings - @Inject - lateinit var widgetComponent: WidgetComponent - @Inject - lateinit var bitmapLoader: NeoBitmapLoader - - @Inject - lateinit var searchEngine: SearchEngine - private var searchResultsCache = mutableMapOf() - private var searchScope = CoroutineScope(serviceJob + Dispatchers.Default) - private var searchJob: Job? = null - - override fun onCreate() { - super.onCreate() - - indexingNotification = IndexingNotification(this) - observingNotification = ObservingNotification(this) - wakeLock = - getSystemServiceCompat(PowerManager::class) - .newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService" - ) - // Initialize any listener-dependent components last as we wouldn't want a listener race - // condition to cause us to load music before we were fully initialize. - indexerContentObserver = SystemContentObserver() - - // Since Auxio is a music player, only specify an audio renderer to save - // battery/apk size/cache size - val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> - arrayOf( - FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), - MediaCodecAudioRenderer( - this, - MediaCodecSelector.DEFAULT, - handler, - audioListener, - AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, - replayGainProcessor - ) - ) - } - - val exoPlayer = - ExoPlayer.Builder(this, audioRenderer) - .setMediaSourceFactory(mediaSourceFactory) - // Enable automatic WakeLock support - .setWakeMode(C.WAKE_MODE_LOCAL) - .setAudioAttributes( - // Signal that we are a music player. - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true - ) - .build() - .also { it.addListener(this) } - - player = NeoPlayer( - this, - exoPlayer, - musicRepository, - playbackManager, - this, - commandFactory, - playbackSettings - ) - setMediaNotificationProvider( - DefaultMediaNotificationProvider.Builder(this) - .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) - .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") - .setChannelName(R.string.lbl_playback) - .build() - .also { it.setSmallIcon(R.drawable.ic_auxio_24) }) - mediaSession = - MediaLibrarySession.Builder(this, player, this).setBitmapLoader(bitmapLoader).build() - addSession(mediaSession) - updateCustomButtons() - - val intentFilter = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(ACTION_INC_REPEAT_MODE) - addAction(ACTION_INVERT_SHUFFLE) - addAction(ACTION_SKIP_PREV) - addAction(ACTION_PLAY_PAUSE) - addAction(ACTION_SKIP_NEXT) - addAction(ACTION_EXIT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - } - - ContextCompat.registerReceiver( - this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED - ) - - musicSettings.registerListener(this) - musicRepository.addUpdateListener(this) - musicRepository.addIndexingListener(this) - musicRepository.registerWorker(this) - - // Initialize any listener-dependent components last as we wouldn't want a listener race - // condition to cause us to load music before we were fully initialize. - playbackManager.registerStateHolder(this) - musicRepository.addUpdateListener(this) - playbackSettings.registerListener(this) - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - if (!playbackManager.progression.isPlaying) { - // Stop the service if not playing, continue playing in the background - // otherwise. - endSession() - } - } - - override fun onDestroy() { - super.onDestroy() - // De-initialize core service components first. - wakeLock.releaseSafe() - // Then cancel the listener-dependent components to ensure that stray reloading - // events will not occur. - indexerContentObserver.release() - musicSettings.unregisterListener(this) - musicRepository.removeUpdateListener(this) - musicRepository.removeIndexingListener(this) - musicRepository.unregisterWorker(this) - // Then cancel any remaining music loading jobs. - serviceJob.cancel() - - // Pause just in case this destruction was unexpected. - playbackManager.playing(false) - playbackManager.unregisterStateHolder(this) - musicRepository.removeUpdateListener(this) - playbackSettings.unregisterListener(this) - - serviceJob.cancel() - - replayGainProcessor.release() - player.release() - if (openAudioEffectSession) { - // Make sure to close the audio session when we release the player. - broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = false - } - - removeSession(mediaSession) - mediaSession.release() - player.release() - } - - // --- INDEXER OVERRIDES --- - - override fun requestIndex(withCache: Boolean) { - logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") - // Cancel the previous music loading job. - currentIndexJob?.cancel() - // Start a new music loading job on a co-routine. - currentIndexJob = musicRepository.index(this, withCache) - } - - override val workerContext: Context - get() = this - - override val scope = indexScope - - override fun onIndexingStateChanged() { - updateForeground(forMusic = true) - } - - // --- INTERNAL --- - - private fun updateForeground(forMusic: Boolean) { - if (inPlayback) { - if (!forMusic) { - val notification = - mediaNotificationProvider.createNotification( - mediaSession, - mediaSession.customLayout, - mediaNotificationManager.actionFactory - ) { notification -> - postMediaNotification(notification, mediaSession) - } - postMediaNotification(notification, mediaSession) - } - return - } - - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - updateLoadingForeground(state.progress) - } else { - updateIdleForeground() - } - } - - private fun updateLoadingForeground(progress: IndexingProgress) { - // When loading, we want to enter the foreground state so that android does - // not shut off the loading process. Note that while we will always post the - // notification when initially starting, we will not update the notification - // unless it indicates that it has changed. - val changed = indexingNotification.updateIndexingState(progress) - if (changed) { - logD("Notification changed, re-posting notification") - startForeground(indexingNotification.code, indexingNotification.build()) - } - // Make sure we can keep the CPU on while loading music - wakeLock.acquireSafe() - } - - private fun updateIdleForeground() { - if (musicSettings.shouldBeObserving) { - // There are a few reasons why we stay in the foreground with automatic rescanning: - // 1. Newer versions of Android have become more and more restrictive regarding - // how a foreground service starts. Thus, it's best to go foreground now so that - // we can go foreground later. - // 2. If a non-foreground service is killed, the app will probably still be alive, - // and thus the music library will not be updated at all. - // TODO: Assuming I unify this with PlaybackService, it's possible that I won't need - // this anymore, or at least I only have to use it when the app task is not removed. - logD("Need to observe, staying in foreground") - startForeground(observingNotification.code, observingNotification.build()) - } else { - // Not observing and done loading, exit foreground. - logD("Exiting foreground") - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) - } - // Release our wake lock (if we were using it) - wakeLock.releaseSafe() - } - - /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.acquireSafe() { - // Avoid unnecessary acquire calls. - if (!wakeLock.isHeld) { - logD("Acquiring wake lock") - // Time out after a minute, which is the average music loading time for a medium-sized - // library. If this runs out, we will re-request the lock, and if music loading is - // shorter than the timeout, it will be released early. - acquire(WAKELOCK_TIMEOUT_MS) - } - } - - /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.releaseSafe() { - // Avoid unnecessary release calls. - if (wakeLock.isHeld) { - logD("Releasing wake lock") - release() - } - } - - // --- SETTING CALLBACKS --- - - override fun onIndexingSettingChanged() { - // Music loading configuration changed, need to reload music. - requestIndex(true) - } - - override fun onObservingChanged() { - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (currentIndexJob == null) { - logD("Not loading, updating idle session") - updateForeground(forMusic = false) - } - } - - /** - * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior - * known to the user as automatic rescanning. The active (and not passive) nature of observing - * the database is what requires [IndexerService] to stay foreground when this is enabled. - */ - private inner class SystemContentObserver : - ContentObserver(Handler(Looper.getMainLooper())), Runnable { - private val handler = Handler(Looper.getMainLooper()) - - init { - contentResolverSafe.registerContentObserver( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this - ) - } - - /** - * Release this instance, preventing it from further observing the database and cancelling - * any pending update events. - */ - fun release() { - handler.removeCallbacks(this) - contentResolverSafe.unregisterContentObserver(this) - } - - override fun onChange(selfChange: Boolean) { - // Batch rapid-fire updates to the library into a single call to run after 500ms - handler.removeCallbacks(this) - handler.postDelayed(this, REINDEX_DELAY_MS) - } - - override fun run() { - // Check here if we should even start a reindex. This is much less bug-prone than - // registering and de-registering this component as this setting changes. - if (musicSettings.shouldBeObserving) { - logD("MediaStore changed, starting re-index") - requestIndex(true) - } - } - } - - // --- PLAYBACKSTATEHOLDER OVERRIDES --- - - override val progression: Progression - get() = - player.currentMediaItem?.let { - Progression.from( - player.playWhenReady, - player.isPlaying, - // The position value can be below zero or past the expected duration, make - // sure we handle that. - player.currentPosition - .coerceAtLeast(0) - .coerceAtMost(player.durationMs ?: Long.MAX_VALUE) - ) - } - ?: Progression.nil() - - override val repeatMode - get() = - when (val repeatMode = player.repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } - - override val parent: MusicParent? - get() = player.parent - - override val audioSessionId: Int - get() = player.audioSessionId - - override fun resolveQueue() = player.resolveQueue() - - override fun newPlayback(command: PlaybackCommand) { - player.newPlayback(command) - updateCustomButtons() - deferSave() - } - - override fun playing(playing: Boolean) { - player.playWhenReady = playing - // Dispatched later once all of the changes have been accumulated - // Playing state is not persisted, do not need to save - } - - override fun repeatMode(repeatMode: RepeatMode) { - player.repeatMode(repeatMode) - deferSave() - updateCustomButtons() - } - - override fun seekTo(positionMs: Long) { - player.seekTo(positionMs) - // Dispatched later once all of the changes have been accumulated - // Deferred save is handled on position discontinuity - } - - override fun next() { - player.seekToNext() - // Deferred save is handled on position discontinuity - } - - override fun prev() { - player.seekToPrevious() - // Deferred save is handled on position discontinuity - } - - override fun goto(index: Int) { - player.goto(index) - // Deferred save is handled on position discontinuity - } - - override fun shuffled(shuffled: Boolean) { - logD("Reordering queue to $shuffled") - player.shuffleModeEnabled = shuffled - deferSave() - updateCustomButtons() - } - - override fun playNext(songs: List, ack: StateAck.PlayNext) { - player.playNext(songs, ack) - deferSave() - } - - override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addToQueue(songs, ack) - deferSave() - } - - override fun move(from: Int, to: Int, ack: StateAck.Move) { - player.move(from, to, ack) - deferSave() - } - - override fun remove(at: Int, ack: StateAck.Remove) { - player.remove(at, ack) - deferSave() - } - - override fun handleDeferred(action: DeferredPlayback): Boolean { - val deviceLibrary = - musicRepository.deviceLibrary - // No library, cannot do anything. - ?: return false - - when (action) { - // Restore state -> Start a new restoreState job - is DeferredPlayback.RestoreState -> { - logD("Restoring playback state") - restoreScope.launch { - persistenceRepository.readState()?.let { - // Apply the saved state on the main thread to prevent code expecting - // state updates on the main thread from crashing. - withContext(Dispatchers.Main) { playbackManager.applySavedState(it, false) } - } - } - } - // Shuffle all -> Start new playback from all songs - is DeferredPlayback.ShuffleAll -> { - logD("Shuffling all tracks") - playbackManager.play( - requireNotNull(commandFactory.all(ShuffleMode.ON)) { - "Invalid playback parameters" - }) - } - // Open -> Try to find the Song for the given file and then play it from all songs - is DeferredPlayback.Open -> { - logD("Opening specified file") - deviceLibrary.findSongForUri(workerContext, action.uri)?.let { song -> - playbackManager.play( - requireNotNull(commandFactory.song(song, ShuffleMode.IMPLICIT)) { - "Invalid playback parameters" - }) - } - } - } - - return true - } - - override fun applySavedState( - parent: MusicParent?, - rawQueue: RawQueue, - ack: StateAck.NewPlayback? - ) { - player.applySavedState(parent, rawQueue, ack) - } - - override fun reset(ack: StateAck.NewPlayback) { - player.reset(ack) - } - - // --- PLAYER OVERRIDES --- - - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - super.onPlayWhenReadyChanged(playWhenReady, reason) - - if (player.playWhenReady) { - // Mark that we have started playing so that the notification can now be posted. - logD("Player has started playing") - inPlayback = true - if (!openAudioEffectSession) { - // Convention to start an audioeffect session on play/pause rather than - // start/stop - logD("Opening audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = true - } - } else if (openAudioEffectSession) { - // Make sure to close the audio session when we stop playback. - logD("Closing audio effect session") - broadcastAudioEffectAction(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) - openAudioEffectSession = false - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - super.onMediaItemTransition(mediaItem, reason) - - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || - reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK - ) { - playbackManager.ack(this, StateAck.IndexMoved) - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - - if (playbackState == Player.STATE_ENDED && player.repeatMode == Player.REPEAT_MODE_OFF) { - goto(0) - player.pause() - } - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - super.onPositionDiscontinuity(oldPosition, newPosition, reason) - if (reason == Player.DISCONTINUITY_REASON_SEEK) { - // TODO: Once position also naturally drifts by some threshold, save - deferSave() - } - } - - override fun onEvents(player: Player, events: Player.Events) { - super.onEvents(player, events) - - if (events.containsAny( - Player.EVENT_PLAY_WHEN_READY_CHANGED, - Player.EVENT_IS_PLAYING_CHANGED, - Player.EVENT_POSITION_DISCONTINUITY - ) - ) { - logD("Player state changed, must synchronize state") - playbackManager.ack(this, StateAck.ProgressionChanged) - } - } - - override fun onPlayerError(error: PlaybackException) { - // TODO: Replace with no skipping and a notification instead - // If there's any issue, just go to the next song. - logE("Player error occured") - logE(error.stackTraceToString()) - playbackManager.next() - } - - // --- OTHER OVERRIDES --- - - override fun onNotificationActionChanged() { - super.onNotificationActionChanged() - updateCustomButtons() - } - - override fun onPauseOnRepeatChanged() { - player.updatePauseOnRepeat() - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - if (changes.deviceLibrary) { - if (musicRepository.deviceLibrary != null) { - // We now have a library, see if we have anything we need to do. - logD("Library obtained, requesting action") - playbackManager.requestAction(this) - } - // Invalidate anything we searched prior. - searchResultsCache.clear() - searchJob?.cancel() - } - } - - // --- MEDIASESSION OVERRIDES --- - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSession - - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - updateForeground(forMusic = false) - } - - private fun postMediaNotification(notification: MediaNotification, session: MediaSession) { - // Pulled from MediaNotificationManager: Need to specify MediaSession token manually - // in notification - val fwkToken = session.sessionCompatToken.token as android.media.session.MediaSession.Token - notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) - startForeground(notification.notificationId, notification.notification) - } - - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): ConnectionResult { - val sessionCommands = - ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() - .add(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) - .add(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) - .add(SessionCommand(ACTION_EXIT, Bundle.EMPTY)) - .build() - return ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands) - .build() - } - - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: LibraryParams? - ): ListenableFuture> { - val result = LibraryResult.ofItem(ExternalUID.Category.ROOT.toMediaItem(this), params) - return Futures.immediateFuture(result) - } - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val music = - when (val uid = ExternalUID.fromString(mediaId)) { - is ExternalUID.Category -> - return Futures.immediateFuture( - LibraryResult.ofItem(uid.toMediaItem(this), null) - ) - - is ExternalUID.Single -> - musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - - is ExternalUID.Joined -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - - null -> null - } - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - - val mediaItem = - when (music) { - is Album -> music.toMediaItem(this, null) - is Artist -> music.toMediaItem(this, null) - is Genre -> music.toMediaItem(this) - is Playlist -> music.toMediaItem(this) - is Song -> music.toMediaItem(this, null) - } - - return Futures.immediateFuture(LibraryResult.ofItem(mediaItem, null)) - } - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return Futures.immediateFuture(LibraryResult.ofItemList(emptyList(), params)) - } - - val items = - getMediaItemList(parentId, deviceLibrary, userLibrary) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - val paginatedItems = - items.paginate(page, pageSize) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - val result = LibraryResult.ofItemList(paginatedItems, params) - return Futures.immediateFuture(result) - } - - private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary - ): List? { - return when (val externalUID = ExternalUID.fromString(id)) { - is ExternalUID.Category -> { - when (externalUID) { - ExternalUID.Category.ROOT -> - listOf( - ExternalUID.Category.SONGS, - ExternalUID.Category.ALBUMS, - ExternalUID.Category.ARTISTS, - ExternalUID.Category.GENRES, - ExternalUID.Category.PLAYLISTS - ) - .map { it.toMediaItem(this) } - - ExternalUID.Category.SONGS -> - deviceLibrary.songs.map { it.toMediaItem(this, null) } - - ExternalUID.Category.ALBUMS -> - deviceLibrary.albums.map { it.toMediaItem(this, null) } - - ExternalUID.Category.ARTISTS -> - deviceLibrary.artists.map { it.toMediaItem(this, null) } - - ExternalUID.Category.GENRES -> deviceLibrary.genres.map { it.toMediaItem(this) } - ExternalUID.Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(this) } - } - } - - is ExternalUID.Single -> { - getChildMediaItems(externalUID.uid) ?: return null - } - - is ExternalUID.Joined -> { - getChildMediaItems(externalUID.childUid) ?: return null - } - - null -> return null - } - } - - private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - item.songs.map { it.toMediaItem(this, item) } - } - - is Artist -> { - (item.explicitAlbums + item.implicitAlbums).map { it.toMediaItem(this, item) } + - item.songs.map { it.toMediaItem(this, item) } - } - - is Genre -> { - item.songs.map { it.toMediaItem(this, item) } - } - - is Playlist -> { - item.songs.map { it.toMediaItem(this, item) } - } - - is Song, - null -> return null - } - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: LibraryParams? - ): ListenableFuture> { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE) - ) - } - - if (query.isEmpty()) { - return Futures.immediateFuture(LibraryResult.ofVoid()) - } - - val future = SettableFuture.create>() - searchTo(query, deviceLibrary, userLibrary) { future.set(LibraryResult.ofVoid()) } - return Futures.immediateFuture(LibraryResult.ofVoid()) - } - - override fun onGetSearchResult( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE) - ) - } - - if (query.isEmpty()) { - return Futures.immediateFuture(LibraryResult.ofItemList(emptyList(), params)) - } - - val items = searchResultsCache[query] - if (items != null) { - val concatenatedItems = items.concat() - val paginatedItems = - concatenatedItems.paginate(page, pageSize) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - val result = LibraryResult.ofItemList(paginatedItems, params) - return Futures.immediateFuture(result) - } - - val future = SettableFuture.create>>() - searchTo(query, deviceLibrary, userLibrary) { - val concatenatedItems = it.concat() - val paginatedItems = concatenatedItems.paginate(page, pageSize) ?: return@searchTo - val result = LibraryResult.ofItemList(paginatedItems, params) - future.set(result) - } - - return future - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(this@AuxioService, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(this@AuxioService, null) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(this@AuxioService, null) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(this@AuxioService) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(this@AuxioService) }) - } - return music - } - - private fun searchTo( - query: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary, - cb: (SearchEngine.Items) -> Unit - ) { - // TODO: Queue up searches rather than clobbering the last one - searchJob?.cancel() - searchJob = - searchScope.launch { - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists - ) - val results = searchEngine.search(items, query) - searchResultsCache[query] = results - cb(results) - } - } - - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = (page + 1) * pageSize - if (pageSize == 0 || start !in indices || end - 1 !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(page * pageSize, (page + 1) * pageSize).toMutableList() - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = - when (customCommand.customAction) { - ACTION_INC_REPEAT_MODE -> { - repeatMode(repeatMode.increment()) - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - ACTION_INVERT_SHUFFLE -> { - shuffled(!player.shuffleModeEnabled) - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - ACTION_EXIT -> { - endSession() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - else -> super.onCustomCommand(session, controller, customCommand, args) - } - - private fun updateCustomButtons() { - val actions = mutableListOf() - - when (playbackSettings.notificationAction) { - ActionMode.REPEAT -> { - actions.add( - CommandButton.Builder() - .setIconResId(playbackManager.repeatMode.icon) - .setDisplayName(getString(R.string.desc_change_repeat)) - .setSessionCommand(SessionCommand(ACTION_INC_REPEAT_MODE, Bundle())) - .build() - ) - } - - ActionMode.SHUFFLE -> { - actions.add( - CommandButton.Builder() - .setIconResId( - if (player.shuffleModeEnabled) R.drawable.ic_shuffle_on_24 - else R.drawable.ic_shuffle_off_24 - ) - .setDisplayName(getString(R.string.lbl_shuffle)) - .setSessionCommand(SessionCommand(ACTION_INVERT_SHUFFLE, Bundle())) - .build() - ) - } - - else -> {} - } - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_close_24) - .setDisplayName(getString(R.string.desc_exit)) - .setSessionCommand(SessionCommand(ACTION_EXIT, Bundle())) - .build() - ) - - mediaSession.setCustomLayout(actions) - } - - private fun deferSave() { - saveJob { - logD("Waiting for save buffer") - delay(SAVE_BUFFER) - yield() - logD("Committing saved state") - persistenceRepository.saveState(playbackManager.toSavedState()) - } - } - - private fun saveJob(block: suspend () -> Unit) { - currentSaveJob?.let { - logD("Discarding prior save job") - it.cancel() - } - currentSaveJob = saveScope.launch { block() } - } - - private fun broadcastAudioEffectAction(event: String) { - logD("Broadcasting AudioEffect event: $event") - sendBroadcast( - Intent(event) - .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) - .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - ) - } - - private fun endSession() { - // This session has ended, so we need to reset this flag for when the next - // session starts. - saveJob { - logD("Committing saved state") - persistenceRepository.saveState(playbackManager.toSavedState()) - withContext(Dispatchers.Main) { - // User could feasibly start playing again if they were fast enough, so - // we need to avoid stopping the foreground state if that's the case. - if (player.isPlaying) { - playbackManager.playing(false) - } - inPlayback = false - updateForeground(forMusic = false) - } - } - } - - /** - * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require - * an active [IntentFilter] to be registered. - */ - private inner class PlaybackReceiver : BroadcastReceiver() { - private var initialHeadsetPlugEventHandled = false - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - // --- SYSTEM EVENTS --- - - // Android has three different ways of handling audio plug events for some reason: - // 1. ACTION_HEADSET_PLUG, which only works with wired headsets - // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires - // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less - // a non-starter since both require me to display a permission prompt - // 3. Some internal framework thing that also handles bluetooth headsets - // Just use ACTION_HEADSET_PLUG. - AudioManager.ACTION_HEADSET_PLUG -> { - logD("Received headset plug event") - when (intent.getIntExtra("state", -1)) { - 0 -> pauseFromHeadsetPlug() - 1 -> playFromHeadsetPlug() - } - - initialHeadsetPlugEventHandled = true - } - - AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { - logD("Received Headset noise event") - pauseFromHeadsetPlug() - } - - // --- AUXIO EVENTS --- - ACTION_PLAY_PAUSE -> { - logD("Received play event") - playbackManager.playing(!playbackManager.progression.isPlaying) - } - - ACTION_INC_REPEAT_MODE -> { - logD("Received repeat mode event") - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - } - - ACTION_INVERT_SHUFFLE -> { - logD("Received shuffle event") - playbackManager.shuffled(!playbackManager.isShuffled) - } - - ACTION_SKIP_PREV -> { - logD("Received skip previous event") - playbackManager.prev() - } - - ACTION_SKIP_NEXT -> { - logD("Received skip next event") - playbackManager.next() - } - - ACTION_EXIT -> { - logD("Received exit event") - playbackManager.playing(false) - endSession() - } - - WidgetProvider.ACTION_WIDGET_UPDATE -> { - logD("Received widget update event") - widgetComponent.update() - } - } - } - - private fun playFromHeadsetPlug() { - // ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached, - // which would result in unexpected playback. Work around it by dropping the first - // call to this function, which should come from that Intent. - if (playbackSettings.headsetAutoplay && - playbackManager.currentSong != null && - initialHeadsetPlugEventHandled - ) { - logD("Device connected, resuming") - playbackManager.playing(true) - } - } - - private fun pauseFromHeadsetPlug() { - if (playbackManager.currentSong != null) { - logD("Device disconnected, pausing") - playbackManager.playing(false) - } - } - } - - companion object { - const val WAKELOCK_TIMEOUT_MS = 60 * 1000L - const val REINDEX_DELAY_MS = 500L - const val SAVE_BUFFER = 5000L - const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" - const val ACTION_INVERT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" - const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" - const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" - const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" - const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" - } -} - -class NeoPlayer( - val context: Context, - val player: ExoPlayer, - val musicRepository: MusicRepository, - val playbackManager: PlaybackStateManager, - val stateHolder: PlaybackStateHolder, - val commandFactory: PlaybackCommand.Factory, - val playbackSettings: PlaybackSettings, -) : ForwardingPlayer(player) { - var parent: MusicParent? = null - private set - - val audioSessionId: Int - get() = player.audioSessionId - - val durationMs: Long? - get() = - musicRepository.deviceLibrary?.let { - currentMediaItem?.mediaMetadata?.extras?.getLong("durationMs") - } - - override fun getAvailableCommands(): Player.Commands { - return super.getAvailableCommands() - .buildUpon() - .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - } - - override fun isCommandAvailable(command: Int): Boolean { - // We can always skip forward and backward (this is to retain parity with the old behavior) - return super.isCommandAvailable(command) || - command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - } - - override fun getMediaMetadata(): MediaMetadata { - // TODO: Append parent to this for patched notification - return player.mediaMetadata - } - - override fun setMediaItems( - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ) { - // We assume the only people calling this method are going to be the MediaSession callbacks, - // since anything else (like newPlayback) will be calling directly on the player. As part - // of this, we expand the given MediaItems into the command that should be sent to the - // player. - val command = - if (mediaItems.size > 1) { - this.playMediaItemSelection(mediaItems, startIndex) - } else { - this.playSingleMediaItem(mediaItems.first()) - } - if (command != null) { - this.newPlayback(command) - player.seekTo(startPositionMs) - } else { - error("Invalid playback configuration") - } - } - - private fun playMediaItemSelection( - mediaItems: List, - startIndex: Int - ): PlaybackCommand? { - val deviceLibrary = musicRepository.deviceLibrary ?: return null - val targetSong = mediaItems.getOrNull(startIndex)?.toSong(deviceLibrary) - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - var index = startIndex - if (targetSong != null) { - while (songs.getOrNull(index)?.uid != targetSong.uid) { - index-- - } - } - return commandFactory.songs(songs, ShuffleMode.OFF) - } - - private fun playSingleMediaItem(mediaItem: MediaItem): PlaybackCommand? { - val uid = ExternalUID.fromString(mediaItem.mediaId) ?: return null - val music: Music - var parent: MusicParent? = null - when (uid) { - is ExternalUID.Single -> { - music = musicRepository.find(uid.uid) ?: return null - } - - is ExternalUID.Joined -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - - else -> return null - } - - return when (music) { - is Song -> inferSongFromParentCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - - override fun seekToNext() { - // Replicate the old pseudo-circular queue behavior when no repeat option is implemented. - // Basically, you can't skip back and wrap around the queue, but you can skip forward and - // wrap around the queue, albeit playback will be paused. - if (repeatMode != REPEAT_MODE_OFF || hasNextMediaItem()) { - player.seekToNext() - if (!playbackSettings.rememberPause) { - player.play() - } - } else { - player.seekTo(currentTimeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET) - // TODO: Dislike the UX implications of this, I feel should I bite the bullet - // and switch to dynamic skip enable/disable? - if (!playbackSettings.rememberPause) { - player.pause() - } - } - // Ack is handled in listener. - } - - override fun seekToPrevious() { - if (playbackSettings.rewindWithPrev) { - player.seekToPrevious() - } else { - player.seekToPreviousMediaItem() - } - if (!playbackSettings.rememberPause) { - player.play() - } - // Ack is handled in listener. - } - - override fun seekTo(mediaItemIndex: Int, positionMs: Long) { - player.seekTo(mediaItemIndex, positionMs) - if (!playbackSettings.rememberPause) { - player.play() - } - // Ack handled in listener. - } - - override fun setRepeatMode(repeatMode: Int) { - player.setRepeatMode(repeatMode) - this.updatePauseOnRepeat() - playbackManager.ack(stateHolder, StateAck.RepeatModeChanged) - } - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - player.setShuffleModeEnabled(shuffleModeEnabled) - if (shuffleModeEnabled) { - // Have to manually refresh the shuffle seed and anchor it to the new current songs - player.setShuffleOrder(BetterShuffleOrder(player.mediaItemCount, player.currentMediaItemIndex)) - } - playbackManager.ack(stateHolder, StateAck.QueueReordered) - } - - override fun addMediaItems(index: Int, mediaItems: MutableList) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - // Sanitize possible MediaBrowser-specific items - val items = mediaItems.mapNotNull { it.toSong(deviceLibrary)?.toMediaItem(context, null) } - if (items.isEmpty()) { - return - } - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(index) - val ack = if (index == player.nextMediaItemIndex) { - StateAck.PlayNext(fakeIndex + 1, items.size) - } else if (index >= mediaItemCount) { - // Add to queue - StateAck.AddToQueue(mediaItemCount, items.size) - } else { - // I really don't want to handle any other case right now and won't until I know - // they occured. - return - } - player.addMediaItems(index, items) - playbackManager.ack(stateHolder, ack) - } - - override fun moveMediaItem(currentIndex: Int, newIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeFrom = indices.indexOf(currentIndex) - val fakeTo = indices.indexOf(newIndex) - val ack = StateAck.Move(fakeFrom, fakeTo) - player.moveMediaItem(currentIndex, newIndex) - playbackManager.ack(stateHolder, ack) - } - - override fun removeMediaItem(index: Int) { - val indices = unscrambleQueueIndices() - val fakeAt = indices.indexOf(index) - player.removeMediaItem(index) - val ack = StateAck.Remove(fakeAt) - playbackManager.ack(stateHolder, ack) - } - - fun newPlayback(command: PlaybackCommand) { - this.parent = command.parent - player.shuffleModeEnabled = shuffleModeEnabled - player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) - val startIndex = - command.song - ?.let { command.queue.indexOf(it) } - .also { check(it != -1) { "Start song not in queue" } } - if (command.shuffled) { - player.setShuffleOrder(BetterShuffleOrder(command.queue.size, startIndex ?: -1)) - } - val target = startIndex ?: player.currentTimeline.getFirstWindowIndex(shuffleModeEnabled) - player.seekTo(target, C.TIME_UNSET) - player.prepare() - player.play() - playbackManager.ack(stateHolder, StateAck.NewPlayback) - } - - fun repeatMode(repeatMode: RepeatMode) { - this.repeatMode = - when (repeatMode) { - RepeatMode.NONE -> REPEAT_MODE_OFF - RepeatMode.ALL -> REPEAT_MODE_ALL - RepeatMode.TRACK -> REPEAT_MODE_ONE - } - } - - fun goto(index: Int) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[index] - this.seekTo(trueIndex, C.TIME_UNSET) // Handles remaining custom logic - } - - fun playNext(songs: List, ack: StateAck.PlayNext) { - val currTimeline = player.currentTimeline - val nextIndex = - if (currTimeline.isEmpty) { - C.INDEX_UNSET - } else { - currTimeline.getNextWindowIndex( - currentMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled - ) - } - - if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) - } else { - player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) - } - playbackManager.ack(stateHolder, ack) - } - - fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) - playbackManager.ack(stateHolder, ack) - } - - fun move(from: Int, to: Int, ack: StateAck.Move) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueFrom = indices[from] - val trueTo = indices[to] - - when { - trueFrom > trueTo -> { - player.moveMediaItem(trueFrom, trueTo) - player.moveMediaItem(trueTo + 1, trueFrom) - } - - trueTo > trueFrom -> { - player.moveMediaItem(trueFrom, trueTo) - player.moveMediaItem(trueTo - 1, trueFrom) - } - } - playbackManager.ack(stateHolder, ack) - } - - fun remove(at: Int, ack: StateAck.Remove) { - val indices = unscrambleQueueIndices() - if (indices.isEmpty()) { - return - } - - val trueIndex = indices[at] - val songWillChange = currentMediaItemIndex == trueIndex - removeMediaItem(trueIndex) - if (songWillChange && !playbackSettings.rememberPause) { - play() - } - playbackManager.ack(stateHolder, ack) - } - - fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) { - this.parent = parent - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) - if (rawQueue.isShuffled) { - player.shuffleModeEnabled = true - player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) - } else { - player.shuffleModeEnabled = false - } - player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) - player.prepare() - ack?.let { playbackManager.ack(stateHolder, it) } - } - - fun reset(ack: StateAck.NewPlayback) { - player.setMediaItems(listOf()) - playbackManager.ack(stateHolder, ack) - } - - fun updatePauseOnRepeat() { - player.pauseAtEndOfMediaItems = - repeatMode == Player.REPEAT_MODE_ONE && playbackSettings.pauseOnRepeat - } - - fun resolveQueue(): RawQueue { - val deviceLibrary = - musicRepository.deviceLibrary - // No library, cannot do anything. - ?: return RawQueue(emptyList(), emptyList(), 0) - val heap = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } - val shuffledMapping = - if (shuffleModeEnabled) { - unscrambleQueueIndices() - } else { - emptyList() - } - return RawQueue( - heap.mapNotNull { it.toSong(deviceLibrary) }, - shuffledMapping, - player.currentMediaItemIndex - ) - } - - private fun unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled - ) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled - ) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue - } -} - -private fun ExternalUID.Category.toMediaItem(context: Context): MediaItem { - val metadata = - MediaMetadata.Builder() - .setTitle(context.getString(nameRes)) - .setIsPlayable(false) - .setIsBrowsable(true) - .setMediaType(mediaType) - .build() - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() -} - -private fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { - val externalUID = - if (parent == null) { - ExternalUID.Single(uid) - } else { - ExternalUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(album.name.resolve(context)) - .setAlbumArtist(album.artists.resolveNames(context)) - .setTrackNumber(track) - .setDiscNumber(disc?.number) - .setGenre(genres.resolveNames(context)) - .setDisplayTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setRecordingYear(album.dates?.min?.year) - .setRecordingMonth(album.dates?.min?.month) - .setRecordingDay(album.dates?.min?.day) - .setReleaseYear(album.dates?.min?.year) - .setReleaseMonth(album.dates?.min?.month) - .setReleaseDay(album.dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) - .setIsPlayable(true) - .setIsBrowsable(false) - .setArtworkUri(album.coverUri.mediaStore) - .setExtras( - Bundle().apply { - putString("uid", externalUID.toString()) - putLong("durationMs", durationMs) - }) - .build() - return MediaItem.Builder() - .setUri(uri) - .setMediaId(externalUID.toString()) - .setMediaMetadata(metadata) - .build() -} - -private fun Album.toMediaItem(context: Context, parent: Artist?): MediaItem { - val externalUID = - if (parent == null) { - ExternalUID.Single(uid) - } else { - ExternalUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(name.resolve(context)) - .setAlbumArtist(artists.resolveNames(context)) - .setRecordingYear(dates?.min?.year) - .setRecordingMonth(dates?.min?.month) - .setRecordingDay(dates?.min?.day) - .setReleaseYear(dates?.min?.year) - .setReleaseMonth(dates?.min?.month) - .setReleaseDay(dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(true) - .setIsBrowsable(true) - .setArtworkUri(coverUri.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun Artist.toMediaItem(context: Context, parent: Genre?): MediaItem { - val externalUID = - if (parent == null) { - ExternalUID.Single(uid) - } else { - ExternalUID.Joined(parent.uid, uid) - } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - context.getString( - R.string.fmt_two, - if (explicitAlbums.isNotEmpty()) { - context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) - } else { - context.getString(R.string.def_album_count) - }, - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - ) - ) - .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(true) - .setIsBrowsable(true) - .setGenre(genres.resolveNames(context)) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun Genre.toMediaItem(context: Context): MediaItem { - val externalUID = ExternalUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - ) - .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(true) - .setIsBrowsable(true) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun Playlist.toMediaItem(context: Context): MediaItem { - val externalUID = ExternalUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - ) - .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(true) - .setIsBrowsable(true) - .setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) - .setExtras(Bundle().apply { putString("uid", externalUID.toString()) }) - .build() - return MediaItem.Builder().setMediaId(externalUID.toString()).setMediaMetadata(metadata).build() -} - -private fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { - val uid = ExternalUID.fromString(mediaId) ?: return null - return when (uid) { - is ExternalUID.Single -> { - deviceLibrary.findSong(uid.uid) - } - - is ExternalUID.Joined -> { - deviceLibrary.findSong(uid.childUid) - } - - is ExternalUID.Category -> null - } -} - -class NeoBitmapLoader -@Inject -constructor( - private val musicRepository: MusicRepository, - private val bitmapProvider: BitmapProvider -) : BitmapLoader { - override fun decodeBitmap(data: ByteArray): ListenableFuture { - TODO("Not yet implemented") - } - - override fun loadBitmap(uri: Uri, options: BitmapFactory.Options?): ListenableFuture { - TODO("Not yet implemented") - } - - override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { - val deviceLibrary = musicRepository.deviceLibrary ?: return null - val future = SettableFuture.create() - val song = - when (val uid = metadata.extras?.getString("uid")?.let { ExternalUID.fromString(it) }) { - is ExternalUID.Single -> deviceLibrary.findSong(uid.uid) - is ExternalUID.Joined -> deviceLibrary.findSong(uid.childUid) - else -> return null - } - ?: return null - bitmapProvider.load( - song, - object : BitmapProvider.Target { - override fun onCompleted(bitmap: Bitmap?) { - if (bitmap == null) { - future.setException(IllegalStateException("Bitmap is null")) - } else { - future.set(bitmap) - } - } - }) - return future - } -} - -sealed interface ExternalUID { - enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : - ExternalUID { - ROOT("root", R.string.info_app_name, null), - SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); - - override fun toString() = "$ID_CATEGORY:$id" - } - - data class Single(val uid: Music.UID) : ExternalUID { - override fun toString() = "$ID_ITEM:$uid" - } - - data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : ExternalUID { - override fun toString() = "$ID_ITEM:$parentUid>$childUid" - } - - companion object { - const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" - const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" - - fun fromString(str: String): ExternalUID? { - val parts = str.split(":", limit = 2) - if (parts.size != 2) { - return null - } - return when (parts[0]) { - ID_CATEGORY -> - when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> null - } - - ID_ITEM -> { - val uids = parts[1].split(">", limit = 2) - if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { Single(it) } - } else { - Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } - } - } - } - - else -> return null - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt rename to app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt index 7bcd6118a..df1c1e604 100644 --- a/app/src/main/java/org/oxycblt/auxio/service/ForegroundServiceNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ForegroundServiceNotification.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.service +package org.oxycblt.auxio.ui import android.content.Context import androidx.annotation.StringRes diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index bc99380ea..9ffd7631f 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -28,11 +28,11 @@ import android.os.Bundle import android.util.SizeF import android.view.View import android.widget.RemoteViews +import org.oxycblt.auxio.AuxioService import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.service.AuxioService import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW