Merge branch 'fix_jukebox' into 'develop'

Fix Jukebox handling

Closes #798

See merge request ultrasonic/ultrasonic!813
This commit is contained in:
Nite 2022-09-16 16:14:33 +00:00
commit 8a093befbf
13 changed files with 1305 additions and 274 deletions

View File

@ -64,6 +64,13 @@
android:exported="false">
</service>
<service
android:name=".service.JukeboxMediaPlayer"
android:label="Ultrasonic Jukebox Media Player Service"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".playback.PlaybackService"
android:label="@string/common.appname"

View File

@ -270,7 +270,7 @@ class NavigationActivity : AppCompatActivity() {
val isVolumeAdjust = isVolumeDown || isVolumeUp
val isJukebox = mediaPlayerController.isJukeboxEnabled
if (isVolumeAdjust && isJukebox) {
mediaPlayerController.adjustJukeboxVolume(isVolumeUp)
mediaPlayerController.adjustVolume(isVolumeUp)
return true
}
return super.onKeyDown(keyCode, event)
@ -291,7 +291,7 @@ class NavigationActivity : AppCompatActivity() {
}
R.id.menu_exit -> {
setResult(Constants.RESULT_CLOSE_ALL)
mediaPlayerController.stopJukeboxService()
mediaPlayerController.onDestroy()
finish()
exit()
}

View File

@ -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() }

View File

@ -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<SessionResult> {
@ -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()
}
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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>(
MediaPlayerController::class.java
)
private var listeners: MutableList<Player.Listener> = mutableListOf()
private val playlist: MutableList<MediaItem> = 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<KeyEvent>(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<JukeboxMediaPlayer>? = null
@JvmStatic
fun requestStart(): ListenableFuture<JukeboxMediaPlayer>? {
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<MediaItem>) {
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<String> = 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<JukeboxTask>()
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
}
}

View File

@ -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
}
}
}

View File

@ -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<MediaItem>) {
TODO("Not yet implemented")
}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {
TODO("Not yet implemented")
}
override fun setMediaItems(
mediaItems: MutableList<MediaItem>,
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<MediaItem>) {
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")
}
}

View File

@ -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<MediaController>? = 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<SessionResult>? {
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<SessionResult> {
@ -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<MediaItem>
get() {
return getPlayList(controller?.shuffleModeEnabled ?: false)

View File

@ -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<MediaItem>
* to be usable as a Media3 Timeline.
*/
class PlaylistTimeline @JvmOverloads constructor(
mediaItems: List<MediaItem>,
shuffledIndices: IntArray = createUnshuffledIndices(
mediaItems.size
)
) :
Timeline() {
private val mediaItems: ImmutableList<MediaItem>
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
}
}
}

View File

@ -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