694 lines
20 KiB
Kotlin
694 lines
20 KiB
Kotlin
/*
|
|
* MediaPlayerController.kt
|
|
* Copyright (C) 2009-2021 Ultrasonic developers
|
|
*
|
|
* Distributed under terms of the GNU GPLv3 license.
|
|
*/
|
|
package org.moire.ultrasonic.service
|
|
|
|
import android.content.ComponentName
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import androidx.core.net.toUri
|
|
import androidx.media3.common.MediaItem
|
|
import androidx.media3.common.MediaMetadata
|
|
import androidx.media3.common.Player
|
|
import androidx.media3.common.Player.STATE_BUFFERING
|
|
import androidx.media3.common.Timeline
|
|
import androidx.media3.session.MediaController
|
|
import androidx.media3.session.SessionToken
|
|
import com.google.common.util.concurrent.MoreExecutors
|
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|
import org.koin.core.component.KoinComponent
|
|
import org.koin.core.component.inject
|
|
import org.moire.ultrasonic.app.UApp
|
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
|
import org.moire.ultrasonic.domain.PlayerState
|
|
import org.moire.ultrasonic.domain.Track
|
|
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
|
import org.moire.ultrasonic.playback.PlaybackService
|
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
|
import org.moire.ultrasonic.service.DownloadService.Companion.getInstance
|
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
|
import org.moire.ultrasonic.util.FileUtil
|
|
import org.moire.ultrasonic.util.Settings
|
|
import timber.log.Timber
|
|
|
|
/**
|
|
* The implementation of the Media Player Controller.
|
|
* This class contains everything that is necessary for the Application UI
|
|
* to control the Media Player implementation.
|
|
*/
|
|
@Suppress("TooManyFunctions")
|
|
class MediaPlayerController(
|
|
private val playbackStateSerializer: PlaybackStateSerializer,
|
|
private val externalStorageMonitor: ExternalStorageMonitor,
|
|
private val downloader: Downloader,
|
|
private val legacyPlaylistManager: LegacyPlaylistManager,
|
|
val context: Context
|
|
) : KoinComponent {
|
|
|
|
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 sessionToken =
|
|
SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
|
|
|
private var mediaControllerFuture = MediaController.Builder(
|
|
context,
|
|
sessionToken
|
|
).buildAsync()
|
|
|
|
var controller: MediaController? = null
|
|
|
|
fun onCreate() {
|
|
if (created) return
|
|
externalStorageMonitor.onCreate { reset() }
|
|
isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault
|
|
|
|
mediaControllerFuture.addListener({
|
|
controller = mediaControllerFuture.get()
|
|
|
|
controller?.addListener(object : Player.Listener {
|
|
/*
|
|
* This will be called everytime the playlist has changed.
|
|
*/
|
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
|
legacyPlaylistManager.rebuildPlaylist(controller!!)
|
|
}
|
|
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
translatePlaybackState(playbackState = playbackState)
|
|
playerStateChangedHandler()
|
|
publishPlaybackState()
|
|
}
|
|
|
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
translatePlaybackState(isPlaying = isPlaying)
|
|
playerStateChangedHandler()
|
|
publishPlaybackState()
|
|
}
|
|
|
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
onTrackCompleted()
|
|
legacyPlaylistManager.updateCurrentPlaying(mediaItem)
|
|
publishPlaybackState()
|
|
}
|
|
})
|
|
|
|
// controller?.play()
|
|
}, MoreExecutors.directExecutor())
|
|
|
|
rxBusSubscription += RxBus.activeServerChangeObservable.subscribe {
|
|
// Update the Jukebox state when the active server has changed
|
|
isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault
|
|
}
|
|
|
|
created = true
|
|
Timber.i("MediaPlayerController created")
|
|
}
|
|
|
|
@Suppress("DEPRECATION")
|
|
fun translatePlaybackState(
|
|
playbackState: Int = controller?.playbackState ?: 0,
|
|
isPlaying: Boolean = controller?.isPlaying ?: false
|
|
) {
|
|
legacyPlayerState = when (playbackState) {
|
|
STATE_BUFFERING -> PlayerState.DOWNLOADING
|
|
Player.STATE_ENDED -> {
|
|
PlayerState.COMPLETED
|
|
}
|
|
Player.STATE_IDLE -> {
|
|
PlayerState.IDLE
|
|
}
|
|
Player.STATE_READY -> {
|
|
if (isPlaying) {
|
|
PlayerState.STARTED
|
|
} else {
|
|
PlayerState.PAUSED
|
|
}
|
|
}
|
|
else -> {
|
|
PlayerState.IDLE
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun playerStateChangedHandler() {
|
|
|
|
val playerState = legacyPlayerState
|
|
val currentPlaying = legacyPlaylistManager.currentPlaying
|
|
|
|
when {
|
|
playerState === PlayerState.PAUSED -> {
|
|
playbackStateSerializer.serialize(
|
|
playList, currentMediaItemIndex, playerPosition
|
|
)
|
|
}
|
|
playerState === PlayerState.STARTED -> {
|
|
scrobbler.scrobble(currentPlaying, false)
|
|
}
|
|
playerState === PlayerState.COMPLETED -> {
|
|
scrobbler.scrobble(currentPlaying, true)
|
|
}
|
|
}
|
|
|
|
// Update widget
|
|
if (currentPlaying != null) {
|
|
updateWidget(playerState, currentPlaying.track)
|
|
}
|
|
|
|
Timber.d("Processed player state change")
|
|
}
|
|
|
|
private fun onTrackCompleted() {
|
|
// This method is called before we update the currentPlaying,
|
|
// so in fact currentPlaying will refer to the track that has just finished.
|
|
if (legacyPlaylistManager.currentPlaying != null) {
|
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
|
if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
|
|
val musicService = getMusicService()
|
|
try {
|
|
musicService.deleteBookmark(song.id)
|
|
} catch (ignored: Exception) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun publishPlaybackState() {
|
|
RxBus.playerStatePublisher.onNext(
|
|
RxBus.StateWithTrack(
|
|
state = legacyPlayerState,
|
|
track = legacyPlaylistManager.currentPlaying,
|
|
index = currentMediaItemIndex
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun updateWidget(playerState: PlayerState, song: Track?) {
|
|
val started = playerState === PlayerState.STARTED
|
|
val context = UApp.applicationContext()
|
|
|
|
UltrasonicAppWidgetProvider4X1.instance?.notifyChange(context, song, started, false)
|
|
UltrasonicAppWidgetProvider4X2.instance?.notifyChange(context, song, started, true)
|
|
UltrasonicAppWidgetProvider4X3.instance?.notifyChange(context, song, started, false)
|
|
UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, started, false)
|
|
}
|
|
|
|
fun onDestroy() {
|
|
if (!created) return
|
|
val context = UApp.applicationContext()
|
|
externalStorageMonitor.onDestroy()
|
|
context.stopService(Intent(context, DownloadService::class.java))
|
|
legacyPlaylistManager.onDestroy()
|
|
downloader.onDestroy()
|
|
created = false
|
|
Timber.i("MediaPlayerController destroyed")
|
|
}
|
|
|
|
@Synchronized
|
|
fun restore(
|
|
songs: List<Track?>?,
|
|
currentPlayingIndex: Int,
|
|
currentPlayingPosition: Int,
|
|
autoPlay: Boolean,
|
|
newPlaylist: Boolean
|
|
) {
|
|
addToPlaylist(
|
|
songs,
|
|
cachePermanently = false,
|
|
autoPlay = false,
|
|
playNext = false,
|
|
shuffle = false,
|
|
newPlaylist = newPlaylist
|
|
)
|
|
|
|
if (currentPlayingIndex != -1) {
|
|
if (jukeboxMediaPlayer.isEnabled) {
|
|
jukeboxMediaPlayer.skip(
|
|
currentPlayingIndex,
|
|
currentPlayingPosition / 1000
|
|
)
|
|
} else {
|
|
seekTo(currentPlayingIndex, currentPlayingPosition)
|
|
}
|
|
|
|
if (autoPlay) {
|
|
prepare()
|
|
play()
|
|
}
|
|
|
|
autoPlayStart = false
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
fun preload() {
|
|
getInstance()
|
|
}
|
|
|
|
@Synchronized
|
|
fun play(index: Int) {
|
|
controller?.seekTo(index, 0L)
|
|
controller?.play()
|
|
}
|
|
|
|
@Synchronized
|
|
fun play() {
|
|
if (jukeboxMediaPlayer.isEnabled) {
|
|
jukeboxMediaPlayer.start()
|
|
} else {
|
|
controller?.play()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
fun prepare() {
|
|
controller?.prepare()
|
|
}
|
|
|
|
@Synchronized
|
|
fun resumeOrPlay() {
|
|
controller?.play()
|
|
}
|
|
|
|
@Synchronized
|
|
fun togglePlayPause() {
|
|
if (playbackState == Player.STATE_IDLE) autoPlayStart = true
|
|
if (controller?.isPlaying == false) {
|
|
controller?.pause()
|
|
} else {
|
|
controller?.play()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
fun seekTo(position: Int) {
|
|
controller?.seekTo(position.toLong())
|
|
}
|
|
|
|
@Synchronized
|
|
fun seekTo(index: Int, position: Int) {
|
|
controller?.seekTo(index, position.toLong())
|
|
}
|
|
|
|
@Synchronized
|
|
fun pause() {
|
|
if (jukeboxMediaPlayer.isEnabled) {
|
|
jukeboxMediaPlayer.stop()
|
|
} else {
|
|
controller?.pause()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
fun stop() {
|
|
if (jukeboxMediaPlayer.isEnabled) {
|
|
jukeboxMediaPlayer.stop()
|
|
} else {
|
|
controller?.stop()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
@Deprecated("Use InsertionMode Syntax")
|
|
@Suppress("LongParameterList")
|
|
fun addToPlaylist(
|
|
songs: List<Track?>?,
|
|
cachePermanently: Boolean,
|
|
autoPlay: Boolean,
|
|
playNext: Boolean,
|
|
shuffle: Boolean,
|
|
newPlaylist: Boolean
|
|
) {
|
|
if (songs == null) return
|
|
|
|
val insertionMode = when {
|
|
newPlaylist -> InsertionMode.CLEAR
|
|
playNext -> InsertionMode.AFTER_CURRENT
|
|
else -> InsertionMode.APPEND
|
|
}
|
|
|
|
val filteredSongs = songs.filterNotNull()
|
|
|
|
addToPlaylist(
|
|
filteredSongs, cachePermanently, autoPlay, shuffle, insertionMode
|
|
)
|
|
}
|
|
|
|
@Synchronized
|
|
fun addToPlaylist(
|
|
songs: List<Track>,
|
|
cachePermanently: Boolean,
|
|
autoPlay: Boolean,
|
|
shuffle: Boolean,
|
|
insertionMode: InsertionMode
|
|
) {
|
|
var insertAt = 0
|
|
|
|
if (insertionMode == InsertionMode.CLEAR) {
|
|
clear()
|
|
}
|
|
|
|
when (insertionMode) {
|
|
InsertionMode.CLEAR -> clear()
|
|
InsertionMode.APPEND -> insertAt = mediaItemCount
|
|
InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex + 1
|
|
}
|
|
|
|
val mediaItems: List<MediaItem> = songs.map {
|
|
val downloadFile = downloader.getDownloadFileForSong(it)
|
|
if (cachePermanently) downloadFile.shouldSave = true
|
|
val result = it.toMediaItem()
|
|
legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it))
|
|
result
|
|
}
|
|
|
|
controller?.addMediaItems(insertAt, mediaItems)
|
|
|
|
jukeboxMediaPlayer.updatePlaylist()
|
|
|
|
if (shuffle) isShufflePlayEnabled = true
|
|
|
|
if (autoPlay) {
|
|
prepare()
|
|
play(0)
|
|
} else {
|
|
downloader.checkDownloads()
|
|
}
|
|
|
|
playbackStateSerializer.serialize(
|
|
legacyPlaylistManager.playlist,
|
|
currentMediaItemIndex,
|
|
playerPosition
|
|
)
|
|
}
|
|
|
|
@Synchronized
|
|
fun downloadBackground(songs: List<Track?>?, save: Boolean) {
|
|
if (songs == null) return
|
|
val filteredSongs = songs.filterNotNull()
|
|
downloader.downloadBackground(filteredSongs, save)
|
|
|
|
playbackStateSerializer.serialize(
|
|
legacyPlaylistManager.playlist,
|
|
currentMediaItemIndex,
|
|
playerPosition
|
|
)
|
|
}
|
|
|
|
fun stopJukeboxService() {
|
|
jukeboxMediaPlayer.stopJukeboxService()
|
|
}
|
|
|
|
@set:Synchronized
|
|
var isShufflePlayEnabled: Boolean
|
|
get() = controller?.shuffleModeEnabled == true
|
|
set(enabled) {
|
|
controller?.shuffleModeEnabled = enabled
|
|
if (enabled) {
|
|
downloader.checkDownloads()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
fun toggleShuffle(): Boolean {
|
|
isShufflePlayEnabled = !isShufflePlayEnabled
|
|
return isShufflePlayEnabled
|
|
}
|
|
|
|
val bufferedPercentage: Int
|
|
get() = controller?.bufferedPercentage ?: 0
|
|
|
|
@Synchronized
|
|
fun moveItemInPlaylist(oldPos: Int, newPos: Int) {
|
|
controller?.moveMediaItem(oldPos, newPos)
|
|
}
|
|
|
|
@set:Synchronized
|
|
var repeatMode: Int
|
|
get() = controller?.repeatMode ?: 0
|
|
set(newMode) {
|
|
controller?.repeatMode = newMode
|
|
}
|
|
|
|
@Synchronized
|
|
@JvmOverloads
|
|
fun clear(serialize: Boolean = true) {
|
|
|
|
controller?.clearMediaItems()
|
|
|
|
if (controller != null && serialize) {
|
|
playbackStateSerializer.serialize(
|
|
listOf(), -1, 0
|
|
)
|
|
}
|
|
|
|
jukeboxMediaPlayer.updatePlaylist()
|
|
}
|
|
|
|
@Synchronized
|
|
fun clearCaches() {
|
|
downloader.clearDownloadFileCache()
|
|
}
|
|
|
|
@Synchronized
|
|
fun clearIncomplete() {
|
|
reset()
|
|
|
|
downloader.clearActiveDownloads()
|
|
downloader.clearBackground()
|
|
|
|
playbackStateSerializer.serialize(
|
|
legacyPlaylistManager.playlist,
|
|
currentMediaItemIndex,
|
|
playerPosition
|
|
)
|
|
|
|
jukeboxMediaPlayer.updatePlaylist()
|
|
}
|
|
|
|
@Synchronized
|
|
fun removeFromPlaylist(position: Int) {
|
|
|
|
controller?.removeMediaItem(position)
|
|
|
|
playbackStateSerializer.serialize(
|
|
legacyPlaylistManager.playlist,
|
|
currentMediaItemIndex,
|
|
playerPosition
|
|
)
|
|
|
|
jukeboxMediaPlayer.updatePlaylist()
|
|
}
|
|
|
|
@Synchronized
|
|
// TODO: Make it require not null
|
|
fun delete(songs: List<Track?>) {
|
|
for (song in songs.filterNotNull()) {
|
|
downloader.getDownloadFileForSong(song).delete()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
// TODO: Make it require not null
|
|
fun unpin(songs: List<Track?>) {
|
|
for (song in songs.filterNotNull()) {
|
|
downloader.getDownloadFileForSong(song).unpin()
|
|
}
|
|
}
|
|
|
|
@Synchronized
|
|
fun previous() {
|
|
controller?.seekToPrevious()
|
|
}
|
|
|
|
@Synchronized
|
|
operator fun next() {
|
|
controller?.seekToNext()
|
|
}
|
|
|
|
@Synchronized
|
|
fun reset() {
|
|
controller?.clearMediaItems()
|
|
}
|
|
|
|
@get:Synchronized
|
|
val playerPosition: Int
|
|
get() {
|
|
return if (jukeboxMediaPlayer.isEnabled) {
|
|
jukeboxMediaPlayer.positionSeconds * 1000
|
|
} else {
|
|
controller?.currentPosition?.toInt() ?: 0
|
|
}
|
|
}
|
|
|
|
@get:Synchronized
|
|
val playerDuration: Int
|
|
get() {
|
|
return controller?.duration?.toInt() ?: return 0
|
|
}
|
|
|
|
@Deprecated("Use Controller.playbackState and Controller.isPlaying")
|
|
@set:Synchronized
|
|
var legacyPlayerState: PlayerState = PlayerState.IDLE
|
|
|
|
val playbackState: Int
|
|
get() = controller?.playbackState ?: 0
|
|
|
|
val isPlaying: Boolean
|
|
get() = controller?.isPlaying ?: false
|
|
|
|
@set:Synchronized
|
|
var isJukeboxEnabled: Boolean
|
|
get() = jukeboxMediaPlayer.isEnabled
|
|
set(jukeboxEnabled) {
|
|
jukeboxMediaPlayer.isEnabled = jukeboxEnabled
|
|
|
|
if (jukeboxEnabled) {
|
|
jukeboxMediaPlayer.startJukeboxService()
|
|
reset()
|
|
|
|
// Cancel current downloads
|
|
downloader.clearActiveDownloads()
|
|
} else {
|
|
jukeboxMediaPlayer.stopJukeboxService()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function calls the music service directly and
|
|
* therefore can't be called from the main thread
|
|
*/
|
|
val isJukeboxAvailable: Boolean
|
|
get() {
|
|
try {
|
|
val username = activeServerProvider.getActiveServer().userName
|
|
return getMusicService().getUser(username).jukeboxRole
|
|
} catch (all: Exception) {
|
|
Timber.w(all, "Error getting user information")
|
|
}
|
|
return false
|
|
}
|
|
|
|
fun adjustJukeboxVolume(up: Boolean) {
|
|
jukeboxMediaPlayer.adjustVolume(up)
|
|
}
|
|
|
|
fun setVolume(volume: Float) {
|
|
controller?.volume = volume
|
|
}
|
|
|
|
fun toggleSongStarred() {
|
|
if (legacyPlaylistManager.currentPlaying == null) return
|
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
|
|
|
Thread {
|
|
val musicService = getMusicService()
|
|
try {
|
|
if (song.starred) {
|
|
musicService.unstar(song.id, null, null)
|
|
} else {
|
|
musicService.star(song.id, null, null)
|
|
}
|
|
} catch (all: Exception) {
|
|
Timber.e(all)
|
|
}
|
|
}.start()
|
|
|
|
// Trigger an update
|
|
// TODO Update Metadata of MediaItem...
|
|
// localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying)
|
|
song.starred = !song.starred
|
|
}
|
|
|
|
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
|
fun setSongRating(rating: Int) {
|
|
if (!Settings.useFiveStarRating) return
|
|
if (legacyPlaylistManager.currentPlaying == null) return
|
|
val song = legacyPlaylistManager.currentPlaying!!.track
|
|
song.userRating = rating
|
|
Thread {
|
|
try {
|
|
getMusicService().setRating(song.id, rating)
|
|
} catch (e: Exception) {
|
|
Timber.e(e)
|
|
}
|
|
}.start()
|
|
// TODO this would be better handled with a Rx command
|
|
// updateNotification()
|
|
}
|
|
|
|
val currentMediaItem: MediaItem?
|
|
get() = controller?.currentMediaItem
|
|
|
|
val currentMediaItemIndex: Int
|
|
get() = controller?.currentMediaItemIndex ?: -1
|
|
|
|
@Deprecated("Use currentMediaItem")
|
|
val currentPlayingLegacy: DownloadFile?
|
|
get() = legacyPlaylistManager.currentPlaying
|
|
|
|
val mediaItemCount: Int
|
|
get() = controller?.mediaItemCount ?: 0
|
|
|
|
@Deprecated("Use mediaItemCount")
|
|
val playlistSize: Int
|
|
get() = legacyPlaylistManager.playlist.size
|
|
|
|
@Deprecated("Use native APIs")
|
|
val playList: List<DownloadFile>
|
|
get() = legacyPlaylistManager.playlist
|
|
|
|
@Deprecated("Use timeline")
|
|
val playListDuration: Long
|
|
get() = legacyPlaylistManager.playlistDuration
|
|
|
|
fun getDownloadFileForSong(song: Track): DownloadFile {
|
|
return downloader.getDownloadFileForSong(song)
|
|
}
|
|
|
|
init {
|
|
Timber.i("MediaPlayerController constructed")
|
|
}
|
|
|
|
enum class InsertionMode {
|
|
CLEAR, APPEND, AFTER_CURRENT
|
|
}
|
|
}
|
|
|
|
fun Track.toMediaItem(): MediaItem {
|
|
|
|
val filePath = FileUtil.getSongFile(this)
|
|
val bitrate = Settings.maxBitRate
|
|
val uri = "$id|$bitrate|$filePath"
|
|
|
|
val metadata = MediaMetadata.Builder()
|
|
metadata.setTitle(title)
|
|
.setArtist(artist)
|
|
.setAlbumTitle(album)
|
|
.setMediaUri(uri.toUri())
|
|
.setAlbumArtist(artist)
|
|
|
|
val mediaItem = MediaItem.Builder()
|
|
.setUri(uri)
|
|
.setMediaId(id)
|
|
.setMediaMetadata(metadata.build())
|
|
|
|
return mediaItem.build()
|
|
}
|