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