Drop androidx.media3

This commit is contained in:
vfsfitvnm
2022-06-25 16:04:52 +02:00
parent 524bea60d9
commit 7e6b5747a2
24 changed files with 626 additions and 666 deletions

View File

@@ -86,15 +86,12 @@ dependencies {
implementation(libs.accompanist.systemuicontroller)
implementation(libs.android.media)
implementation(libs.media3.session)
implementation(libs.media3.exoplayer)
implementation(libs.exoplayer)
implementation(libs.room)
kapt(libs.room.compiler)
implementation(projects.youtubeMusic)
implementation(libs.guava.coroutines)
coreLibraryDesugaring(libs.desugaring)
}

View File

@@ -31,6 +31,7 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -63,12 +64,23 @@
<service
android:name=".services.PlayerService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</service>
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<receiver
android:name=".services.PlayerService$StopServiceBroadcastReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -1,8 +1,12 @@
package it.vfsfitvnm.vimusic
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
@@ -27,10 +31,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.valentinilk.shimmer.LocalShimmerTheme
import com.valentinilk.shimmer.defaultShimmerTheme
import it.vfsfitvnm.vimusic.enums.ColorPaletteMode
import it.vfsfitvnm.vimusic.services.PlayerService
import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
@@ -42,11 +48,34 @@ import it.vfsfitvnm.vimusic.ui.views.PlayerView
import it.vfsfitvnm.vimusic.utils.*
@ExperimentalAnimationApi
@ExperimentalFoundationApi
class MainActivity : ComponentActivity() {
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
if (service is PlayerService.Binder) {
this@MainActivity.binder = service
}
}
override fun onServiceDisconnected(name: ComponentName?) {
binder = null
}
}
private var binder by mutableStateOf<PlayerService.Binder?>(null)
private var uri by mutableStateOf<Uri?>(null, neverEqualPolicy())
override fun onStart() {
super.onStart()
bindService(intent<PlayerService>(), serviceConnection, Context.BIND_AUTO_CREATE)
startService(intent<PlayerService>())
}
override fun onStop() {
unbindService(serviceConnection)
super.onStop()
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -112,7 +141,7 @@ class MainActivity : ComponentActivity() {
LocalColorPalette provides colorPalette,
LocalShimmerTheme provides shimmerTheme,
LocalTypography provides rememberTypography(colorPalette.text),
LocalYoutubePlayer provides rememberYoutubePlayer((application as MainApplication).mediaControllerFuture),
LocalPlayerServiceBinder provides binder,
LocalMenuState provides rememberMenuState(),
LocalHapticFeedback provides rememberHapticFeedback()
) {
@@ -151,3 +180,5 @@ class MainActivity : ComponentActivity() {
uri = intent?.data
}
}
val LocalPlayerServiceBinder = staticCompositionLocalOf<PlayerService.Binder?> { null }

View File

@@ -1,31 +1,16 @@
package it.vfsfitvnm.vimusic
import android.app.Application
import android.content.ComponentName
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import com.google.common.util.concurrent.ListenableFuture
import it.vfsfitvnm.vimusic.services.PlayerService
import it.vfsfitvnm.vimusic.utils.preferences
@ExperimentalAnimationApi
@ExperimentalFoundationApi
class MainApplication : Application(), ImageLoaderFactory {
lateinit var mediaControllerFuture: ListenableFuture<MediaController>
override fun onCreate() {
super.onCreate()
DatabaseInitializer()
val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java))
mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
}
override fun newImageLoader(): ImageLoader {

View File

@@ -2,30 +2,28 @@ package it.vfsfitvnm.vimusic.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media.session.MediaButtonReceiver
import androidx.media3.common.*
import androidx.media3.common.util.Util
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
@@ -34,83 +32,63 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.analytics.PlaybackStats
import androidx.media3.exoplayer.analytics.PlaybackStatsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.*
import androidx.media3.session.MediaNotification.ActionFactory
import coil.Coil
import coil.request.ImageRequest
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.roundToInt
import kotlin.system.exitProcess
val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY)
val StartArtistRadioCommand = SessionCommand("StartArtistRadioCommand", Bundle.EMPTY)
val StopRadioCommand = SessionCommand("StopRadioCommand", Bundle.EMPTY)
val GetCacheSizeCommand = SessionCommand("GetCacheSizeCommand", Bundle.EMPTY)
val GetSongCacheSizeCommand = SessionCommand("GetSongCacheSizeCommand", Bundle.EMPTY)
val DeleteSongCacheCommand = SessionCommand("DeleteSongCacheCommand", Bundle.EMPTY)
val SetSkipSilenceCommand = SessionCommand("SetSkipSilenceCommand", Bundle.EMPTY)
val GetAudioSessionIdCommand = SessionCommand("GetAudioSessionIdCommand", Bundle.EMPTY)
val SetSleepTimerCommand = SessionCommand("SetSleepTimerCommand", Bundle.EMPTY)
val GetSleepTimerMillisLeftCommand = SessionCommand("GetSleepTimerMillisLeftCommand", Bundle.EMPTY)
val CancelSleepTimerCommand = SessionCommand("CancelSleepTimerCommand", Bundle.EMPTY)
@ExperimentalAnimationApi
@ExperimentalFoundationApi
class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotification.Provider,
PlaybackStatsListener.Callback, Player.Listener {
companion object {
private const val NotificationId = 1001
private const val NotificationChannelId = "default_channel_id"
private const val SleepTimerNotificationId = 1002
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
}
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback {
private lateinit var mediaSession: MediaSessionCompat
private lateinit var cache: SimpleCache
private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSession
private val stateBuilder = PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SEEK_TO
)
private val metadataBuilder = MediaMetadataCompat.Builder()
private lateinit var notificationManager: NotificationManager
private var timerJob: TimerJob? = null
private var notificationThumbnailSize: Int = 0
private var lastArtworkUri: Uri? = null
private var lastBitmap: Bitmap? = null
private var radio: YoutubePlayer.Radio? = null
private var sleepTimerJob: Job? = null
private var sleepTimerRealtime: Long? = null
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
override fun onBind(intent: Intent?) = Binder()
override fun onCreate() {
super.onCreate()
notificationThumbnailSize = (256 * resources.displayMetrics.density).roundToInt()
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
createNotificationChannel()
setMediaNotificationProvider(this)
val cacheEvictor = LeastRecentlyUsedCacheEvictor(preferences.exoPlayerDiskCacheMaxSizeBytes)
cache = SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(this))
@@ -132,153 +110,39 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
player.repeatMode = preferences.repeatMode
player.skipSilenceEnabled = preferences.skipSilence
player.playWhenReady = true
player.addListener(this)
player.addAnalyticsListener(PlaybackStatsListener(false, this))
mediaSession = MediaSession.Builder(this, player)
.withSessionActivity()
.setCallback(this)
.build()
mediaSession = MediaSessionCompat(this, "PlayerService")
mediaSession.setCallback(SessionCallback(player))
mediaSession.setPlaybackState(stateBuilder.build())
mediaSession.isActive = true
}
player.addListener(this)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
MediaButtonReceiver.handleIntent(mediaSession, intent)
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady) {
notificationManager.cancel(NotificationId)
stopSelf()
}
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
player.removeListener(this)
player.stop()
player.release()
mediaSession.isActive = false
mediaSession.release()
cache.release()
super.onDestroy()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession {
return mediaSession
}
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val sessionCommands = SessionCommands.Builder()
.add(StartRadioCommand)
.add(StartArtistRadioCommand)
.add(StopRadioCommand)
.add(GetCacheSizeCommand)
.add(GetSongCacheSizeCommand)
.add(DeleteSongCacheCommand)
.add(SetSkipSilenceCommand)
.add(GetAudioSessionIdCommand)
.add(SetSleepTimerCommand)
.add(GetSleepTimerMillisLeftCommand)
.add(CancelSleepTimerCommand)
.build()
val playerCommands = Player.Commands.Builder().addAllCommands().build()
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand) {
StartRadioCommand, StartArtistRadioCommand -> {
radio = null
YoutubePlayer.Radio(
videoId = args.getString("videoId"),
playlistId = args.getString("playlistId"),
playlistSetVideoId = args.getString("playlistSetVideoId"),
parameters = args.getString("params"),
).let {
coroutineScope.launch(Dispatchers.Main) {
when (customCommand) {
StartRadioCommand -> player.addMediaItems(it.process().drop(1))
StartArtistRadioCommand -> player.forcePlayFromBeginning(it.process())
}
radio = it
}
}
}
StopRadioCommand -> radio = null
GetCacheSizeCommand -> {
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("cacheSize" to cache.cacheSpace)
)
)
}
GetSongCacheSizeCommand -> {
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("cacheSize" to cache.getCachedBytes(
args.getString("videoId") ?: "",
0,
C.LENGTH_UNSET.toLong()
))
)
)
}
DeleteSongCacheCommand -> {
args.getString("videoId")?.let { videoId ->
cache.removeResource(videoId)
}
}
SetSkipSilenceCommand -> {
player.skipSilenceEnabled = args.getBoolean("skipSilence")
}
GetAudioSessionIdCommand -> {
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("audioSessionId" to player.audioSessionId)
)
)
}
SetSleepTimerCommand -> {
val delayMillis = args.getLong("delayMillis", 2000)
sleepTimerJob = coroutineScope.launch {
sleepTimerRealtime = SystemClock.elapsedRealtime() + delayMillis
delay(delayMillis)
withContext(Dispatchers.Main) {
val notification = NotificationCompat.Builder(
this@PlayerService,
SleepTimerNotificationChannelId
)
.setContentTitle("Sleep timer ended")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setShowWhen(true)
.setSmallIcon(R.drawable.app_icon)
.build()
notificationManager.notify(SleepTimerNotificationId, notification)
}
exitProcess(0)
}
}
GetSleepTimerMillisLeftCommand -> {
return Futures.immediateFuture(sleepTimerRealtime?.let {
(SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("millisLeft" to it - SystemClock.elapsedRealtime())
))
} ?: SessionResult(SessionResult.RESULT_ERROR_INVALID_STATE))
}
CancelSleepTimerCommand -> {
sleepTimerJob?.cancel()
sleepTimerJob = null
sleepTimerRealtime = null
}
}
return super.onCustomCommand(session, controller, customCommand, args)
}
override fun onPlaybackStatsReady(
eventTime: AnalyticsListener.EventTime,
playbackStats: PlaybackStats
@@ -308,63 +172,62 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
player.volume = player.currentMediaItem?.mediaId?.let { mediaId ->
songPendingLoudnessDb.getOrElse(mediaId) {
player.currentMediaItem?.mediaMetadata?.extras?.getFloat("loudnessDb")
}?.takeIf { it > 0 }?.let { loudnessDb ->
(1f - (0.01f + loudnessDb / 14)).coerceIn(0.1f, 1f)
}
?.takeIf { it > 0 }
?.let { loudnessDb ->
(1f - (0.01f + loudnessDb / 15)).coerceIn(0.1f, 1f)
}
} ?: 1f
}
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
return Futures.immediateFuture(
mediaItems.map { mediaItem ->
mediaItem.buildUpon()
.setUri(mediaItem.mediaId)
.setCustomCacheKey(mediaItem.mediaId)
.build()
}
)
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
@Player.DiscontinuityReason reason: Int
) {
stateBuilder
.setState(PlaybackStateCompat.STATE_NONE, newPosition.positionMs, 1f)
.setBufferedPosition(player.bufferedPosition)
updateNotification()
}
override fun createNotification(
session: MediaSession,
customLayout: ImmutableList<CommandButton>,
actionFactory: ActionFactory,
onNotificationChangedCallback: MediaNotification.Provider.Callback
): MediaNotification {
fun invalidate() {
onNotificationChangedCallback.onNotificationChanged(
createNotification(
session,
customLayout,
actionFactory,
onNotificationChangedCallback
)
override fun onIsPlayingChanged(isPlaying: Boolean) {
stateBuilder
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
player.currentPosition,
1f
)
}
.setBufferedPosition(player.bufferedPosition)
updateNotification()
}
private fun updateNotification() {
if (player.duration != C.TIME_UNSET) {
metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, player.duration)
mediaSession.setMetadata(metadataBuilder.build())
}
mediaSession.setPlaybackState(stateBuilder.build())
createNotification()
}
private fun createNotification() {
fun NotificationCompat.Builder.addMediaAction(
@DrawableRes resId: Int,
@StringRes stringId: Int,
@Player.Command command: Int
description: String,
@PlaybackStateCompat.MediaKeyAction command: Long
): NotificationCompat.Builder {
return addAction(
actionFactory.createMediaAction(
mediaSession,
IconCompat.createWithResource(this@PlayerService, resId),
getString(stringId),
command
NotificationCompat.Action(
resId,
description,
MediaButtonReceiver.buildMediaButtonPendingIntent(this@PlayerService, command)
)
)
}
val mediaMetadata = mediaSession.player.mediaMetadata
val mediaMetadata = player.mediaMetadata
val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId)
.setContentTitle(mediaMetadata.title)
@@ -375,72 +238,75 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
.setShowWhen(false)
.setSmallIcon(R.drawable.app_icon)
.setOngoing(false)
.setContentIntent(mediaSession.sessionActivity)
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(
mediaSession,
Player.COMMAND_STOP.toLong()
)
)
.setContentIntent(activityPendingIntent<MainActivity>())
.setDeleteIntent(broadCastPendingIntent<StopServiceBroadcastReceiver>())
.setChannelId(NotificationChannelId)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2)
.setMediaSession(mediaSession.sessionCompatToken as android.support.v4.media.session.MediaSessionCompat.Token)
.setMediaSession(mediaSession.sessionToken)
)
.addMediaAction(
R.drawable.play_skip_back,
R.string.media3_controls_seek_to_previous_description,
Player.COMMAND_SEEK_TO_PREVIOUS
"Skip back",
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
).addMediaAction(
if (mediaSession.player.playbackState == Player.STATE_ENDED || !mediaSession.player.playWhenReady) R.drawable.play else R.drawable.pause,
if (mediaSession.player.playbackState == Player.STATE_ENDED || !mediaSession.player.playWhenReady) R.string.media3_controls_play_description else R.string.media3_controls_pause_description,
Player.COMMAND_PLAY_PAUSE
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) R.drawable.play else R.drawable.pause,
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) "Play" else "Pause",
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) PlaybackStateCompat.ACTION_PLAY else PlaybackStateCompat.ACTION_PAUSE
)
.addMediaAction(
R.drawable.play_skip_forward,
R.string.media3_controls_seek_to_next_description,
Player.COMMAND_SEEK_TO_NEXT
"Skip forward",
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
if (lastArtworkUri != mediaMetadata.artworkUri) {
coroutineScope.launch(Dispatchers.IO) {
lastBitmap = Coil.imageLoader(applicationContext).execute(
ImageRequest.Builder(applicationContext)
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
.build()
).drawable?.let {
lastArtworkUri = mediaMetadata.artworkUri
(it as BitmapDrawable).bitmap
} ?: resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
lastArtworkUri = mediaMetadata.artworkUri
withContext(Dispatchers.Main) {
invalidate()
}
}
Coil.imageLoader(applicationContext).enqueue(
ImageRequest.Builder(applicationContext)
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
.listener(
onError = { _, _ ->
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
notificationManager.notify(NotificationId, builder.setLargeIcon(lastBitmap).build())
},
onSuccess = { _, result ->
lastBitmap = (result.drawable as BitmapDrawable).bitmap
notificationManager.notify(NotificationId, builder.setLargeIcon(lastBitmap).build())
}
)
.build()
)
}
return MediaNotification(NotificationId, builder.build())
}
val notificationCompat = builder.build()
startForeground(NotificationId, notificationCompat)
override fun handleCustomCommand(
session: MediaSession,
action: String,
extras: Bundle
): Boolean = false
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_DETACH)
} else {
stopForeground(false)
}
notificationManager.notify(NotificationId, notificationCompat)
}
}
private fun createNotificationChannel() {
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Util.SDK_INT < 26) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
with(notificationManager) {
if (getNotificationChannel(NotificationChannelId) == null) {
createNotificationChannel(
NotificationChannel(
NotificationChannelId,
getString(R.string.default_notification_channel_name),
"Now playing",
NotificationManager.IMPORTANCE_LOW
)
)
@@ -504,10 +370,12 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
if (mediaItem?.mediaId == videoId) {
Database.internal.queryExecutor.execute {
Database.update(Database.insert(mediaItem).copy(
loudnessDb = loudnessDb,
contentLength = format.contentLength
))
Database.update(
Database.insert(mediaItem).copy(
loudnessDb = loudnessDb,
contentLength = format.contentLength
)
)
}
}
@@ -553,14 +421,99 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
}
}
private fun MediaSession.Builder.withSessionActivity(): MediaSession.Builder {
return setSessionActivity(
PendingIntent.getActivity(
this@PlayerService,
0,
Intent(this@PlayerService, MainActivity::class.java),
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
inner class Binder : android.os.Binder() {
val player: ExoPlayer
get() = this@PlayerService.player
val cache: Cache
get() = this@PlayerService.cache
val sleepTimerMillisLeft: StateFlow<Long?>?
get() = timerJob?.millisLeft
fun startSleepTimer(delayMillis: Long) {
timerJob?.cancel()
timerJob = coroutineScope.timer(delayMillis) {
val notification = NotificationCompat
.Builder(this@PlayerService, SleepTimerNotificationChannelId)
.setContentTitle("Sleep timer ended")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setShowWhen(true)
.setSmallIcon(R.drawable.app_icon)
.build()
notificationManager.notify(SleepTimerNotificationId, notification)
exitProcess(0)
}
}
fun cancelSleepTimer() {
timerJob?.cancel()
timerJob = null
}
fun startRadio(
endpoint: NavigationEndpoint.Endpoint.Watch?,
) {
startRadio(
videoId = endpoint?.videoId,
playlistId = endpoint?.playlistId,
playlistSetVideoId = endpoint?.playlistSetVideoId,
parameters = endpoint?.params,
justAdd = false
)
)
}
fun startRadio(
videoId: String?,
playlistId: String? = null,
playlistSetVideoId: String? = null,
parameters: String? = null,
justAdd: Boolean = true
) {
radio = null
YoutubePlayer.Radio(
videoId, playlistId, playlistSetVideoId, parameters
).let {
coroutineScope.launch(Dispatchers.Main) {
if (justAdd) {
player.addMediaItems(it.process().drop(1))
} else {
player.forcePlayFromBeginning(it.process())
}
radio = it
}
}
}
fun stopRadio() {
radio = null
}
}
private class SessionCallback(private val player: Player) : MediaSessionCompat.Callback() {
override fun onPlay() = player.play()
override fun onPause() = player.pause()
override fun onSkipToPrevious() = player.seekToPrevious()
override fun onSkipToNext() = player.seekToNext()
override fun onSeekTo(pos: Long) = player.seekTo(pos)
}
class StopServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
context.stopService(context.intent<PlayerService>())
}
}
companion object {
private const val NotificationId = 1001
private const val NotificationChannelId = "default_channel_id"
private const val SleepTimerNotificationId = 1002
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
}
}

View File

@@ -20,14 +20,12 @@ import androidx.media3.common.Player
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.empty
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.services.DeleteSongCacheCommand
import it.vfsfitvnm.vimusic.services.StartRadioCommand
import it.vfsfitvnm.vimusic.services.StopRadioCommand
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute
@@ -66,7 +64,7 @@ fun InHistoryMediaItemMenu(
// https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
) {
val mediaController = LocalYoutubePlayer.current?.mediaController
val binder = LocalPlayerServiceBinder.current
val coroutineScope = rememberCoroutineScope()
@@ -82,7 +80,7 @@ fun InHistoryMediaItemMenu(
},
onConfirm = {
onDismiss()
mediaController?.sendCustomCommand(DeleteSongCacheCommand, bundleOf("videoId" to song.song.id))
binder?.cache?.removeResource(song.song.id)
coroutineScope.launch(Dispatchers.IO) {
Database.delete(song.song)
}
@@ -147,32 +145,24 @@ fun NonQueuedMediaItemMenu(
onDeleteFromDatabase: (() -> Unit)? = null,
onRemoveFromFavorites: (() -> Unit)? = null,
) {
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
BaseMediaItemMenu(
mediaItem = mediaItem,
onDismiss = onDismiss,
onStartRadio = {
player?.mediaController?.run {
forcePlay(mediaItem)
sendCustomCommand(StartRadioCommand, bundleOf(
"videoId" to mediaItem.mediaId,
"playlistId" to mediaItem.mediaMetadata.extras?.getString("playlistId")
))
}
binder?.player?.forcePlay(mediaItem)
binder?.startRadio(videoId = mediaItem.mediaId, playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId"))
},
onPlaySingle = {
player?.mediaController?.run {
sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
forcePlay(mediaItem)
}
binder?.player?.forcePlay(mediaItem)
},
onPlayNext = {
binder?.player?.addNext(mediaItem)
},
onEnqueue = {
binder?.player?.enqueue(mediaItem)
},
onPlayNext = if (player?.playbackState == Player.STATE_READY) ({
player.mediaController.addNext(mediaItem)
}) else null,
onEnqueue = if (player?.playbackState == Player.STATE_READY) ({
player.mediaController.enqueue(mediaItem)
}) else null,
onRemoveFromPlaylist = onRemoveFromPlaylist,
onDeleteFromDatabase = onDeleteFromDatabase,
onRemoveFromFavorites = onRemoveFromFavorites,
@@ -190,14 +180,14 @@ fun QueuedMediaItemMenu(
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide },
onGlobalRouteEmitted: (() -> Unit)? = null
) {
val player = LocalYoutubePlayer.current
val player = LocalPlayerServiceBinder.current?.player
BaseMediaItemMenu(
mediaItem = mediaItem,
onDismiss = onDismiss,
onRemoveFromQueue = if (player?.mediaItemIndex != indexInQueue) ({
player?.mediaController?.removeMediaItem(indexInQueue)
}) else null,
onRemoveFromQueue = {
player?.removeMediaItem(indexInQueue)
},
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = modifier
)

View File

@@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.os.Bundle
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -27,10 +26,9 @@ import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.services.StartArtistRadioCommand
import it.vfsfitvnm.vimusic.services.StopRadioCommand
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
@@ -79,7 +77,8 @@ fun ArtistScreen(
}
host {
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val density = LocalDensity.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
@@ -160,10 +159,7 @@ fun ArtistScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player?.mediaController?.sendCustomCommand(
StartArtistRadioCommand,
artist.shuffleEndpoint.asBundle
)
binder?.startRadio(artist.shuffleEndpoint)
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
@@ -180,10 +176,7 @@ fun ArtistScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player?.mediaController?.sendCustomCommand(
StartArtistRadioCommand,
artist.radioEndpoint.asBundle
)
binder?.startRadio(artist.radioEndpoint)
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
@@ -224,14 +217,8 @@ fun ArtistScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(enabled = songs.isNotEmpty()) {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayFromBeginning(
songs
.shuffled()
.map(SongWithInfo::asMediaItem)
)
}
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(songs.shuffled().map(SongWithInfo::asMediaItem))
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
@@ -248,10 +235,8 @@ fun ArtistScreen(
song = song,
thumbnailSize = songThumbnailSizePx,
onClick = {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayAtIndex(songs.map(SongWithInfo::asMediaItem), index)
}
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(songs.map(SongWithInfo::asMediaItem), index)
},
menuContent = {
InHistoryMediaItemMenu(song = song)

View File

@@ -1,7 +1,6 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.net.Uri
import android.os.Bundle
import androidx.compose.animation.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@@ -32,15 +31,18 @@ import androidx.compose.ui.zIndex
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.fastFade
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.SongCollection
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SearchQuery
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.services.StopRadioCommand
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.components.themed.InFavoritesMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem
@@ -144,7 +146,7 @@ fun HomeScreen() {
}
host {
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val density = LocalDensity.current
val thumbnailSize = remember {
@@ -357,10 +359,8 @@ fun HomeScreen() {
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(enabled = songCollection.isNotEmpty()) {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayFromBeginning(songCollection.shuffled().map(SongWithInfo::asMediaItem))
}
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(songCollection.shuffled().map(SongWithInfo::asMediaItem))
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
@@ -379,10 +379,8 @@ fun HomeScreen() {
song = song,
thumbnailSize = thumbnailSize,
onClick = {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayAtIndex(songCollection.map(SongWithInfo::asMediaItem), index)
}
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(songCollection.map(SongWithInfo::asMediaItem), index)
},
menuContent = {
when (preferences.homePageSongCollection) {

View File

@@ -1,7 +1,6 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.net.Uri
import android.os.Bundle
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -18,16 +17,15 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.services.StopRadioCommand
import it.vfsfitvnm.vimusic.ui.components.Error
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.Message
@@ -70,7 +68,7 @@ fun IntentUriScreen(uri: Uri) {
val menuState = LocalMenuState.current
val colorPalette = LocalColorPalette.current
val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val coroutineScope = rememberCoroutineScope()
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
@@ -164,14 +162,13 @@ fun IntentUriScreen(uri: Uri) {
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
enabled = player?.playbackState == Player.STATE_READY,
onClick = {
menuState.hide()
items.valueOrNull
?.map(YouTube.Item.Song::asMediaItem)
?.let { mediaItems ->
player?.mediaController?.enqueue(
binder?.player?.enqueue(
mediaItems
)
}
@@ -238,10 +235,8 @@ fun IntentUriScreen(uri: Uri) {
song = item,
thumbnailSizePx = density.run { 54.dp.roundToPx() },
onClick = {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayAtIndex(currentItems.value.map(YouTube.Item.Song::asMediaItem), index)
}
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(currentItems.value.map(YouTube.Item.Song::asMediaItem), index)
}
)
}

View File

@@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.os.Bundle
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -22,16 +21,15 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.services.StopRadioCommand
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.*
@@ -76,7 +74,7 @@ fun LocalPlaylistScreen(
val hapticFeedback = LocalHapticFeedback.current
val menuState = LocalMenuState.current
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
@@ -163,14 +161,10 @@ fun LocalPlaylistScreen(
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
enabled = playlistWithSongs.songs.isNotEmpty() && player?.playbackState == Player.STATE_READY,
enabled = playlistWithSongs.songs.isNotEmpty(),
onClick = {
menuState.hide()
player?.mediaController?.enqueue(
playlistWithSongs.songs.map(
SongWithInfo::asMediaItem
)
)
binder?.player?.enqueue(playlistWithSongs.songs.map(SongWithInfo::asMediaItem))
}
)
@@ -234,10 +228,8 @@ fun LocalPlaylistScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayFromBeginning(playlistWithSongs.songs.map(SongWithInfo::asMediaItem).shuffled())
}
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(playlistWithSongs.songs.map(SongWithInfo::asMediaItem).shuffled())
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
@@ -254,10 +246,8 @@ fun LocalPlaylistScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayFromBeginning(playlistWithSongs.songs.map(SongWithInfo::asMediaItem))
}
binder?.stopRadio()
binder?.player?.forcePlayFromBeginning(playlistWithSongs.songs.map(SongWithInfo::asMediaItem))
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
@@ -280,10 +270,8 @@ fun LocalPlaylistScreen(
song = song,
thumbnailSize = thumbnailSize,
onClick = {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
it.forcePlayAtIndex(playlistWithSongs.songs.map(SongWithInfo::asMediaItem), index)
}
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(playlistWithSongs.songs.map(SongWithInfo::asMediaItem), index)
},
menuContent = {
InPlaylistMediaItemMenu(

View File

@@ -1,7 +1,6 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.content.Intent
import android.os.Bundle
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -25,17 +24,16 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.services.StopRadioCommand
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
@@ -87,7 +85,8 @@ fun PlaylistOrAlbumScreen(
host {
val context = LocalContext.current
val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val menuState = LocalMenuState.current
@@ -142,7 +141,6 @@ fun PlaylistOrAlbumScreen(
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
enabled = player?.playbackState == Player.STATE_READY,
onClick = {
menuState.hide()
playlistOrAlbum.valueOrNull?.let { album ->
@@ -151,7 +149,7 @@ fun PlaylistOrAlbumScreen(
song.toMediaItem(browseId, album)
}
?.let { mediaItems ->
player?.mediaController?.enqueue(
binder?.player?.enqueue(
mediaItems
)
}
@@ -282,16 +280,14 @@ fun PlaylistOrAlbumScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
playlistOrAlbum.items
?.shuffled()
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
it.forcePlayFromBeginning(mediaItems)
}
}
binder?.stopRadio()
playlistOrAlbum.items
?.shuffled()
?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(mediaItems)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
@@ -308,13 +304,11 @@ fun PlaylistOrAlbumScreen(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
playlistOrAlbum.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
it.forcePlayFromBeginning(mediaItems)
}
binder?.stopRadio()
playlistOrAlbum.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum)
}?.let { mediaItems ->
binder?.player?.forcePlayFromBeginning(mediaItems)
}
}
.shadow(elevation = 2.dp, shape = CircleShape)
@@ -340,13 +334,11 @@ fun PlaylistOrAlbumScreen(
authors = (song.authors ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name },
durationText = song.durationText,
onClick = {
player?.mediaController?.let {
it.sendCustomCommand(StopRadioCommand, Bundle.EMPTY)
playlistOrAlbum.valueOrNull?.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
}?.let { mediaItems ->
it.forcePlayAtIndex(mediaItems, index)
}
binder?.stopRadio()
playlistOrAlbum.valueOrNull?.items?.mapNotNull { song ->
song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!)
}?.let { mediaItems ->
binder?.player?.forcePlayAtIndex(mediaItems, index)
}
},
startContent = {

View File

@@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic.ui.screens
import android.os.Bundle
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -26,16 +25,15 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import coil.compose.AsyncImage
import com.valentinilk.shimmer.Shimmer
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.services.StartRadioCommand
import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
@@ -45,7 +43,6 @@ import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -60,7 +57,7 @@ fun SearchResultScreen(
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val preferences = LocalPreferences.current
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val lazyListState = rememberLazyListState()
@@ -218,13 +215,13 @@ fun SearchResultScreen(
is YouTube.Item.Album -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Playlist -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Song -> player?.mediaController?.let {
it.forcePlay(item.asMediaItem)
it.sendCustomCommand(StartRadioCommand, item.info.endpoint.asBundle)
is YouTube.Item.Song -> {
binder?.player?.forcePlay(item.asMediaItem)
binder?.startRadio(item.info.endpoint)
}
is YouTube.Item.Video -> player?.mediaController?.let {
it.forcePlay(item.asMediaItem)
it.sendCustomCommand(StartRadioCommand, item.info.endpoint.asBundle)
is YouTube.Item.Video -> {
binder?.player?.forcePlay(item.asMediaItem)
binder?.startRadio(item.info.endpoint)
}
}
}
@@ -573,13 +570,3 @@ fun SmallArtistItem(
)
}
}
val NavigationEndpoint.Endpoint.Watch?.asBundle: Bundle
get() = this?.let {
bundleOf(
"videoId" to videoId,
"playlistId" to playlistId,
"playlistSetVideoId" to playlistSetVideoId,
"params" to params,
)
} ?: Bundle.EMPTY

View File

@@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic.ui.screens.settings
import android.os.Bundle
import android.text.format.Formatter
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.*
@@ -16,19 +15,17 @@ import androidx.compose.ui.unit.dp
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.services.GetCacheSizeCommand
import it.vfsfitvnm.vimusic.ui.components.SeekBar
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.screens.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoilApi::class)
@@ -58,9 +55,7 @@ fun OtherSettingsScreen() {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val preferences = LocalPreferences.current
val mediaController = LocalYoutubePlayer.current?.mediaController
val coilDiskCache = Coil.imageLoader(context).diskCache
val binder = LocalPlayerServiceBinder.current
val coroutineScope = rememberCoroutineScope()
@@ -97,7 +92,7 @@ fun OtherSettingsScreen() {
)
}
coilDiskCache?.let { diskCache ->
Coil.imageLoader(context).diskCache?.let { diskCache ->
var diskCacheSize by remember(diskCache) {
mutableStateOf(diskCache.size)
}
@@ -168,9 +163,11 @@ fun OtherSettingsScreen() {
)
}
mediaController?.let { mediaController ->
val diskCacheSize by produceState(initialValue = 0L) {
value = mediaController.sendCustomCommand(GetCacheSizeCommand, Bundle.EMPTY).await().extras.getLong("cacheSize")
binder?.cache?.let { cache ->
val diskCacheSize by remember {
derivedStateOf {
cache.cacheSpace
}
}
var scrubbingDiskCacheMaxSize by remember {

View File

@@ -2,7 +2,6 @@ package it.vfsfitvnm.vimusic.ui.screens.settings
import android.content.Intent
import android.media.audiofx.AudioEffect
import android.os.Bundle
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -18,12 +17,9 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.media3.common.C
import androidx.media3.session.SessionResult
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.services.*
import it.vfsfitvnm.vimusic.ui.components.ChunkyButton
import it.vfsfitvnm.vimusic.ui.components.Pager
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
@@ -32,10 +28,10 @@ import it.vfsfitvnm.vimusic.ui.components.themed.DefaultDialog
import it.vfsfitvnm.vimusic.ui.screens.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.isActive
import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.flow.flowOf
@ExperimentalAnimationApi
@@ -64,39 +60,23 @@ fun PlayerSettingsScreen() {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val preferences = LocalPreferences.current
val mediaController = LocalYoutubePlayer.current?.mediaController
val binder = LocalPlayerServiceBinder.current
val activityResultLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
}
val audioSessionId by produceState(initialValue = C.AUDIO_SESSION_ID_UNSET, mediaController) {
val audioSessionId = remember(binder) {
val hasEqualizer = context.packageManager.resolveActivity(
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL),
0
) != null
if (hasEqualizer) {
value =
mediaController?.sendCustomCommand(GetAudioSessionIdCommand, Bundle.EMPTY)
?.await()?.extras?.getInt("audioSessionId", C.AUDIO_SESSION_ID_UNSET)
?: C.AUDIO_SESSION_ID_UNSET
}
if (hasEqualizer) binder?.player?.audioSessionId else null
}
var sleepTimerMillisLeft by remember {
mutableStateOf<Long?>(null)
}
LaunchedEffect(mediaController) {
while (isActive) {
sleepTimerMillisLeft =
mediaController?.syncCommand(GetSleepTimerMillisLeftCommand)
?.takeIf { it.resultCode == SessionResult.RESULT_SUCCESS }
?.extras?.getLong("millisLeft")
delay(1000)
}
}
val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft
?: flowOf(null)).collectAsState(initial = null)
var isShowingSleepTimerDialog by remember {
mutableStateOf(false)
@@ -112,8 +92,7 @@ fun PlayerSettingsScreen() {
isShowingSleepTimerDialog = false
},
onConfirm = {
mediaController?.syncCommand(CancelSleepTimerCommand)
sleepTimerMillisLeft = null
binder?.cancelSleepTimer()
}
)
} else {
@@ -199,14 +178,7 @@ fun PlayerSettingsScreen() {
shape = RoundedCornerShape(36.dp),
isEnabled = hours > 0 || minutes > 0,
onClick = {
mediaController?.syncCommand(
SetSleepTimerCommand,
bundleOf("delayMillis" to (hours * 60 + minutes * 15) * 60 * 1000L)
)
sleepTimerMillisLeft =
mediaController?.syncCommand(GetSleepTimerMillisLeftCommand)?.extras?.getLong(
"millisLeft"
)
binder?.startSleepTimer((hours * 60 + minutes * 15) * 60 * 1000L)
isShowingSleepTimerDialog = false
}
)
@@ -253,10 +225,7 @@ fun PlayerSettingsScreen() {
text = "Skip silent parts during playback",
isChecked = preferences.skipSilence,
onCheckedChange = {
mediaController?.sendCustomCommand(
SetSkipSilenceCommand,
bundleOf("skipSilence" to it)
)
binder?.player?.skipSilenceEnabled = it
preferences.skipSilence = it
}
)
@@ -284,7 +253,7 @@ fun PlayerSettingsScreen() {
}
)
},
isEnabled = audioSessionId != C.AUDIO_SESSION_ID_UNSET && audioSessionId != AudioEffect.ERROR_BAD_VALUE
isEnabled = audioSessionId != null
)
SettingsEntry(

View File

@@ -6,14 +6,15 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -25,33 +26,29 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.Error
import it.vfsfitvnm.vimusic.ui.components.MusicBars
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer
import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
import it.vfsfitvnm.vimusic.utils.YoutubePlayer
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.youtubemusic.Outcome
import kotlinx.coroutines.launch
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.MusicBars
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.utils.PlayerState
@ExperimentalAnimationApi
@Composable
fun CurrentPlaylistView(
player: Player?,
playerState: PlayerState?,
layoutState: BottomSheetState,
onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val colorPalette = LocalColorPalette.current
val thumbnailSize = remember {
@@ -61,30 +58,26 @@ fun CurrentPlaylistView(
}
val isPaused by derivedStateOf {
player?.playbackState == Player.STATE_ENDED || player?.playWhenReady == false
playerState?.playbackState == Player.STATE_ENDED || playerState?.playWhenReady == false
}
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
val coroutineScope = rememberCoroutineScope()
val lazyListState =
rememberLazyListState(initialFirstVisibleItemIndex = player?.mediaItemIndex ?: 0)
rememberLazyListState(initialFirstVisibleItemIndex = playerState?.mediaItemIndex ?: 0)
val reorderingState = rememberReorderingState(player?.mediaItems ?: emptyList())
val reorderingState = rememberReorderingState(playerState?.mediaItems ?: emptyList())
LazyColumn(
state = lazyListState,
modifier = modifier
.nestedScroll(remember {
layoutState.nestedScrollConnection(player?.mediaItemIndex == 0)
layoutState.nestedScrollConnection(playerState?.mediaItemIndex == 0)
})
) {
itemsIndexed(
items = player?.mediaItems ?: emptyList()
items = playerState?.mediaItems ?: emptyList()
) { index, mediaItem ->
val isPlayingThisMediaItem by derivedStateOf {
player?.mediaItemIndex == index
playerState?.mediaItemIndex == index
}
SongItem(
@@ -93,13 +86,13 @@ fun CurrentPlaylistView(
onClick = {
if (isPlayingThisMediaItem) {
if (isPaused) {
player?.mediaController?.play()
player?.play()
} else {
player?.mediaController?.pause()
player?.pause()
}
} else {
player?.mediaController?.playWhenReady = true
player?.mediaController?.seekToDefaultPosition(index)
player?.playWhenReady = true
player?.seekToDefaultPosition(index)
}
},
menuContent = {
@@ -151,7 +144,7 @@ fun CurrentPlaylistView(
)
},
onDragEnd = { reachedIndex ->
player?.mediaController?.moveMediaItem(index, reachedIndex)
player?.moveMediaItem(index, reachedIndex)
}
)
)
@@ -165,7 +158,7 @@ fun CurrentPlaylistView(
// SideEffect {
// coroutineScope.launch {
// YoutubePlayer.Radio.process(
// player.mediaController,
// playerState.mediaController,
// force = true
// )
// }
@@ -194,7 +187,7 @@ fun CurrentPlaylistView(
// error = nextContinuation.error,
// onRetry = {
// coroutineScope.launch {
// YoutubePlayer.Radio.process(player.mediaController, force = true)
// YoutubePlayer.Radio.process(playerState.mediaController, force = true)
// }
// }
// )

View File

@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import it.vfsfitvnm.route.Route
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.empty
@@ -44,13 +45,13 @@ import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun PlayerBottomSheet(
player: Player?,
playerState: PlayerState?,
layoutState: BottomSheetState,
song: Song?,
onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier,
) {
val player = LocalYoutubePlayer.current ?: return
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
@@ -60,7 +61,7 @@ fun PlayerBottomSheet(
var route by rememberRoute()
var nextOutcome by remember(player.mediaItem!!.mediaId) {
var nextOutcome by remember(playerState?.mediaItem?.mediaId) {
mutableStateOf<Outcome<YouTube.NextResult>>(Outcome.Initial)
}
@@ -183,13 +184,19 @@ fun PlayerBottomSheet(
coroutineScope.launch(Dispatchers.Main) {
lyricsOutcome = Outcome.Loading
val mediaItem = player?.currentMediaItem!!
if (nextOutcome.isEvaluable) {
nextOutcome = Outcome.Loading
val mediaItemIndex = player.currentMediaItemIndex
nextOutcome = withContext(Dispatchers.IO) {
YouTube.next(
player.mediaItem!!.mediaId,
player.mediaItem!!.mediaMetadata.extras?.getString("playlistId"),
player.mediaItemIndex
mediaItem.mediaId,
mediaItem.mediaMetadata.extras?.getString("playlistId"),
mediaItemIndex
)
}
}
@@ -200,7 +207,7 @@ fun PlayerBottomSheet(
lyrics ?: ""
}.map { lyrics ->
withContext(Dispatchers.IO) {
(song ?: player.mediaItem?.let(Database::insert))?.let {
(song ?: mediaItem.let(Database::insert)).let {
Database.update(it.copy(lyrics = lyrics))
}
}
@@ -209,7 +216,7 @@ fun PlayerBottomSheet(
}
},
onSearchOnline = {
player.mediaMetadata.let {
player?.mediaMetadata?.let {
context.startActivity(Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(
SearchManager.QUERY,
@@ -219,8 +226,9 @@ fun PlayerBottomSheet(
}
},
onLyricsUpdate = { lyrics ->
val mediaItem = player?.currentMediaItem
coroutineScope.launch(Dispatchers.IO) {
(song ?: player.mediaItem?.let(Database::insert))?.let {
(song ?: mediaItem?.let(Database::insert))?.let {
Database.update(it.copy(lyrics = lyrics))
}
}
@@ -230,6 +238,8 @@ fun PlayerBottomSheet(
host {
CurrentPlaylistView(
player = player,
playerState = playerState,
layoutState = layoutState,
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = Modifier

View File

@@ -30,15 +30,16 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheSpan
import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.services.GetSongCacheSizeCommand
import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
@@ -67,12 +68,15 @@ fun PlayerView(
val typography = LocalTypography.current
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val player = LocalYoutubePlayer.current
val binder = LocalPlayerServiceBinder.current
val context = LocalContext.current
val player = binder?.player
val playerState = rememberYoutubePlayer(player)
val coroutineScope = rememberCoroutineScope()
player?.mediaItem ?: return
playerState?.mediaItem ?: return
val smallThumbnailSize = remember {
density.run { 64.dp.roundToPx() }
@@ -108,7 +112,7 @@ fun PlayerView(
y = 1.dp.toPx()
),
end = Offset(
x = ((size.width - offset) * player.progress) + offset,
x = ((size.width - offset) * playerState.progress) + offset,
y = 1.dp.toPx()
),
strokeWidth = 2.dp.toPx()
@@ -116,7 +120,7 @@ fun PlayerView(
}
) {
AsyncImage(
model = player.mediaMetadata.artworkUri.thumbnail(smallThumbnailSize),
model = playerState.mediaMetadata.artworkUri.thumbnail(smallThumbnailSize),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -129,13 +133,13 @@ fun PlayerView(
.weight(1f)
) {
BasicText(
text = player.mediaMetadata.title?.toString() ?: "",
text = playerState.mediaMetadata.title?.toString() ?: "",
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = player.mediaMetadata.artist?.toString() ?: "",
text = playerState.mediaMetadata.artist?.toString() ?: "",
style = typography.xs,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -143,16 +147,16 @@ fun PlayerView(
}
when {
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
playerState.playbackState == Player.STATE_ENDED || !playerState.playWhenReady -> Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
if (player.playbackState == Player.STATE_IDLE) {
player.mediaController.prepare()
if (playerState.playbackState == Player.STATE_IDLE) {
player?.prepare()
}
player.mediaController.play()
player?.play()
}
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
@@ -164,7 +168,7 @@ fun PlayerView(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.pause()
player?.pause()
}
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
@@ -175,10 +179,11 @@ fun PlayerView(
}
}
) {
val song by remember(player.mediaItem?.mediaId) {
player.mediaItem?.mediaId?.let(Database::songFlow)?.distinctUntilChanged() ?: flowOf(
null
)
val song by remember(playerState.mediaItem?.mediaId) {
playerState.mediaItem?.mediaId?.let(Database::songFlow)?.distinctUntilChanged()
?: flowOf(
null
)
}.collectAsState(initial = null, context = Dispatchers.IO)
var isShowingStatsForNerds by rememberSaveable {
@@ -192,7 +197,7 @@ fun PlayerView(
.padding(bottom = 72.dp)
.fillMaxSize()
) {
var scrubbingPosition by remember(player.mediaItemIndex) {
var scrubbingPosition by remember(playerState.mediaItemIndex) {
mutableStateOf<Long?>(null)
}
@@ -211,8 +216,8 @@ fun PlayerView(
.clickable {
menuState.display {
QueuedMediaItemMenu(
mediaItem = player.mediaItem ?: MediaItem.EMPTY,
indexInQueue = player.mediaItemIndex,
mediaItem = playerState.mediaItem ?: MediaItem.EMPTY,
indexInQueue = playerState.mediaItemIndex,
onDismiss = menuState::hide,
onGlobalRouteEmitted = layoutState.collapse
)
@@ -223,9 +228,9 @@ fun PlayerView(
)
}
if (player.error == null) {
if (playerState.error == null) {
AnimatedContent(
targetState = player.mediaItemIndex,
targetState = playerState.mediaItemIndex,
transitionSpec = {
val slideDirection =
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
@@ -240,7 +245,7 @@ fun PlayerView(
.align(Alignment.CenterHorizontally)
) {
val artworkUri = remember(it) {
player.mediaController.getMediaItemAt(it).mediaMetadata.artworkUri.thumbnail(
player?.getMediaItemAt(it)?.mediaMetadata?.artworkUri.thumbnail(
thumbnailSizePx
)
}
@@ -273,13 +278,37 @@ fun PlayerView(
enter = fadeIn(),
exit = fadeOut(),
) {
val cachedPercentage = remember(song?.contentLength) {
song?.contentLength?.let { contentLength ->
player.mediaController.syncCommand(
GetSongCacheSizeCommand,
bundleOf("videoId" to song?.id)
).extras.getLong("cacheSize").toFloat() / contentLength * 100
}?.roundToInt() ?: 0
var cachedBytes by remember(song?.id) {
mutableStateOf(binder?.cache?.getCachedBytes(song?.id ?: "", 0, -1) ?: 0L)
}
DisposableEffect(song?.id) {
val listener = object : Cache.Listener {
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
cachedBytes += span.length
}
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
cachedBytes -= span.length
}
override fun onSpanTouched(
cache: Cache,
oldSpan: CacheSpan,
newSpan: CacheSpan
) = Unit
}
song?.id?.let { key ->
binder?.cache?.addListener(key, listener)
}
onDispose {
song?.id?.let { key ->
binder?.cache?.removeListener(key, listener)
}
}
}
Column(
@@ -321,7 +350,7 @@ fun PlayerView(
Column {
BasicText(
text = "${player.volume.times(100).roundToInt()}%",
text = "${playerState.volume.times(100).roundToInt()}%",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
@@ -332,12 +361,21 @@ fun PlayerView(
)
BasicText(
text = song?.contentLength?.let { contentLength ->
Formatter.formatShortFileSize(context, contentLength)
Formatter.formatShortFileSize(
context,
contentLength
)
} ?: "Unknown",
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
BasicText(
text = "$cachedPercentage%",
text = buildString {
append(Formatter.formatShortFileSize(context, cachedBytes))
song?.contentLength?.let { contentLenght ->
append(" (${(cachedBytes.toFloat() / contentLenght * 100).roundToInt()}%)")
}
},
style = typography.xs.semiBold.color(BlackColorPalette.text)
)
}
@@ -354,16 +392,20 @@ fun PlayerView(
onClick = {
song?.let { song ->
coroutineScope.launch(Dispatchers.IO) {
YouTube.player(song.id).map { body ->
Database.update(
song.copy(
loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
contentLength = body.streamingData?.adaptiveFormats?.findLast { format ->
format.itag == 251 || format.itag == 140
}?.let(PlayerResponse.StreamingData.AdaptiveFormat::contentLength)
YouTube
.player(song.id)
.map { body ->
Database.update(
song.copy(
loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
contentLength = body.streamingData?.adaptiveFormats
?.findLast { format ->
format.itag == 251 || format.itag == 140
}
?.let(PlayerResponse.StreamingData.AdaptiveFormat::contentLength)
)
)
)
}
}
}
}
}
@@ -387,18 +429,18 @@ fun PlayerView(
.size(thumbnailSizeDp)
) {
Error(
error = Outcome.Error.Unhandled(player.error!!),
error = Outcome.Error.Unhandled(playerState.error!!),
onRetry = {
player.mediaController.playWhenReady = true
player.mediaController.prepare()
player.error = null
player?.playWhenReady = true
player?.prepare()
playerState.error = null
}
)
}
}
BasicText(
text = player.mediaMetadata.title?.toString() ?: "",
text = playerState.mediaMetadata.title?.toString() ?: "",
style = typography.l.bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -407,7 +449,7 @@ fun PlayerView(
)
BasicText(
text = player.mediaMetadata.extras?.getStringArrayList("artistNames")
text = playerState.mediaMetadata.extras?.getStringArrayList("artistNames")
?.joinToString("") ?: "",
style = typography.s.semiBold.secondary,
maxLines = 1,
@@ -417,24 +459,24 @@ fun PlayerView(
)
SeekBar(
value = scrubbingPosition ?: player.currentPosition,
value = scrubbingPosition ?: playerState.currentPosition,
minimumValue = 0,
maximumValue = player.duration,
maximumValue = playerState.duration,
onDragStart = {
scrubbingPosition = it
},
onDrag = { delta ->
scrubbingPosition = if (player.duration != C.TIME_UNSET) {
scrubbingPosition?.plus(delta)?.coerceIn(0, player.duration)
scrubbingPosition = if (playerState.duration != C.TIME_UNSET) {
scrubbingPosition?.plus(delta)?.coerceIn(0, playerState.duration)
} else {
null
}
},
onDragEnd = {
player.mediaController.seekTo(
scrubbingPosition ?: player.mediaController.currentPosition
)
player.currentPosition = player.mediaController.currentPosition
scrubbingPosition?.let { scrubbingPosition ->
player?.seekTo(scrubbingPosition)
playerState.currentPosition = scrubbingPosition
}
scrubbingPosition = null
},
color = colorPalette.text,
@@ -456,16 +498,16 @@ fun PlayerView(
) {
BasicText(
text = DateUtils.formatElapsedTime(
(scrubbingPosition ?: player.currentPosition) / 1000
(scrubbingPosition ?: playerState.currentPosition) / 1000
),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (player.duration != C.TIME_UNSET) {
if (playerState.duration != C.TIME_UNSET) {
BasicText(
text = DateUtils.formatElapsedTime(player.duration / 1000),
text = DateUtils.formatElapsedTime(playerState.duration / 1000),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -488,7 +530,7 @@ fun PlayerView(
modifier = Modifier
.clickable {
coroutineScope.launch(Dispatchers.IO) {
(song ?: player.mediaItem?.let(Database::insert))?.let {
(song ?: playerState.mediaItem?.let(Database::insert))?.let {
Database.update(it.toggleLike())
}
}
@@ -503,24 +545,24 @@ fun PlayerView(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.seekToPrevious()
player?.seekToPrevious()
}
.padding(horizontal = 16.dp)
.size(32.dp)
)
when {
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
playerState.playbackState == Player.STATE_ENDED || !playerState.playWhenReady -> Image(
painter = painterResource(R.drawable.play_circle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
if (player.playbackState == Player.STATE_IDLE) {
player.mediaController.prepare()
if (player?.playbackState == Player.STATE_IDLE) {
player.prepare()
}
player.mediaController.play()
player?.play()
}
.size(64.dp)
)
@@ -530,7 +572,7 @@ fun PlayerView(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.pause()
player?.pause()
}
.size(64.dp)
)
@@ -542,7 +584,7 @@ fun PlayerView(
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.seekToNext()
player?.seekToNext()
}
.padding(horizontal = 16.dp)
.size(32.dp)
@@ -551,7 +593,7 @@ fun PlayerView(
Image(
painter = painterResource(
if (player.repeatMode == Player.REPEAT_MODE_ONE) {
if (playerState.repeatMode == Player.REPEAT_MODE_ONE) {
R.drawable.repeat_one
} else {
R.drawable.repeat
@@ -559,7 +601,7 @@ fun PlayerView(
),
contentDescription = null,
colorFilter = ColorFilter.tint(
if (player.repeatMode == Player.REPEAT_MODE_OFF) {
if (playerState.repeatMode == Player.REPEAT_MODE_OFF) {
colorPalette.textDisabled
} else {
colorPalette.text
@@ -567,10 +609,13 @@ fun PlayerView(
),
modifier = Modifier
.clickable {
player.mediaController.repeatMode =
(player.mediaController.repeatMode + 2) % 3
preferences.repeatMode = player.mediaController.repeatMode
player?.repeatMode
?.plus(2)
?.mod(3)
?.let { repeatMode ->
player.repeatMode = repeatMode
preferences.repeatMode = repeatMode
}
}
.padding(horizontal = 16.dp)
.size(28.dp)
@@ -579,6 +624,8 @@ fun PlayerView(
}
PlayerBottomSheet(
player = player,
playerState = playerState,
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound - 128.dp),
onGlobalRouteEmitted = layoutState.collapse,
song = song,

View File

@@ -0,0 +1,23 @@
package it.vfsfitvnm.vimusic.utils
import android.app.Activity
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
inline fun <reified T> Context.intent(): Intent =
Intent(this@Context, T::class.java)
inline fun <reified T: BroadcastReceiver> Context.broadCastPendingIntent(
requestCode: Int = 0,
flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0,
): PendingIntent =
PendingIntent.getBroadcast(this, requestCode, intent<T>(), flags)
inline fun <reified T: Activity> Context.activityPendingIntent(
requestCode: Int = 0,
flags: Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0,
): PendingIntent =
PendingIntent.getActivity(this, requestCode, intent<T>(), flags)

View File

@@ -1,24 +0,0 @@
package it.vfsfitvnm.vimusic.utils
import android.os.Bundle
import androidx.media3.session.MediaController
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.guava.await
suspend fun MediaController.send(command: SessionCommand, args: Bundle = Bundle.EMPTY): SessionResult {
return sendCustomCommand(command, args).await()
}
fun MediaController.command(command: SessionCommand, args: Bundle = Bundle.EMPTY, listener: ((SessionResult) -> Unit)? = null) {
val future = sendCustomCommand(command, args)
listener?.let {
future.addListener({ it(future.get()) }, MoreExecutors.directExecutor())
}
}
fun MediaController.syncCommand(command: SessionCommand, args: Bundle = Bundle.EMPTY): SessionResult {
return sendCustomCommand(command, args).get()
}

View File

@@ -6,10 +6,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.media3.common.*
import androidx.media3.session.MediaController
import kotlin.math.absoluteValue
open class PlayerState(val mediaController: MediaController) : Player.Listener {
open class PlayerState(val mediaController: Player) : Player.Listener {
private val handler = Handler(Looper.getMainLooper())
var currentPosition by mutableStateOf(mediaController.currentPosition)
@@ -52,7 +51,6 @@ open class PlayerState(val mediaController: MediaController) : Player.Listener {
init {
handler.post(object : Runnable {
override fun run() {
duration = mediaController.duration
currentPosition = mediaController.currentPosition
handler.postDelayed(this, 500)
}
@@ -64,6 +62,7 @@ open class PlayerState(val mediaController: MediaController) : Player.Listener {
}
override fun onPlaybackStateChanged(playbackState: Int) {
duration = mediaController.duration
this.playbackState = playbackState
}

View File

@@ -0,0 +1,43 @@
package it.vfsfitvnm.vimusic.utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
interface TimerJob {
val millisLeft: StateFlow<Long?>
fun cancel()
}
fun CoroutineScope.timer(delayMillis: Long, onCompletion: () -> Unit): TimerJob {
val millisLeft = MutableStateFlow<Long?>(delayMillis)
val job = launch {
while (isActive && millisLeft.value != null) {
delay(1000)
millisLeft.emit(millisLeft.value?.minus(1000)?.takeIf { it > 0 })
}
}
val disposableHandle = job.invokeOnCompletion {
if (it == null) {
onCompletion()
}
}
return object : TimerJob {
override val millisLeft: StateFlow<Long?>
get() = millisLeft.asStateFlow()
override fun cancel() {
millisLeft.value = null
disposableHandle.dispose()
job.cancel()
}
}
}

View File

@@ -2,15 +2,13 @@ package it.vfsfitvnm.vimusic.utils
import androidx.compose.runtime.*
import androidx.media3.common.MediaItem
import androidx.media3.session.MediaController
import com.google.common.util.concurrent.ListenableFuture
import androidx.media3.common.Player
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.withContext
class YoutubePlayer(mediaController: MediaController) : PlayerState(mediaController) {
class YoutubePlayer(mediaController: Player) : PlayerState(mediaController) {
data class Radio(
private val videoId: String? = null,
private val playlistId: String? = null,
@@ -45,22 +43,13 @@ class YoutubePlayer(mediaController: MediaController) : PlayerState(mediaControl
}
}
val LocalYoutubePlayer = compositionLocalOf<YoutubePlayer?> { null }
@Composable
fun rememberYoutubePlayer(
mediaControllerFuture: ListenableFuture<MediaController>
player: Player?
): YoutubePlayer? {
val mediaController by produceState<MediaController?>(initialValue = null) {
value = mediaControllerFuture.await()
}
val playerState = remember(mediaController) {
YoutubePlayer(mediaController ?: return@remember null).also {
// TODO: should we remove the listener later on?
mediaController?.addListener(it)
return remember(player) {
YoutubePlayer(player ?: return@remember null).also {
player.addListener(it)
}
}
return playerState
}

View File

@@ -144,6 +144,8 @@ val SongWithInfo.asMediaItem: MediaItem
.build()
)
.setMediaId(song.id)
.setUri(song.id)
.setCustomCacheKey(song.id)
.build()
fun YouTube.PlaylistOrAlbum.Item.toMediaItem(
@@ -172,6 +174,8 @@ fun YouTube.PlaylistOrAlbum.Item.toMediaItem(
.build()
)
.setMediaId(info.endpoint?.videoId ?: return null)
.setUri(info.endpoint?.videoId ?: return null)
.setCustomCacheKey(info.endpoint?.videoId ?: return null)
.build()
}

View File

@@ -36,8 +36,7 @@ dependencyResolutionManagement {
alias("room-compiler").to("androidx.room", "room-compiler").versionRef("room")
version("media3", "1.0.0-beta01")
alias("media3-session").to("androidx.media3", "media3-session").versionRef("media3")
alias("media3-exoplayer").to("androidx.media3", "media3-exoplayer").versionRef("media3")
alias("exoplayer").to("androidx.media3", "media3-exoplayer").versionRef("media3")
version("ktor", "2.0.2")
alias("ktor-client-core").to("io.ktor", "ktor-client-core").versionRef("ktor")
@@ -49,8 +48,6 @@ dependencyResolutionManagement {
alias("brotli").to("org.brotli", "dec").version("0.1.2")
alias("guava-coroutines").to("org.jetbrains.kotlinx", "kotlinx-coroutines-guava").version("1.6.2")
alias("desugaring").to("com.android.tools", "desugar_jdk_libs").version("1.1.5")
}