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.accompanist.systemuicontroller)
implementation(libs.android.media) implementation(libs.android.media)
implementation(libs.media3.session) implementation(libs.exoplayer)
implementation(libs.media3.exoplayer)
implementation(libs.room) implementation(libs.room)
kapt(libs.room.compiler) kapt(libs.room.compiler)
implementation(projects.youtubeMusic) implementation(projects.youtubeMusic)
implementation(libs.guava.coroutines)
coreLibraryDesugaring(libs.desugaring) coreLibraryDesugaring(libs.desugaring)
} }

View File

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

View File

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

View File

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

View File

@@ -2,30 +2,28 @@ package it.vfsfitvnm.vimusic.services
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.support.v4.media.MediaMetadataCompat
import android.os.SystemClock import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.DrawableRes 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.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.os.bundleOf import androidx.media.session.MediaButtonReceiver
import androidx.media3.common.* import androidx.media3.common.*
import androidx.media3.common.util.Util
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache 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.PlaybackStats
import androidx.media3.exoplayer.analytics.PlaybackStatsListener import androidx.media3.exoplayer.analytics.PlaybackStatsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.*
import androidx.media3.session.MediaNotification.ActionFactory
import coil.Coil import coil.Coil
import coil.request.ImageRequest 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.Database
import it.vfsfitvnm.vimusic.MainActivity import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.utils.* import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess import kotlin.system.exitProcess
val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY) class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback {
val StartArtistRadioCommand = SessionCommand("StartArtistRadioCommand", Bundle.EMPTY) private lateinit var mediaSession: MediaSessionCompat
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"
}
private lateinit var cache: SimpleCache private lateinit var cache: SimpleCache
private lateinit var player: ExoPlayer 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 lateinit var notificationManager: NotificationManager
private var timerJob: TimerJob? = null
private var notificationThumbnailSize: Int = 0 private var notificationThumbnailSize: Int = 0
private var lastArtworkUri: Uri? = null private var lastArtworkUri: Uri? = null
private var lastBitmap: Bitmap? = null private var lastBitmap: Bitmap? = null
private var radio: YoutubePlayer.Radio? = 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 coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
private val songPendingLoudnessDb = mutableMapOf<String, Float?>() private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
override fun onBind(intent: Intent?) = Binder()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationThumbnailSize = (256 * resources.displayMetrics.density).roundToInt() notificationThumbnailSize = (256 * resources.displayMetrics.density).roundToInt()
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
createNotificationChannel() createNotificationChannel()
setMediaNotificationProvider(this)
val cacheEvictor = LeastRecentlyUsedCacheEvictor(preferences.exoPlayerDiskCacheMaxSizeBytes) val cacheEvictor = LeastRecentlyUsedCacheEvictor(preferences.exoPlayerDiskCacheMaxSizeBytes)
cache = SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(this)) cache = SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(this))
@@ -132,153 +110,39 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
player.repeatMode = preferences.repeatMode player.repeatMode = preferences.repeatMode
player.skipSilenceEnabled = preferences.skipSilence player.skipSilenceEnabled = preferences.skipSilence
player.playWhenReady = true player.playWhenReady = true
player.addListener(this)
player.addAnalyticsListener(PlaybackStatsListener(false, this)) player.addAnalyticsListener(PlaybackStatsListener(false, this))
mediaSession = MediaSession.Builder(this, player) mediaSession = MediaSessionCompat(this, "PlayerService")
.withSessionActivity() mediaSession.setCallback(SessionCallback(player))
.setCallback(this) mediaSession.setPlaybackState(stateBuilder.build())
.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() { override fun onDestroy() {
player.removeListener(this)
player.stop()
player.release() player.release()
mediaSession.isActive = false
mediaSession.release() mediaSession.release()
cache.release() cache.release()
super.onDestroy() 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( override fun onPlaybackStatsReady(
eventTime: AnalyticsListener.EventTime, eventTime: AnalyticsListener.EventTime,
playbackStats: PlaybackStats playbackStats: PlaybackStats
@@ -308,63 +172,62 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
player.volume = player.currentMediaItem?.mediaId?.let { mediaId -> player.volume = player.currentMediaItem?.mediaId?.let { mediaId ->
songPendingLoudnessDb.getOrElse(mediaId) { songPendingLoudnessDb.getOrElse(mediaId) {
player.currentMediaItem?.mediaMetadata?.extras?.getFloat("loudnessDb") player.currentMediaItem?.mediaMetadata?.extras?.getFloat("loudnessDb")
} }?.takeIf { it > 0 }?.let { loudnessDb ->
?.takeIf { it > 0 } (1f - (0.01f + loudnessDb / 14)).coerceIn(0.1f, 1f)
?.let { loudnessDb ->
(1f - (0.01f + loudnessDb / 15)).coerceIn(0.1f, 1f)
} }
} ?: 1f } ?: 1f
} }
} }
override fun onAddMediaItems( override fun onPositionDiscontinuity(
mediaSession: MediaSession, oldPosition: Player.PositionInfo,
controller: MediaSession.ControllerInfo, newPosition: Player.PositionInfo,
mediaItems: List<MediaItem> @Player.DiscontinuityReason reason: Int
): ListenableFuture<List<MediaItem>> { ) {
return Futures.immediateFuture( stateBuilder
mediaItems.map { mediaItem -> .setState(PlaybackStateCompat.STATE_NONE, newPosition.positionMs, 1f)
mediaItem.buildUpon() .setBufferedPosition(player.bufferedPosition)
.setUri(mediaItem.mediaId)
.setCustomCacheKey(mediaItem.mediaId) updateNotification()
.build()
}
)
} }
override fun createNotification( override fun onIsPlayingChanged(isPlaying: Boolean) {
session: MediaSession, stateBuilder
customLayout: ImmutableList<CommandButton>, .setState(
actionFactory: ActionFactory, if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
onNotificationChangedCallback: MediaNotification.Provider.Callback player.currentPosition,
): MediaNotification { 1f
fun invalidate() {
onNotificationChangedCallback.onNotificationChanged(
createNotification(
session,
customLayout,
actionFactory,
onNotificationChangedCallback
)
) )
.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( fun NotificationCompat.Builder.addMediaAction(
@DrawableRes resId: Int, @DrawableRes resId: Int,
@StringRes stringId: Int, description: String,
@Player.Command command: Int @PlaybackStateCompat.MediaKeyAction command: Long
): NotificationCompat.Builder { ): NotificationCompat.Builder {
return addAction( return addAction(
actionFactory.createMediaAction( NotificationCompat.Action(
mediaSession, resId,
IconCompat.createWithResource(this@PlayerService, resId), description,
getString(stringId), MediaButtonReceiver.buildMediaButtonPendingIntent(this@PlayerService, command)
command
) )
) )
} }
val mediaMetadata = mediaSession.player.mediaMetadata val mediaMetadata = player.mediaMetadata
val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId) val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId)
.setContentTitle(mediaMetadata.title) .setContentTitle(mediaMetadata.title)
@@ -375,72 +238,75 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
.setShowWhen(false) .setShowWhen(false)
.setSmallIcon(R.drawable.app_icon) .setSmallIcon(R.drawable.app_icon)
.setOngoing(false) .setOngoing(false)
.setContentIntent(mediaSession.sessionActivity) .setContentIntent(activityPendingIntent<MainActivity>())
.setDeleteIntent( .setDeleteIntent(broadCastPendingIntent<StopServiceBroadcastReceiver>())
actionFactory.createMediaActionPendingIntent( .setChannelId(NotificationChannelId)
mediaSession,
Player.COMMAND_STOP.toLong()
)
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle( .setStyle(
androidx.media.app.NotificationCompat.MediaStyle() androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2) .setShowActionsInCompactView(0, 1, 2)
.setMediaSession(mediaSession.sessionCompatToken as android.support.v4.media.session.MediaSessionCompat.Token) .setMediaSession(mediaSession.sessionToken)
) )
.addMediaAction( .addMediaAction(
R.drawable.play_skip_back, R.drawable.play_skip_back,
R.string.media3_controls_seek_to_previous_description, "Skip back",
Player.COMMAND_SEEK_TO_PREVIOUS PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
).addMediaAction( ).addMediaAction(
if (mediaSession.player.playbackState == Player.STATE_ENDED || !mediaSession.player.playWhenReady) R.drawable.play else R.drawable.pause, if (player.playbackState == Player.STATE_ENDED || !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, if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) "Play" else "Pause",
Player.COMMAND_PLAY_PAUSE if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) PlaybackStateCompat.ACTION_PLAY else PlaybackStateCompat.ACTION_PAUSE
) )
.addMediaAction( .addMediaAction(
R.drawable.play_skip_forward, R.drawable.play_skip_forward,
R.string.media3_controls_seek_to_next_description, "Skip forward",
Player.COMMAND_SEEK_TO_NEXT PlaybackStateCompat.ACTION_SKIP_TO_NEXT
) )
if (lastArtworkUri != mediaMetadata.artworkUri) { if (lastArtworkUri != mediaMetadata.artworkUri) {
coroutineScope.launch(Dispatchers.IO) { lastArtworkUri = mediaMetadata.artworkUri
lastBitmap = Coil.imageLoader(applicationContext).execute(
Coil.imageLoader(applicationContext).enqueue(
ImageRequest.Builder(applicationContext) ImageRequest.Builder(applicationContext)
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize)) .data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
.build() .listener(
).drawable?.let { onError = { _, _ ->
lastArtworkUri = mediaMetadata.artworkUri lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
(it as BitmapDrawable).bitmap
} ?: resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize) ?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
notificationManager.notify(NotificationId, builder.setLargeIcon(lastBitmap).build())
withContext(Dispatchers.Main) { },
invalidate() 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( if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) {
session: MediaSession, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
action: String, stopForeground(STOP_FOREGROUND_DETACH)
extras: Bundle } else {
): Boolean = false stopForeground(false)
}
notificationManager.notify(NotificationId, notificationCompat)
}
}
private fun createNotificationChannel() { private fun createNotificationChannel() {
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 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) { with(notificationManager) {
if (getNotificationChannel(NotificationChannelId) == null) { if (getNotificationChannel(NotificationChannelId) == null) {
createNotificationChannel( createNotificationChannel(
NotificationChannel( NotificationChannel(
NotificationChannelId, NotificationChannelId,
getString(R.string.default_notification_channel_name), "Now playing",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
) )
) )
@@ -504,10 +370,12 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
if (mediaItem?.mediaId == videoId) { if (mediaItem?.mediaId == videoId) {
Database.internal.queryExecutor.execute { Database.internal.queryExecutor.execute {
Database.update(Database.insert(mediaItem).copy( Database.update(
Database.insert(mediaItem).copy(
loudnessDb = loudnessDb, loudnessDb = loudnessDb,
contentLength = format.contentLength contentLength = format.contentLength
)) )
)
} }
} }
@@ -553,14 +421,99 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
} }
} }
private fun MediaSession.Builder.withSessionActivity(): MediaSession.Builder { inner class Binder : android.os.Binder() {
return setSessionActivity( val player: ExoPlayer
PendingIntent.getActivity( get() = this@PlayerService.player
this@PlayerService,
0, val cache: Cache
Intent(this@PlayerService, MainActivity::class.java), get() = this@PlayerService.cache
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
) 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.RouteHandler
import it.vfsfitvnm.route.empty import it.vfsfitvnm.route.empty
import it.vfsfitvnm.vimusic.Database import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Playlist import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo 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.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute
@@ -66,7 +64,7 @@ fun InHistoryMediaItemMenu(
// https://issuetracker.google.com/issues/226410236 // https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide } onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
) { ) {
val mediaController = LocalYoutubePlayer.current?.mediaController val binder = LocalPlayerServiceBinder.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -82,7 +80,7 @@ fun InHistoryMediaItemMenu(
}, },
onConfirm = { onConfirm = {
onDismiss() onDismiss()
mediaController?.sendCustomCommand(DeleteSongCacheCommand, bundleOf("videoId" to song.song.id)) binder?.cache?.removeResource(song.song.id)
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
Database.delete(song.song) Database.delete(song.song)
} }
@@ -147,32 +145,24 @@ fun NonQueuedMediaItemMenu(
onDeleteFromDatabase: (() -> Unit)? = null, onDeleteFromDatabase: (() -> Unit)? = null,
onRemoveFromFavorites: (() -> Unit)? = null, onRemoveFromFavorites: (() -> Unit)? = null,
) { ) {
val player = LocalYoutubePlayer.current val binder = LocalPlayerServiceBinder.current
BaseMediaItemMenu( BaseMediaItemMenu(
mediaItem = mediaItem, mediaItem = mediaItem,
onDismiss = onDismiss, onDismiss = onDismiss,
onStartRadio = { onStartRadio = {
player?.mediaController?.run { binder?.player?.forcePlay(mediaItem)
forcePlay(mediaItem) binder?.startRadio(videoId = mediaItem.mediaId, playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId"))
sendCustomCommand(StartRadioCommand, bundleOf(
"videoId" to mediaItem.mediaId,
"playlistId" to mediaItem.mediaMetadata.extras?.getString("playlistId")
))
}
}, },
onPlaySingle = { onPlaySingle = {
player?.mediaController?.run { binder?.player?.forcePlay(mediaItem)
sendCustomCommand(StopRadioCommand, Bundle.EMPTY) },
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, onRemoveFromPlaylist = onRemoveFromPlaylist,
onDeleteFromDatabase = onDeleteFromDatabase, onDeleteFromDatabase = onDeleteFromDatabase,
onRemoveFromFavorites = onRemoveFromFavorites, onRemoveFromFavorites = onRemoveFromFavorites,
@@ -190,14 +180,14 @@ fun QueuedMediaItemMenu(
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }, onDismiss: () -> Unit = LocalMenuState.current.let { it::hide },
onGlobalRouteEmitted: (() -> Unit)? = null onGlobalRouteEmitted: (() -> Unit)? = null
) { ) {
val player = LocalYoutubePlayer.current val player = LocalPlayerServiceBinder.current?.player
BaseMediaItemMenu( BaseMediaItemMenu(
mediaItem = mediaItem, mediaItem = mediaItem,
onDismiss = onDismiss, onDismiss = onDismiss,
onRemoveFromQueue = if (player?.mediaItemIndex != indexInQueue) ({ onRemoveFromQueue = {
player?.mediaController?.removeMediaItem(indexInQueue) player?.removeMediaItem(indexInQueue)
}) else null, },
onGlobalRouteEmitted = onGlobalRouteEmitted, onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = modifier modifier = modifier
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
package it.vfsfitvnm.vimusic.ui.screens package it.vfsfitvnm.vimusic.ui.screens
import android.os.Bundle
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.valentinilk.shimmer.Shimmer import com.valentinilk.shimmer.Shimmer
import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.services.StartRadioCommand
import it.vfsfitvnm.vimusic.ui.components.* import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder 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.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -60,7 +57,7 @@ fun SearchResultScreen(
val colorPalette = LocalColorPalette.current val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current val typography = LocalTypography.current
val preferences = LocalPreferences.current val preferences = LocalPreferences.current
val player = LocalYoutubePlayer.current val binder = LocalPlayerServiceBinder.current
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@@ -218,13 +215,13 @@ fun SearchResultScreen(
is YouTube.Item.Album -> playlistOrAlbumRoute(item.info.endpoint!!.browseId) is YouTube.Item.Album -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Artist -> artistRoute(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.Playlist -> playlistOrAlbumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Song -> player?.mediaController?.let { is YouTube.Item.Song -> {
it.forcePlay(item.asMediaItem) binder?.player?.forcePlay(item.asMediaItem)
it.sendCustomCommand(StartRadioCommand, item.info.endpoint.asBundle) binder?.startRadio(item.info.endpoint)
} }
is YouTube.Item.Video -> player?.mediaController?.let { is YouTube.Item.Video -> {
it.forcePlay(item.asMediaItem) binder?.player?.forcePlay(item.asMediaItem)
it.sendCustomCommand(StartRadioCommand, item.info.endpoint.asBundle) 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 package it.vfsfitvnm.vimusic.ui.screens.settings
import android.os.Bundle
import android.text.format.Formatter import android.text.format.Formatter
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.* import androidx.compose.foundation.*
@@ -16,19 +15,17 @@ import androidx.compose.ui.unit.dp
import coil.Coil import coil.Coil
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.services.GetCacheSizeCommand
import it.vfsfitvnm.vimusic.ui.components.SeekBar import it.vfsfitvnm.vimusic.ui.components.SeekBar
import it.vfsfitvnm.vimusic.ui.components.TopAppBar import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.screens.* import it.vfsfitvnm.vimusic.ui.screens.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.LocalPreferences import it.vfsfitvnm.vimusic.utils.LocalPreferences
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
import it.vfsfitvnm.vimusic.utils.secondary import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@@ -58,9 +55,7 @@ fun OtherSettingsScreen() {
val colorPalette = LocalColorPalette.current val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current val typography = LocalTypography.current
val preferences = LocalPreferences.current val preferences = LocalPreferences.current
val mediaController = LocalYoutubePlayer.current?.mediaController val binder = LocalPlayerServiceBinder.current
val coilDiskCache = Coil.imageLoader(context).diskCache
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -97,7 +92,7 @@ fun OtherSettingsScreen() {
) )
} }
coilDiskCache?.let { diskCache -> Coil.imageLoader(context).diskCache?.let { diskCache ->
var diskCacheSize by remember(diskCache) { var diskCacheSize by remember(diskCache) {
mutableStateOf(diskCache.size) mutableStateOf(diskCache.size)
} }
@@ -168,9 +163,11 @@ fun OtherSettingsScreen() {
) )
} }
mediaController?.let { mediaController -> binder?.cache?.let { cache ->
val diskCacheSize by produceState(initialValue = 0L) { val diskCacheSize by remember {
value = mediaController.sendCustomCommand(GetCacheSizeCommand, Bundle.EMPTY).await().extras.getLong("cacheSize") derivedStateOf {
cache.cacheSpace
}
} }
var scrubbingDiskCacheMaxSize by remember { var scrubbingDiskCacheMaxSize by remember {

View File

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

View File

@@ -6,14 +6,15 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -25,33 +26,29 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.Player import androidx.media3.common.Player
import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer 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.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import kotlinx.coroutines.launch 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 @ExperimentalAnimationApi
@Composable @Composable
fun CurrentPlaylistView( fun CurrentPlaylistView(
player: Player?,
playerState: PlayerState?,
layoutState: BottomSheetState, layoutState: BottomSheetState,
onGlobalRouteEmitted: () -> Unit, onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val colorPalette = LocalColorPalette.current val colorPalette = LocalColorPalette.current
val thumbnailSize = remember { val thumbnailSize = remember {
@@ -61,30 +58,26 @@ fun CurrentPlaylistView(
} }
val isPaused by derivedStateOf { 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 = 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( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = modifier modifier = modifier
.nestedScroll(remember { .nestedScroll(remember {
layoutState.nestedScrollConnection(player?.mediaItemIndex == 0) layoutState.nestedScrollConnection(playerState?.mediaItemIndex == 0)
}) })
) { ) {
itemsIndexed( itemsIndexed(
items = player?.mediaItems ?: emptyList() items = playerState?.mediaItems ?: emptyList()
) { index, mediaItem -> ) { index, mediaItem ->
val isPlayingThisMediaItem by derivedStateOf { val isPlayingThisMediaItem by derivedStateOf {
player?.mediaItemIndex == index playerState?.mediaItemIndex == index
} }
SongItem( SongItem(
@@ -93,13 +86,13 @@ fun CurrentPlaylistView(
onClick = { onClick = {
if (isPlayingThisMediaItem) { if (isPlayingThisMediaItem) {
if (isPaused) { if (isPaused) {
player?.mediaController?.play() player?.play()
} else { } else {
player?.mediaController?.pause() player?.pause()
} }
} else { } else {
player?.mediaController?.playWhenReady = true player?.playWhenReady = true
player?.mediaController?.seekToDefaultPosition(index) player?.seekToDefaultPosition(index)
} }
}, },
menuContent = { menuContent = {
@@ -151,7 +144,7 @@ fun CurrentPlaylistView(
) )
}, },
onDragEnd = { reachedIndex -> onDragEnd = { reachedIndex ->
player?.mediaController?.moveMediaItem(index, reachedIndex) player?.moveMediaItem(index, reachedIndex)
} }
) )
) )
@@ -165,7 +158,7 @@ fun CurrentPlaylistView(
// SideEffect { // SideEffect {
// coroutineScope.launch { // coroutineScope.launch {
// YoutubePlayer.Radio.process( // YoutubePlayer.Radio.process(
// player.mediaController, // playerState.mediaController,
// force = true // force = true
// ) // )
// } // }
@@ -194,7 +187,7 @@ fun CurrentPlaylistView(
// error = nextContinuation.error, // error = nextContinuation.error,
// onRetry = { // onRetry = {
// coroutineScope.launch { // 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.draw.scale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import it.vfsfitvnm.route.Route import it.vfsfitvnm.route.Route
import it.vfsfitvnm.route.RouteHandler import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.empty import it.vfsfitvnm.route.empty
@@ -44,13 +45,13 @@ import kotlinx.coroutines.withContext
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
fun PlayerBottomSheet( fun PlayerBottomSheet(
player: Player?,
playerState: PlayerState?,
layoutState: BottomSheetState, layoutState: BottomSheetState,
song: Song?, song: Song?,
onGlobalRouteEmitted: () -> Unit, onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val player = LocalYoutubePlayer.current ?: return
val colorPalette = LocalColorPalette.current val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current val typography = LocalTypography.current
@@ -60,7 +61,7 @@ fun PlayerBottomSheet(
var route by rememberRoute() var route by rememberRoute()
var nextOutcome by remember(player.mediaItem!!.mediaId) { var nextOutcome by remember(playerState?.mediaItem?.mediaId) {
mutableStateOf<Outcome<YouTube.NextResult>>(Outcome.Initial) mutableStateOf<Outcome<YouTube.NextResult>>(Outcome.Initial)
} }
@@ -183,13 +184,19 @@ fun PlayerBottomSheet(
coroutineScope.launch(Dispatchers.Main) { coroutineScope.launch(Dispatchers.Main) {
lyricsOutcome = Outcome.Loading lyricsOutcome = Outcome.Loading
val mediaItem = player?.currentMediaItem!!
if (nextOutcome.isEvaluable) { if (nextOutcome.isEvaluable) {
nextOutcome = Outcome.Loading nextOutcome = Outcome.Loading
val mediaItemIndex = player.currentMediaItemIndex
nextOutcome = withContext(Dispatchers.IO) { nextOutcome = withContext(Dispatchers.IO) {
YouTube.next( YouTube.next(
player.mediaItem!!.mediaId, mediaItem.mediaId,
player.mediaItem!!.mediaMetadata.extras?.getString("playlistId"), mediaItem.mediaMetadata.extras?.getString("playlistId"),
player.mediaItemIndex mediaItemIndex
) )
} }
} }
@@ -200,7 +207,7 @@ fun PlayerBottomSheet(
lyrics ?: "" lyrics ?: ""
}.map { lyrics -> }.map { lyrics ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
(song ?: player.mediaItem?.let(Database::insert))?.let { (song ?: mediaItem.let(Database::insert)).let {
Database.update(it.copy(lyrics = lyrics)) Database.update(it.copy(lyrics = lyrics))
} }
} }
@@ -209,7 +216,7 @@ fun PlayerBottomSheet(
} }
}, },
onSearchOnline = { onSearchOnline = {
player.mediaMetadata.let { player?.mediaMetadata?.let {
context.startActivity(Intent(Intent.ACTION_WEB_SEARCH).apply { context.startActivity(Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra( putExtra(
SearchManager.QUERY, SearchManager.QUERY,
@@ -219,8 +226,9 @@ fun PlayerBottomSheet(
} }
}, },
onLyricsUpdate = { lyrics -> onLyricsUpdate = { lyrics ->
val mediaItem = player?.currentMediaItem
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
(song ?: player.mediaItem?.let(Database::insert))?.let { (song ?: mediaItem?.let(Database::insert))?.let {
Database.update(it.copy(lyrics = lyrics)) Database.update(it.copy(lyrics = lyrics))
} }
} }
@@ -230,6 +238,8 @@ fun PlayerBottomSheet(
host { host {
CurrentPlaylistView( CurrentPlaylistView(
player = player,
playerState = playerState,
layoutState = layoutState, layoutState = layoutState,
onGlobalRouteEmitted = onGlobalRouteEmitted, onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = Modifier modifier = Modifier

View File

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

View File

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

View File

@@ -36,8 +36,7 @@ dependencyResolutionManagement {
alias("room-compiler").to("androidx.room", "room-compiler").versionRef("room") alias("room-compiler").to("androidx.room", "room-compiler").versionRef("room")
version("media3", "1.0.0-beta01") version("media3", "1.0.0-beta01")
alias("media3-session").to("androidx.media3", "media3-session").versionRef("media3") alias("exoplayer").to("androidx.media3", "media3-exoplayer").versionRef("media3")
alias("media3-exoplayer").to("androidx.media3", "media3-exoplayer").versionRef("media3")
version("ktor", "2.0.2") version("ktor", "2.0.2")
alias("ktor-client-core").to("io.ktor", "ktor-client-core").versionRef("ktor") 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("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") alias("desugaring").to("com.android.tools", "desugar_jdk_libs").version("1.1.5")
} }