/* * 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 android.widget.Toast import androidx.core.net.toUri import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.session.MediaController 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.MoreExecutors import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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.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.MusicServiceFactory.getMusicService import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.MainThreadExecutor 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 mainScope = CoroutineScope(Dispatchers.Main) private var sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) private var mediaControllerFuture = MediaController.Builder( context, sessionToken ).buildAsync() var controller: MediaController? = null private lateinit var listeners: Player.Listener 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) { legacyPlaylistManager.rebuildPlaylist(controller!!) } override fun onPlaybackStateChanged(playbackState: Int) { playerStateChangedHandler() publishPlaybackState() } override fun onIsPlayingChanged(isPlaying: Boolean) { playerStateChangedHandler() publishPlaybackState() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { onTrackCompleted() legacyPlaylistManager.updateCurrentPlaying(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()) rxBusSubscription += RxBus.activeServerChangeObservable.subscribe { // Update the Jukebox state when the active server has changed isJukeboxEnabled = activeServerProvider.getActiveServer().jukeboxByDefault } rxBusSubscription += RxBus.throttledPlaylistObservable.subscribe { // Even though Rx should launch on the main thread it doesn't always :( mainScope.launch { serializeCurrentSession() } } rxBusSubscription += RxBus.throttledPlayerStateObservable.subscribe { // Even though Rx should launch on the main thread it doesn't always :( mainScope.launch { serializeCurrentSession() } } rxBusSubscription += RxBus.stopCommandObservable.subscribe { // Clear the widget when we stop the service updateWidget(null) } created = true Timber.i("MediaPlayerController started") } private fun playerStateChangedHandler() { val currentPlaying = legacyPlaylistManager.currentPlaying when (playbackState) { Player.STATE_READY -> { if (isPlaying) { scrobbler.scrobble(currentPlaying, false) } } Player.STATE_ENDED -> { scrobbler.scrobble(currentPlaying, true) } } // Update widget if (currentPlaying != null) { updateWidget(currentPlaying.track) } } 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() { val newState = RxBus.StateWithTrack( track = legacyPlaylistManager.currentPlaying, index = currentMediaItemIndex, isPlaying = isPlaying, state = playbackState ) RxBus.playerStatePublisher.onNext(newState) Timber.i("New PlaybackState: %s", newState) } private fun updateWidget(song: Track?) { val context = UApp.applicationContext() UltrasonicAppWidgetProvider4X1.instance?.notifyChange(context, song, isPlaying, false) UltrasonicAppWidgetProvider4X2.instance?.notifyChange(context, song, isPlaying, true) UltrasonicAppWidgetProvider4X3.instance?.notifyChange(context, song, isPlaying, false) UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, false) } 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)) legacyPlaylistManager.onDestroy() downloader.onDestroy() created = false Timber.i("MediaPlayerController destroyed") } @Synchronized fun restore( songs: List, currentPlayingIndex: Int, currentPlayingPosition: Int, autoPlay: Boolean, newPlaylist: Boolean ) { val insertionMode = if (newPlaylist) InsertionMode.CLEAR else InsertionMode.APPEND addToPlaylist( songs, cachePermanently = false, autoPlay = false, shuffle = false, insertionMode = insertionMode ) if (currentPlayingIndex != -1) { if (jukeboxMediaPlayer.isEnabled) { jukeboxMediaPlayer.skip( currentPlayingIndex, currentPlayingPosition / 1000 ) } else { seekTo(currentPlayingIndex, currentPlayingPosition) } prepare() if (autoPlay) { play() } autoPlayStart = false } } @Synchronized fun play(index: Int) { controller?.seekTo(index, 0L) controller?.play() } @Synchronized fun play() { if (jukeboxMediaPlayer.isEnabled) { jukeboxMediaPlayer.start() } else { controller?.prepare() 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 == true) { controller?.pause() } else { controller?.play() } } @Synchronized fun seekTo(position: Int) { Timber.i("SeekTo: %s", position) controller?.seekTo(position.toLong()) } @Synchronized fun seekTo(index: Int, position: Int) { Timber.i("SeekTo: %s %s", index, position) 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 fun addToPlaylist( songs: List, cachePermanently: Boolean, autoPlay: Boolean, shuffle: Boolean, insertionMode: InsertionMode ) { var insertAt = 0 when (insertionMode) { InsertionMode.CLEAR -> clear() InsertionMode.APPEND -> insertAt = mediaItemCount InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex + 1 } val mediaItems: List = 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 prepare() if (autoPlay) { play(0) } } @Synchronized fun downloadBackground(songs: List?, save: Boolean) { if (songs == null) return val filteredSongs = songs.filterNotNull() downloader.downloadBackground(filteredSongs, save) } 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() jukeboxMediaPlayer.updatePlaylist() } @Synchronized fun removeFromPlaylist(position: Int) { controller?.removeMediaItem(position) jukeboxMediaPlayer.updatePlaylist() } @Synchronized private fun serializeCurrentSession() { // Don't serialize invalid sessions if (currentMediaItemIndex == -1) return playbackStateSerializer.serialize( legacyPlaylistManager.playlist, currentMediaItemIndex, playerPosition ) } @Synchronized // TODO: Make it require not null fun delete(songs: List) { for (song in songs.filterNotNull()) { downloader.getDownloadFileForSong(song).delete() } } @Synchronized // TODO: Make it require not null fun unpin(songs: List) { 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 } 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 controller?.setRating( HeartRating(!song.starred) ).let { Futures.addCallback( it, object : FutureCallback { override fun onSuccess(result: SessionResult?) { // Trigger an update // TODO Update Metadata of MediaItem... // localMediaPlayer.setCurrentPlaying(localMediaPlayer.currentPlaying) song.starred = !song.starred } override fun onFailure(t: Throwable) { Toast.makeText( context, "There was an error updating the rating", Toast.LENGTH_SHORT ).show() } }, MainThreadExecutor() ) } } @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 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 instance initiated") } 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 rmd = MediaItem.RequestMetadata.Builder() .setMediaUri(uri.toUri()) .build() val metadata = MediaMetadata.Builder() metadata.setTitle(title) .setArtist(artist) .setAlbumTitle(album) .setAlbumArtist(artist) .setUserRating(HeartRating(starred)) .build() val mediaItem = MediaItem.Builder() .setUri(uri) .setMediaId(id) .setRequestMetadata(rmd) .setMediaMetadata(metadata.build()) return mediaItem.build() }