mirror of
https://github.com/antebudimir/tempus.git
synced 2026-04-15 16:27:26 +00:00
Merge branch 'eddyizm:development' into development
This commit is contained in:
commit
ea76afee09
16 changed files with 1067 additions and 1140 deletions
|
|
@ -1,579 +1,6 @@
|
||||||
package com.cappielloantonio.tempo.service
|
package com.cappielloantonio.tempo.service
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
|
||||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
import android.app.TaskStackBuilder
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.os.Binder
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.common.*
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
|
||||||
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
|
|
||||||
import androidx.media3.session.*
|
|
||||||
import androidx.media3.session.MediaSession.ControllerInfo
|
|
||||||
import com.cappielloantonio.tempo.R
|
|
||||||
import com.cappielloantonio.tempo.repository.QueueRepository
|
|
||||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
|
||||||
import com.cappielloantonio.tempo.util.AssetLinkUtil
|
|
||||||
import com.cappielloantonio.tempo.util.Constants
|
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil
|
|
||||||
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
|
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil
|
|
||||||
import com.cappielloantonio.tempo.util.Preferences
|
|
||||||
import com.cappielloantonio.tempo.util.ReplayGainUtil
|
|
||||||
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
|
|
||||||
import com.google.common.collect.ImmutableList
|
|
||||||
import com.google.common.util.concurrent.Futures
|
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
|
||||||
|
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class MediaService : MediaLibraryService() {
|
class MediaService : BaseMediaService()
|
||||||
private val TAG = "MediaService"
|
|
||||||
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
|
|
||||||
|
|
||||||
private lateinit var player: ExoPlayer
|
|
||||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
|
||||||
private lateinit var shuffleCommands: List<CommandButton>
|
|
||||||
private lateinit var repeatCommands: List<CommandButton>
|
|
||||||
private lateinit var networkCallback: CustomNetworkCallback
|
|
||||||
lateinit var equalizerManager: EqualizerManager
|
|
||||||
|
|
||||||
private var customLayout = ImmutableList.of<CommandButton>()
|
|
||||||
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
|
|
||||||
private var widgetUpdateScheduled = false
|
|
||||||
private val widgetUpdateRunnable = object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
if (!player.isPlaying) {
|
|
||||||
widgetUpdateScheduled = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
|
||||||
fun getEqualizerManager(): EqualizerManager {
|
|
||||||
return this@MediaService.equalizerManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
|
||||||
"android.media3.session.demo.SHUFFLE_ON"
|
|
||||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
|
||||||
"android.media3.session.demo.SHUFFLE_OFF"
|
|
||||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
|
|
||||||
"android.media3.session.demo.REPEAT_OFF"
|
|
||||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
|
|
||||||
"android.media3.session.demo.REPEAT_ONE"
|
|
||||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
|
|
||||||
"android.media3.session.demo.REPEAT_ALL"
|
|
||||||
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
|
|
||||||
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateMediaItems() {
|
|
||||||
Log.d(TAG, "update items");
|
|
||||||
val n = player.mediaItemCount
|
|
||||||
val k = player.currentMediaItemIndex
|
|
||||||
val current = player.currentPosition
|
|
||||||
val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))}
|
|
||||||
player.clearMediaItems()
|
|
||||||
player.setMediaItems(items, k, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
|
|
||||||
var wasWifi = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
val manager = getSystemService(ConnectivityManager::class.java)
|
|
||||||
val network = manager.activeNetwork
|
|
||||||
val capabilities = manager.getNetworkCapabilities(network)
|
|
||||||
if (capabilities != null)
|
|
||||||
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
|
|
||||||
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
||||||
if (isWifi != wasWifi) {
|
|
||||||
wasWifi = isWifi
|
|
||||||
widgetUpdateHandler.post(Runnable {
|
|
||||||
updateMediaItems()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
initializeCustomCommands()
|
|
||||||
initializePlayer()
|
|
||||||
initializeMediaLibrarySession()
|
|
||||||
restorePlayerFromQueue()
|
|
||||||
initializePlayerListener()
|
|
||||||
initializeEqualizerManager()
|
|
||||||
initializeNetworkListener()
|
|
||||||
|
|
||||||
setPlayer(player)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
|
||||||
return mediaLibrarySession
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
releaseNetworkCallback()
|
|
||||||
equalizerManager.release()
|
|
||||||
stopWidgetUpdates()
|
|
||||||
releasePlayer()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
|
||||||
// Check if the intent is for our custom equalizer binder
|
|
||||||
if (intent?.action == ACTION_BIND_EQUALIZER) {
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
// Otherwise, handle it as a normal MediaLibraryService connection
|
|
||||||
return super.onBind(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
|
|
||||||
|
|
||||||
override fun onConnect(
|
|
||||||
session: MediaSession,
|
|
||||||
controller: ControllerInfo
|
|
||||||
): MediaSession.ConnectionResult {
|
|
||||||
val connectionResult = super.onConnect(session, controller)
|
|
||||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
|
||||||
|
|
||||||
(shuffleCommands + repeatCommands).forEach { commandButton ->
|
|
||||||
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
customLayout = buildCustomLayout(session.player)
|
|
||||||
|
|
||||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
|
||||||
.setAvailableSessionCommands(availableSessionCommands.build())
|
|
||||||
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
|
|
||||||
.setCustomLayout(customLayout)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPostConnect(session: MediaSession, controller: ControllerInfo) {
|
|
||||||
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
|
|
||||||
ignoreFuture(mediaLibrarySession.setCustomLayout(controller, customLayout))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
|
|
||||||
val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0]
|
|
||||||
val repeat = when (player.repeatMode) {
|
|
||||||
Player.REPEAT_MODE_ONE -> repeatCommands[1]
|
|
||||||
Player.REPEAT_MODE_ALL -> repeatCommands[2]
|
|
||||||
else -> repeatCommands[0]
|
|
||||||
}
|
|
||||||
return ImmutableList.of(shuffle, repeat)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCustomCommand(
|
|
||||||
session: MediaSession,
|
|
||||||
controller: ControllerInfo,
|
|
||||||
customCommand: SessionCommand,
|
|
||||||
args: Bundle
|
|
||||||
): ListenableFuture<SessionResult> {
|
|
||||||
when (customCommand.customAction) {
|
|
||||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> player.shuffleModeEnabled = true
|
|
||||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> player.shuffleModeEnabled = false
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
|
|
||||||
val nextMode = when (player.repeatMode) {
|
|
||||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
|
||||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
|
|
||||||
else -> Player.REPEAT_MODE_OFF
|
|
||||||
}
|
|
||||||
player.repeatMode = nextMode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customLayout = librarySessionCallback.buildCustomLayout(player)
|
|
||||||
session.setCustomLayout(customLayout)
|
|
||||||
|
|
||||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAddMediaItems(
|
|
||||||
mediaSession: MediaSession,
|
|
||||||
controller: ControllerInfo,
|
|
||||||
mediaItems: List<MediaItem>
|
|
||||||
): ListenableFuture<List<MediaItem>> {
|
|
||||||
val updatedMediaItems = mediaItems.map { mediaItem ->
|
|
||||||
val mediaMetadata = mediaItem.mediaMetadata
|
|
||||||
|
|
||||||
val newMetadata = mediaMetadata.buildUpon()
|
|
||||||
.setArtist(
|
|
||||||
if (mediaMetadata.artist != null) mediaMetadata.artist
|
|
||||||
else mediaMetadata.extras?.getString("uri") ?: ""
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
mediaItem.buildUpon()
|
|
||||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
|
||||||
.setMediaMetadata(newMetadata)
|
|
||||||
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
return Futures.immediateFuture(updatedMediaItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeCustomCommands() {
|
|
||||||
shuffleCommands = listOf(
|
|
||||||
getShuffleCommandButton(
|
|
||||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY)
|
|
||||||
),
|
|
||||||
getShuffleCommandButton(
|
|
||||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
repeatCommands = listOf(
|
|
||||||
getRepeatCommandButton(
|
|
||||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY)
|
|
||||||
),
|
|
||||||
getRepeatCommandButton(
|
|
||||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY)
|
|
||||||
),
|
|
||||||
getRepeatCommandButton(
|
|
||||||
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
customLayout = ImmutableList.of(shuffleCommands[0], repeatCommands[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializePlayer() {
|
|
||||||
player = ExoPlayer.Builder(this)
|
|
||||||
.setRenderersFactory(getRenderersFactory())
|
|
||||||
.setMediaSourceFactory(getMediaSourceFactory())
|
|
||||||
.setAudioAttributes(AudioAttributes.DEFAULT, true)
|
|
||||||
.setHandleAudioBecomingNoisy(true)
|
|
||||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
|
||||||
.setLoadControl(initializeLoadControl())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
|
|
||||||
player.repeatMode = Preferences.getRepeatMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeEqualizerManager() {
|
|
||||||
equalizerManager = EqualizerManager()
|
|
||||||
val audioSessionId = player.audioSessionId
|
|
||||||
attachEqualizerIfPossible(audioSessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeMediaLibrarySession() {
|
|
||||||
val sessionActivityPendingIntent =
|
|
||||||
TaskStackBuilder.create(this).run {
|
|
||||||
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
|
|
||||||
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaLibrarySession =
|
|
||||||
MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
|
||||||
.setSessionActivity(sessionActivityPendingIntent)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
if (!customLayout.isEmpty()) {
|
|
||||||
mediaLibrarySession.setCustomLayout(customLayout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeNetworkListener() {
|
|
||||||
networkCallback = CustomNetworkCallback()
|
|
||||||
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback)
|
|
||||||
updateMediaItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restorePlayerFromQueue() {
|
|
||||||
if (player.mediaItemCount > 0) return
|
|
||||||
|
|
||||||
val queueRepository = QueueRepository()
|
|
||||||
val storedQueue = queueRepository.media
|
|
||||||
if (storedQueue.isNullOrEmpty()) return
|
|
||||||
|
|
||||||
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
|
|
||||||
if (mediaItems.isEmpty()) return
|
|
||||||
|
|
||||||
val lastIndex = try {
|
|
||||||
queueRepository.lastPlayedMediaIndex
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0
|
|
||||||
}.coerceIn(0, mediaItems.size - 1)
|
|
||||||
|
|
||||||
val lastPosition = try {
|
|
||||||
queueRepository.lastPlayedMediaTimestamp
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0L
|
|
||||||
}.let { if (it < 0L) 0L else it }
|
|
||||||
|
|
||||||
player.setMediaItems(mediaItems, lastIndex, lastPosition)
|
|
||||||
player.prepare()
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializePlayerListener() {
|
|
||||||
player.addListener(object : Player.Listener {
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
||||||
if (mediaItem == null) return
|
|
||||||
|
|
||||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
|
||||||
MediaManager.setLastPlayedTimestamp(mediaItem)
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
|
||||||
Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex);
|
|
||||||
ReplayGainUtil.setReplayGain(player, tracks)
|
|
||||||
val currentMediaItem = player.currentMediaItem
|
|
||||||
if (currentMediaItem != null) {
|
|
||||||
val item = MappingUtil.mapMediaItem(currentMediaItem)
|
|
||||||
if (item.mediaMetadata.extras != null)
|
|
||||||
MediaManager.scrobble(item, false)
|
|
||||||
|
|
||||||
if (player.nextMediaItemIndex == C.INDEX_UNSET)
|
|
||||||
MediaManager.continuousPlay(player.currentMediaItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
|
|
||||||
if (MediaManager.justStarted.get()) {
|
|
||||||
Log.d(TAG, "update shuffle order")
|
|
||||||
MediaManager.justStarted.set(false)
|
|
||||||
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
|
|
||||||
shuffledList.shuffle()
|
|
||||||
val index = shuffledList.indexOf(player.currentMediaItemIndex)
|
|
||||||
// swap current media index to the first index
|
|
||||||
if (index > -1 && shuffledList.isNotEmpty())
|
|
||||||
run { val tmp = shuffledList[0]; shuffledList[0] = shuffledList[index]; shuffledList[index] = tmp}
|
|
||||||
player.shuffleOrder = DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
||||||
if (!isPlaying) {
|
|
||||||
MediaManager.setPlayingPausedTimestamp(
|
|
||||||
player.currentMediaItem,
|
|
||||||
player.currentPosition
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
MediaManager.scrobble(player.currentMediaItem, false)
|
|
||||||
}
|
|
||||||
if (isPlaying) {
|
|
||||||
scheduleWidgetUpdates()
|
|
||||||
} else {
|
|
||||||
stopWidgetUpdates()
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
super.onPlaybackStateChanged(playbackState)
|
|
||||||
if (!player.hasNextMediaItem() &&
|
|
||||||
playbackState == Player.STATE_ENDED &&
|
|
||||||
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
|
|
||||||
) {
|
|
||||||
MediaManager.scrobble(player.currentMediaItem, true)
|
|
||||||
MediaManager.saveChronology(player.currentMediaItem)
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPositionDiscontinuity(
|
|
||||||
oldPosition: Player.PositionInfo,
|
|
||||||
newPosition: Player.PositionInfo,
|
|
||||||
reason: Int
|
|
||||||
) {
|
|
||||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
|
||||||
|
|
||||||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
|
|
||||||
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
|
||||||
MediaManager.scrobble(oldPosition.mediaItem, true)
|
|
||||||
MediaManager.saveChronology(oldPosition.mediaItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
|
||||||
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
|
||||||
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
|
|
||||||
customLayout = librarySessionCallback.buildCustomLayout(player)
|
|
||||||
mediaLibrarySession.setCustomLayout(customLayout)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRepeatModeChanged(repeatMode: Int) {
|
|
||||||
Preferences.setRepeatMode(repeatMode)
|
|
||||||
customLayout = librarySessionCallback.buildCustomLayout(player)
|
|
||||||
mediaLibrarySession.setCustomLayout(customLayout)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
|
||||||
attachEqualizerIfPossible(audioSessionId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (player.isPlaying) {
|
|
||||||
scheduleWidgetUpdates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPlayer(player: Player) {
|
|
||||||
mediaLibrarySession.player = player
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releasePlayer() {
|
|
||||||
player.release()
|
|
||||||
mediaLibrarySession.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseNetworkCallback() {
|
|
||||||
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("PrivateResource")
|
|
||||||
private fun getShuffleCommandButton(sessionCommand: SessionCommand): CommandButton {
|
|
||||||
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
|
|
||||||
return CommandButton.Builder()
|
|
||||||
.setDisplayName(
|
|
||||||
getString(
|
|
||||||
if (isOn) R.string.exo_controls_shuffle_on_description
|
|
||||||
else R.string.exo_controls_shuffle_off_description
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setSessionCommand(sessionCommand)
|
|
||||||
.setIconResId(if (isOn) R.drawable.exo_icon_shuffle_off else R.drawable.exo_icon_shuffle_on)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("PrivateResource")
|
|
||||||
private fun getRepeatCommandButton(sessionCommand: SessionCommand): CommandButton {
|
|
||||||
val icon = when (sessionCommand.customAction) {
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.drawable.exo_icon_repeat_one
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.drawable.exo_icon_repeat_all
|
|
||||||
else -> R.drawable.exo_icon_repeat_off
|
|
||||||
}
|
|
||||||
val description = when (sessionCommand.customAction) {
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description
|
|
||||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description
|
|
||||||
else -> R.string.exo_controls_repeat_off_description
|
|
||||||
}
|
|
||||||
return CommandButton.Builder()
|
|
||||||
.setDisplayName(getString(description))
|
|
||||||
.setSessionCommand(sessionCommand)
|
|
||||||
.setIconResId(icon)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture<SessionResult>) {
|
|
||||||
/* Do nothing. */
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeLoadControl(): DefaultLoadControl {
|
|
||||||
return DefaultLoadControl.Builder()
|
|
||||||
.setBufferDurationsMs(
|
|
||||||
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
|
||||||
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
|
||||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
|
||||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateWidget() {
|
|
||||||
val mi = player.currentMediaItem
|
|
||||||
val title = mi?.mediaMetadata?.title?.toString()
|
|
||||||
?: mi?.mediaMetadata?.extras?.getString("title")
|
|
||||||
val artist = mi?.mediaMetadata?.artist?.toString()
|
|
||||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
|
||||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
|
||||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
|
||||||
val extras = mi?.mediaMetadata?.extras
|
|
||||||
val coverId = extras?.getString("coverArtId")
|
|
||||||
val songLink = extras?.getString("assetLinkSong")
|
|
||||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
|
||||||
val albumLink = extras?.getString("assetLinkAlbum")
|
|
||||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
|
||||||
val artistLink = extras?.getString("assetLinkArtist")
|
|
||||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
|
||||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
|
||||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
|
||||||
WidgetUpdateManager.updateFromState(
|
|
||||||
this,
|
|
||||||
title ?: "",
|
|
||||||
artist ?: "",
|
|
||||||
album ?: "",
|
|
||||||
coverId,
|
|
||||||
player.isPlaying,
|
|
||||||
player.shuffleModeEnabled,
|
|
||||||
player.repeatMode,
|
|
||||||
position,
|
|
||||||
duration,
|
|
||||||
songLink,
|
|
||||||
albumLink,
|
|
||||||
artistLink
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleWidgetUpdates() {
|
|
||||||
if (widgetUpdateScheduled) return
|
|
||||||
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
|
|
||||||
widgetUpdateScheduled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopWidgetUpdates() {
|
|
||||||
if (!widgetUpdateScheduled) return
|
|
||||||
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
|
|
||||||
widgetUpdateScheduled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
|
|
||||||
if (audioSessionId == 0 || audioSessionId == -1) return false
|
|
||||||
val attached = equalizerManager.attachToSession(audioSessionId)
|
|
||||||
if (attached) {
|
|
||||||
val enabled = Preferences.isEqualizerEnabled()
|
|
||||||
equalizerManager.setEnabled(enabled)
|
|
||||||
val bands = equalizerManager.getNumberOfBands()
|
|
||||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
|
||||||
for (i in 0 until bands) {
|
|
||||||
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
|
|
||||||
}
|
|
||||||
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
|
|
||||||
}
|
|
||||||
return attached
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
|
|
||||||
|
|
||||||
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package com.cappielloantonio.tempo.repository;
|
package com.cappielloantonio.tempo.repository;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
import androidx.lifecycle.Observer;
|
||||||
|
|
||||||
import com.cappielloantonio.tempo.App;
|
import com.cappielloantonio.tempo.App;
|
||||||
import com.cappielloantonio.tempo.database.AppDatabase;
|
import com.cappielloantonio.tempo.database.AppDatabase;
|
||||||
|
|
@ -52,6 +55,8 @@ public class QueueRepository {
|
||||||
public MutableLiveData<PlayQueue> getPlayQueue() {
|
public MutableLiveData<PlayQueue> getPlayQueue() {
|
||||||
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
|
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
|
||||||
|
|
||||||
|
Log.d(TAG, "Getting play queue from server...");
|
||||||
|
|
||||||
App.getSubsonicClientInstance(false)
|
App.getSubsonicClientInstance(false)
|
||||||
.getBookmarksClient()
|
.getBookmarksClient()
|
||||||
.getPlayQueue()
|
.getPlayQueue()
|
||||||
|
|
@ -59,12 +64,19 @@ public class QueueRepository {
|
||||||
@Override
|
@Override
|
||||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
|
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
|
||||||
playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue());
|
PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue();
|
||||||
|
Log.d(TAG, "Server returned play queue with " +
|
||||||
|
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
|
||||||
|
playQueue.setValue(serverQueue);
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Server returned no play queue");
|
||||||
|
playQueue.setValue(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||||
|
Log.e(TAG, "Failed to get play queue", t);
|
||||||
playQueue.setValue(null);
|
playQueue.setValue(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -73,18 +85,24 @@ public class QueueRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void savePlayQueue(List<String> ids, String current, long position) {
|
public void savePlayQueue(List<String> ids, String current, long position) {
|
||||||
|
Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current);
|
||||||
|
|
||||||
App.getSubsonicClientInstance(false)
|
App.getSubsonicClientInstance(false)
|
||||||
.getBookmarksClient()
|
.getBookmarksClient()
|
||||||
.savePlayQueue(ids, current, position)
|
.savePlayQueue(ids, current, position)
|
||||||
.enqueue(new Callback<ApiResponse>() {
|
.enqueue(new Callback<ApiResponse>() {
|
||||||
@Override
|
@Override
|
||||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||||
|
if (response.isSuccessful()) {
|
||||||
|
Log.d(TAG, "Play queue saved successfully");
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Play queue save failed with code: " + response.code());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||||
|
Log.e(TAG, "Play queue save failed", t);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -123,10 +141,9 @@ public class QueueRepository {
|
||||||
|
|
||||||
private boolean isMediaInQueue(List<Queue> queue, Child media) {
|
private boolean isMediaInQueue(List<Queue> queue, Child media) {
|
||||||
if (queue == null || media == null) return false;
|
if (queue == null || media == null) return false;
|
||||||
|
|
||||||
return queue.stream().anyMatch(queueItem ->
|
return queue.stream().anyMatch(queueItem ->
|
||||||
queueItem != null && media.getId() != null &&
|
queueItem != null && media.getId() != null &&
|
||||||
queueItem.getId().equals(media.getId())
|
queueItem.getId().equals(media.getId())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,8 +163,8 @@ public class QueueRepository {
|
||||||
List<Child> filteredToAdd = toAdd;
|
List<Child> filteredToAdd = toAdd;
|
||||||
final List<Queue> finalMedia = media;
|
final List<Queue> finalMedia = media;
|
||||||
filteredToAdd = toAdd.stream()
|
filteredToAdd = toAdd.stream()
|
||||||
.filter(child -> !isMediaInQueue(finalMedia, child))
|
.filter(child -> !isMediaInQueue(finalMedia, child))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
for (int i = 0; i < filteredToAdd.size(); i++) {
|
for (int i = 0; i < filteredToAdd.size(); i++) {
|
||||||
Queue queueItem = new Queue(filteredToAdd.get(i));
|
Queue queueItem = new Queue(filteredToAdd.get(i));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,590 @@
|
||||||
|
package com.cappielloantonio.tempo.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.TaskStackBuilder
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.media3.common.*
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
|
||||||
|
import androidx.media3.session.*
|
||||||
|
import androidx.media3.session.MediaSession.ControllerInfo
|
||||||
|
import com.cappielloantonio.tempo.R
|
||||||
|
import com.cappielloantonio.tempo.repository.QueueRepository
|
||||||
|
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
||||||
|
import com.cappielloantonio.tempo.util.*
|
||||||
|
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
|
||||||
|
import com.google.common.collect.ImmutableList
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
open class BaseMediaService : MediaLibraryService() {
|
||||||
|
companion object {
|
||||||
|
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
||||||
|
"android.media3.session.demo.SHUFFLE_ON"
|
||||||
|
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
||||||
|
"android.media3.session.demo.SHUFFLE_OFF"
|
||||||
|
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
|
||||||
|
"android.media3.session.demo.REPEAT_OFF"
|
||||||
|
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
|
||||||
|
"android.media3.session.demo.REPEAT_ONE"
|
||||||
|
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
|
||||||
|
"android.media3.session.demo.REPEAT_ALL"
|
||||||
|
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
|
||||||
|
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
|
||||||
|
}
|
||||||
|
|
||||||
|
protected lateinit var exoplayer: ExoPlayer
|
||||||
|
protected lateinit var mediaLibrarySession: MediaLibrarySession
|
||||||
|
private lateinit var networkCallback: CustomNetworkCallback
|
||||||
|
private lateinit var equalizerManager: EqualizerManager
|
||||||
|
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var widgetUpdateScheduled = false
|
||||||
|
private val widgetUpdateRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val player = mediaLibrarySession.player
|
||||||
|
if (!player.isPlaying) {
|
||||||
|
widgetUpdateScheduled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateWidget(player)
|
||||||
|
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
|
open fun playerInitHook() {
|
||||||
|
initializeExoPlayer()
|
||||||
|
initializeMediaLibrarySession(exoplayer)
|
||||||
|
initializePlayerListener(exoplayer)
|
||||||
|
setPlayer(null, exoplayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback {
|
||||||
|
return CustomMediaLibrarySessionCallback(baseContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMediaItems(player: Player) {
|
||||||
|
Log.d(javaClass.toString(), "update items")
|
||||||
|
val n = player.mediaItemCount
|
||||||
|
val k = player.currentMediaItemIndex
|
||||||
|
val current = player.currentPosition
|
||||||
|
val items = (0..n - 1).map { MappingUtil.mapMediaItem(player.getMediaItemAt(it)) }
|
||||||
|
player.clearMediaItems()
|
||||||
|
player.setMediaItems(items, k, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restorePlayerFromQueue(player: Player) {
|
||||||
|
if (player.mediaItemCount > 0) return
|
||||||
|
|
||||||
|
val queueRepository = QueueRepository()
|
||||||
|
val storedQueue = queueRepository.media
|
||||||
|
if (storedQueue.isNullOrEmpty()) return
|
||||||
|
|
||||||
|
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
|
||||||
|
if (mediaItems.isEmpty()) return
|
||||||
|
|
||||||
|
val lastIndex = try {
|
||||||
|
queueRepository.lastPlayedMediaIndex
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0
|
||||||
|
}.coerceIn(0, mediaItems.size - 1)
|
||||||
|
|
||||||
|
val lastPosition = try {
|
||||||
|
queueRepository.lastPlayedMediaTimestamp
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}.let { if (it < 0L) 0L else it }
|
||||||
|
|
||||||
|
player.setMediaItems(mediaItems, lastIndex, lastPosition)
|
||||||
|
player.prepare()
|
||||||
|
updateWidget(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializePlayerListener(player: Player) {
|
||||||
|
player.addListener(object : Player.Listener {
|
||||||
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
Log.d(javaClass.toString(), "onMediaItemTransition" + player.currentMediaItemIndex)
|
||||||
|
if (mediaItem == null) return
|
||||||
|
|
||||||
|
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
||||||
|
MediaManager.setLastPlayedTimestamp(mediaItem)
|
||||||
|
}
|
||||||
|
updateWidget(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
|
Log.d(javaClass.toString(), "onTracksChanged " + player.currentMediaItemIndex)
|
||||||
|
ReplayGainUtil.setReplayGain(player, tracks)
|
||||||
|
val currentMediaItem = player.currentMediaItem
|
||||||
|
if (currentMediaItem != null) {
|
||||||
|
val item = MappingUtil.mapMediaItem(currentMediaItem)
|
||||||
|
if (item.mediaMetadata.extras != null)
|
||||||
|
MediaManager.scrobble(item, false)
|
||||||
|
|
||||||
|
if (player.nextMediaItemIndex == C.INDEX_UNSET)
|
||||||
|
MediaManager.continuousPlay(player.currentMediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player is ExoPlayer) {
|
||||||
|
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
|
||||||
|
if (MediaManager.justStarted.get()) {
|
||||||
|
Log.d(javaClass.toString(), "update shuffle order")
|
||||||
|
MediaManager.justStarted.set(false)
|
||||||
|
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
|
||||||
|
shuffledList.shuffle()
|
||||||
|
val index = shuffledList.indexOf(player.currentMediaItemIndex)
|
||||||
|
// swap current media index to the first index
|
||||||
|
if (index > -1 && shuffledList.isNotEmpty()) {
|
||||||
|
val tmp = shuffledList[0]
|
||||||
|
shuffledList[0] = shuffledList[index]
|
||||||
|
shuffledList[index] = tmp
|
||||||
|
}
|
||||||
|
player.shuffleOrder =
|
||||||
|
DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
Log.d(javaClass.toString(), "onIsPlayingChanged " + player.currentMediaItemIndex)
|
||||||
|
if (!isPlaying) {
|
||||||
|
MediaManager.setPlayingPausedTimestamp(
|
||||||
|
player.currentMediaItem,
|
||||||
|
player.currentPosition
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
MediaManager.scrobble(player.currentMediaItem, false)
|
||||||
|
}
|
||||||
|
if (isPlaying) {
|
||||||
|
scheduleWidgetUpdates()
|
||||||
|
} else {
|
||||||
|
stopWidgetUpdates()
|
||||||
|
}
|
||||||
|
updateWidget(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
Log.d(javaClass.toString(), "onPlaybackStateChanged")
|
||||||
|
super.onPlaybackStateChanged(playbackState)
|
||||||
|
if (!player.hasNextMediaItem() &&
|
||||||
|
playbackState == Player.STATE_ENDED &&
|
||||||
|
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
|
||||||
|
) {
|
||||||
|
MediaManager.scrobble(player.currentMediaItem, true)
|
||||||
|
MediaManager.saveChronology(player.currentMediaItem)
|
||||||
|
}
|
||||||
|
updateWidget(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPositionDiscontinuity(
|
||||||
|
oldPosition: Player.PositionInfo,
|
||||||
|
newPosition: Player.PositionInfo,
|
||||||
|
reason: Int
|
||||||
|
) {
|
||||||
|
Log.d(javaClass.toString(), "onPositionDiscontinuity")
|
||||||
|
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||||
|
|
||||||
|
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
|
||||||
|
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||||
|
MediaManager.scrobble(oldPosition.mediaItem, true)
|
||||||
|
MediaManager.saveChronology(oldPosition.mediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||||
|
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||||
|
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRepeatModeChanged(repeatMode: Int) {
|
||||||
|
Preferences.setRepeatMode(repeatMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||||
|
Log.d(javaClass.toString(), "onAudioSessionIdChanged")
|
||||||
|
attachEqualizerIfPossible(audioSessionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (player.isPlaying) {
|
||||||
|
scheduleWidgetUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
|
||||||
|
if (oldPlayer === newPlayer) return
|
||||||
|
if (oldPlayer != null) {
|
||||||
|
val currentQueue = getQueueFromPlayer(oldPlayer)
|
||||||
|
val currentIndex = oldPlayer.currentMediaItemIndex
|
||||||
|
val currentPosition = oldPlayer.currentPosition
|
||||||
|
val isPlaying = oldPlayer.playWhenReady
|
||||||
|
oldPlayer.stop()
|
||||||
|
newPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
|
||||||
|
newPlayer.playWhenReady = isPlaying
|
||||||
|
newPlayer.prepare()
|
||||||
|
}
|
||||||
|
mediaLibrarySession.player = newPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun releasePlayers() {
|
||||||
|
exoplayer.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getQueueFromPlayer(player: Player): List<MediaItem> {
|
||||||
|
return (0..player.mediaItemCount - 1).map(player::getMediaItemAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
val player = mediaLibrarySession.player
|
||||||
|
|
||||||
|
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
playerInitHook()
|
||||||
|
initializeEqualizerManager()
|
||||||
|
initializeNetworkListener()
|
||||||
|
restorePlayerFromQueue(mediaLibrarySession.player)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
||||||
|
return mediaLibrarySession
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
releaseNetworkCallback()
|
||||||
|
equalizerManager.release()
|
||||||
|
stopWidgetUpdates()
|
||||||
|
releasePlayers()
|
||||||
|
mediaLibrarySession.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
// Check if the intent is for our custom equalizer binder
|
||||||
|
if (intent?.action == ACTION_BIND_EQUALIZER) {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
// Otherwise, handle it as a normal MediaLibraryService connection
|
||||||
|
return super.onBind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeExoPlayer() {
|
||||||
|
exoplayer = ExoPlayer.Builder(this)
|
||||||
|
.setRenderersFactory(getRenderersFactory())
|
||||||
|
.setMediaSourceFactory(getMediaSourceFactory())
|
||||||
|
.setAudioAttributes(AudioAttributes.DEFAULT, true)
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||||
|
.setLoadControl(initializeLoadControl())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
exoplayer.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
|
||||||
|
exoplayer.repeatMode = Preferences.getRepeatMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeEqualizerManager() {
|
||||||
|
equalizerManager = EqualizerManager()
|
||||||
|
val audioSessionId = exoplayer.audioSessionId
|
||||||
|
attachEqualizerIfPossible(audioSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeMediaLibrarySession(player: Player) {
|
||||||
|
Log.d(javaClass.toString(), "initializeMediaLibrarySession")
|
||||||
|
val sessionActivityPendingIntent =
|
||||||
|
TaskStackBuilder.create(this).run {
|
||||||
|
addNextIntent(Intent(baseContext, MainActivity::class.java))
|
||||||
|
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaLibrarySession =
|
||||||
|
MediaLibrarySession.Builder(this, player, getMediaLibrarySessionCallback())
|
||||||
|
.setSessionActivity(sessionActivityPendingIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeNetworkListener() {
|
||||||
|
networkCallback = CustomNetworkCallback()
|
||||||
|
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(
|
||||||
|
networkCallback
|
||||||
|
)
|
||||||
|
updateMediaItems(mediaLibrarySession.player)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeLoadControl(): DefaultLoadControl {
|
||||||
|
return DefaultLoadControl.Builder()
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||||
|
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||||
|
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||||||
|
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseNetworkCallback() {
|
||||||
|
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWidget(player: Player) {
|
||||||
|
val mi = player.currentMediaItem
|
||||||
|
val title = mi?.mediaMetadata?.title?.toString()
|
||||||
|
?: mi?.mediaMetadata?.extras?.getString("title")
|
||||||
|
val artist = mi?.mediaMetadata?.artist?.toString()
|
||||||
|
?: mi?.mediaMetadata?.extras?.getString("artist")
|
||||||
|
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
||||||
|
?: mi?.mediaMetadata?.extras?.getString("album")
|
||||||
|
val extras = mi?.mediaMetadata?.extras
|
||||||
|
val coverId = extras?.getString("coverArtId")
|
||||||
|
val songLink = extras?.getString("assetLinkSong")
|
||||||
|
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
||||||
|
val albumLink = extras?.getString("assetLinkAlbum")
|
||||||
|
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
||||||
|
val artistLink = extras?.getString("assetLinkArtist")
|
||||||
|
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
||||||
|
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||||
|
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||||
|
WidgetUpdateManager.updateFromState(
|
||||||
|
this,
|
||||||
|
title ?: "",
|
||||||
|
artist ?: "",
|
||||||
|
album ?: "",
|
||||||
|
coverId,
|
||||||
|
player.isPlaying,
|
||||||
|
player.shuffleModeEnabled,
|
||||||
|
player.repeatMode,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
songLink,
|
||||||
|
albumLink,
|
||||||
|
artistLink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWidgetUpdates() {
|
||||||
|
if (widgetUpdateScheduled) return
|
||||||
|
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
|
||||||
|
widgetUpdateScheduled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopWidgetUpdates() {
|
||||||
|
if (!widgetUpdateScheduled) return
|
||||||
|
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
|
||||||
|
widgetUpdateScheduled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
|
||||||
|
if (audioSessionId == 0 || audioSessionId == -1) return false
|
||||||
|
val attached = equalizerManager.attachToSession(audioSessionId)
|
||||||
|
if (attached) {
|
||||||
|
val enabled = Preferences.isEqualizerEnabled()
|
||||||
|
equalizerManager.setEnabled(enabled)
|
||||||
|
val bands = equalizerManager.getNumberOfBands()
|
||||||
|
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
||||||
|
for (i in 0 until bands) {
|
||||||
|
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
|
||||||
|
}
|
||||||
|
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
|
||||||
|
}
|
||||||
|
return attached
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
|
||||||
|
|
||||||
|
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
private class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
|
||||||
|
private val shuffleCommands: List<CommandButton>
|
||||||
|
private val repeatCommands: List<CommandButton>
|
||||||
|
|
||||||
|
constructor(ctx: Context) {
|
||||||
|
shuffleCommands = listOf(
|
||||||
|
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON,
|
||||||
|
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
|
||||||
|
)
|
||||||
|
.map { getShuffleCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
|
||||||
|
repeatCommands = listOf(
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE,
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
|
||||||
|
)
|
||||||
|
.map { getRepeatCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnect(
|
||||||
|
session: MediaSession,
|
||||||
|
controller: ControllerInfo
|
||||||
|
): MediaSession.ConnectionResult {
|
||||||
|
val connectionResult = super.onConnect(session, controller)
|
||||||
|
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||||
|
|
||||||
|
(shuffleCommands + repeatCommands).forEach { commandButton ->
|
||||||
|
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||||
|
.setAvailableSessionCommands(availableSessionCommands.build())
|
||||||
|
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
|
||||||
|
.setMediaButtonPreferences(buildCustomLayout(session.player))
|
||||||
|
.build()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCustomCommand(
|
||||||
|
session: MediaSession,
|
||||||
|
controller: ControllerInfo,
|
||||||
|
customCommand: SessionCommand,
|
||||||
|
args: Bundle
|
||||||
|
): ListenableFuture<SessionResult> {
|
||||||
|
Log.d(javaClass.toString(), "onCustomCommand")
|
||||||
|
when (customCommand.customAction) {
|
||||||
|
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
|
||||||
|
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
|
||||||
|
val nextMode = when (session.player.repeatMode) {
|
||||||
|
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
||||||
|
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
|
||||||
|
else -> Player.REPEAT_MODE_OFF
|
||||||
|
}
|
||||||
|
session.player.repeatMode = nextMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.setMediaButtonPreferences(buildCustomLayout(session.player))
|
||||||
|
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAddMediaItems(
|
||||||
|
mediaSession: MediaSession,
|
||||||
|
controller: ControllerInfo,
|
||||||
|
mediaItems: List<MediaItem>
|
||||||
|
): ListenableFuture<List<MediaItem>> {
|
||||||
|
Log.d(javaClass.toString(), "onAddMediaItems")
|
||||||
|
val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||||
|
val mediaMetadata = mediaItem.mediaMetadata
|
||||||
|
val newMetadata = mediaMetadata.buildUpon()
|
||||||
|
.setArtist(
|
||||||
|
if (mediaMetadata.artist != null) mediaMetadata.artist
|
||||||
|
else mediaMetadata.extras?.getString("uri") ?: ""
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mediaItem.buildUpon()
|
||||||
|
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||||
|
.setMediaMetadata(newMetadata)
|
||||||
|
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return Futures.immediateFuture(updatedMediaItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("PrivateResource")
|
||||||
|
private fun getShuffleCommandButton(
|
||||||
|
sessionCommand: SessionCommand,
|
||||||
|
ctx: Context
|
||||||
|
): CommandButton {
|
||||||
|
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
|
||||||
|
return CommandButton.Builder(if (isOn) CommandButton.ICON_SHUFFLE_OFF else CommandButton.ICON_SHUFFLE_ON)
|
||||||
|
.setSessionCommand(sessionCommand)
|
||||||
|
.setDisplayName(
|
||||||
|
ctx.getString(
|
||||||
|
if (isOn) R.string.exo_controls_shuffle_on_description
|
||||||
|
else R.string.exo_controls_shuffle_off_description
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("PrivateResource")
|
||||||
|
private fun getRepeatCommandButton(
|
||||||
|
sessionCommand: SessionCommand,
|
||||||
|
ctx: Context
|
||||||
|
): CommandButton {
|
||||||
|
val icon = when (sessionCommand.customAction) {
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL
|
||||||
|
else -> CommandButton.ICON_REPEAT_OFF
|
||||||
|
}
|
||||||
|
val description = when (sessionCommand.customAction) {
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description
|
||||||
|
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description
|
||||||
|
else -> R.string.exo_controls_repeat_off_description
|
||||||
|
}
|
||||||
|
return CommandButton.Builder(icon)
|
||||||
|
.setSessionCommand(sessionCommand)
|
||||||
|
.setDisplayName(ctx.getString(description))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
|
||||||
|
val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0]
|
||||||
|
val repeat = when (player.repeatMode) {
|
||||||
|
Player.REPEAT_MODE_ONE -> repeatCommands[1]
|
||||||
|
Player.REPEAT_MODE_ALL -> repeatCommands[2]
|
||||||
|
else -> repeatCommands[0]
|
||||||
|
}
|
||||||
|
return ImmutableList.of(shuffle, repeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
|
||||||
|
var wasWifi = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
val manager = getSystemService(ConnectivityManager::class.java)
|
||||||
|
val network = manager.activeNetwork
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network)
|
||||||
|
if (capabilities != null)
|
||||||
|
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
networkCapabilities: NetworkCapabilities
|
||||||
|
) {
|
||||||
|
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
if (isWifi != wasWifi) {
|
||||||
|
wasWifi = isWifi
|
||||||
|
widgetUpdateHandler.post {
|
||||||
|
updateMediaItems(mediaLibrarySession.player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class LocalBinder : Binder() {
|
||||||
|
fun getEqualizerManager(): EqualizerManager {
|
||||||
|
return equalizerManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
|
||||||
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
package com.cappielloantonio.tempo.service;
|
package com.cappielloantonio.tempo.service;
|
||||||
|
|
||||||
import android.content.ComponentName;
|
import android.content.ComponentName;
|
||||||
import android.util.Log;
|
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.OptIn;
|
import androidx.annotation.OptIn;
|
||||||
import androidx.lifecycle.LifecycleOwner;
|
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.Observer;
|
import androidx.lifecycle.Observer;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
|
|
@ -188,27 +187,28 @@ public class MediaManager {
|
||||||
try {
|
try {
|
||||||
if (mediaBrowserListenableFuture.isDone()) {
|
if (mediaBrowserListenableFuture.isDone()) {
|
||||||
final MediaBrowser browser = mediaBrowserListenableFuture.get();
|
final MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||||
|
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> {
|
||||||
|
justStarted.set(true);
|
||||||
|
browser.setMediaItems(items, startIndex, 0);
|
||||||
|
browser.prepare();
|
||||||
|
|
||||||
|
Player.Listener timelineListener = new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||||
|
int itemCount = browser.getMediaItemCount();
|
||||||
|
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
|
||||||
|
browser.seekTo(startIndex, 0);
|
||||||
|
browser.play();
|
||||||
|
browser.removeListener(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
browser.addListener(timelineListener);
|
||||||
|
});
|
||||||
|
|
||||||
backgroundExecutor.execute(() -> {
|
backgroundExecutor.execute(() -> {
|
||||||
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
|
|
||||||
enqueueDatabase(media, true, 0);
|
enqueueDatabase(media, true, 0);
|
||||||
new Handler(Looper.getMainLooper()).post(() -> {
|
|
||||||
justStarted.set(true);
|
|
||||||
browser.setMediaItems(items, startIndex, 0);
|
|
||||||
browser.prepare();
|
|
||||||
Player.Listener timelineListener = new Player.Listener() {
|
|
||||||
@Override
|
|
||||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
|
||||||
int itemCount = browser.getMediaItemCount();
|
|
||||||
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
|
|
||||||
browser.seekTo(startIndex, 0);
|
|
||||||
browser.play();
|
|
||||||
browser.removeListener(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
browser.addListener(timelineListener);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (ExecutionException | InterruptedException e) {
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ import com.cappielloantonio.tempo.databinding.ItemPlayerQueueSongBinding;
|
||||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||||
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||||
import com.cappielloantonio.tempo.interfaces.MediaIndexCallback;
|
import com.cappielloantonio.tempo.interfaces.MediaIndexCallback;
|
||||||
|
import com.cappielloantonio.tempo.service.DownloaderManager;
|
||||||
import com.cappielloantonio.tempo.service.MediaManager;
|
import com.cappielloantonio.tempo.service.MediaManager;
|
||||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||||
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
|
|
@ -94,6 +96,20 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(holder.itemView.getContext());
|
||||||
|
|
||||||
|
if (downloaderManager != null) {
|
||||||
|
boolean isDownloaded = downloaderManager.isDownloaded(song.getId());
|
||||||
|
|
||||||
|
if (isDownloaded) {
|
||||||
|
holder.item.downloadIndicatorIcon.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
if (Preferences.showItemRating()) {
|
if (Preferences.showItemRating()) {
|
||||||
if (song.getStarred() == null && song.getUserRating() == null) {
|
if (song.getStarred() == null && song.getUserRating() == null) {
|
||||||
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.fragment.app.Fragment
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.cappielloantonio.tempo.R
|
import com.cappielloantonio.tempo.R
|
||||||
import com.cappielloantonio.tempo.service.EqualizerManager
|
import com.cappielloantonio.tempo.service.EqualizerManager
|
||||||
|
import com.cappielloantonio.tempo.service.BaseMediaService
|
||||||
import com.cappielloantonio.tempo.service.MediaService
|
import com.cappielloantonio.tempo.service.MediaService
|
||||||
import com.cappielloantonio.tempo.util.Preferences
|
import com.cappielloantonio.tempo.util.Preferences
|
||||||
|
|
||||||
|
|
@ -35,7 +36,7 @@ class EqualizerFragment : Fragment() {
|
||||||
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
|
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == MediaService.ACTION_EQUALIZER_UPDATED) {
|
if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) {
|
||||||
initUI()
|
initUI()
|
||||||
restoreEqualizerPreferences()
|
restoreEqualizerPreferences()
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +46,7 @@ class EqualizerFragment : Fragment() {
|
||||||
private val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||||
val binder = service as MediaService.LocalBinder
|
val binder = service as BaseMediaService.LocalBinder
|
||||||
equalizerManager = binder.getEqualizerManager()
|
equalizerManager = binder.getEqualizerManager()
|
||||||
initUI()
|
initUI()
|
||||||
restoreEqualizerPreferences()
|
restoreEqualizerPreferences()
|
||||||
|
|
@ -60,14 +61,14 @@ class EqualizerFragment : Fragment() {
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
Intent(requireContext(), MediaService::class.java).also { intent ->
|
Intent(requireContext(), MediaService::class.java).also { intent ->
|
||||||
intent.action = MediaService.ACTION_BIND_EQUALIZER
|
intent.action = BaseMediaService.ACTION_BIND_EQUALIZER
|
||||||
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||||
}
|
}
|
||||||
if (!receiverRegistered) {
|
if (!receiverRegistered) {
|
||||||
ContextCompat.registerReceiver(
|
ContextCompat.registerReceiver(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
equalizerUpdatedReceiver,
|
equalizerUpdatedReceiver,
|
||||||
IntentFilter(MediaService.ACTION_EQUALIZER_UPDATED),
|
IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED),
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
)
|
)
|
||||||
receiverRegistered = true
|
receiverRegistered = true
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,20 @@ package com.cappielloantonio.tempo.ui.fragment;
|
||||||
|
|
||||||
import android.content.ComponentName;
|
import android.content.ComponentName;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
import androidx.lifecycle.Observer;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.media3.session.MediaBrowser;
|
import androidx.media3.session.MediaBrowser;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.session.SessionToken;
|
import androidx.media3.session.SessionToken;
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
|
@ -19,11 +23,17 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding;
|
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding;
|
||||||
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||||
|
import com.cappielloantonio.tempo.service.DownloaderManager;
|
||||||
import com.cappielloantonio.tempo.service.MediaManager;
|
import com.cappielloantonio.tempo.service.MediaManager;
|
||||||
import com.cappielloantonio.tempo.service.MediaService;
|
import com.cappielloantonio.tempo.service.MediaService;
|
||||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||||
|
import com.cappielloantonio.tempo.subsonic.models.PlayQueue;
|
||||||
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
|
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
|
||||||
|
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
@ -31,6 +41,7 @@ import com.google.common.util.concurrent.MoreExecutors;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
|
|
@ -39,6 +50,18 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
|
|
||||||
private InnerFragmentPlayerQueueBinding bind;
|
private InnerFragmentPlayerQueueBinding bind;
|
||||||
|
|
||||||
|
private com.google.android.material.floatingactionbutton.FloatingActionButton fabMenuToggle;
|
||||||
|
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabClearQueue;
|
||||||
|
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabShuffleQueue;
|
||||||
|
|
||||||
|
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveToPlaylist;
|
||||||
|
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabDownloadAll;
|
||||||
|
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabLoadQueue;
|
||||||
|
|
||||||
|
private boolean isMenuOpen = false;
|
||||||
|
private final int ANIMATION_DURATION = 250;
|
||||||
|
private final float FAB_VERTICAL_SPACING_DP = 70f;
|
||||||
|
|
||||||
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
|
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
|
||||||
private PlaybackViewModel playbackViewModel;
|
private PlaybackViewModel playbackViewModel;
|
||||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||||
|
|
@ -53,6 +76,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
|
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
|
||||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||||
|
|
||||||
|
fabMenuToggle = bind.fabMenuToggle;
|
||||||
|
fabClearQueue = bind.fabClearQueue;
|
||||||
|
fabShuffleQueue = bind.fabShuffleQueue;
|
||||||
|
|
||||||
|
fabSaveToPlaylist = bind.fabSaveToPlaylist;
|
||||||
|
fabDownloadAll = bind.fabDownloadAll;
|
||||||
|
fabLoadQueue = bind.fabLoadQueue;
|
||||||
|
|
||||||
|
fabMenuToggle.setOnClickListener(v -> toggleFabMenu());
|
||||||
|
fabClearQueue.setOnClickListener(v -> handleClearQueueClick());
|
||||||
|
fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick());
|
||||||
|
|
||||||
|
fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick());
|
||||||
|
fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick());
|
||||||
|
fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick());
|
||||||
|
|
||||||
|
// Hide Load Queue FAB if sync is disabled
|
||||||
|
if (!Preferences.isSyncronizationEnabled()) {
|
||||||
|
fabLoadQueue.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
initQueueRecyclerView();
|
initQueueRecyclerView();
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
|
|
@ -62,8 +106,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
public void onStart() {
|
public void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
initializeBrowser();
|
initializeBrowser();
|
||||||
bindMediaController();
|
|
||||||
|
|
||||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||||
observePlayback();
|
observePlayback();
|
||||||
}
|
}
|
||||||
|
|
@ -105,18 +147,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
|
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bindMediaController() {
|
|
||||||
mediaBrowserListenableFuture.addListener(() -> {
|
|
||||||
try {
|
|
||||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
|
||||||
initShuffleButton(mediaBrowser);
|
|
||||||
initCleanButton(mediaBrowser);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
exception.printStackTrace();
|
|
||||||
}
|
|
||||||
}, MoreExecutors.directExecutor());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setMediaBrowserListenableFuture() {
|
private void setMediaBrowserListenableFuture() {
|
||||||
playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||||
}
|
}
|
||||||
|
|
@ -149,18 +179,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
|
|
||||||
fromPosition = viewHolder.getBindingAdapterPosition();
|
fromPosition = viewHolder.getBindingAdapterPosition();
|
||||||
toPosition = target.getBindingAdapterPosition();
|
toPosition = target.getBindingAdapterPosition();
|
||||||
|
|
||||||
/*
|
|
||||||
* Per spostare un elemento nella coda devo:
|
|
||||||
* - Spostare graficamente la traccia da una posizione all'altra con Collections.swap()
|
|
||||||
* - Spostare nel db la traccia, tramite QueueRepository
|
|
||||||
* - Notificare il Service dell'avvenuto spostamento con MusicPlayerRemote.moveSong()
|
|
||||||
*
|
|
||||||
* In onMove prendo la posizione di inizio e fine, ma solo al rilascio dell'elemento procedo allo spostamento
|
|
||||||
* In questo modo evito che ad ogni cambio di posizione vada a riscrivere nel db
|
|
||||||
* Al rilascio dell'elemento chiamo il metodo clearView()
|
|
||||||
*/
|
|
||||||
|
|
||||||
Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition);
|
Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition);
|
||||||
recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition);
|
recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition);
|
||||||
|
|
||||||
|
|
@ -188,46 +206,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
}).attachToRecyclerView(bind.playerQueueRecyclerView);
|
}).attachToRecyclerView(bind.playerQueueRecyclerView);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initShuffleButton(MediaBrowser mediaBrowser) {
|
|
||||||
bind.playerShuffleQueueFab.setOnClickListener(view -> {
|
|
||||||
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
|
||||||
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
|
|
||||||
|
|
||||||
if (startPosition < endPosition) {
|
|
||||||
ArrayList<Integer> pool = new ArrayList<>();
|
|
||||||
|
|
||||||
for (int i = startPosition; i <= endPosition; i++) {
|
|
||||||
pool.add(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (pool.size() >= 2) {
|
|
||||||
int fromPosition = (int) (Math.random() * (pool.size()));
|
|
||||||
int positionA = pool.get(fromPosition);
|
|
||||||
pool.remove(fromPosition);
|
|
||||||
|
|
||||||
int toPosition = (int) (Math.random() * (pool.size()));
|
|
||||||
int positionB = pool.get(toPosition);
|
|
||||||
pool.remove(toPosition);
|
|
||||||
|
|
||||||
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
|
|
||||||
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initCleanButton(MediaBrowser mediaBrowser) {
|
|
||||||
bind.playerCleanQueueButton.setOnClickListener(view -> {
|
|
||||||
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
|
||||||
int endPosition = playerSongQueueAdapter.getItems().size();
|
|
||||||
|
|
||||||
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
|
||||||
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateNowPlayingItem() {
|
private void updateNowPlayingItem() {
|
||||||
playerSongQueueAdapter.notifyDataSetChanged();
|
playerSongQueueAdapter.notifyDataSetChanged();
|
||||||
}
|
}
|
||||||
|
|
@ -259,4 +237,216 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||||
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
|
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the visibility and animates all six secondary FABs.
|
||||||
|
*/
|
||||||
|
private void toggleFabMenu() {
|
||||||
|
if (isMenuOpen) {
|
||||||
|
// CLOSE MENU (Reverse order for visual effect)
|
||||||
|
if (Preferences.isSyncronizationEnabled()) {
|
||||||
|
closeFab(fabLoadQueue, 4);
|
||||||
|
}
|
||||||
|
closeFab(fabSaveToPlaylist, 3);
|
||||||
|
closeFab(fabClearQueue, 2);
|
||||||
|
closeFab(fabDownloadAll, 1);
|
||||||
|
closeFab(fabShuffleQueue, 0);
|
||||||
|
|
||||||
|
fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start();
|
||||||
|
} else {
|
||||||
|
// OPEN MENU (lowest index at bottom)
|
||||||
|
openFab(fabShuffleQueue, 0);
|
||||||
|
openFab(fabDownloadAll, 1);
|
||||||
|
openFab(fabClearQueue, 2);
|
||||||
|
openFab(fabSaveToPlaylist, 3);
|
||||||
|
if (Preferences.isSyncronizationEnabled()) {
|
||||||
|
openFab(fabLoadQueue, 4);
|
||||||
|
}
|
||||||
|
fabMenuToggle.animate().rotation(45f).setDuration(ANIMATION_DURATION).start();
|
||||||
|
}
|
||||||
|
isMenuOpen = !isMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openFab(View fab, int index) {
|
||||||
|
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
|
||||||
|
|
||||||
|
fab.setVisibility(View.VISIBLE);
|
||||||
|
fab.setAlpha(0f);
|
||||||
|
fab.setTranslationY(displacement); // Start at the hidden (closed) position
|
||||||
|
|
||||||
|
fab.animate()
|
||||||
|
.translationY(0f)
|
||||||
|
.alpha(1f)
|
||||||
|
.setDuration(ANIMATION_DURATION)
|
||||||
|
.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeFab(View fab, int index) {
|
||||||
|
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
|
||||||
|
|
||||||
|
fab.animate()
|
||||||
|
.translationY(displacement)
|
||||||
|
.alpha(0f)
|
||||||
|
.setDuration(ANIMATION_DURATION)
|
||||||
|
.withEndAction(() -> fab.setVisibility(View.GONE))
|
||||||
|
.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleShuffleQueueClick() {
|
||||||
|
Log.d(TAG, "Shuffle Queue Clicked!");
|
||||||
|
|
||||||
|
mediaBrowserListenableFuture.addListener(() -> {
|
||||||
|
try {
|
||||||
|
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||||
|
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
||||||
|
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
|
||||||
|
|
||||||
|
if (startPosition < endPosition) {
|
||||||
|
ArrayList<Integer> pool = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = startPosition; i <= endPosition; i++) {
|
||||||
|
pool.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pool.size() >= 2) {
|
||||||
|
int fromPosition = (int) (Math.random() * (pool.size()));
|
||||||
|
int positionA = pool.get(fromPosition);
|
||||||
|
pool.remove(fromPosition);
|
||||||
|
|
||||||
|
int toPosition = (int) (Math.random() * (pool.size()));
|
||||||
|
int positionB = pool.get(toPosition);
|
||||||
|
pool.remove(toPosition);
|
||||||
|
|
||||||
|
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
|
||||||
|
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error shuffling queue", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFabMenu();
|
||||||
|
}, MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleClearQueueClick() {
|
||||||
|
Log.d(TAG, "Clear Queue Clicked!");
|
||||||
|
|
||||||
|
mediaBrowserListenableFuture.addListener(() -> {
|
||||||
|
try {
|
||||||
|
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||||
|
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
||||||
|
int endPosition = playerSongQueueAdapter.getItems().size();
|
||||||
|
|
||||||
|
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
||||||
|
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition - startPosition);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error clearing queue", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFabMenu();
|
||||||
|
}, MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSaveToPlaylistClick() {
|
||||||
|
Log.d(TAG, "Save to Playlist Clicked!");
|
||||||
|
|
||||||
|
List<Child> queueSongs = playerSongQueueAdapter.getItems();
|
||||||
|
|
||||||
|
if (queueSongs == null || queueSongs.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
|
||||||
|
toggleFabMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bundle bundle = new Bundle();
|
||||||
|
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(queueSongs));
|
||||||
|
|
||||||
|
PlaylistChooserDialog dialog = new PlaylistChooserDialog();
|
||||||
|
dialog.setArguments(bundle);
|
||||||
|
dialog.show(requireActivity().getSupportFragmentManager(), null);
|
||||||
|
|
||||||
|
toggleFabMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDownloadAllClick() {
|
||||||
|
Log.d(TAG, "Download All Clicked!");
|
||||||
|
|
||||||
|
List<Child> queueSongs = playerSongQueueAdapter.getItems();
|
||||||
|
|
||||||
|
if (queueSongs == null || queueSongs.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
|
||||||
|
toggleFabMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MediaItem> mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs);
|
||||||
|
|
||||||
|
List<com.cappielloantonio.tempo.model.Download> downloadModels = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Child child : queueSongs) {
|
||||||
|
com.cappielloantonio.tempo.model.Download downloadModel =
|
||||||
|
new com.cappielloantonio.tempo.model.Download(child);
|
||||||
|
downloadModel.setArtist(child.getArtist());
|
||||||
|
downloadModel.setAlbum(child.getAlbum());
|
||||||
|
downloadModel.setCoverArtId(child.getCoverArtId());
|
||||||
|
downloadModels.add(downloadModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(requireContext());
|
||||||
|
|
||||||
|
if (downloaderManager != null) {
|
||||||
|
downloaderManager.download(mediaItemsToDownload, downloadModels);
|
||||||
|
Toast.makeText(requireContext(), "Starting download of " + queueSongs.size() + " songs in the background.", Toast.LENGTH_SHORT).show();
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "DownloaderManager not initialized. Check DownloadUtil.");
|
||||||
|
Toast.makeText(requireContext(), "Download service unavailable.", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
toggleFabMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLoadQueueClick() {
|
||||||
|
Log.d(TAG, "Load Queue Clicked!");
|
||||||
|
if (!Preferences.isSyncronizationEnabled()) {
|
||||||
|
toggleFabMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerBottomSheetViewModel playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
|
||||||
|
|
||||||
|
playerBottomSheetViewModel.getPlayQueue().observe(getViewLifecycleOwner(), new Observer<PlayQueue>() {
|
||||||
|
@Override
|
||||||
|
public void onChanged(PlayQueue playQueue) {
|
||||||
|
playerBottomSheetViewModel.getPlayQueue().removeObserver(this);
|
||||||
|
|
||||||
|
if (playQueue != null && playQueue.getEntries() != null && !playQueue.getEntries().isEmpty()) {
|
||||||
|
int currentIndex = 0;
|
||||||
|
for (int i = 0; i < playQueue.getEntries().size(); i++) {
|
||||||
|
if (playQueue.getEntries().get(i).getId().equals(playQueue.getCurrent())) {
|
||||||
|
currentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaManager.startQueue(mediaBrowserListenableFuture, playQueue.getEntries(), currentIndex);
|
||||||
|
|
||||||
|
Toast.makeText(requireContext(), "Queue loaded", Toast.LENGTH_SHORT).show();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), "No saved queue found", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFabMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
new Handler().postDelayed(() -> {
|
||||||
|
if (isMenuOpen) {
|
||||||
|
toggleFabMenu();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
package com.cappielloantonio.tempo.util;
|
package com.cappielloantonio.tempo.util;
|
||||||
|
|
||||||
import androidx.annotation.OptIn;
|
import androidx.annotation.OptIn;
|
||||||
|
import androidx.media3.common.C;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.common.Metadata;
|
import androidx.media3.common.Metadata;
|
||||||
import androidx.media3.common.Tracks;
|
import androidx.media3.common.Tracks;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.media3.exoplayer.ExoPlayer;
|
import androidx.media3.common.Player;
|
||||||
|
|
||||||
import com.cappielloantonio.tempo.model.ReplayGain;
|
import com.cappielloantonio.tempo.model.ReplayGain;
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ import java.util.Objects;
|
||||||
public class ReplayGainUtil {
|
public class ReplayGainUtil {
|
||||||
private static final String[] tags = {"REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN", "R128_TRACK_GAIN", "R128_ALBUM_GAIN"};
|
private static final String[] tags = {"REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN", "R128_TRACK_GAIN", "R128_ALBUM_GAIN"};
|
||||||
|
|
||||||
public static void setReplayGain(ExoPlayer player, Tracks tracks) {
|
public static void setReplayGain(Player player, Tracks tracks) {
|
||||||
List<Metadata> metadata = getMetadata(tracks);
|
List<Metadata> metadata = getMetadata(tracks);
|
||||||
List<ReplayGain> gains = getReplayGains(metadata);
|
List<ReplayGain> gains = getReplayGains(metadata);
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ public class ReplayGainUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gains.size() == 0) gains.add(0, new ReplayGain());
|
if (gains.isEmpty()) gains.add(0, new ReplayGain());
|
||||||
if (gains.size() == 1) gains.add(1, new ReplayGain());
|
if (gains.size() == 1) gains.add(1, new ReplayGain());
|
||||||
|
|
||||||
return gains;
|
return gains;
|
||||||
|
|
@ -108,7 +109,7 @@ public class ReplayGainUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void applyReplayGain(ExoPlayer player, List<ReplayGain> gains) {
|
private static void applyReplayGain(Player player, List<ReplayGain> gains) {
|
||||||
if (Objects.equals(Preferences.getReplayGainMode(), "disabled") || gains == null || gains.isEmpty()) {
|
if (Objects.equals(Preferences.getReplayGainMode(), "disabled") || gains == null || gains.isEmpty()) {
|
||||||
setNoReplayGain(player);
|
setNoReplayGain(player);
|
||||||
return;
|
return;
|
||||||
|
|
@ -137,33 +138,33 @@ public class ReplayGainUtil {
|
||||||
setNoReplayGain(player);
|
setNoReplayGain(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setNoReplayGain(ExoPlayer player) {
|
private static void setNoReplayGain(Player player) {
|
||||||
setReplayGain(player, 0f);
|
setReplayGain(player, 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setTrackReplayGain(ExoPlayer player, List<ReplayGain> gains) {
|
private static void setTrackReplayGain(Player player, List<ReplayGain> gains) {
|
||||||
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
|
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
|
||||||
|
|
||||||
setReplayGain(player, trackGain != 0f ? trackGain : 0f);
|
setReplayGain(player, trackGain != 0f ? trackGain : 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setAlbumReplayGain(ExoPlayer player, List<ReplayGain> gains) {
|
private static void setAlbumReplayGain(Player player, List<ReplayGain> gains) {
|
||||||
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
|
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
|
||||||
|
|
||||||
setReplayGain(player, albumGain != 0f ? albumGain : 0f);
|
setReplayGain(player, albumGain != 0f ? albumGain : 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setAutoReplayGain(ExoPlayer player, List<ReplayGain> gains) {
|
private static void setAutoReplayGain(Player player, List<ReplayGain> gains) {
|
||||||
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
|
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
|
||||||
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
|
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
|
||||||
|
|
||||||
setReplayGain(player, albumGain != 0f ? albumGain : trackGain);
|
setReplayGain(player, albumGain != 0f ? albumGain : trackGain);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean areTracksConsecutive(ExoPlayer player) {
|
private static boolean areTracksConsecutive(Player player) {
|
||||||
MediaItem currentMediaItem = player.getCurrentMediaItem();
|
MediaItem currentMediaItem = player.getCurrentMediaItem();
|
||||||
int currentMediaItemIndex = player.getCurrentMediaItemIndex();
|
int prevMediaItemIndex = player.getPreviousMediaItemIndex();
|
||||||
MediaItem pastMediaItem = currentMediaItemIndex > 0 ? player.getMediaItemAt(currentMediaItemIndex - 1) : null;
|
MediaItem pastMediaItem = prevMediaItemIndex == C.INDEX_UNSET ? null : player.getMediaItemAt(prevMediaItemIndex);
|
||||||
|
|
||||||
return currentMediaItem != null &&
|
return currentMediaItem != null &&
|
||||||
pastMediaItem != null &&
|
pastMediaItem != null &&
|
||||||
|
|
@ -172,7 +173,7 @@ public class ReplayGainUtil {
|
||||||
pastMediaItem.mediaMetadata.albumTitle.toString().equals(currentMediaItem.mediaMetadata.albumTitle.toString());
|
pastMediaItem.mediaMetadata.albumTitle.toString().equals(currentMediaItem.mediaMetadata.albumTitle.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setReplayGain(ExoPlayer player, float gain) {
|
private static void setReplayGain(Player player, float gain) {
|
||||||
player.setVolume((float) Math.pow(10f, gain / 20f));
|
player.setVolume((float) Math.pow(10f, gain / 20f));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
import com.cappielloantonio.tempo.repository.AlbumRepository;
|
import com.cappielloantonio.tempo.repository.AlbumRepository;
|
||||||
import com.cappielloantonio.tempo.repository.DownloadRepository;
|
|
||||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
|
|
@ -21,7 +20,6 @@ import java.util.List;
|
||||||
|
|
||||||
public class AlbumListPageViewModel extends AndroidViewModel {
|
public class AlbumListPageViewModel extends AndroidViewModel {
|
||||||
private final AlbumRepository albumRepository;
|
private final AlbumRepository albumRepository;
|
||||||
private final DownloadRepository downloadRepository;
|
|
||||||
|
|
||||||
public String title;
|
public String title;
|
||||||
public ArtistID3 artist;
|
public ArtistID3 artist;
|
||||||
|
|
@ -32,9 +30,7 @@ public class AlbumListPageViewModel extends AndroidViewModel {
|
||||||
|
|
||||||
public AlbumListPageViewModel(@NonNull Application application) {
|
public AlbumListPageViewModel(@NonNull Application application) {
|
||||||
super(application);
|
super(application);
|
||||||
|
|
||||||
albumRepository = new AlbumRepository();
|
albumRepository = new AlbumRepository();
|
||||||
downloadRepository = new DownloadRepository();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public LiveData<List<AlbumID3>> getAlbumList(LifecycleOwner owner) {
|
public LiveData<List<AlbumID3>> getAlbumList(LifecycleOwner owner) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.viewmodel;
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.OptIn;
|
import androidx.annotation.OptIn;
|
||||||
|
|
@ -12,6 +13,7 @@ import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
import androidx.lifecycle.Observer;
|
import androidx.lifecycle.Observer;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
|
import androidx.media3.session.MediaBrowser;
|
||||||
|
|
||||||
import com.cappielloantonio.tempo.interfaces.StarCallback;
|
import com.cappielloantonio.tempo.interfaces.StarCallback;
|
||||||
import com.cappielloantonio.tempo.model.Download;
|
import com.cappielloantonio.tempo.model.Download;
|
||||||
|
|
@ -291,13 +293,13 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||||
List<String> ids = queue.stream().map(Child::getId).collect(Collectors.toList());
|
List<String> ids = queue.stream().map(Child::getId).collect(Collectors.toList());
|
||||||
|
|
||||||
if (media != null) {
|
if (media != null) {
|
||||||
queueRepository.savePlayQueue(ids, media.getId(), 0);
|
// TODO: We need to get the actual playback position here
|
||||||
|
Log.d(TAG, "Saving play queue - Current: " + media.getId() + ", Items: " + ids.size());
|
||||||
|
queueRepository.savePlayQueue(ids, media.getId(), 0); // Still hardcoded to 0 for now
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void observeCachedLyrics(LifecycleOwner owner, String songId) {
|
private void observeCachedLyrics(LifecycleOwner owner, String songId) {
|
||||||
if (TextUtils.isEmpty(songId)) {
|
if (TextUtils.isEmpty(songId)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/player_clean_queue_button"
|
|
||||||
style="@style/TitleMedium"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="24dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/player_queue_clean_all_button" />
|
|
||||||
|
|
||||||
<com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost
|
<com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
@ -21,20 +14,74 @@
|
||||||
android:id="@+id/player_queue_recycler_view"
|
android:id="@+id/player_queue_recycler_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_marginTop="40dp"
|
|
||||||
android:paddingTop="8dp"
|
android:paddingTop="8dp"
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||||
|
|
||||||
</com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost>
|
</com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost>
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
<LinearLayout
|
||||||
android:id="@+id/player_shuffle_queue_fab"
|
android:id="@+id/fab_menu_container"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="end"
|
||||||
android:layout_gravity="bottom|end"
|
android:layout_gravity="bottom|end"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
android:contentDescription="@string/content_description_shuffle_button"
|
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior">
|
||||||
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
|
|
||||||
app:srcCompat="@drawable/ic_shuffle" />
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
android:id="@+id/fab_save_to_playlist"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="@string/player_queue_save_to_playlist"
|
||||||
|
app:icon="@android:drawable/ic_menu_edit" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
android:id="@+id/fab_clear_queue"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="@string/player_queue_clean_all_button"
|
||||||
|
app:icon="@android:drawable/ic_menu_delete" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
android:id="@+id/fab_download_all"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="@string/menu_download_all_button"
|
||||||
|
app:icon="@android:drawable/stat_sys_download_done" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
android:id="@+id/fab_load_queue"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="@string/player_queue_load_queue"
|
||||||
|
app:icon="@android:drawable/ic_menu_revert" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
|
android:id="@+id/fab_shuffle_queue"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="@string/content_description_shuffle_button"
|
||||||
|
app:icon="@drawable/ic_shuffle" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/fab_menu_toggle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Toggle FAB Action menu"
|
||||||
|
tools:ignore="HardcodedText"
|
||||||
|
app:srcCompat="@drawable/ic_add" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
@ -139,6 +139,17 @@
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/download_indicator_icon"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:src="@drawable/ic_download" app:layout_constraintBottom_toBottomOf="@+id/queue_song_cover_image_view"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/queue_song_holder_image"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/queue_song_cover_image_view"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/queue_song_holder_image"
|
android:id="@+id/queue_song_holder_image"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@
|
||||||
<item name="android:statusBarColor">?attr/colorSurface</item>
|
<item name="android:statusBarColor">?attr/colorSurface</item>
|
||||||
<item name="android:navigationBarColor">?attr/colorSurface</item>
|
<item name="android:navigationBarColor">?attr/colorSurface</item>
|
||||||
<item name="android:scrollbars">none</item>
|
<item name="android:scrollbars">none</item>
|
||||||
|
|
||||||
|
<item name="floatingActionButtonStyle">@style/FloatingActionButtonStyle</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="Divider">
|
<style name="Divider">
|
||||||
|
|
@ -40,6 +42,21 @@
|
||||||
<item name="android:background">@color/dividerColor</item>
|
<item name="android:background">@color/dividerColor</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style name="FloatingActionButtonStyle" parent="Widget.MaterialComponents.FloatingActionButton">
|
||||||
|
<item name="backgroundTint">?attr/colorSecondary</item>
|
||||||
|
<item name="tint">?attr/colorOnPrimary</item>
|
||||||
|
<item name="shapeAppearanceOverlay">@style/FabShapeStyle</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="FabShapeStyle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
|
||||||
|
<item name="cornerSize">50%</item>
|
||||||
|
<item name="cornerSizeBottomLeft">0dp</item>
|
||||||
|
<item name="cornerFamilyTopLeft">rounded</item>
|
||||||
|
<item name="cornerFamilyTopRight">rounded</item>
|
||||||
|
<item name="cornerFamilyBottomLeft">rounded</item>
|
||||||
|
<item name="cornerFamilyBottomRight">rounded</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="NoConnectionTextView">
|
<style name="NoConnectionTextView">
|
||||||
<item name="background">?attr/colorErrorContainer</item>
|
<item name="background">?attr/colorErrorContainer</item>
|
||||||
<item name="android:textColor">?attr/colorOnErrorContainer</item>
|
<item name="android:textColor">?attr/colorOnErrorContainer</item>
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,8 @@
|
||||||
<string name="player_playback_speed">%1$.2fx</string>
|
<string name="player_playback_speed">%1$.2fx</string>
|
||||||
<string name="player_queue_clean_all_button">Clean play queue</string>
|
<string name="player_queue_clean_all_button">Clean play queue</string>
|
||||||
<string name="player_queue_save_queue_success">Saved play queue</string>
|
<string name="player_queue_save_queue_success">Saved play queue</string>
|
||||||
|
<string name="player_queue_save_to_playlist">Save Queue to Playlist</string>
|
||||||
|
<string name="player_queue_load_queue">Load Queue</string>
|
||||||
<string name="player_lyrics_download_content_description">Download lyrics for offline playback</string>
|
<string name="player_lyrics_download_content_description">Download lyrics for offline playback</string>
|
||||||
<string name="player_lyrics_downloaded_content_description">Lyrics downloaded for offline playback</string>
|
<string name="player_lyrics_downloaded_content_description">Lyrics downloaded for offline playback</string>
|
||||||
<string name="player_lyrics_download_success">Lyrics saved for offline playback.</string>
|
<string name="player_lyrics_download_success">Lyrics saved for offline playback.</string>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@
|
||||||
<item name="android:statusBarColor">?attr/colorSurface</item>
|
<item name="android:statusBarColor">?attr/colorSurface</item>
|
||||||
<item name="android:navigationBarColor">?attr/colorSurface</item>
|
<item name="android:navigationBarColor">?attr/colorSurface</item>
|
||||||
<item name="android:scrollbars">none</item>
|
<item name="android:scrollbars">none</item>
|
||||||
|
|
||||||
|
<item name="floatingActionButtonStyle">@style/FloatingActionButtonStyle</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="Divider">
|
<style name="Divider">
|
||||||
|
|
@ -47,6 +49,21 @@
|
||||||
<item name="android:background">@color/dividerColor</item>
|
<item name="android:background">@color/dividerColor</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style name="FloatingActionButtonStyle" parent="Widget.MaterialComponents.FloatingActionButton">
|
||||||
|
<item name="backgroundTint">?attr/colorSecondary</item>
|
||||||
|
<item name="tint">?attr/colorOnPrimary</item>
|
||||||
|
<item name="shapeAppearanceOverlay">@style/FabShapeStyle</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="FabShapeStyle" parent="ShapeAppearance.MaterialComponents.SmallComponent">
|
||||||
|
<item name="cornerSize">50%</item>
|
||||||
|
<item name="cornerSizeBottomLeft">0dp</item>
|
||||||
|
<item name="cornerFamilyTopLeft">rounded</item>
|
||||||
|
<item name="cornerFamilyTopRight">rounded</item>
|
||||||
|
<item name="cornerFamilyBottomLeft">rounded</item>
|
||||||
|
<item name="cornerFamilyBottomRight">rounded</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="NoConnectionTextView">
|
<style name="NoConnectionTextView">
|
||||||
<item name="background">?attr/colorErrorContainer</item>
|
<item name="background">?attr/colorErrorContainer</item>
|
||||||
<item name="android:textColor">?attr/colorOnErrorContainer</item>
|
<item name="android:textColor">?attr/colorOnErrorContainer</item>
|
||||||
|
|
|
||||||
|
|
@ -1,182 +1,18 @@
|
||||||
package com.cappielloantonio.tempo.service
|
package com.cappielloantonio.tempo.service
|
||||||
|
|
||||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
|
||||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
import android.app.TaskStackBuilder
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.os.Binder
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media3.cast.CastPlayer
|
import androidx.media3.cast.CastPlayer
|
||||||
import androidx.media3.cast.SessionAvailabilityListener
|
import androidx.media3.cast.SessionAvailabilityListener
|
||||||
import androidx.media3.common.AudioAttributes
|
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.Tracks
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.session.MediaLibraryService
|
|
||||||
import androidx.media3.session.MediaSession.ControllerInfo
|
|
||||||
import com.cappielloantonio.tempo.repository.AutomotiveRepository
|
import com.cappielloantonio.tempo.repository.AutomotiveRepository
|
||||||
import com.cappielloantonio.tempo.repository.QueueRepository
|
|
||||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
|
||||||
import com.cappielloantonio.tempo.util.AssetLinkUtil
|
|
||||||
import com.cappielloantonio.tempo.util.Constants
|
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil
|
|
||||||
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
|
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil
|
|
||||||
import com.cappielloantonio.tempo.util.Preferences
|
|
||||||
import com.cappielloantonio.tempo.util.ReplayGainUtil
|
|
||||||
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
|
|
||||||
import com.google.android.gms.cast.framework.CastContext
|
import com.google.android.gms.cast.framework.CastContext
|
||||||
import com.google.android.gms.common.ConnectionResult
|
import com.google.android.gms.common.ConnectionResult
|
||||||
import com.google.android.gms.common.GoogleApiAvailability
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class MediaService : MediaLibraryService(), SessionAvailabilityListener {
|
class MediaService : BaseMediaService(), SessionAvailabilityListener {
|
||||||
private lateinit var automotiveRepository: AutomotiveRepository
|
private val automotiveRepository = AutomotiveRepository()
|
||||||
private lateinit var player: ExoPlayer
|
|
||||||
private lateinit var castPlayer: CastPlayer
|
private lateinit var castPlayer: CastPlayer
|
||||||
private lateinit var mediaLibrarySession: MediaLibrarySession
|
|
||||||
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
|
|
||||||
private lateinit var networkCallback: CustomNetworkCallback
|
|
||||||
lateinit var equalizerManager: EqualizerManager
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
|
||||||
fun getEqualizerManager(): EqualizerManager {
|
|
||||||
return this@MediaService.equalizerManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
|
|
||||||
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
|
|
||||||
}
|
|
||||||
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
|
|
||||||
private var widgetUpdateScheduled = false
|
|
||||||
private val widgetUpdateRunnable = object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
if (!player.isPlaying) {
|
|
||||||
widgetUpdateScheduled = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateMediaItems() {
|
|
||||||
Log.d("MediaService", "update items");
|
|
||||||
val n = player.mediaItemCount
|
|
||||||
val k = player.currentMediaItemIndex
|
|
||||||
val current = player.currentPosition
|
|
||||||
val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))}
|
|
||||||
player.clearMediaItems()
|
|
||||||
player.setMediaItems(items, k, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
|
|
||||||
var wasWifi = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
val manager = getSystemService(ConnectivityManager::class.java)
|
|
||||||
val network = manager.activeNetwork
|
|
||||||
val capabilities = manager.getNetworkCapabilities(network)
|
|
||||||
if (capabilities != null)
|
|
||||||
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
|
|
||||||
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
|
||||||
if (isWifi != wasWifi) {
|
|
||||||
wasWifi = isWifi
|
|
||||||
widgetUpdateHandler.post(Runnable {
|
|
||||||
updateMediaItems()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
initializeRepository()
|
|
||||||
initializePlayer()
|
|
||||||
initializeMediaLibrarySession()
|
|
||||||
restorePlayerFromQueue()
|
|
||||||
initializePlayerListener()
|
|
||||||
initializeCastPlayer()
|
|
||||||
initializeEqualizerManager()
|
|
||||||
initializeNetworkListener()
|
|
||||||
|
|
||||||
setPlayer(
|
|
||||||
null,
|
|
||||||
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
|
||||||
return mediaLibrarySession
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
val player = mediaLibrarySession.player
|
|
||||||
|
|
||||||
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
releaseNetworkCallback()
|
|
||||||
equalizerManager.release()
|
|
||||||
stopWidgetUpdates()
|
|
||||||
releasePlayer()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
|
||||||
// Check if the intent is for our custom equalizer binder
|
|
||||||
if (intent?.action == ACTION_BIND_EQUALIZER) {
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
// Otherwise, handle it as a normal MediaLibraryService connection
|
|
||||||
return super.onBind(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeRepository() {
|
|
||||||
automotiveRepository = AutomotiveRepository()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeEqualizerManager() {
|
|
||||||
equalizerManager = EqualizerManager()
|
|
||||||
val audioSessionId = player.audioSessionId
|
|
||||||
attachEqualizerIfPossible(audioSessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializePlayer() {
|
|
||||||
player = ExoPlayer.Builder(this)
|
|
||||||
.setRenderersFactory(getRenderersFactory())
|
|
||||||
.setMediaSourceFactory(DynamicMediaSourceFactory(this))
|
|
||||||
.setAudioAttributes(AudioAttributes.DEFAULT, true)
|
|
||||||
.setHandleAudioBecomingNoisy(true)
|
|
||||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
|
||||||
.setLoadControl(initializeLoadControl())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
|
|
||||||
player.repeatMode = Preferences.getRepeatMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun initializeCastPlayer() {
|
private fun initializeCastPlayer() {
|
||||||
|
|
@ -184,284 +20,41 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
|
||||||
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
|
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
|
||||||
) {
|
) {
|
||||||
CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this))
|
CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this))
|
||||||
.addOnSuccessListener { castContext ->
|
.addOnSuccessListener { castContext ->
|
||||||
castPlayer = CastPlayer(castContext)
|
castPlayer = CastPlayer(castContext)
|
||||||
castPlayer.setSessionAvailabilityListener(this@MediaService)
|
castPlayer.setSessionAvailabilityListener(this@MediaService)
|
||||||
|
initializePlayerListener(castPlayer)
|
||||||
if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) {
|
if (castPlayer.isCastSessionAvailable)
|
||||||
setPlayer(player, castPlayer)
|
setPlayer(mediaLibrarySession.player, castPlayer)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeMediaLibrarySession() {
|
override fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback {
|
||||||
val sessionActivityPendingIntent =
|
|
||||||
TaskStackBuilder.create(this).run {
|
|
||||||
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
|
|
||||||
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
librarySessionCallback = createLibrarySessionCallback()
|
|
||||||
mediaLibrarySession =
|
|
||||||
MediaLibrarySession.Builder(this, player, librarySessionCallback)
|
|
||||||
.setSessionActivity(sessionActivityPendingIntent)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeNetworkListener() {
|
|
||||||
networkCallback = CustomNetworkCallback()
|
|
||||||
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback)
|
|
||||||
updateMediaItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restorePlayerFromQueue() {
|
|
||||||
if (player.mediaItemCount > 0) return
|
|
||||||
|
|
||||||
val queueRepository = QueueRepository()
|
|
||||||
val storedQueue = queueRepository.media
|
|
||||||
if (storedQueue.isNullOrEmpty()) return
|
|
||||||
|
|
||||||
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
|
|
||||||
if (mediaItems.isEmpty()) return
|
|
||||||
|
|
||||||
val lastIndex = try {
|
|
||||||
queueRepository.lastPlayedMediaIndex
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0
|
|
||||||
}.coerceIn(0, mediaItems.size - 1)
|
|
||||||
|
|
||||||
val lastPosition = try {
|
|
||||||
queueRepository.lastPlayedMediaTimestamp
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0L
|
|
||||||
}.let { if (it < 0L) 0L else it }
|
|
||||||
|
|
||||||
player.setMediaItems(mediaItems, lastIndex, lastPosition)
|
|
||||||
player.prepare()
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
|
|
||||||
return MediaLibrarySessionCallback(this, automotiveRepository)
|
return MediaLibrarySessionCallback(this, automotiveRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializePlayerListener() {
|
override fun playerInitHook() {
|
||||||
player.addListener(object : Player.Listener {
|
super.playerInitHook()
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
initializeCastPlayer()
|
||||||
if (mediaItem == null) return
|
if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable)
|
||||||
|
setPlayer(null, castPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
override fun releasePlayers() {
|
||||||
MediaManager.setLastPlayedTimestamp(mediaItem)
|
if (this::castPlayer.isInitialized) {
|
||||||
}
|
castPlayer.setSessionAvailabilityListener(null)
|
||||||
updateWidget()
|
castPlayer.release()
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
|
||||||
ReplayGainUtil.setReplayGain(player, tracks)
|
|
||||||
|
|
||||||
val currentMediaItem = player.currentMediaItem
|
|
||||||
if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
|
|
||||||
MediaManager.scrobble(currentMediaItem, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
|
|
||||||
MediaManager.continuousPlay(player.currentMediaItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
||||||
if (!isPlaying) {
|
|
||||||
MediaManager.setPlayingPausedTimestamp(
|
|
||||||
player.currentMediaItem,
|
|
||||||
player.currentPosition
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
MediaManager.scrobble(player.currentMediaItem, false)
|
|
||||||
}
|
|
||||||
if (isPlaying) {
|
|
||||||
scheduleWidgetUpdates()
|
|
||||||
} else {
|
|
||||||
stopWidgetUpdates()
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
super.onPlaybackStateChanged(playbackState)
|
|
||||||
|
|
||||||
if (!player.hasNextMediaItem() &&
|
|
||||||
playbackState == Player.STATE_ENDED &&
|
|
||||||
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
|
|
||||||
) {
|
|
||||||
MediaManager.scrobble(player.currentMediaItem, true)
|
|
||||||
MediaManager.saveChronology(player.currentMediaItem)
|
|
||||||
}
|
|
||||||
updateWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPositionDiscontinuity(
|
|
||||||
oldPosition: Player.PositionInfo,
|
|
||||||
newPosition: Player.PositionInfo,
|
|
||||||
reason: Int
|
|
||||||
) {
|
|
||||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
|
||||||
|
|
||||||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
|
|
||||||
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
|
||||||
MediaManager.scrobble(oldPosition.mediaItem, true)
|
|
||||||
MediaManager.saveChronology(oldPosition.mediaItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
|
||||||
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
|
||||||
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRepeatModeChanged(repeatMode: Int) {
|
|
||||||
Preferences.setRepeatMode(repeatMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
|
||||||
attachEqualizerIfPossible(audioSessionId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (player.isPlaying) {
|
|
||||||
scheduleWidgetUpdates()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateWidget() {
|
|
||||||
val mi = player.currentMediaItem
|
|
||||||
val title = mi?.mediaMetadata?.title?.toString()
|
|
||||||
?: mi?.mediaMetadata?.extras?.getString("title")
|
|
||||||
val artist = mi?.mediaMetadata?.artist?.toString()
|
|
||||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
|
||||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
|
||||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
|
||||||
val extras = mi?.mediaMetadata?.extras
|
|
||||||
val coverId = extras?.getString("coverArtId")
|
|
||||||
val songLink = extras?.getString("assetLinkSong")
|
|
||||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
|
||||||
val albumLink = extras?.getString("assetLinkAlbum")
|
|
||||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
|
||||||
val artistLink = extras?.getString("assetLinkArtist")
|
|
||||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
|
||||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
|
||||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
|
||||||
WidgetUpdateManager.updateFromState(
|
|
||||||
this,
|
|
||||||
title ?: "",
|
|
||||||
artist ?: "",
|
|
||||||
album ?: "",
|
|
||||||
coverId,
|
|
||||||
player.isPlaying,
|
|
||||||
player.shuffleModeEnabled,
|
|
||||||
player.repeatMode,
|
|
||||||
position,
|
|
||||||
duration,
|
|
||||||
songLink,
|
|
||||||
albumLink,
|
|
||||||
artistLink
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleWidgetUpdates() {
|
|
||||||
if (widgetUpdateScheduled) return
|
|
||||||
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
|
|
||||||
widgetUpdateScheduled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopWidgetUpdates() {
|
|
||||||
if (!widgetUpdateScheduled) return
|
|
||||||
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
|
|
||||||
widgetUpdateScheduled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeLoadControl(): DefaultLoadControl {
|
|
||||||
return DefaultLoadControl.Builder()
|
|
||||||
.setBufferDurationsMs(
|
|
||||||
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
|
||||||
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
|
||||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
|
||||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getQueueFromPlayer(player: Player): List<MediaItem> {
|
|
||||||
val queue = mutableListOf<MediaItem>()
|
|
||||||
for (i in 0 until player.mediaItemCount) {
|
|
||||||
queue.add(player.getMediaItemAt(i))
|
|
||||||
}
|
|
||||||
return queue
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
|
|
||||||
if (oldPlayer === newPlayer) return
|
|
||||||
oldPlayer?.stop()
|
|
||||||
mediaLibrarySession.player = newPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releasePlayer() {
|
|
||||||
if (this::castPlayer.isInitialized) castPlayer.setSessionAvailabilityListener(null)
|
|
||||||
if (this::castPlayer.isInitialized) castPlayer.release()
|
|
||||||
player.release()
|
|
||||||
mediaLibrarySession.release()
|
|
||||||
automotiveRepository.deleteMetadata()
|
automotiveRepository.deleteMetadata()
|
||||||
|
super.releasePlayers()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun releaseNetworkCallback() {
|
|
||||||
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
|
|
||||||
|
|
||||||
override fun onCastSessionAvailable() {
|
override fun onCastSessionAvailable() {
|
||||||
val currentQueue = getQueueFromPlayer(player)
|
setPlayer(exoplayer, castPlayer)
|
||||||
val currentIndex = player.currentMediaItemIndex
|
|
||||||
val currentPosition = player.currentPosition
|
|
||||||
val isPlaying = player.playWhenReady
|
|
||||||
|
|
||||||
setPlayer(player, castPlayer)
|
|
||||||
|
|
||||||
castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
|
|
||||||
castPlayer.playWhenReady = isPlaying
|
|
||||||
castPlayer.prepare()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCastSessionUnavailable() {
|
override fun onCastSessionUnavailable() {
|
||||||
val currentQueue = getQueueFromPlayer(castPlayer)
|
setPlayer(castPlayer, exoplayer)
|
||||||
val currentIndex = castPlayer.currentMediaItemIndex
|
|
||||||
val currentPosition = castPlayer.currentPosition
|
|
||||||
val isPlaying = castPlayer.playWhenReady
|
|
||||||
|
|
||||||
setPlayer(castPlayer, player)
|
|
||||||
|
|
||||||
player.setMediaItems(currentQueue, currentIndex, currentPosition)
|
|
||||||
player.playWhenReady = isPlaying
|
|
||||||
player.prepare()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
|
|
||||||
if (audioSessionId == 0 || audioSessionId == -1) return false
|
|
||||||
val attached = equalizerManager.attachToSession(audioSessionId)
|
|
||||||
if (attached) {
|
|
||||||
val enabled = Preferences.isEqualizerEnabled()
|
|
||||||
equalizerManager.setEnabled(enabled)
|
|
||||||
val bands = equalizerManager.getNumberOfBands()
|
|
||||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
|
||||||
for (i in 0 until bands) {
|
|
||||||
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
|
|
||||||
}
|
|
||||||
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
|
|
||||||
}
|
|
||||||
return attached
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue