diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index f932da80..4a3235d5 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -64,6 +64,13 @@ android:exported="false"> + + + { setResult(Constants.RESULT_CLOSE_ALL) - mediaPlayerController.stopJukeboxService() + mediaPlayerController.onDestroy() finish() exit() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 70f6968b..cf9d5d42 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -3,7 +3,6 @@ package org.moire.ultrasonic.di import org.koin.dsl.module import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.ExternalStorageMonitor -import org.moire.ultrasonic.service.JukeboxMediaPlayer import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.PlaybackStateSerializer @@ -12,7 +11,6 @@ import org.moire.ultrasonic.service.PlaybackStateSerializer * This Koin module contains the registration of classes related to the media player */ val mediaPlayerModule = module { - single { JukeboxMediaPlayer() } single { MediaPlayerLifecycleSupport() } single { PlaybackStateSerializer() } single { ExternalStorageMonitor() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 83117a03..82a99dbf 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -35,10 +35,8 @@ import android.widget.Toast import android.widget.ViewFlipper import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.common.Timeline import androidx.media3.session.SessionResult import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController @@ -363,6 +361,7 @@ class PlayerFragment : // Use launch to ensure running it in the main thread launch { onPlaylistChanged() + onSliderProgressChanged() } } @@ -373,12 +372,6 @@ class PlayerFragment : } } - mediaPlayerController.controller?.addListener(object : Player.Listener { - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - onSliderProgressChanged() - } - }) - // Query the Jukebox state in an IO Context ioScope.launch(CommunicationError.getHandler(context)) { try { @@ -671,9 +664,7 @@ class PlayerFragment : val isStarred = track.starred - mediaPlayerController.controller?.setRating( - HeartRating(!isStarred) - )?.let { + mediaPlayerController.toggleSongStarred()?.let { Futures.addCallback( it, object : FutureCallback { @@ -882,7 +873,7 @@ class PlayerFragment : @SuppressLint("NotifyDataSetChanged") override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val pos = viewHolder.bindingAdapterPosition - val item = mediaPlayerController.controller?.getMediaItemAt(pos) + val item = mediaPlayerController.getMediaItemAt(pos) mediaPlayerController.removeFromPlaylist(pos) val songRemoved = String.format( @@ -1073,9 +1064,9 @@ class PlayerFragment : private fun seek(forward: Boolean) { launch(CommunicationError.getHandler(context)) { if (forward) { - mediaPlayerController.controller?.seekForward() + mediaPlayerController.seekForward() } else { - mediaPlayerController.controller?.seekBack() + mediaPlayerController.seekBack() } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 675bb39e..dbf4a862 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -80,9 +80,6 @@ class PlaybackService : MediaLibraryService(), KoinComponent { isStarted = false stopForeground(true) stopSelf() - - // Clear Koin - UApp.instance!!.shutdownKoin() } private val resolver: ResolvingDataSource.Resolver = ResolvingDataSource.Resolver { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index a111d29b..81e7f865 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -10,7 +10,6 @@ package org.moire.ultrasonic.service import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager -import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.Build @@ -22,10 +21,9 @@ import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit import org.koin.android.ext.android.inject import org.moire.ultrasonic.R -import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.app.UApp -import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.SimpleServiceBinder +import org.moire.ultrasonic.util.Util import timber.log.Timber /** @@ -116,7 +114,7 @@ class DownloadService : Service() { .setWhen(System.currentTimeMillis()) .setShowWhen(false) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(getPendingIntentForContent()) + .setContentIntent(Util.getPendingIntentToShowPlayer(this)) .setPriority(NotificationCompat.PRIORITY_LOW) } @@ -156,18 +154,6 @@ class DownloadService : Service() { return notificationBuilder.build() } - private fun getPendingIntentForContent(): PendingIntent { - val intent = Intent(this, NavigationActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - var flags = PendingIntent.FLAG_UPDATE_CURRENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // needed starting Android 12 (S = 31) - flags = flags or PendingIntent.FLAG_IMMUTABLE - } - intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) - return PendingIntent.getActivity(this, 0, intent, flags) - } - @Suppress("MagicNumber") companion object { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index bfcc627b..5ba849be 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -55,7 +55,6 @@ class Downloader( private val imageLoaderProvider: ImageLoaderProvider by inject() private val activeServerProvider: ActiveServerProvider by inject() private val mediaController: MediaPlayerController by inject() - private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() var started: Boolean = false var shouldStop: Boolean = false @@ -163,7 +162,7 @@ class Downloader( return } - if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { + if (JukeboxMediaPlayer.running.get() || !Util.isNetworkConnected()) { return } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index 0479a9eb..f95e2e09 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -6,14 +6,44 @@ */ package org.moire.ultrasonic.service +import android.annotation.SuppressLint import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import android.os.Handler +import android.os.IBinder import android.os.Looper import android.view.Gravity +import android.view.KeyEvent +import android.view.KeyEvent.KEYCODE_MEDIA_NEXT +import android.view.KeyEvent.KEYCODE_MEDIA_PAUSE +import android.view.KeyEvent.KEYCODE_MEDIA_PLAY +import android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE +import android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS +import android.view.KeyEvent.KEYCODE_MEDIA_STOP import android.view.LayoutInflater import android.view.View import android.widget.ProgressBar import android.widget.Toast +import androidx.core.app.NotificationManagerCompat +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.DeviceInfo +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.Util +import androidx.media3.session.MediaSession +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture import java.util.concurrent.Executors import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.ScheduledFuture @@ -21,62 +51,399 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import kotlin.math.roundToInt -import org.koin.java.KoinJavaComponent.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException import org.moire.ultrasonic.api.subsonic.SubsonicRESTException import org.moire.ultrasonic.app.UApp.Companion.applicationContext import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.JukeboxStatus +import org.moire.ultrasonic.playback.MediaNotificationProvider import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.Util.getPendingIntentToShowPlayer import org.moire.ultrasonic.util.Util.sleepQuietly -import org.moire.ultrasonic.util.Util.toast import timber.log.Timber +private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L +private const val SEEK_INCREMENT_SECONDS = 5L +private const val SEEK_START_AFTER_SECONDS = 5 + /** * Provides an asynchronous interface to the remote jukebox on the Subsonic server. * * TODO: Report warning if queue fills up. - * TODO: Create shutdown method? * TODO: Disable repeat. * TODO: Persist RC state? * TODO: Minimize status updates. */ -class JukeboxMediaPlayer { +@Suppress("TooManyFunctions") +class JukeboxMediaPlayer : JukeboxUnimplementedFunctions(), Player { private val tasks = TaskQueue() private val executorService = Executors.newSingleThreadScheduledExecutor() private var statusUpdateFuture: ScheduledFuture<*>? = null private val timeOfLastUpdate = AtomicLong() private var jukeboxStatus: JukeboxStatus? = null + private var previousJukeboxStatus: JukeboxStatus? = null private var gain = 0.5f private var volumeToast: VolumeToast? = null - private val running = AtomicBoolean() private var serviceThread: Thread? = null - private var enabled = false - // TODO: These create circular references, try to refactor - private val mediaPlayerControllerLazy = inject( - MediaPlayerController::class.java - ) + private var listeners: MutableList = mutableListOf() + private val playlist: MutableList = mutableListOf() + private var currentIndex: Int = 0 + private val notificationProvider = MediaNotificationProvider(applicationContext()) + private lateinit var mediaSession: MediaSession + private lateinit var notificationManagerCompat: NotificationManagerCompat - fun startJukeboxService() { - if (running.get()) { - return - } + @Suppress("MagicNumber") + override fun onCreate() { + super.onCreate() + if (running.get()) return running.set(true) + + tasks.clear() + updatePlaylist() + stop() + + startFuture?.set(this) + startProcessTasks() + + notificationManagerCompat = NotificationManagerCompat.from(this) + mediaSession = MediaSession.Builder(applicationContext(), this) + .setId("jukebox") + .setSessionActivity(getPendingIntentToShowPlayer(this)) + .build() + val notification = notificationProvider.createNotification( + mediaSession, + ImmutableList.of(), + JukeboxNotificationActionFactory() + ) {} + + if (Util.SDK_INT >= 29) { + startForeground( + notification.notificationId, + notification.notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground( + notification.notificationId, notification.notification + ) + } + Timber.d("Started Jukebox Service") } - fun stopJukeboxService() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + if (Intent.ACTION_MEDIA_BUTTON != intent?.action) return START_STICKY + + val extras = intent.extras + if ((extras != null) && extras.containsKey(Intent.EXTRA_KEY_EVENT)) { + val event = extras.getParcelable(Intent.EXTRA_KEY_EVENT) + when (event?.keyCode) { + KEYCODE_MEDIA_PLAY -> play() + KEYCODE_MEDIA_PAUSE -> stop() + KEYCODE_MEDIA_STOP -> stop() + KEYCODE_MEDIA_PLAY_PAUSE -> if (isPlaying) stop() else play() + KEYCODE_MEDIA_PREVIOUS -> seekToPrevious() + KEYCODE_MEDIA_NEXT -> seekToNext() + } + } + return START_STICKY + } + + override fun onDestroy() { + if (!running.get()) return running.set(false) + sleepQuietly(1000) if (serviceThread != null) { serviceThread!!.interrupt() } + + tasks.clear() + stop() + stopForeground(true) + mediaSession.release() + + super.onDestroy() Timber.d("Stopped Jukebox Service") } + override fun onBind(p0: Intent?): IBinder? { + return null + } + + fun requestStop() { + stopSelf() + } + + private fun updateNotification() { + val notification = notificationProvider.createNotification( + mediaSession, + ImmutableList.of(), + JukeboxNotificationActionFactory() + ) {} + notificationManagerCompat.notify(notification.notificationId, notification.notification) + } + + companion object { + val running = AtomicBoolean() + private var startFuture: SettableFuture? = null + + @JvmStatic + fun requestStart(): ListenableFuture? { + if (running.get()) return null + startFuture = SettableFuture.create() + val context = applicationContext() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService( + Intent(context, JukeboxMediaPlayer::class.java) + ) + } else { + context.startService(Intent(context, JukeboxMediaPlayer::class.java)) + } + Timber.i("JukeboxMediaPlayer starting...") + return startFuture + } + } + + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + } + + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + } + + override fun getCurrentMediaItem(): MediaItem? { + if (playlist.isEmpty()) return null + if (currentIndex < 0 || currentIndex >= playlist.size) return null + return playlist[currentIndex] + } + + override fun getCurrentMediaItemIndex(): Int { + return currentIndex + } + + override fun getCurrentPeriodIndex(): Int { + return currentIndex + } + + override fun getContentPosition(): Long { + return currentPosition + } + + override fun play() { + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + startStatusUpdate() + tasks.add(Start()) + } + + override fun seekTo(positionMs: Long) { + seekTo(currentIndex, positionMs) + } + + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + tasks.remove(Skip::class.java) + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + startStatusUpdate() + val positionSeconds = (positionMs / 1000).toInt() + if (jukeboxStatus != null) { + jukeboxStatus!!.positionSeconds = positionSeconds + } + tasks.add(Skip(mediaItemIndex, positionSeconds)) + currentIndex = mediaItemIndex + } + + override fun seekBack() { + seekTo(0L.coerceAtMost((jukeboxStatus?.positionSeconds ?: 0) - SEEK_INCREMENT_SECONDS)) + } + + override fun seekForward() { + seekTo((jukeboxStatus?.positionSeconds ?: 0) + SEEK_INCREMENT_SECONDS) + } + + override fun prepare() {} + + override fun isPlaying(): Boolean { + return jukeboxStatus?.isPlaying ?: false + } + + override fun getPlaybackState(): Int { + return when (jukeboxStatus?.isPlaying) { + true -> Player.STATE_READY + null, false -> Player.STATE_IDLE + } + } + + override fun getAvailableCommands(): Player.Commands { + val commandsBuilder = Player.Commands.Builder().addAll( + Player.COMMAND_SET_VOLUME, + Player.COMMAND_GET_VOLUME + ) + if (isPlaying) commandsBuilder.add(Player.COMMAND_STOP) + if (playlist.isNotEmpty()) { + commandsBuilder.addAll( + Player.COMMAND_GET_CURRENT_MEDIA_ITEM, + Player.COMMAND_GET_MEDIA_ITEMS_METADATA, + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_PREPARE, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_MEDIA_ITEM, + ) + if (currentIndex > 0) commandsBuilder.addAll( + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM + ) + if (currentIndex < playlist.size - 1) commandsBuilder.addAll( + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + ) + } + return commandsBuilder.build() + } + + override fun isCommandAvailable(command: Int): Boolean { + return availableCommands.contains(command) + } + + override fun getPlayWhenReady(): Boolean { + return isPlaying + } + + override fun pause() { + stop() + } + + override fun stop() { + tasks.remove(Stop::class.java) + tasks.remove(Start::class.java) + stopStatusUpdate() + tasks.add(Stop()) + } + + override fun getCurrentTimeline(): Timeline { + return PlaylistTimeline(playlist) + } + + override fun getMediaItemCount(): Int { + return playlist.size + } + + override fun getMediaItemAt(index: Int): MediaItem { + if (playlist.size == 0) return MediaItem.EMPTY + if (index < 0 || index >= playlist.size) return MediaItem.EMPTY + return playlist[index] + } + + override fun getShuffleModeEnabled(): Boolean { + return false + } + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {} + + override fun setVolume(volume: Float) { + gain = volume + tasks.remove(SetGain::class.java) + tasks.add(SetGain(volume)) + val context = applicationContext() + if (volumeToast == null) volumeToast = VolumeToast(context) + volumeToast!!.setVolume(volume) + } + + override fun getVolume(): Float { + return gain + } + + override fun getDeviceVolume(): Int { + return (gain * 100).toInt() + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + playlist.addAll(index, mediaItems) + updatePlaylist() + } + + override fun getBufferedPercentage(): Int { + return 0 + } + + override fun moveMediaItem(currentIndex: Int, newIndex: Int) { + if (playlist.size == 0) return + if (currentIndex < 0 || currentIndex >= playlist.size) return + if (newIndex < 0 || newIndex >= playlist.size) return + + val insertIndex = if (newIndex < currentIndex) newIndex else newIndex - 1 + val item = playlist.removeAt(currentIndex) + playlist.add(insertIndex, item) + updatePlaylist() + } + + override fun removeMediaItem(index: Int) { + if (playlist.size == 0) return + if (index < 0 || index >= playlist.size) return + playlist.removeAt(index) + updatePlaylist() + } + + override fun clearMediaItems() { + playlist.clear() + currentIndex = 0 + updatePlaylist() + } + + override fun getRepeatMode(): Int { + return Player.REPEAT_MODE_OFF + } + + override fun setRepeatMode(repeatMode: Int) {} + + override fun getCurrentPosition(): Long { + return positionSeconds * 1000L + } + + override fun getDuration(): Long { + if (playlist.isEmpty()) return 0 + if (currentIndex < 0 || currentIndex >= playlist.size) return 0 + + return ( + playlist[currentIndex].mediaMetadata.extras?.getInt("duration") ?: 0 + ).toLong() * 1000 + } + + override fun getContentDuration(): Long { + return duration + } + + override fun getMediaMetadata(): MediaMetadata { + if (playlist.isEmpty()) return MediaMetadata.EMPTY + if (currentIndex < 0 || currentIndex >= playlist.size) return MediaMetadata.EMPTY + + return playlist[currentIndex].mediaMetadata + } + + override fun seekToNext() { + if (currentIndex < 0 || currentIndex >= playlist.size) return + currentIndex++ + seekTo(currentIndex, 0) + } + + override fun seekToPrevious() { + if ((jukeboxStatus?.positionSeconds ?: 0) > SEEK_START_AFTER_SECONDS) { + seekTo(currentIndex, 0) + return + } + if (currentIndex <= 0) return + currentIndex-- + seekTo(currentIndex, 0) + } + private fun startProcessTasks() { serviceThread = object : Thread() { override fun run() { @@ -111,6 +478,8 @@ class JukeboxMediaPlayer { private fun processTasks() { while (running.get()) { + // Sleep a bit to spare processor time if we loop a lot + sleepQuietly(10) var task: JukeboxTask? = null try { if (!isOffline()) { @@ -122,88 +491,110 @@ class JukeboxMediaPlayer { } catch (x: Throwable) { onError(task, x) } - sleepQuietly(1) } } private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) { timeOfLastUpdate.set(System.currentTimeMillis()) + previousJukeboxStatus = this.jukeboxStatus this.jukeboxStatus = jukeboxStatus + currentIndex = jukeboxStatus.currentPlayingIndex ?: currentIndex + + if (jukeboxStatus.isPlaying != previousJukeboxStatus?.isPlaying) { + Handler(Looper.getMainLooper()).post { + listeners.forEach { + it.onPlaybackStateChanged( + if (jukeboxStatus.isPlaying) Player.STATE_READY else Player.STATE_IDLE + ) + it.onIsPlayingChanged(jukeboxStatus.isPlaying) + } + } + } + + if (jukeboxStatus.currentPlayingIndex != previousJukeboxStatus?.currentPlayingIndex) { + currentIndex = jukeboxStatus.currentPlayingIndex ?: 0 + val currentMedia = + if (currentIndex > 0 && currentIndex < playlist.size) playlist[currentIndex] + else MediaItem.EMPTY + Handler(Looper.getMainLooper()).post { + listeners.forEach { + it.onMediaItemTransition( + currentMedia, + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK + ) + } + } + } + + updateNotification() } private fun onError(task: JukeboxTask?, x: Throwable) { if (x is ApiNotSupportedException && task !is Stop) { - disableJukeboxOnError(x, R.string.download_jukebox_server_too_old) + Handler(Looper.getMainLooper()).post { + listeners.forEach { + it.onPlayerError( + PlaybackException( + "Jukebox server too old", + null, + R.string.download_jukebox_server_too_old + ) + ) + } + } } else if (x is OfflineException && task !is Stop) { - disableJukeboxOnError(x, R.string.download_jukebox_offline) + Handler(Looper.getMainLooper()).post { + listeners.forEach { + it.onPlayerError( + PlaybackException( + "Jukebox offline", + null, + R.string.download_jukebox_offline + ) + ) + } + } } else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) { - disableJukeboxOnError(x, R.string.download_jukebox_not_authorized) + Handler(Looper.getMainLooper()).post { + listeners.forEach { + it.onPlayerError( + PlaybackException( + "Jukebox not authorized", + null, + R.string.download_jukebox_not_authorized + ) + ) + } + } } else { Timber.e(x, "Failed to process jukebox task") } } - private fun disableJukeboxOnError(x: Throwable, resourceId: Int) { - Timber.w(x.toString()) - val context = applicationContext() - Handler(Looper.getMainLooper()).post { toast(context, resourceId, false) } - mediaPlayerControllerLazy.value.isJukeboxEnabled = false - } - - fun updatePlaylist() { - if (!enabled) return + private fun updatePlaylist() { + if (!running.get()) return tasks.remove(Skip::class.java) tasks.remove(Stop::class.java) tasks.remove(Start::class.java) val ids: MutableList = ArrayList() - for (item in mediaPlayerControllerLazy.value.playlist) { + for (item in playlist) { ids.add(item.mediaId) } tasks.add(SetPlaylist(ids)) - } - - fun skip(index: Int, offsetSeconds: Int) { - tasks.remove(Skip::class.java) - tasks.remove(Stop::class.java) - tasks.remove(Start::class.java) - startStatusUpdate() - if (jukeboxStatus != null) { - jukeboxStatus!!.positionSeconds = offsetSeconds + Handler(Looper.getMainLooper()).post { + listeners.forEach { + it.onTimelineChanged( + PlaylistTimeline(playlist), + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED + ) + } } - tasks.add(Skip(index, offsetSeconds)) - } - - fun stop() { - tasks.remove(Stop::class.java) - tasks.remove(Start::class.java) - stopStatusUpdate() - tasks.add(Stop()) - } - - fun start() { - tasks.remove(Stop::class.java) - tasks.remove(Start::class.java) - startStatusUpdate() - tasks.add(Start()) - } - - @Synchronized - fun adjustVolume(up: Boolean) { - val delta = if (up) 0.05f else -0.05f - gain += delta - gain = gain.coerceAtLeast(0.0f) - gain = gain.coerceAtMost(1.0f) - tasks.remove(SetGain::class.java) - tasks.add(SetGain(gain)) - val context = applicationContext() - if (volumeToast == null) volumeToast = VolumeToast(context) - volumeToast!!.setVolume(gain) } private val musicService: MusicService get() = getMusicService() - val positionSeconds: Int + private val positionSeconds: Int get() { if (jukeboxStatus == null || jukeboxStatus!!.positionSeconds == null || @@ -219,20 +610,6 @@ class JukeboxMediaPlayer { return jukeboxStatus!!.positionSeconds!! } - var isEnabled: Boolean - set(enabled) { - Timber.d("Jukebox Service setting enabled to %b", enabled) - this.enabled = enabled - tasks.clear() - if (enabled) { - updatePlaylist() - } - stop() - } - get() { - return enabled - } - private class TaskQueue { private val queue = LinkedBlockingQueue() fun add(jukeboxTask: JukeboxTask) { @@ -317,6 +694,7 @@ class JukeboxMediaPlayer { } } + @SuppressLint("InflateParams") private class VolumeToast(context: Context) : Toast(context) { private val progressBar: ProgressBar fun setVolume(volume: Float) { @@ -335,7 +713,100 @@ class JukeboxMediaPlayer { } } - companion object { - private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L + // The constants below are necessary so a MediaSession can be built from the Jukebox Service + override fun isCurrentMediaItemDynamic(): Boolean { + return false + } + + override fun getTrackSelectionParameters(): TrackSelectionParameters { + return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + } + + override fun getMaxSeekToPreviousPosition(): Long { + return SEEK_START_AFTER_SECONDS * 1000L + } + + override fun getSeekBackIncrement(): Long { + return SEEK_INCREMENT_SECONDS * 1000L + } + + override fun getSeekForwardIncrement(): Long { + return SEEK_INCREMENT_SECONDS * 1000L + } + + override fun isLoading(): Boolean { + return false + } + + override fun getPlaybackSuppressionReason(): Int { + return Player.PLAYBACK_SUPPRESSION_REASON_NONE + } + + override fun isDeviceMuted(): Boolean { + return false + } + + override fun getCurrentCues(): CueGroup { + return CueGroup.EMPTY + } + + override fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.DEFAULT + } + + override fun getVideoSize(): VideoSize { + return VideoSize(0, 0) + } + + override fun getContentBufferedPosition(): Long { + return bufferedPosition + } + + override fun getCurrentLiveOffset(): Long { + return C.TIME_UNSET + } + + override fun getTotalBufferedDuration(): Long { + return 0 + } + + override fun isPlayingAd(): Boolean { + return false + } + + override fun getCurrentAdIndexInAdGroup(): Int { + return C.INDEX_UNSET + } + + override fun getCurrentAdGroupIndex(): Int { + return C.INDEX_UNSET + } + + override fun canAdvertiseSession(): Boolean { + return true + } + + override fun getApplicationLooper(): Looper { + return applicationContext().mainLooper + } + + override fun getPlaylistMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun getDeviceInfo(): DeviceInfo { + return DeviceInfo(DeviceInfo.PLAYBACK_TYPE_REMOTE, 0, 1) + } + + override fun getPlayerError(): PlaybackException? { + return null + } + + override fun getPlaybackParameters(): PlaybackParameters { + return PlaybackParameters(1F, 1F) + } + + override fun getBufferedPosition(): Long { + return 0 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxNotificationActionFactory.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxNotificationActionFactory.kt new file mode 100644 index 00000000..26930a4e --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxNotificationActionFactory.kt @@ -0,0 +1,95 @@ +/* + * JukeboxNotificationActionFactory.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.media3.common.Player +import androidx.media3.common.util.Util +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaSession +import org.moire.ultrasonic.app.UApp + +/** + * This class creates Intents and Actions to be used with the Media Notification + * of the Jukebox Service + */ +class JukeboxNotificationActionFactory : MediaNotification.ActionFactory { + override fun createMediaAction( + mediaSession: MediaSession, + icon: IconCompat, + title: CharSequence, + command: Int + ): NotificationCompat.Action { + return NotificationCompat.Action( + icon, title, createMediaActionPendingIntent(mediaSession, command.toLong()) + ) + } + + override fun createCustomAction( + mediaSession: MediaSession, + icon: IconCompat, + title: CharSequence, + customAction: String, + extras: Bundle + ): NotificationCompat.Action { + return NotificationCompat.Action( + icon, title, null + ) + } + + override fun createCustomActionFromCustomCommandButton( + mediaSession: MediaSession, + customCommandButton: CommandButton + ): NotificationCompat.Action { + return NotificationCompat.Action(null, null, null) + } + + @Suppress("MagicNumber") + override fun createMediaActionPendingIntent( + mediaSession: MediaSession, + command: Long + ): PendingIntent { + val keyCode: Int = toKeyCode(command) + val intent = Intent(Intent.ACTION_MEDIA_BUTTON) + intent.component = ComponentName(UApp.applicationContext(), JukeboxMediaPlayer::class.java) + intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + return if (Util.SDK_INT >= 26 && command == Player.COMMAND_PLAY_PAUSE.toLong()) { + return PendingIntent.getForegroundService( + UApp.applicationContext(), keyCode, intent, PendingIntent.FLAG_IMMUTABLE + ) + } else { + PendingIntent.getService( + UApp.applicationContext(), + keyCode, + intent, + if (Util.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0 + ) + } + } + + private fun toKeyCode(action: @Player.Command Long): Int { + return when (action.toInt()) { + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_NEXT -> KeyEvent.KEYCODE_MEDIA_NEXT + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_PREVIOUS -> KeyEvent.KEYCODE_MEDIA_PREVIOUS + Player.COMMAND_STOP -> KeyEvent.KEYCODE_MEDIA_STOP + Player.COMMAND_SEEK_FORWARD -> KeyEvent.KEYCODE_MEDIA_FAST_FORWARD + Player.COMMAND_SEEK_BACK -> KeyEvent.KEYCODE_MEDIA_REWIND + Player.COMMAND_PLAY_PAUSE -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + else -> KeyEvent.KEYCODE_UNKNOWN + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt new file mode 100644 index 00000000..82e38d17 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxUnimplementedFunctions.kt @@ -0,0 +1,260 @@ +/* + * JukeboxUnimplemented.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.app.Service +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +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 androidx.media3.common.Tracks + +/** + * This class helps to hide the unused (thus unimplemented) functions + * of the crowded Player interface, so the JukeboxMediaPlayer class can be a bit clearer. + */ +@Suppress("TooManyFunctions") +abstract class JukeboxUnimplementedFunctions : Service(), Player { + + override fun setMediaItems(mediaItems: MutableList) { + TODO("Not yet implemented") + } + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + TODO("Not yet implemented") + } + + override fun setMediaItems( + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ) { + TODO("Not yet implemented") + } + + override fun setMediaItem(mediaItem: MediaItem) { + TODO("Not yet implemented") + } + + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) { + TODO("Not yet implemented") + } + + override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) { + TODO("Not yet implemented") + } + + override fun addMediaItem(mediaItem: MediaItem) { + TODO("Not yet implemented") + } + + override fun addMediaItem(index: Int, mediaItem: MediaItem) { + TODO("Not yet implemented") + } + + override fun addMediaItems(mediaItems: MutableList) { + TODO("Not yet implemented") + } + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) { + TODO("Not yet implemented") + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + TODO("Not yet implemented") + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + TODO("Not yet implemented") + } + + override fun seekToDefaultPosition() { + TODO("Not yet implemented") + } + + override fun seekToDefaultPosition(mediaItemIndex: Int) { + TODO("Not yet implemented") + } + + override fun hasPrevious(): Boolean { + TODO("Not yet implemented") + } + + override fun hasPreviousWindow(): Boolean { + TODO("Not yet implemented") + } + + override fun hasPreviousMediaItem(): Boolean { + TODO("Not yet implemented") + } + + override fun previous() { + TODO("Not yet implemented") + } + + override fun seekToPreviousWindow() { + TODO("Not yet implemented") + } + + override fun seekToPreviousMediaItem() { + TODO("Not yet implemented") + } + + override fun hasNext(): Boolean { + TODO("Not yet implemented") + } + + override fun hasNextWindow(): Boolean { + TODO("Not yet implemented") + } + + override fun hasNextMediaItem(): Boolean { + TODO("Not yet implemented") + } + + override fun next() { + TODO("Not yet implemented") + } + + override fun seekToNextWindow() { + TODO("Not yet implemented") + } + + override fun seekToNextMediaItem() { + TODO("Not yet implemented") + } + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + TODO("Not yet implemented") + } + + override fun setPlaybackSpeed(speed: Float) { + TODO("Not yet implemented") + } + + override fun stop(reset: Boolean) { + TODO("Not yet implemented") + } + + override fun release() { + TODO("Not yet implemented") + } + + override fun getCurrentTracks(): Tracks { + TODO("Not yet implemented") + } + + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) { + TODO("Not yet implemented") + } + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + TODO("Not yet implemented") + } + + override fun getCurrentManifest(): Any? { + TODO("Not yet implemented") + } + + override fun getCurrentWindowIndex(): Int { + TODO("Not yet implemented") + } + + override fun getNextWindowIndex(): Int { + TODO("Not yet implemented") + } + + override fun getNextMediaItemIndex(): Int { + TODO("Not yet implemented") + } + + override fun getPreviousWindowIndex(): Int { + TODO("Not yet implemented") + } + + override fun getPreviousMediaItemIndex(): Int { + TODO("Not yet implemented") + } + + override fun isCurrentWindowDynamic(): Boolean { + TODO("Not yet implemented") + } + + override fun isCurrentWindowLive(): Boolean { + TODO("Not yet implemented") + } + + override fun isCurrentMediaItemLive(): Boolean { + TODO("Not yet implemented") + } + + override fun isCurrentWindowSeekable(): Boolean { + TODO("Not yet implemented") + } + + override fun isCurrentMediaItemSeekable(): Boolean { + TODO("Not yet implemented") + } + + override fun clearVideoSurface() { + TODO("Not yet implemented") + } + + override fun clearVideoSurface(surface: Surface?) { + TODO("Not yet implemented") + } + + override fun setVideoSurface(surface: Surface?) { + TODO("Not yet implemented") + } + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + TODO("Not yet implemented") + } + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + TODO("Not yet implemented") + } + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + TODO("Not yet implemented") + } + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + TODO("Not yet implemented") + } + + override fun setVideoTextureView(textureView: TextureView?) { + TODO("Not yet implemented") + } + + override fun clearVideoTextureView(textureView: TextureView?) { + TODO("Not yet implemented") + } + + override fun setDeviceVolume(volume: Int) { + TODO("Not yet implemented") + } + + override fun increaseDeviceVolume() { + TODO("Not yet implemented") + } + + override fun decreaseDeviceVolume() { + TODO("Not yet implemented") + } + + override fun setDeviceMuted(muted: Boolean) { + TODO("Not yet implemented") + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 90654207..801e9643 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -9,10 +9,13 @@ package org.moire.ultrasonic.service import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper import android.widget.Toast import androidx.media3.common.C import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO import androidx.media3.common.Player.REPEAT_MODE_OFF @@ -22,6 +25,7 @@ import androidx.media3.session.SessionResult import androidx.media3.session.SessionToken import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope @@ -40,11 +44,15 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.setPin import org.moire.ultrasonic.util.toMediaItem import org.moire.ultrasonic.util.toTrack import timber.log.Timber +private const val CONTROLLER_SWITCH_DELAY = 500L +private const val VOLUME_DELTA = 0.05f + /** * The implementation of the Media Player Controller. * This class contains everything that is necessary for the Application UI @@ -57,18 +65,15 @@ class MediaPlayerController( private val downloader: Downloader, val context: Context ) : KoinComponent { + private val activeServerProvider: ActiveServerProvider by inject() private var created = false var suggestedPlaylistName: String? = null var keepScreenOn = false - var showVisualization = false private var autoPlayStart = false private val scrobbler = Scrobbler() - private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() - private val activeServerProvider: ActiveServerProvider by inject() - private val rxBusSubscription: CompositeDisposable = CompositeDisposable() private var mainScope = CoroutineScope(Dispatchers.Main) @@ -76,89 +81,89 @@ class MediaPlayerController( private var sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) - private var mediaControllerFuture = MediaController.Builder( - context, - sessionToken - ).buildAsync() + private var mediaControllerFuture: ListenableFuture? = null - var controller: MediaController? = null + private var controller: Player? = null - private lateinit var listeners: Player.Listener + private var listeners: Player.Listener = object : Player.Listener { + + /* + * Log all events + */ + override fun onEvents(player: Player, events: Player.Events) { + for (i in 0 until events.size()) { + Timber.i("Media3 Event, event type: %s", events[i]) + } + } + + /* + * This will be called everytime the playlist has changed. + * We run the event through RxBus in order to throttle them + */ + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack)) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + clearBookmark() + // TRANSITION_REASON_AUTO means that the previous track finished playing and a new one has started. + if (reason == MEDIA_ITEM_TRANSITION_REASON_AUTO && cachedMediaItem != null) { + scrobbler.scrobble(cachedMediaItem?.toTrack(), true) + } + cachedMediaItem = mediaItem + publishPlaybackState() + } + + /* + * If the same item is contained in a playlist multiple times directly after each + * other, Media3 on emits a PositionDiscontinuity event. + * Can be removed if https://github.com/androidx/media/issues/68 is fixed. + */ + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + playerStateChangedHandler() + publishPlaybackState() + } + + override fun onPlayerError(error: PlaybackException) { + Timber.w(error.toString()) + if (!isJukeboxEnabled) return + + val context = UApp.applicationContext() + mainScope.launch { + Util.toast( + context, + error.errorCode, + false + ) + } + isJukeboxEnabled = false + } + } private var cachedMediaItem: MediaItem? = null fun onCreate(onCreated: () -> Unit) { if (created) return externalStorageMonitor.onCreate { reset() } - isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault - - mediaControllerFuture.addListener({ - controller = mediaControllerFuture.get() - - Timber.i("MediaController Instance received") - - listeners = object : Player.Listener { - - /* - * Log all events - */ - override fun onEvents(player: Player, events: Player.Events) { - for (i in 0 until events.size()) { - Timber.i("Media3 Event, event type: %s", events[i]) - } - } - - /* - * This will be called everytime the playlist has changed. - * We run the event through RxBus in order to throttle them - */ - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack)) - } - - override fun onPlaybackStateChanged(playbackState: Int) { - playerStateChangedHandler() - publishPlaybackState() - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - playerStateChangedHandler() - publishPlaybackState() - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - clearBookmark() - // TRANSITION_REASON_AUTO means that the previous track finished playing and a new one has started. - if (reason == MEDIA_ITEM_TRANSITION_REASON_AUTO && cachedMediaItem != null) { - scrobbler.scrobble(cachedMediaItem?.toTrack(), true) - } - cachedMediaItem = mediaItem - publishPlaybackState() - } - - /* - * If the same item is contained in a playlist multiple times directly after each - * other, Media3 on emits a PositionDiscontinuity event. - * Can be removed if https://github.com/androidx/media/issues/68 is fixed. - */ - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - playerStateChangedHandler() - publishPlaybackState() - } - } - - controller?.addListener(listeners) - - onCreated() - - Timber.i("MediaPlayerController creation complete") - - // controller?.play() - }, MoreExecutors.directExecutor()) + if (activeServerProvider.getActiveServer().jukeboxByDefault) { + switchToJukebox(onCreated) + } else { + switchToLocalPlayer(onCreated) + } rxBusSubscription += RxBus.activeServerChangeObservable.subscribe { // Update the Jukebox state when the active server has changed @@ -200,6 +205,23 @@ class MediaPlayerController( Timber.i("MediaPlayerController started") } + fun onDestroy() { + if (!created) return + + // First stop listening to events + rxBusSubscription.dispose() + controller?.removeListener(listeners) + releaseController() + + // Shutdown the rest + val context = UApp.applicationContext() + externalStorageMonitor.onDestroy() + context.stopService(Intent(context, DownloadService::class.java)) + downloader.onDestroy() + created = false + Timber.i("MediaPlayerController destroyed") + } + private fun playerStateChangedHandler() { val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return @@ -254,22 +276,6 @@ class MediaPlayerController( UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, true) } - fun onDestroy() { - if (!created) return - - // First stop listening to events - rxBusSubscription.dispose() - controller?.removeListener(listeners) - - // Shutdown the rest - val context = UApp.applicationContext() - externalStorageMonitor.onDestroy() - context.stopService(Intent(context, DownloadService::class.java)) - downloader.onDestroy() - created = false - Timber.i("MediaPlayerController destroyed") - } - @Synchronized fun restore( state: PlaybackState, @@ -291,15 +297,7 @@ class MediaPlayerController( isShufflePlayEnabled = state.shufflePlay if (state.currentPlayingIndex != -1) { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.skip( - state.currentPlayingIndex, - state.currentPlayingPosition / 1000 - ) - } else { - seekTo(state.currentPlayingIndex, state.currentPlayingPosition) - } - + seekTo(state.currentPlayingIndex, state.currentPlayingPosition) prepare() if (autoPlay) { @@ -318,12 +316,8 @@ class MediaPlayerController( @Synchronized fun play() { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.start() - } else { - controller?.prepare() - controller?.play() - } + controller?.prepare() + controller?.play() } @Synchronized @@ -359,26 +353,27 @@ class MediaPlayerController( if (controller?.currentTimeline?.isEmpty != false || index >= controller!!.currentTimeline.windowCount ) return + Timber.i("SeekTo: %s %s", index, position) controller?.seekTo(index, position.toLong()) } + fun seekForward() { + controller?.seekForward() + } + + fun seekBack() { + controller?.seekBack() + } + @Synchronized fun pause() { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.stop() - } else { - controller?.pause() - } + controller?.pause() } @Synchronized fun stop() { - if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.stop() - } else { - controller?.stop() - } + controller?.stop() } @Synchronized @@ -405,8 +400,6 @@ class MediaPlayerController( controller?.addMediaItems(insertAt, mediaItems) - jukeboxMediaPlayer.updatePlaylist() - if (shuffle) isShufflePlayEnabled = true prepare() @@ -423,10 +416,6 @@ class MediaPlayerController( downloader.downloadBackground(filteredSongs, save) } - fun stopJukeboxService() { - jukeboxMediaPlayer.stopJukeboxService() - } - @set:Synchronized var isShufflePlayEnabled: Boolean get() = controller?.shuffleModeEnabled == true @@ -468,8 +457,6 @@ class MediaPlayerController( listOf(), -1, 0, isShufflePlayEnabled, repeatMode ) } - - jukeboxMediaPlayer.updatePlaylist() } @Synchronized @@ -495,10 +482,7 @@ class MediaPlayerController( @Synchronized fun removeFromPlaylist(position: Int) { - controller?.removeMediaItem(position) - - jukeboxMediaPlayer.updatePlaylist() } @Synchronized @@ -549,17 +533,17 @@ class MediaPlayerController( @get:Synchronized val playerPosition: Int get() { - return if (jukeboxMediaPlayer.isEnabled) { - jukeboxMediaPlayer.positionSeconds * 1000 - } else { - controller?.currentPosition?.toInt() ?: 0 - } + return controller?.currentPosition?.toInt() ?: 0 } @get:Synchronized val playerDuration: Int get() { - return controller?.duration?.toInt() ?: return 0 + // Media3 will only report a duration when the file is prepared + val reportedDuration = controller?.duration ?: C.TIME_UNSET + if (reportedDuration != C.TIME_UNSET) return reportedDuration.toInt() + // If Media3 doesn't know the duration yet, use the duration in the metadata + return (currentMediaItem?.mediaMetadata?.extras?.getInt("duration") ?: 0) * 1000 } val playbackState: Int @@ -570,21 +554,103 @@ class MediaPlayerController( @set:Synchronized var isJukeboxEnabled: Boolean - get() = jukeboxMediaPlayer.isEnabled + get() = controller is JukeboxMediaPlayer set(jukeboxEnabled) { - jukeboxMediaPlayer.isEnabled = jukeboxEnabled - if (jukeboxEnabled) { - jukeboxMediaPlayer.startJukeboxService() - reset() - - // Cancel current downloads - downloader.clearActiveDownloads() + switchToJukebox {} } else { - jukeboxMediaPlayer.stopJukeboxService() + switchToLocalPlayer {} } } + private fun switchToJukebox(onCreated: () -> Unit) { + if (JukeboxMediaPlayer.running.get()) return + val currentPlaylist = playlist + val currentIndex = controller?.currentMediaItemIndex ?: 0 + val currentPosition = controller?.currentPosition ?: 0 + downloader.clearActiveDownloads() + controller?.pause() + controller?.stop() + val oldController = controller + controller = null // While we switch, the controller shouldn't be available + + // Stop() won't work if we don't give it time to be processed + Handler(Looper.getMainLooper()).postDelayed({ + if (oldController != null) releaseLocalPlayer(oldController) + setupJukebox { + controller?.addMediaItems(0, currentPlaylist) + controller?.seekTo(currentIndex, currentPosition) + onCreated() + } + }, CONTROLLER_SWITCH_DELAY) + } + + private fun switchToLocalPlayer(onCreated: () -> Unit) { + val currentPlaylist = playlist + val currentIndex = controller?.currentMediaItemIndex ?: 0 + val currentPosition = controller?.currentPosition ?: 0 + controller?.stop() + val oldController = controller + controller = null // While we switch, the controller shouldn't be available + + Handler(Looper.getMainLooper()).postDelayed({ + if (oldController != null) releaseJukebox(oldController) + setupLocalPlayer { + controller?.addMediaItems(0, currentPlaylist) + controller?.seekTo(currentIndex, currentPosition) + onCreated() + } + }, CONTROLLER_SWITCH_DELAY) + } + + private fun releaseController() { + when (controller) { + null -> return + is JukeboxMediaPlayer -> releaseJukebox(controller) + is MediaController -> releaseLocalPlayer(controller) + } + } + + private fun setupLocalPlayer(onCreated: () -> Unit) { + mediaControllerFuture = MediaController.Builder( + context, + sessionToken + ).buildAsync() + + mediaControllerFuture?.addListener({ + controller = mediaControllerFuture?.get() + + Timber.i("MediaController Instance received") + controller?.addListener(listeners) + onCreated() + Timber.i("MediaPlayerController creation complete") + }, MoreExecutors.directExecutor()) + } + + private fun releaseLocalPlayer(player: Player?) { + player?.removeListener(listeners) + player?.release() + if (mediaControllerFuture != null) MediaController.releaseFuture(mediaControllerFuture!!) + Timber.i("MediaPlayerController released") + } + + private fun setupJukebox(onCreated: () -> Unit) { + val jukeboxFuture = JukeboxMediaPlayer.requestStart() + jukeboxFuture?.addListener({ + controller = jukeboxFuture.get() + onCreated() + controller?.addListener(listeners) + Timber.i("JukeboxService creation complete") + }, MoreExecutors.directExecutor()) + } + + private fun releaseJukebox(player: Player?) { + val jukebox = player as JukeboxMediaPlayer? + jukebox?.removeListener(listeners) + jukebox?.requestStop() + Timber.i("JukeboxService released") + } + /** * This function calls the music service directly and * therefore can't be called from the main thread @@ -600,21 +666,26 @@ class MediaPlayerController( return false } - fun adjustJukeboxVolume(up: Boolean) { - jukeboxMediaPlayer.adjustVolume(up) + fun adjustVolume(up: Boolean) { + val delta = if (up) VOLUME_DELTA else -VOLUME_DELTA + var gain = controller?.volume ?: return + gain += delta + gain = gain.coerceAtLeast(0.0f) + gain = gain.coerceAtMost(1.0f) + controller?.volume = gain } fun setVolume(volume: Float) { controller?.volume = volume } - fun toggleSongStarred() { - if (currentMediaItem == null) return + fun toggleSongStarred(): ListenableFuture? { + if (currentMediaItem == null) return null val song = currentMediaItem!!.toTrack() - controller?.setRating( + return (controller as? MediaController)?.setRating( HeartRating(!song.starred) - ).let { + )?.let { Futures.addCallback( it, object : FutureCallback { @@ -635,6 +706,7 @@ class MediaPlayerController( }, MainThreadExecutor() ) + it } } @@ -672,6 +744,10 @@ class MediaPlayerController( return getPlayList(false) } + fun getMediaItemAt(index: Int): MediaItem? { + return controller?.getMediaItemAt(index) + } + val playlistInPlayOrder: List get() { return getPlayList(controller?.shuffleModeEnabled ?: false) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt new file mode 100644 index 00000000..5de98ca6 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaylistTimeline.kt @@ -0,0 +1,136 @@ +/* + * PlaylistTimeline.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.Assertions +import androidx.media3.common.util.Util +import com.google.common.collect.ImmutableList +import java.util.Arrays + +/** + * This class wraps a simple playlist provided as List + * to be usable as a Media3 Timeline. + */ +class PlaylistTimeline @JvmOverloads constructor( + mediaItems: List, + shuffledIndices: IntArray = createUnshuffledIndices( + mediaItems.size + ) +) : + Timeline() { + private val mediaItems: ImmutableList + private val shuffledIndices: IntArray + private val indicesInShuffled: IntArray + override fun getWindowCount(): Int { + return mediaItems.size + } + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long + ): Window { + window[ + 0, mediaItems[windowIndex], null, 0, 0, 0, true, false, null, 0, Util.msToUs( + DEFAULT_DURATION_MS + ), windowIndex, windowIndex + ] = + 0 + window.isPlaceholder = false + return window + } + + override fun getNextWindowIndex( + windowIndex: Int, + repeatMode: @Player.RepeatMode Int, + shuffleModeEnabled: Boolean + ): Int { + if (repeatMode == Player.REPEAT_MODE_ONE) { + return windowIndex + } + if (windowIndex == getLastWindowIndex(shuffleModeEnabled)) { + return if (repeatMode == Player.REPEAT_MODE_ALL) getFirstWindowIndex(shuffleModeEnabled) + else C.INDEX_UNSET + } + return if (shuffleModeEnabled) shuffledIndices[indicesInShuffled[windowIndex] + 1] + else windowIndex + 1 + } + + override fun getPreviousWindowIndex( + windowIndex: Int, + repeatMode: @Player.RepeatMode Int, + shuffleModeEnabled: Boolean + ): Int { + if (repeatMode == Player.REPEAT_MODE_ONE) { + return windowIndex + } + if (windowIndex == getFirstWindowIndex(shuffleModeEnabled)) { + return if (repeatMode == Player.REPEAT_MODE_ALL) getLastWindowIndex(shuffleModeEnabled) + else C.INDEX_UNSET + } + return if (shuffleModeEnabled) shuffledIndices[indicesInShuffled[windowIndex] - 1] + else windowIndex - 1 + } + + override fun getLastWindowIndex(shuffleModeEnabled: Boolean): Int { + if (isEmpty) { + return C.INDEX_UNSET + } + return if (shuffleModeEnabled) shuffledIndices[windowCount - 1] else windowCount - 1 + } + + override fun getFirstWindowIndex(shuffleModeEnabled: Boolean): Int { + if (isEmpty) { + return C.INDEX_UNSET + } + return if (shuffleModeEnabled) shuffledIndices[0] else 0 + } + + override fun getPeriodCount(): Int { + return windowCount + } + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + period[null, null, periodIndex, Util.msToUs(DEFAULT_DURATION_MS)] = + 0 + return period + } + + override fun getIndexOfPeriod(uid: Any): Int { + throw UnsupportedOperationException() + } + + override fun getUidOfPeriod(periodIndex: Int): Any { + throw UnsupportedOperationException() + } + + companion object { + private const val DEFAULT_DURATION_MS: Long = 100 + private fun createUnshuffledIndices(length: Int): IntArray { + val indices = IntArray(length) + for (i in 0 until length) { + indices[i] = i + } + return indices + } + } + + init { + Assertions.checkState(mediaItems.size == shuffledIndices.size) + this.mediaItems = ImmutableList.copyOf(mediaItems) + this.shuffledIndices = Arrays.copyOf(shuffledIndices, shuffledIndices.size) + indicesInShuffled = IntArray(shuffledIndices.size) + for (i in shuffledIndices.indices) { + indicesInShuffled[shuffledIndices[i]] = i + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 558d2959..4873efd4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -9,8 +9,10 @@ package org.moire.ultrasonic.util import android.annotation.SuppressLint import android.app.Activity +import android.app.PendingIntent import android.content.ContentResolver import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -40,6 +42,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import org.moire.ultrasonic.R +import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.app.UApp.Companion.applicationContext import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.MusicDirectory @@ -652,6 +655,18 @@ object Util { ) } + fun getPendingIntentToShowPlayer(context: Context): PendingIntent { + val intent = Intent(context, NavigationActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // needed starting Android 12 (S = 31) + flags = flags or PendingIntent.FLAG_IMMUTABLE + } + intent.putExtra(Constants.INTENT_SHOW_PLAYER, true) + return PendingIntent.getActivity(context, 0, intent, flags) + } + private val connectivityManager: ConnectivityManager get() = appContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager