diff --git a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt index 2e3669b6..38d8f09e 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/MainActivity.kt @@ -145,13 +145,11 @@ class MainActivity : AppCompatActivity() { uiViewModel.fromNotification.value = true return } - println("Intent: $data") val uri = data if (uri != null) { fun createSnack(id: Int) { val snackbar by viewModels() val message = getString(id) - println("bruh : $message") snackbar.create(SnackBar.Message(message)) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt b/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt index b01edaec..4c44fbe6 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/PlaybackService.kt @@ -16,23 +16,27 @@ import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import dagger.hilt.android.AndroidEntryPoint -import dev.brahmkshatriya.echo.common.clients.LibraryClient +import dev.brahmkshatriya.echo.playback.Current import dev.brahmkshatriya.echo.playback.PlayerBitmapLoader -import dev.brahmkshatriya.echo.playback.PlayerListener +import dev.brahmkshatriya.echo.playback.PlayerEventListener import dev.brahmkshatriya.echo.playback.PlayerSessionCallback -import dev.brahmkshatriya.echo.playback.Queue +import dev.brahmkshatriya.echo.playback.Radio import dev.brahmkshatriya.echo.playback.RenderersFactory +import dev.brahmkshatriya.echo.playback.ResumptionUtils import dev.brahmkshatriya.echo.playback.StreamableDataSource import dev.brahmkshatriya.echo.playback.TrackResolver -import dev.brahmkshatriya.echo.playback.getLikeButton -import dev.brahmkshatriya.echo.playback.getRepeatButton +import dev.brahmkshatriya.echo.playback.TrackingListener import dev.brahmkshatriya.echo.plugger.MusicExtension -import dev.brahmkshatriya.echo.plugger.getExtension +import dev.brahmkshatriya.echo.plugger.TrackerExtension import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.CLOSE_PLAYER +import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.KEEP_QUEUE import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.SKIP_SILENCE +import dev.brahmkshatriya.echo.viewmodels.SnackBar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -40,13 +44,22 @@ import javax.inject.Inject @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService() { @Inject - lateinit var extensionFlow: MutableStateFlow + lateinit var extFlow: MutableStateFlow @Inject - lateinit var extensionList: MutableStateFlow?> + lateinit var extListFlow: MutableStateFlow?> @Inject - lateinit var global: Queue + lateinit var trackerList: MutableStateFlow?> + + @Inject + lateinit var throwFlow: MutableSharedFlow + + @Inject + lateinit var messageFlow: MutableSharedFlow + + @Inject + lateinit var stateFlow: MutableStateFlow @Inject lateinit var settings: SharedPreferences @@ -55,7 +68,7 @@ class PlaybackService : MediaLibraryService() { lateinit var cache: SimpleCache @Inject - lateinit var listener: PlayerListener + lateinit var current: MutableStateFlow private var mediaLibrarySession: MediaLibrarySession? = null private val scope = CoroutineScope(Dispatchers.Main) @@ -63,8 +76,9 @@ class PlaybackService : MediaLibraryService() { override fun onCreate() { super.onCreate() - val player = createExoplayer() - listener.setup(player, scope) + val exoPlayer = createExoplayer() + + exoPlayer.prepare() val intent = Intent(this, MainActivity::class.java) .putExtra("fromNotification", true) @@ -73,12 +87,12 @@ class PlaybackService : MediaLibraryService() { .getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) val callback = PlayerSessionCallback( - this, scope, global, extensionList, extensionFlow + this, settings, scope, extListFlow, extFlow, throwFlow, messageFlow, stateFlow ) - val mediaLibrarySession = MediaLibrarySession.Builder(this, player, callback) + val session = MediaLibrarySession.Builder(this, exoPlayer, callback) .setSessionActivity(pendingIntent) - .setBitmapLoader(PlayerBitmapLoader(this, global, scope)) + .setBitmapLoader(PlayerBitmapLoader(this, exoPlayer, scope)) .build() val notificationProvider = @@ -88,13 +102,29 @@ class PlaybackService : MediaLibraryService() { notificationProvider.setSmallIcon(R.drawable.ic_mono) setMediaNotificationProvider(notificationProvider) - global.listenToChanges(scope, mediaLibrarySession, ::updateLayout) + exoPlayer.addListener(PlayerEventListener(this, session, current, extListFlow)) + exoPlayer.addListener( + Radio(session,this, settings, scope, extListFlow, throwFlow, messageFlow, stateFlow) + ) + exoPlayer.addListener( + TrackingListener(session, scope, extListFlow, trackerList, throwFlow) + ) settings.registerOnSharedPreferenceChangeListener { prefs, key -> when (key) { - SKIP_SILENCE -> player.skipSilenceEnabled = prefs.getBoolean(key, true) + SKIP_SILENCE -> exoPlayer.skipSilenceEnabled = prefs.getBoolean(key, true) } } - this.mediaLibrarySession = mediaLibrarySession + + val keepQueue = settings.getBoolean(KEEP_QUEUE, true) + if (keepQueue) scope.launch { + extListFlow.first { it != null } + ResumptionUtils.recoverPlaylist(this@PlaybackService).apply { + exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs) + exoPlayer.prepare() + } + } + + this.mediaLibrarySession = session } @@ -108,12 +138,10 @@ class PlaybackService : MediaLibraryService() { val cacheFactory = CacheDataSource .Factory().setCache(cache) .setUpstreamDataSourceFactory(streamableFactory) - val dataSourceFactory = - ResolvingDataSource.Factory( - cacheFactory, - TrackResolver(this, global, extensionList, settings) - ) + val trackResolver = TrackResolver(this, extListFlow, settings) + + val dataSourceFactory = ResolvingDataSource.Factory(cacheFactory, trackResolver) val factory = DefaultMediaSourceFactory(this) .setDataSourceFactory(dataSourceFactory) @@ -124,24 +152,10 @@ class PlaybackService : MediaLibraryService() { .setSkipSilenceEnabled(settings.getBoolean(SKIP_SILENCE, true)) .setAudioAttributes(audioAttributes, true) .build() - } - - private fun updateLayout() { - val context = this@PlaybackService - val track = global.current ?: return - val mediaLibrarySession = mediaLibrarySession ?: return - val player = mediaLibrarySession.player - val supportsLike = extensionList.getExtension(track.clientId)?.client is LibraryClient - - val commandButtons = listOfNotNull( - getRepeatButton(context, player.repeatMode), - getLikeButton(context, track.liked).takeIf { supportsLike } - ) - mediaLibrarySession.setCustomLayout(commandButtons) + .also { trackResolver.player = it } } override fun onDestroy() { - scope.launch { global.clearQueue(false) } mediaLibrarySession?.run { player.release() release() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/di/AppModule.kt b/app/src/main/java/dev/brahmkshatriya/echo/di/AppModule.kt index 15ae3a4b..e2a9f86b 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/di/AppModule.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/di/AppModule.kt @@ -14,10 +14,8 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dev.brahmkshatriya.echo.EchoDatabase import dev.brahmkshatriya.echo.db.models.UserEntity -import dev.brahmkshatriya.echo.playback.PlayerListener -import dev.brahmkshatriya.echo.playback.Queue -import dev.brahmkshatriya.echo.plugger.MusicExtension -import dev.brahmkshatriya.echo.plugger.TrackerExtension +import dev.brahmkshatriya.echo.playback.Current +import dev.brahmkshatriya.echo.playback.Radio import dev.brahmkshatriya.echo.viewmodels.SnackBar import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -29,10 +27,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) class AppModule { - @Provides - @Singleton - fun provideGlobalQueue() = Queue() - @Provides @Singleton fun provideThrowableFlow() = MutableSharedFlow() @@ -46,6 +40,16 @@ class AppModule { fun provideSettingsPreferences(application: Application): SharedPreferences = application.getSharedPreferences(application.packageName, Context.MODE_PRIVATE) + @Provides + @Singleton + fun provideDatabase(application: Application) = Room.databaseBuilder( + application, EchoDatabase::class.java, "echo-database" + ).fallbackToDestructiveMigration().build() + + @Provides + @Singleton + fun provideLoginUserFlow() = MutableSharedFlow() + @Provides @Singleton @UnstableApi @@ -60,25 +64,9 @@ class AppModule { @Provides @Singleton - fun provideDatabase(application: Application) = Room.databaseBuilder( - application, EchoDatabase::class.java, "echo-database" - ).fallbackToDestructiveMigration().build() - - @Provides - @Singleton - fun provideLoginUserFlow() = MutableSharedFlow() + fun currentMediaItemFlow() = MutableStateFlow(null) @Provides @Singleton - fun providePlayerListener( - application: Application, - extensionList: MutableStateFlow?>, - trackerListFlow: MutableStateFlow?>, - global: Queue, - settings: SharedPreferences, - throwableFlow: MutableSharedFlow, - messageFlow: MutableSharedFlow, - ) = PlayerListener( - application, extensionList, trackerListFlow, global, settings, throwableFlow, messageFlow - ) + fun provideExtensionListFlow() = MutableStateFlow(Radio.State.Empty) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt b/app/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt index 214c9b96..10e37139 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/download/Downloader.kt @@ -13,9 +13,9 @@ import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem import dev.brahmkshatriya.echo.common.models.StreamableAudio import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.db.models.DownloadEntity -import dev.brahmkshatriya.echo.playback.TrackResolver import dev.brahmkshatriya.echo.plugger.MusicExtension import dev.brahmkshatriya.echo.plugger.getExtension +import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.selectStream import dev.brahmkshatriya.echo.utils.getFromCache import dev.brahmkshatriya.echo.utils.saveToCache import kotlinx.coroutines.Dispatchers @@ -80,7 +80,7 @@ class Downloader( val track = loaded.copy(album = album) val settings = getSharedPreferences(packageName, Context.MODE_PRIVATE) - val stream = TrackResolver.selectStream(settings, track.audioStreamables) + val stream = selectStream(settings, track.audioStreamables) ?: throw Exception("No Stream Found") val audio = client.getStreamableAudio(stream) val folder = "Echo${parent?.title?.let { "/$it" } ?: ""}" diff --git a/app/src/main/java/dev/brahmkshatriya/echo/offline/MediaStoreUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/offline/MediaStoreUtils.kt index 5146e54c..6e70913b 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/offline/MediaStoreUtils.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/offline/MediaStoreUtils.kt @@ -537,7 +537,6 @@ object MediaStoreUtils { val playlistsFinal = playlists.map { it.first.also { playlist -> - println("Playlist : ${playlist.title} with ${it.second.size} songs") playlist.songList.addAll(it.second.map { value -> idMap!![value] ?: if (DEBUG_MISSING_SONG) throw NullPointerException( diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt new file mode 100644 index 00000000..ea37ccb6 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/Current.kt @@ -0,0 +1,9 @@ +package dev.brahmkshatriya.echo.playback + +import androidx.media3.common.MediaItem + +data class Current( + val index: Int, + val mediaItem: MediaItem, + val isLoaded: Boolean +) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt new file mode 100644 index 00000000..71aec09c --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/MediaItemUtils.kt @@ -0,0 +1,87 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.SharedPreferences +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.ThumbRating +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.selectStreamIndex +import dev.brahmkshatriya.echo.utils.getParcel + +object MediaItemUtils { + + fun build( + settings: SharedPreferences?, + track: Track, + clientId: String, + context: EchoMediaItem?, + ): MediaItem { + val item = MediaItem.Builder() + item.setUri(track.id) + val metadata = track.toMetaData(settings, clientId, context) + item.setMediaMetadata(metadata) + item.setMediaId(track.id) + return item.build() + } + + fun build(settings: SharedPreferences?, mediaItem: MediaItem, track: Track): MediaItem = + with(mediaItem) { + val item = buildUpon() + val metadata = track.toMetaData(settings, clientId, context, true) + item.setMediaMetadata(metadata) + return item.build() + } + + private fun Track.toMetaData( + settings: SharedPreferences?, + clientId: String, + context: EchoMediaItem?, + loaded: Boolean = false, + audioStreamIndex: Int? = null + ) = MediaMetadata.Builder() + .setTitle(title) + .setArtist(artists.joinToString(", ") { it.name }) + .setArtworkUri(id.toUri()) + .setUserRating(ThumbRating(liked)) + .setIsPlayable(true) + .setIsBrowsable(false) + .setExtras( + bundleOf( + "track" to this, + "clientId" to clientId, + "context" to context, + "loaded" to loaded, + "audioStream" to selectStream(settings, loaded, audioStreamIndex) + ) + ) + .setIsPlayable(loaded) + .build() + + private fun Track.selectStream( + settings: SharedPreferences?, + loaded: Boolean, + audioStreamIndex: Int? + ): Int? { + if (!loaded) return null + if (settings == null) return audioStreamIndex + return audioStreamIndex ?: selectStreamIndex(settings, audioStreamables) + } + + val MediaMetadata.isLoaded get() = extras?.getBoolean("loaded") ?: false + val MediaMetadata.track get() = requireNotNull(extras?.getParcel("track")) + val MediaMetadata.clientId get() = requireNotNull(extras?.getString("clientId")) + val MediaMetadata.context get() = extras?.getParcel("context") + val MediaMetadata.audioStreamIndex get() = extras?.getInt("audioStream") ?: -1 + val MediaMetadata.isLiked get() = (userRating as? ThumbRating)?.isThumbsUp == true + + val MediaItem.track get() = mediaMetadata.track + val MediaItem.clientId get() = mediaMetadata.clientId + val MediaItem.context get() = mediaMetadata.context + val MediaItem.isLoaded get() = mediaMetadata.isLoaded + val MediaItem.audioStreamIndex get() = mediaMetadata.audioStreamIndex + val MediaItem.isLiked get() = mediaMetadata.isLiked + +} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerBitmapLoader.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerBitmapLoader.kt index ae889a1e..f085fdc9 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerBitmapLoader.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerBitmapLoader.kt @@ -4,18 +4,24 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import androidx.media3.common.Player import androidx.media3.common.util.BitmapLoader import androidx.media3.common.util.UnstableApi import com.google.common.util.concurrent.ListenableFuture import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.playback.TrackResolver.Companion.getMediaItemById import dev.brahmkshatriya.echo.utils.loadBitmap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.future +import kotlinx.coroutines.withContext @UnstableApi class PlayerBitmapLoader( - val context: Context, private val global: Queue, private val scope: CoroutineScope + val context: Context, + val player: Player, + private val scope: CoroutineScope ) : BitmapLoader { override fun supportsMimeType(mimeType: String) = true @@ -28,9 +34,9 @@ class PlayerBitmapLoader( get() = context.loadBitmap(R.drawable.art_music) ?: error("Empty bitmap") override fun loadBitmap(uri: Uri): ListenableFuture = scope.future(Dispatchers.IO) { - val track = global.getTrack(uri.toString())?.run { - loaded ?: unloaded - } - track?.cover?.loadBitmap(context) ?: emptyBitmap + val (_, mediaItem) = withContext(Dispatchers.Main) { + player.getMediaItemById(uri.toString()) + } ?: return@future emptyBitmap + mediaItem.track.cover?.loadBitmap(context) ?: emptyBitmap } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCommands.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCommands.kt new file mode 100644 index 00000000..59e4ae29 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerCommands.kt @@ -0,0 +1,51 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import android.os.Bundle +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.CommandButton +import androidx.media3.session.SessionCommand +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLiked + +object PlayerCommands { + val likeCommand = SessionCommand("liked", Bundle.EMPTY) + val unlikeCommand = SessionCommand("unliked", Bundle.EMPTY) + val repeatCommand = SessionCommand("repeat", Bundle.EMPTY) + val repeatOffCommand = SessionCommand("repeat_off", Bundle.EMPTY) + val repeatOneCommand = SessionCommand("repeat_one", Bundle.EMPTY) + val radioCommand = SessionCommand("radio", Bundle.EMPTY) + + fun getLikeButton(context: Context, item: MediaItem) = run { + val builder = CommandButton.Builder() + if (!item.isLiked) builder + .setDisplayName(context.getString(R.string.like)) + .setIconResId(R.drawable.ic_heart_outline) + .setSessionCommand(likeCommand) + else builder + .setDisplayName(context.getString(R.string.unlike)) + .setIconResId(R.drawable.ic_heart_filled) + .setSessionCommand(unlikeCommand) + builder.build() + } + + fun getRepeatButton(context: Context, repeat: Int) = run { + val builder = CommandButton.Builder() + builder.setDisplayName(context.getString(R.string.repeat)) + when (repeat) { + Player.REPEAT_MODE_ONE -> builder + .setIconResId(R.drawable.ic_repeat_one) + .setSessionCommand(repeatOffCommand) + + Player.REPEAT_MODE_OFF -> builder + .setIconResId(R.drawable.ic_repeat_off) + .setSessionCommand(repeatCommand) + + else -> builder + .setIconResId(R.drawable.ic_repeat) + .setSessionCommand(repeatOneCommand) + } + builder.build() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerEventListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerEventListener.kt new file mode 100644 index 00000000..ec3e200d --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerEventListener.kt @@ -0,0 +1,77 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.session.MediaLibraryService +import dev.brahmkshatriya.echo.common.clients.LibraryClient +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.PlayerCommands.getLikeButton +import dev.brahmkshatriya.echo.playback.PlayerCommands.getRepeatButton +import dev.brahmkshatriya.echo.plugger.MusicExtension +import dev.brahmkshatriya.echo.plugger.getExtension +import kotlinx.coroutines.flow.MutableStateFlow + +class PlayerEventListener( + private val context: Context, + private val session: MediaLibraryService.MediaLibrarySession, + private val currentFlow: MutableStateFlow, + private val extensionList: MutableStateFlow?>, +) : Player.Listener { + + private val handler = Handler(Looper.getMainLooper()) + private val runnable = Runnable { updateCurrent() } + + private fun updateCurrent() { + handler.removeCallbacks(runnable) + if(player.isPlaying) ResumptionUtils.saveCurrentPos(context, player.currentPosition) + handler.postDelayed(runnable, 1000) + } + + val player get() = session.player + + private fun updateCustomLayout() { + val item = player.currentMediaItem ?: return + val supportsLike = extensionList.getExtension(item.clientId)?.client is LibraryClient + + val commandButtons = listOfNotNull( + getRepeatButton(context, player.repeatMode), + getLikeButton(context, item).takeIf { supportsLike } + ) + session.setCustomLayout(commandButtons) + } + + private fun updateCurrentFlow() { + currentFlow.value = player.currentMediaItem?.let { + Current(player.currentMediaItemIndex, it, it.isLoaded) + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + updateCurrentFlow() + updateCustomLayout() + updateCurrent() + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + updateCurrentFlow() + updateCustomLayout() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateCurrentFlow() + ResumptionUtils.saveQueue(context, player.currentMediaItemIndex, player.mediaItems) + } + + private val Player.mediaItems get() = (0 until mediaItemCount).map { getMediaItemAt(it) } + + override fun onRepeatModeChanged(repeatMode: Int) { + updateCustomLayout() + } + +} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerListener.kt index 3667f161..bd2ab37a 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerListener.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerListener.kt @@ -1,283 +1,72 @@ package dev.brahmkshatriya.echo.playback -import android.content.Context -import android.content.SharedPreferences -import android.os.Handler -import android.os.Looper -import androidx.media3.common.PlaybackException +import androidx.annotation.CallSuper +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.Tracks -import androidx.media3.exoplayer.ExoPlayer -import dev.brahmkshatriya.echo.common.clients.RadioClient -import dev.brahmkshatriya.echo.common.clients.TrackerClient -import dev.brahmkshatriya.echo.common.models.EchoMediaItem -import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem -import dev.brahmkshatriya.echo.common.models.Playlist -import dev.brahmkshatriya.echo.common.models.Track -import dev.brahmkshatriya.echo.plugger.MusicExtension -import dev.brahmkshatriya.echo.plugger.TrackerExtension -import dev.brahmkshatriya.echo.plugger.getExtension -import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.AUTO_START_RADIO -import dev.brahmkshatriya.echo.utils.tryWith -import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.noClient -import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.radioNotSupported -import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel -import dev.brahmkshatriya.echo.viewmodels.SnackBar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -class PlayerListener( - private val context: Context, - private val extensionListFlow: MutableStateFlow?>, - private val trackerListFlow: MutableStateFlow?>, - private val global: Queue, - private val settings: SharedPreferences, - private val throwableFlow: MutableSharedFlow, - private val messageFlow: MutableSharedFlow -) : Player.Listener { - - private lateinit var player: Player - private lateinit var scope: CoroutineScope - - fun setup(player: ExoPlayer, scope: CoroutineScope) { - this.player = player - this.scope = scope - player.addListener(this) - viewModel?.let { setViewModel(it) } - } - - - private var viewModel: PlayerViewModel? = null - fun setViewModel(playerViewModel: PlayerViewModel) = with(playerViewModel) { - viewModel = this - if (!::player.isInitialized) return@with - isPlaying.value = player.isPlaying - buffering.value = player.playbackState == Player.STATE_BUFFERING - shuffle.value = player.shuffleModeEnabled - repeat.value = player.repeatMode - } - - private fun updateCurrent() { - val mediaItems = (0 until player.mediaItemCount).map { - player.getMediaItemAt(it).mediaId - } - global.updateQueue(mediaItems) - val index = player.currentMediaItemIndex - global.saveQueue(context, index) - scope.launch { - global.currentIndex = index - global.currentIndexFlow.emit(index) - viewModel?.totalDuration?.value = player.duration.toInt() - global.updateFlow.emit(Unit) +import androidx.media3.common.util.UnstableApi +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded + +open class PlayerListener(val player: Player) : Player.Listener { + open fun onTrackStart(mediaItem: MediaItem) {} + open fun onTrackEnd(mediaItem: MediaItem) {} + + private var current: MediaItem? = null + private var loaded = false + private var playing = false + + @CallSuper + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + current?.let { + if (!it.isLoaded) return@let + onTrackEnd(it) } - } - + current = mediaItem + loaded = mediaItem?.isLoaded ?: false - private val updateProgressRunnable = Runnable { updateProgress() } - private val handler = Handler(Looper.getMainLooper()).also { - it.post(updateProgressRunnable) + if (!loaded || !player.playWhenReady) return + playing = true + onTrackStart(mediaItem!!) } - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - Player.STATE_BUFFERING -> - viewModel?.buffering?.value = true + @CallSuper + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + if (loaded) return + val mediaItem = player.currentMediaItem ?: return + if (!mediaItem.isLoaded) return - Player.STATE_READY -> { - viewModel?.buffering?.value = false - } + current = mediaItem + loaded = true - else -> Unit - } - updateProgress() + if (!player.playWhenReady) return + playing = true + onTrackStart(mediaItem) } + @CallSuper override fun onIsPlayingChanged(isPlaying: Boolean) { - viewModel?.isPlaying?.value = isPlaying - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int - ) { - updateNavigation() - updateProgress() - } - - private suspend fun tryWith(block: suspend () -> T) = tryWith(throwableFlow, block) - - override fun onTracksChanged(tracks: Tracks) { - super.onTracksChanged(tracks) - stoppedPlaying() - if (tracks.isEmpty) return - updateCurrent() - if (!player.hasNextMediaItem()) { - val autoStartRadio = settings.getBoolean(AUTO_START_RADIO, true) - if (autoStartRadio) global.current?.let { track -> - scope.launch(Dispatchers.IO) { - val list = extensionListFlow.first { it != null } - val extension = list?.find { it.metadata.id == track.clientId } - radio(extension) { - when (val item = track.context) { - is EchoMediaItem.Lists.PlaylistItem -> radio(item.playlist) - is EchoMediaItem.Lists.AlbumItem -> radio(item.album) - else -> radio(track.loaded ?: track.onLoad.first()) - } - } - } - } - } - startedPlaying(player.currentMediaItem?.mediaId) - } + if (playing) return + if (!isPlaying) return + if (!loaded) return + val mediaItem = player.currentMediaItem ?: return - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - updateCurrent() - updateNavigation() - updateProgress() + playing = true + onTrackStart(mediaItem) } - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - viewModel?.shuffle?.value = shuffleModeEnabled - val indexes = mutableListOf(0) - var index = 0 - while (index != -1) { - index = player.currentTimeline.getNextWindowIndex( - index, - Player.REPEAT_MODE_OFF, - shuffleModeEnabled - ) - if (index != -1) indexes.add(index) - } - //TODO Update ui with indexes - } - - private fun trackMedia( - mediaId: String?, - block: suspend TrackerClient.(clientId: String, context: EchoMediaItem?, track: Track) -> Unit + @CallSuper + @UnstableApi + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int ) { - val streamableTrack = global.getTrack(mediaId) ?: return - val client = extensionListFlow.getExtension(streamableTrack.clientId)?.client ?: return - val track = streamableTrack.loaded ?: streamableTrack.unloaded - val clientId = streamableTrack.clientId - val trackers = trackerListFlow.value?.filter { it.metadata.enabled } ?: emptyList() - println("track : $track") - scope.launch(Dispatchers.IO) { - if (client is TrackerClient) - tryWith { client.block(clientId, streamableTrack.context, track) } - println("trackers : $trackers") - trackers.forEach { - launch { - println("Tracking : ${it.metadata.name}") - tryWith { it.client.block(clientId, streamableTrack.context, track) } - } - } - } - } - - private var currentlyPlaying: String? = null - private fun startedPlaying(mediaId: String?) { - currentlyPlaying = mediaId - trackMedia(mediaId) { clientId, context, loaded -> - onStartedPlaying(clientId, context, loaded) - } - } - - private fun markedAsPlayed() { - val mediaId = currentlyPlaying - trackMedia(mediaId) { clientId, context, loaded -> - onMarkAsPlayed(clientId, context, loaded) - } - } - - private fun stoppedPlaying() { - val mediaId = currentlyPlaying - trackMedia(mediaId) { clientId, context, loaded -> - onStoppedPlaying(clientId, context, loaded) - } - } - - private val markAsPlayedTime = 30 * 1000L // 30 seconds - private var markedAsPlayed = false - private fun updateProgress() { - viewModel?.progress?.value = - player.currentPosition.toInt() to player.bufferedPosition.toInt() - - if (player.currentPosition <= 0) markedAsPlayed = false - val current = player.currentMediaItem - if (current != null) { - global.saveCurrentPos(context, player.currentPosition) - if (player.currentPosition > markAsPlayedTime && !markedAsPlayed) { - markedAsPlayed() - markedAsPlayed = true - } - } - - handler.removeCallbacks(updateProgressRunnable) - val playbackState = player.playbackState - if (playbackState != ExoPlayer.STATE_IDLE && playbackState != ExoPlayer.STATE_ENDED) { - var delayMs: Long - if (player.playWhenReady && playbackState == ExoPlayer.STATE_READY) { - delayMs = delay - player.currentPosition % delay - if (delayMs < delay * threshold) { - delayMs += delay - } - } else { - delayMs = delay - } - handler.postDelayed(updateProgressRunnable, delayMs) - } - } + if (reason == 2) return + val mediaItem = newPosition.mediaItem ?: return + if (oldPosition.mediaItem != mediaItem) return + if (newPosition.positionMs != 0L) return - private val delay = 500L - private val threshold = 0.2f - - override fun onRepeatModeChanged(repeatMode: Int) { - updateNavigation() - viewModel?.repeat?.value = repeatMode - global.repeat.value = repeatMode - } - - private fun updateNavigation() { - val index = player.currentMediaItemIndex - val enablePrevious = index >= 0 - val enableNext = player.hasNextMediaItem() - viewModel?.nextEnabled?.value = enableNext - viewModel?.previousEnabled?.value = enablePrevious - } - - override fun onPlayerError(error: PlaybackException) { - viewModel?.createException(error) - } - - //TODO Continuous Playlist - suspend fun radio( - extension: MusicExtension?, - block: suspend RadioClient.() -> Playlist - ) = when (val client = extension?.client) { - null -> { - messageFlow.emit(context.noClient()); null - } - - !is RadioClient -> { - messageFlow.emit(context.radioNotSupported(extension.metadata.name)); null - } - - else -> { - val playlist = tryWith { block(client) } - val tracks = playlist?.let { tryWith { client.loadTracks(it).loadFirst() } } - tracks?.let { - global.addTracks( - extension.metadata.id, - playlist.toMediaItem(), - it - ).first - } - } + onTrackEnd(mediaItem) + onTrackStart(mediaItem) } - - } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerSessionCallback.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerSessionCallback.kt index 3a647cb0..6470dacd 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerSessionCallback.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerSessionCallback.kt @@ -1,11 +1,13 @@ package dev.brahmkshatriya.echo.playback import android.content.Context +import android.content.SharedPreferences import android.os.Bundle import android.os.Handler import android.os.Looper import android.widget.Toast import androidx.annotation.OptIn +import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Rating @@ -23,42 +25,47 @@ import dev.brahmkshatriya.echo.common.clients.LibraryClient import dev.brahmkshatriya.echo.common.clients.SearchClient import dev.brahmkshatriya.echo.common.clients.TrackClient import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem import dev.brahmkshatriya.echo.common.models.MediaItemsContainer +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.plugger.MusicExtension import dev.brahmkshatriya.echo.plugger.getExtension +import dev.brahmkshatriya.echo.utils.getParcel import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.noClient import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.searchNotSupported import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.trackNotSupported +import dev.brahmkshatriya.echo.viewmodels.SnackBar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.guava.future import kotlinx.coroutines.withContext class PlayerSessionCallback( private val context: Context, + private val settings: SharedPreferences, private val scope: CoroutineScope, - private val global: Queue, - private val extensionListFlow: StateFlow?>, + private val extensionList: StateFlow?>, private val extensionFlow: StateFlow, + private val throwableFlow: MutableSharedFlow, + private val messageFlow: MutableSharedFlow, + private val radioFlow: MutableStateFlow ) : MediaLibraryService.MediaLibrarySession.Callback { + @OptIn(UnstableApi::class) override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo + session: MediaSession, controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { - val connectionResult = super.onConnect(session, controller) - println("onConnect called") - return MediaSession.ConnectionResult.accept( - connectionResult.availableSessionCommands.buildUpon() - .add(likeCommand).add(unlikeCommand) - .add(repeatCommand).add(repeatOffCommand).add(repeatOneCommand) - .add(recoverQueue) - .build(), - connectionResult.availablePlayerCommands - ) + val sessionCommands = with(PlayerCommands) { + MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() + .add(likeCommand).add(unlikeCommand).add(repeatCommand).add(repeatOffCommand) + .add(repeatOneCommand).add(radioCommand).build() + } + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands).build() } @OptIn(UnstableApi::class) @@ -67,90 +74,73 @@ class PlayerSessionCallback( controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle - ): ListenableFuture { + ): ListenableFuture = with(PlayerCommands) { val player = session.player - val mediaId = player.currentMediaItem?.mediaId - return when (customCommand) { - likeCommand -> rateMediaItem(ThumbRating(true), mediaId) - unlikeCommand -> rateMediaItem(ThumbRating(false), mediaId) + when (customCommand) { + likeCommand -> onSetRating(session, controller, ThumbRating(true)) + unlikeCommand -> onSetRating(session, controller, ThumbRating(false)) repeatOffCommand -> setRepeat(player, Player.REPEAT_MODE_OFF) repeatOneCommand -> setRepeat(player, Player.REPEAT_MODE_ONE) repeatCommand -> setRepeat(player, Player.REPEAT_MODE_ALL) - recoverQueue -> scope.future { - extensionListFlow.first { it != null } - global.recoverPlaylist(context).apply { - player.setMediaItems(mediaItems, startIndex, startPositionMs) - player.prepare() - } - SessionResult(RESULT_SUCCESS) - } - + radioCommand -> radio(player, args) else -> super.onCustomCommand(session, controller, customCommand, args) } } + private fun radio(player: Player, args: Bundle) = scope.future { + val error = SessionResult(SessionResult.RESULT_ERROR_UNKNOWN) + val clientId = args.getString("clientId") ?: return@future error + val item = args.getParcel("item") ?: return@future error + val loaded = Radio.start( + context, messageFlow, throwableFlow, radioFlow, extensionList, clientId, item, 0 + ) ?: return@future error + val mediaItem = MediaItemUtils.build( + settings, loaded.tracks[0], loaded.clientId, loaded.playlist.toMediaItem() + ) + player.setMediaItem(mediaItem) + player.prepare() + player.playWhenReady = true + SessionResult(RESULT_SUCCESS) + } + private fun setRepeat(player: Player, repeat: Int) = run { player.repeatMode = repeat Futures.immediateFuture(SessionResult(RESULT_SUCCESS)) } - private fun rateMediaItem( - rating: ThumbRating, - mediaId: String? = null, - ) = scope.future { - val errorIO = SessionResult(SessionResult.RESULT_ERROR_IO) - val streamableTrack = global.getTrack(mediaId) ?: return@future errorIO - val client = - extensionListFlow.getExtension(streamableTrack.clientId)?.client - ?: return@future errorIO - if (client !is LibraryClient) return@future errorIO - val track = streamableTrack.current - val liked = withContext(Dispatchers.IO) { - runCatching { client.likeTrack(track, rating.isThumbsUp) } - }.getOrElse { - global.onLiked.emit(track.liked) - return@future SessionResult( - SessionResult.RESULT_ERROR_UNKNOWN, - Bundle().apply { putSerializable("error", it) } - ) - } - streamableTrack.liked = liked - delay(1000) - global.onLiked.emit(liked) - SessionResult(RESULT_SUCCESS) - } - override fun onSetRating( - session: MediaSession, - controller: MediaSession.ControllerInfo, - rating: Rating + session: MediaSession, controller: MediaSession.ControllerInfo, rating: Rating ): ListenableFuture { return if (rating !is ThumbRating) super.onSetRating(session, controller, rating) - else { - val mediaId = session.player.currentMediaItem?.mediaId - ?: return super.onSetRating(session, controller, rating) - rateMediaItem(rating, mediaId) + else scope.future { + val errorIO = SessionResult(SessionResult.RESULT_ERROR_IO) + val item = session.player.currentMediaItem ?: return@future errorIO + val client = extensionList.getExtension(item.clientId)?.client ?: return@future errorIO + if (client !is LibraryClient) return@future errorIO + val track = item.track + val liked = withContext(Dispatchers.IO) { + runCatching { client.likeTrack(track, rating.isThumbsUp) } + }.getOrElse { + return@future SessionResult( + SessionResult.RESULT_ERROR_UNKNOWN, bundleOf("error" to it) + ) + } + val newItem = item.run { + buildUpon().setMediaMetadata( + mediaMetadata.buildUpon().setUserRating(ThumbRating(liked)).build() + ) + }.build() + session.player.replaceMediaItem(session.player.currentMediaItemIndex, newItem) + SessionResult(RESULT_SUCCESS, bundleOf("liked" to liked)) } } - override fun onSetRating( - session: MediaSession, - controller: MediaSession.ControllerInfo, - mediaId: String, - rating: Rating - ): ListenableFuture { - return if (rating !is ThumbRating) super.onSetRating(session, controller, rating) - else rateMediaItem(rating, mediaId) - } - @UnstableApi override fun onPlaybackResumption( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo + mediaSession: MediaSession, controller: MediaSession.ControllerInfo ): ListenableFuture = scope.future { - extensionListFlow.first { it != null } - return@future global.recoverPlaylist(context) + return@future ResumptionUtils.recoverPlaylist(context) } // Google Assistant Stuff @@ -161,13 +151,18 @@ class PlayerSessionCallback( } override fun onAddMediaItems( - mediaSession: MediaSession, controller: MediaSession.ControllerInfo, + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, mediaItems: MutableList ): ListenableFuture> { //Look at the first item's search query, if it's null, return the super method - val query = mediaItems.firstOrNull()?.requestMetadata?.searchQuery - ?: return super.onAddMediaItems(mediaSession, controller, mediaItems) + val query = + mediaItems.firstOrNull()?.requestMetadata?.searchQuery ?: return super.onAddMediaItems( + mediaSession, + controller, + mediaItems + ) fun default(reason: Context.() -> String): ListenableFuture> { toast(reason.invoke(context)) @@ -175,12 +170,10 @@ class PlayerSessionCallback( } val extension = extensionFlow.value - val client = extension?.client - ?: return default { noClient().message } - if (client !is SearchClient) - return default { searchNotSupported(extension.metadata.id).message } - if (client !is TrackClient) - return default { trackNotSupported(extension.metadata.id).message } + val client = extension?.client ?: return default { noClient().message } + val id = extension.metadata.id + if (client !is SearchClient) return default { searchNotSupported(id).message } + if (client !is TrackClient) return default { trackNotSupported(id).message } return scope.future { val itemsContainers = runCatching { client.searchFeed(query, null).loadFirst() @@ -200,8 +193,7 @@ class PlayerSessionCallback( } is MediaItemsContainer.Item -> { - val item = it.media as? EchoMediaItem.TrackItem - ?: return@mapNotNull null + val item = it.media as? EchoMediaItem.TrackItem ?: return@mapNotNull null listOf(item.track) } @@ -209,19 +201,21 @@ class PlayerSessionCallback( } }.flatten() if (tracks.isEmpty()) default { getString(R.string.could_not_find_anything, query) } - val items = global.addTracks(extension.metadata.id, null, tracks).second - items.toMutableList() + tracks.map { MediaItemUtils.build(settings, it, id, null) }.toMutableList() } } @UnstableApi override fun onSetMediaItems( - mediaSession: MediaSession, controller: MediaSession.ControllerInfo, - mediaItems: MutableList, startIndex: Int, startPositionMs: Long - ) = scope.future { - global.clearQueue() - super.onSetMediaItems(mediaSession, controller, mediaItems, startIndex, startPositionMs) - .get() + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long + ): ListenableFuture { + radioFlow.value = Radio.State.Empty + return super.onSetMediaItems( + mediaSession, controller, mediaItems, startIndex, startPositionMs + ) } - } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerUtils.kt deleted file mode 100644 index 1370c060..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/PlayerUtils.kt +++ /dev/null @@ -1,105 +0,0 @@ -package dev.brahmkshatriya.echo.playback - -import android.content.Context -import android.net.Uri -import android.os.Bundle -import androidx.annotation.OptIn -import androidx.core.net.toUri -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player -import androidx.media3.common.ThumbRating -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSpec -import androidx.media3.session.CommandButton -import androidx.media3.session.SessionCommand -import dev.brahmkshatriya.echo.R -import dev.brahmkshatriya.echo.common.models.Track - -val likeCommand = SessionCommand("liked", Bundle.EMPTY) -val unlikeCommand = SessionCommand("unliked", Bundle.EMPTY) -val repeatCommand = SessionCommand("repeat", Bundle.EMPTY) -val repeatOffCommand = SessionCommand("repeat_off", Bundle.EMPTY) -val repeatOneCommand = SessionCommand("repeat_one", Bundle.EMPTY) -val recoverQueue = SessionCommand("recover_queue", Bundle.EMPTY) - -fun getLikeButton(context: Context, liked: Boolean) = run { - val builder = CommandButton.Builder() - if (!liked) builder - .setDisplayName(context.getString(R.string.like)) - .setIconResId(R.drawable.ic_heart_outline) - .setSessionCommand(likeCommand) - else builder - .setDisplayName(context.getString(R.string.unlike)) - .setIconResId(R.drawable.ic_heart_filled) - .setSessionCommand(unlikeCommand) - builder.build() -} - -fun getRepeatButton(context: Context, repeat: Int) = run { - val builder = CommandButton.Builder() - builder.setDisplayName(context.getString(R.string.repeat)) - when (repeat) { - Player.REPEAT_MODE_ONE -> builder - .setIconResId(R.drawable.ic_repeat_one) - .setSessionCommand(repeatOffCommand) - - Player.REPEAT_MODE_OFF -> builder - .setIconResId(R.drawable.ic_repeat_off) - .setSessionCommand(repeatCommand) - - else -> builder - .setIconResId(R.drawable.ic_repeat) - .setSessionCommand(repeatOneCommand) - } - builder.build() -} - -fun mediaItemBuilder( - track: Track -): MediaItem { - val item = MediaItem.Builder() - item.setUri(track.id) - val metadata = track.toMetaData() - item.setMediaMetadata(metadata) - - val mediaId = track.id - item.setMediaId(mediaId) - return item.build() -} - -fun Track.toMetaData() = MediaMetadata.Builder() - .setTitle(title) - .setArtist(artists.joinToString(", ") { it.name }) - .setArtworkUri(id.toUri()) - .setUserRating(ThumbRating(liked)) - .setIsPlayable(true) - .setIsBrowsable(false) - .build() - -@OptIn(UnstableApi::class) -fun DataSpec.copy( - uri: Uri? = null, - uriPositionOffset: Long? = null, - httpMethod: Int? = null, - httpBody: ByteArray? = null, - httpRequestHeaders: Map? = null, - position: Long? = null, - length: Long? = null, - key: String? = null, - flags: Int? = null, - customData: Any? = null -): DataSpec { - return DataSpec.Builder() - .setUri(uri ?: this.uri) - .setUriPositionOffset(uriPositionOffset ?: this.uriPositionOffset) - .setHttpMethod(httpMethod ?: this.httpMethod) - .setHttpBody(httpBody ?: this.httpBody) - .setHttpRequestHeaders(httpRequestHeaders ?: this.httpRequestHeaders) - .setPosition(position ?: this.position) - .setLength(length ?: this.length) - .setKey(key ?: this.key) - .setFlags(flags ?: this.flags) - .setCustomData(customData ?: this.customData) - .build() -} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/Queue.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/Queue.kt deleted file mode 100644 index ac4bc7a6..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/Queue.kt +++ /dev/null @@ -1,188 +0,0 @@ -package dev.brahmkshatriya.echo.playback - -import android.content.Context -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player.REPEAT_MODE_OFF -import androidx.media3.common.util.UnstableApi -import androidx.media3.session.MediaSession -import dev.brahmkshatriya.echo.common.models.EchoMediaItem -import dev.brahmkshatriya.echo.common.models.StreamableAudio -import dev.brahmkshatriya.echo.common.models.Track -import dev.brahmkshatriya.echo.utils.collect -import dev.brahmkshatriya.echo.utils.getFromCache -import dev.brahmkshatriya.echo.utils.getListFromCache -import dev.brahmkshatriya.echo.utils.saveToCache -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import java.util.Collections.synchronizedList - -class Queue { - val updateFlow = MutableSharedFlow() - - private val trackQueue = synchronizedList(mutableListOf()) - private val playerQueue = synchronizedList(mutableListOf()) - val queue get() = playerQueue.toList() - - var currentIndex = -1 - val currentIndexFlow = MutableSharedFlow() - val current get() = playerQueue.getOrNull(currentIndex) - val currentAudioFlow = MutableStateFlow(null) - - fun getTrack(mediaId: String?) = trackQueue.find { it.unloaded.id == mediaId } - - private val clearQueueFlow = MutableSharedFlow() - suspend fun clearQueue(emit: Boolean = true) { - trackQueue.clear() - playerQueue.clear() - if (emit) clearQueueFlow.emit(Unit) - } - - private val removeTrackFlow = MutableSharedFlow() - suspend fun removeTrack(index: Int) { - val track = playerQueue[index] - trackQueue.remove(track) - removeTrackFlow.emit(index) - if (trackQueue.isEmpty()) clearQueueFlow.emit(Unit) - } - - private val addTrackFlow = MutableSharedFlow>>() - suspend fun addTracks( - client: String, context: EchoMediaItem?, tracks: List, offset: Int = 0 - ): Pair> { - var position = currentIndex + 1 - position += offset - position = position.coerceIn(0, playerQueue.size) - - val items = tracks.map { track -> - mediaItemBuilder(track) - } - val queueItems = tracks.map { track -> - StreamableTrack(track, client, context) - } - trackQueue.addAll(queueItems) - val mediaItems = position to items - addTrackFlow.emit(mediaItems) - return mediaItems - } - - private val moveTrackFlow = MutableSharedFlow>() - suspend fun moveTrack(fromIndex: Int, toIndex: Int) { - moveTrackFlow.emit(fromIndex to toIndex) - } - - fun updateQueue(mediaItems: List) { - val queue = mediaItems.mapNotNull { getTrack(it) } - playerQueue.clear() - playerQueue.addAll(queue) - } - - data class StreamableTrack( - val unloaded: Track, - val clientId: String, - val context: EchoMediaItem? = null, - var loaded: Track? = null, - var liked: Boolean = unloaded.liked, - val onLoad: MutableSharedFlow = MutableSharedFlow(), - ) { - val current get() = loaded ?: unloaded - } - - val repeat = MutableStateFlow(REPEAT_MODE_OFF) - val shuffle = MutableStateFlow(false) - val onLiked = MutableSharedFlow() - - fun listenToChanges( - scope: CoroutineScope, - session: MediaSession, - updateLayout: () -> Unit - ) = scope.launch { - val player = session.player - collect(addTrackFlow) { (index, item) -> - player.addMediaItems(index, item) - player.prepare() - } - collect(moveTrackFlow) { (new, old) -> - player.moveMediaItem(old, new) - } - collect(removeTrackFlow) { - player.removeMediaItem(it) - } - collect(clearQueueFlow) { - player.pause() - player.clearMediaItems() - player.stop() - } - collect(currentIndexFlow) { - val track = current ?: return@collect - updateLayout() - val loaded = track.loaded ?: track.onLoad.first() - val metadata = loaded.toMetaData() - player.apply { - val mediaItem = currentMediaItem ?: return@apply - val newItem = mediaItem.buildUpon().setMediaMetadata(metadata).build() - replaceMediaItem(currentMediaItemIndex, newItem) - } - updateLayout() - } - collect(onLiked) { - updateLayout() - } - collect(repeat) { - player.repeatMode = it - updateLayout() - } - collect(shuffle) { - player.shuffleModeEnabled = it - updateLayout() - } - } - - fun saveQueue(context: Context, currentIndex: Int) { - context.saveToCache("queue_tracks", trackQueue.map { it.unloaded }, "queue") - context.saveToCache("queue_clients", "queue") { parcel -> - parcel.writeStringList(trackQueue.map { it.clientId }) - } - context.saveToCache("queue_index", "queue") { - it.writeInt(currentIndex) - } - } - - fun saveCurrentPos(context: Context, position: Long) { - context.saveToCache("position", "queue") { - it.writeLong(position) - } - } - - private fun recoverQueue(context: Context): List? { - val queue = context.getListFromCache("queue_tracks", Track.creator, "queue") - val clientIds = context.getFromCache("queue_clients", "queue") { - it.createStringArrayList() - } - val streamableTracks = queue?.mapIndexedNotNull { index, track -> - val clientId = clientIds?.getOrNull(index) ?: return@mapIndexedNotNull null - StreamableTrack(track, clientId) - } ?: return null - trackQueue.addAll(streamableTracks) - return streamableTracks - } - - private fun recoverIndex(context: Context) = - context.getFromCache("queue_index", "queue") { it.readInt() } - - private fun recoverPosition(context: Context) = - context.getFromCache("position", "queue") { it.readLong() } - - @OptIn(UnstableApi::class) - fun recoverPlaylist(context: Context): MediaSession.MediaItemsWithStartPosition { - val recoveredTracks = recoverQueue(context) ?: emptyList() - val index = recoverIndex(context) ?: C.INDEX_UNSET - val position = recoverPosition(context) ?: 0L - val items = recoveredTracks.map { mediaItemBuilder(it.unloaded) } - return MediaSession.MediaItemsWithStartPosition(items, index, position) - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/Radio.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/Radio.kt new file mode 100644 index 00000000..97dcf8d9 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/Radio.kt @@ -0,0 +1,169 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import android.content.SharedPreferences +import androidx.media3.common.MediaItem +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.clients.RadioClient +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Lists +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Profile +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.TrackItem +import dev.brahmkshatriya.echo.common.models.Playlist +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.plugger.MusicExtension +import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.AUTO_START_RADIO +import dev.brahmkshatriya.echo.utils.tryWith +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.noClient +import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.radioNotSupported +import dev.brahmkshatriya.echo.viewmodels.SnackBar +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class Radio( + session: MediaLibrarySession, + private val context: Context, + private val settings: SharedPreferences, + private val scope: CoroutineScope, + private val extensionList: StateFlow?>, + private val throwFlow: MutableSharedFlow, + private val messageFlow: MutableSharedFlow, + private val stateFlow: MutableStateFlow, +) : PlayerListener(session.player) { + + sealed class State { + data object Empty : State() + data object Loading : State() + data class Loaded( + val clientId: String, val playlist: Playlist, val tracks: List, val played: Int + ) : State() + } + + companion object { + suspend fun start( + context: Context, + messageFlow: MutableSharedFlow, + throwableFlow: MutableSharedFlow, + stateFlow: MutableStateFlow, + extensionListFlow: StateFlow?>, + clientId: String, + item: EchoMediaItem, + play: Int = -1 + ): State.Loaded? { + val list = extensionListFlow.first { it != null } + val extension = list?.find { it.metadata.id == clientId } + when (val client = extension?.client) { + null -> { + messageFlow.emit(context.noClient()) + } + + !is RadioClient -> { + messageFlow.emit(context.radioNotSupported(extension.metadata.name)) + } + + else -> { + stateFlow.value = State.Loading + + suspend fun tryIO(block: suspend () -> T): T? = + withContext(Dispatchers.IO) { + tryWith(throwableFlow) { block() } + } + + val playlist = tryIO { + when (item) { + is TrackItem -> client.radio(item.track) + is Lists.PlaylistItem -> client.radio(item.playlist) + is Lists.AlbumItem -> client.radio(item.album) + is Profile.ArtistItem -> client.radio(item.artist) + is Profile.UserItem -> throw Exception("User radio not supported") + } + } + + if (playlist != null) { + val tracks = tryIO { client.loadTracks(playlist).loadFirst() } + val state = if (!tracks.isNullOrEmpty()) State.Loaded( + clientId, + playlist, + tracks, + play + ) + else { + messageFlow.emit( + SnackBar.Message( + context.getString(R.string.radio_playlist_empty) + ) + ) + null + } + stateFlow.value = state ?: State.Empty + return state + } else { + stateFlow.value = State.Empty + } + } + } + return null + } + } + + private fun play(loaded: State.Loaded, play: Int): Boolean { + val track = loaded.tracks.getOrNull(play) ?: return false + val item = MediaItemUtils.build( + settings, track, loaded.clientId, loaded.playlist.toMediaItem() + ) + player.addMediaItem(item) + player.prepare() + player.playWhenReady = true + return true + } + + private fun loadPlaylist() { + val mediaItem = player.currentMediaItem ?: return + val client = mediaItem.clientId + val item = mediaItem.context ?: mediaItem.track.toMediaItem() + scope.launch { + val loaded = start( + context, messageFlow, throwFlow, stateFlow, extensionList, client, item, 0 + ) ?: return@launch + play(loaded, 0) + } + } + + override fun onTrackStart(mediaItem: MediaItem) { + val autoStartRadio = settings.getBoolean(AUTO_START_RADIO, true) + if (!autoStartRadio) return + if (player.hasNextMediaItem()) return + when (val state = stateFlow.value) { + is State.Empty -> { + if (stateFlow.value != State.Empty) return + loadPlaylist() + } + + State.Loading -> {} + is State.Loaded -> { + val toBePlayed = state.played + 1 + if (toBePlayed == state.tracks.size) loadPlaylist() + if (play(state, toBePlayed)) { + stateFlow.value = state.copy(played = toBePlayed) + } + } + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + if(player.mediaItemCount == 0) stateFlow.value = State.Empty + } +} + diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/RenderersFactory.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/RenderersFactory.kt index 2512735a..470e09f1 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/RenderersFactory.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/RenderersFactory.kt @@ -15,14 +15,13 @@ class RenderersFactory(context: Context) : DefaultRenderersFactory(context) { enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean ) = run { - - val minDuration = 10_00_000L - val maxDuration = 20_00_000L - val ratio = 1f - val minVolume = 0 - val threshHold = 256.toShort() - val silenceSkippingAudioProcessor = - SilenceSkippingAudioProcessor(minDuration, ratio, maxDuration, minVolume, threshHold) + val silenceSkippingAudioProcessor = SilenceSkippingAudioProcessor( + 10_00_000L, + 1f, + 20_00_000L, + 0, + 256 + ) DefaultAudioSink.Builder(context) .setEnableFloatOutput(enableFloatOutput) diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/ResumptionUtils.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/ResumptionUtils.kt new file mode 100644 index 00000000..161973b6 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/ResumptionUtils.kt @@ -0,0 +1,71 @@ +package dev.brahmkshatriya.echo.playback + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.utils.getFromCache +import dev.brahmkshatriya.echo.utils.getListFromCache +import dev.brahmkshatriya.echo.utils.saveToCache + +object ResumptionUtils { + fun saveQueue(context: Context, currentIndex: Int, list: List) { + val tracks = list.map { it.track } + val clients = list.map { it.clientId } + val contexts = list.map { it.context } + context.saveToCache("queue_tracks", tracks, "queue") + context.saveToCache("queue_contexts", contexts, "queue") + context.saveToCache("queue_clients", "queue") { parcel -> + parcel.writeStringList(clients) + } + context.saveToCache("queue_index", "queue") { + it.writeInt(currentIndex) + } + } + + fun saveCurrentPos(context: Context, position: Long) { + context.saveToCache("position", "queue") { + it.writeLong(position) + } + } + + private fun recoverQueue(context: Context): List? { + val settings = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val tracks = context.getListFromCache( + "queue_tracks", Track.creator, "queue" + ) + val clientIds = context.getFromCache("queue_clients", "queue") { + it.createStringArrayList() + } + val contexts = context.getListFromCache( + "queue_contexts", EchoMediaItem.creator, "queue" + ) + return tracks?.mapIndexedNotNull { index, track -> + val clientId = clientIds?.getOrNull(index) ?: return@mapIndexedNotNull null + val item = contexts?.getOrNull(index) + MediaItemUtils.build(settings, track, clientId, item) + } ?: return null + } + + private fun recoverIndex(context: Context) = + context.getFromCache("queue_index", "queue") { it.readInt() } + + private fun recoverPosition(context: Context) = + context.getFromCache("position", "queue") { it.readLong() } + + @OptIn(UnstableApi::class) + fun recoverPlaylist(context: Context): MediaSession.MediaItemsWithStartPosition { + val items = recoverQueue(context) ?: emptyList() + val index = recoverIndex(context) ?: C.INDEX_UNSET + val position = recoverPosition(context) ?: 0L + return MediaSession.MediaItemsWithStartPosition(items, index, position) + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/StreamableDataSource.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/StreamableDataSource.kt index 7caa6288..7a693aaa 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/StreamableDataSource.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/StreamableDataSource.kt @@ -8,6 +8,7 @@ import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultDataSource import dev.brahmkshatriya.echo.common.models.StreamableAudio +import dev.brahmkshatriya.echo.playback.TrackResolver.Companion.copy @UnstableApi class StreamableDataSource( diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackResolver.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackResolver.kt index 3cf877d2..af332a78 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackResolver.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackResolver.kt @@ -2,102 +2,126 @@ package dev.brahmkshatriya.echo.playback import android.content.Context import android.content.SharedPreferences +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSpec import androidx.media3.datasource.ResolvingDataSource.Resolver import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.clients.TrackClient -import dev.brahmkshatriya.echo.common.models.Streamable import dev.brahmkshatriya.echo.common.models.StreamableAudio import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.MediaItemUtils.audioStreamIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.plugger.MusicExtension import dev.brahmkshatriya.echo.plugger.getExtension -import dev.brahmkshatriya.echo.ui.settings.AudioFragment import dev.brahmkshatriya.echo.utils.getFromCache import dev.brahmkshatriya.echo.utils.saveToCache import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.noClient import dev.brahmkshatriya.echo.viewmodels.ExtensionViewModel.Companion.trackNotSupported +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking class TrackResolver( private val context: Context, - private val global: Queue, private val extensionListFlow: MutableStateFlow?>, private val settings: SharedPreferences ) : Resolver { + lateinit var player: Player + @UnstableApi override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { - val id = dataSpec.uri.toString() - val streamable = dataSpec.customData as? StreamableAudio ?: run { - val track = global.getTrack(id) - ?: throw Exception(context.getString(R.string.track_not_found)) - val extension = extensionListFlow.getExtension(track.clientId) - val client = extension?.client - ?: throw Exception(context.noClient().message) + val (index, mediaItem) = runBlocking(Dispatchers.Main) { + player.getMediaItemById(dataSpec.uri.toString()) + } ?: throw Exception(context.getString(R.string.track_not_found)) + + val track = mediaItem.track + val clientId = mediaItem.clientId + + val extension = extensionListFlow.getExtension(clientId) + val client = extension?.client + ?: throw Exception(context.noClient().message) + + if (client !is TrackClient) + throw Exception(context.trackNotSupported(extension.metadata.name).message) - if (client !is TrackClient) - throw Exception(context.trackNotSupported(extension.metadata.name).message) + val loadedTrack = if (!mediaItem.isLoaded) loadTrack(client, track) else track + val newMediaItem = MediaItemUtils.build(settings, mediaItem, loadedTrack) - val loadedTrack = getTrack(track, client).getOrElse { throw Exception(it) } - loadAudio(loadedTrack, client).getOrElse { throw Exception(it) } + runBlocking(Dispatchers.Main) { + player.replaceMediaItem(index, newMediaItem) } - global.currentAudioFlow.value = streamable - return dataSpec.copy(customData = streamable) - } - private fun getTrack( - streamableTrack: Queue.StreamableTrack, client: TrackClient - ) = runCatching { - val track = streamableTrack.loaded ?: loadTrack(client, streamableTrack) - current = track - track + val streamable = loadAudio(client, newMediaItem).getOrElse { throw Exception(it) } + return dataSpec.copy(customData = streamable) } - - private fun loadTrack(client: TrackClient, streamableTrack: Queue.StreamableTrack): Track { - val id = streamableTrack.unloaded.id - val track = getTrackFromCache(id) ?: runBlocking { - runCatching { client.loadTrack(streamableTrack.unloaded) } + private fun loadTrack(client: TrackClient, track: Track): Track { + val id = track.id + val loaded = getTrackFromCache(id) ?: runBlocking { + runCatching { client.loadTrack(track) } }.getOrThrow() - context.saveToCache(id, track) - if (streamableTrack.loaded == null) streamableTrack.run { - loaded = track - liked = track.liked - runBlocking { - onLoad.emit(track) - global.onLiked.emit(track.liked) - } - } - return track + context.saveToCache(id, loaded) + return loaded } - private fun loadAudio(track: Track, client: TrackClient): Result { - val streamable = selectStream(settings, track.audioStreamables) - ?: throw Exception(context.getString(R.string.no_streams_found)) + private fun loadAudio(client: TrackClient, mediaItem: MediaItem): Result { + val streams = mediaItem.track.audioStreamables + val index = mediaItem.audioStreamIndex + val streamable = streams[index] return runBlocking { runCatching { client.getStreamableAudio(streamable) } } } - private var current: Track? = null private fun getTrackFromCache(id: String): Track? { - val track = current?.takeIf { it.id == id } - ?: context.getFromCache(id, Track.creator) ?: return null + val track = context.getFromCache(id, Track.creator) ?: return null return if (!track.isExpired()) track else null } private fun Track.isExpired() = System.currentTimeMillis() > expiresAt companion object { - fun selectStream(settings: SharedPreferences, streamables: List) = - when (settings.getString(AudioFragment.AudioPreference.STREAM_QUALITY, "lowest")) { - "highest" -> streamables.maxByOrNull { it.quality } - "medium" -> streamables.sortedBy { it.quality }.getOrNull(streamables.size / 2) - "lowest" -> streamables.minByOrNull { it.quality } - else -> streamables.firstOrNull() + @OptIn(UnstableApi::class) + fun DataSpec.copy( + uri: Uri? = null, + uriPositionOffset: Long? = null, + httpMethod: Int? = null, + httpBody: ByteArray? = null, + httpRequestHeaders: Map? = null, + position: Long? = null, + length: Long? = null, + key: String? = null, + flags: Int? = null, + customData: Any? = null + ): DataSpec { + return DataSpec.Builder() + .setUri(uri ?: this.uri) + .setUriPositionOffset(uriPositionOffset ?: this.uriPositionOffset) + .setHttpMethod(httpMethod ?: this.httpMethod) + .setHttpBody(httpBody ?: this.httpBody) + .setHttpRequestHeaders(httpRequestHeaders ?: this.httpRequestHeaders) + .setPosition(position ?: this.position) + .setLength(length ?: this.length) + .setKey(key ?: this.key) + .setFlags(flags ?: this.flags) + .setCustomData(customData ?: this.customData) + .build() + } + + fun Player.getMediaItemById(id: String): Pair? { + (0 until mediaItemCount).forEach { index -> + val mediaItem = getMediaItemAt(index) + if (mediaItem.mediaId == id) return index to mediaItem } + return null + } } } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackingListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackingListener.kt new file mode 100644 index 00000000..e89982f3 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/playback/TrackingListener.kt @@ -0,0 +1,87 @@ +package dev.brahmkshatriya.echo.playback + +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import dev.brahmkshatriya.echo.common.clients.TrackerClient +import dev.brahmkshatriya.echo.common.models.EchoMediaItem +import dev.brahmkshatriya.echo.common.models.Track +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.plugger.MusicExtension +import dev.brahmkshatriya.echo.plugger.TrackerExtension +import dev.brahmkshatriya.echo.plugger.getExtension +import dev.brahmkshatriya.echo.utils.PauseCountDown +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +@UnstableApi +class TrackingListener( + session: MediaLibrarySession, + private val scope: CoroutineScope, + private val extensionList: MutableStateFlow?>, + private val trackerList: MutableStateFlow?>, + private val throwableFlow: MutableSharedFlow +) : PlayerListener(session.player) { + + private var current: MediaItem? = null + private var timer: PauseCountDown? = null + + private suspend fun tryWith(block: suspend () -> T) = + dev.brahmkshatriya.echo.utils.tryWith(throwableFlow, block) + + private fun trackMedia( + block: suspend TrackerClient.(clientId: String, context: EchoMediaItem?, track: Track) -> Unit + ) { + val item = current ?: return + val clientId = item.clientId + val track = item.track + + val client = extensionList.getExtension(clientId)?.client ?: return + val trackers = trackerList.value?.filter { it.metadata.enabled } ?: emptyList() + + scope.launch(Dispatchers.IO) { + if (client is TrackerClient) + tryWith { client.block(clientId, item.context, track) } + trackers.forEach { + launch { + tryWith { it.client.block(clientId, item.context, track) } + } + } + } + } + + + override fun onTrackStart(mediaItem: MediaItem) { + current = mediaItem + trackMedia(TrackerClient::onStartedPlaying) + timer = object : PauseCountDown(30000) { + override fun onTimerTick(millisUntilFinished: Long) { + // Can be Implemented + } + + override fun onTimerFinish() { + trackMedia(TrackerClient::onMarkAsPlayed) + } + } + timer?.start() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + // Can be implemented + if (isPlaying) timer?.start() + else timer?.pause() + } + + override fun onTrackEnd(mediaItem: MediaItem) { + timer?.pause() + timer = null + trackMedia(TrackerClient::onStoppedPlaying) + current = null + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/common/ConfigureFeedUI.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/common/ConfigureFeedUI.kt index fda6bcbb..8811f666 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/common/ConfigureFeedUI.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/common/ConfigureFeedUI.kt @@ -81,7 +81,6 @@ fun Fragment.configureFeedUI( } observe(viewModel.feed) { - println("Feed : $it") mediaContainerAdapter.submit(it) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt index b2d134fb..1bc92edb 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistFragment.kt @@ -18,12 +18,13 @@ import dev.brahmkshatriya.echo.common.clients.EditPlaylistCoverClient import dev.brahmkshatriya.echo.common.models.Playlist import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.FragmentEditPlaylistBinding -import dev.brahmkshatriya.echo.playback.Queue +import dev.brahmkshatriya.echo.playback.MediaItemUtils import dev.brahmkshatriya.echo.plugger.getExtension import dev.brahmkshatriya.echo.ui.common.openFragment import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Add import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Move import dev.brahmkshatriya.echo.ui.editplaylist.EditPlaylistViewModel.ListAction.Remove +import dev.brahmkshatriya.echo.ui.player.PlaylistAdapter import dev.brahmkshatriya.echo.utils.autoCleared import dev.brahmkshatriya.echo.utils.dpToPx import dev.brahmkshatriya.echo.utils.getParcel @@ -205,7 +206,7 @@ class EditPlaylistFragment : Fragment() { } } val touchHelper = ItemTouchHelper(callback) - val adapter = PlaylistAdapter(null, object : PlaylistAdapter.Callback() { + val adapter = PlaylistAdapter(object : PlaylistAdapter.Callback() { override fun onDragHandleTouched(viewHolder: RecyclerView.ViewHolder) { touchHelper.startDrag(viewHolder) } @@ -224,7 +225,7 @@ class EditPlaylistFragment : Fragment() { observe(viewModel.currentTracks) { tracks -> tracks?.let { adapter.submitList(it.map { track -> - Queue.StreamableTrack(track, clientId) + false to MediaItemUtils.build(null, track, clientId, null) }) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistViewModel.kt index 529a5709..db517576 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/EditPlaylistViewModel.kt @@ -27,7 +27,7 @@ class EditPlaylistViewModel @Inject constructor( throwableFlow: MutableSharedFlow, val extensionListFlow: MutableStateFlow?>, private val mutableMessageFlow: MutableSharedFlow, - private val context: Application + private val context: Application, ) : CatchingViewModel(throwableFlow) { var loading: Boolean? = null diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/PlaylistAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/PlaylistAdapter.kt deleted file mode 100644 index 34b01ae1..00000000 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/editplaylist/PlaylistAdapter.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.brahmkshatriya.echo.ui.editplaylist - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import dev.brahmkshatriya.echo.R -import dev.brahmkshatriya.echo.databinding.ItemPlaylistItemBinding -import dev.brahmkshatriya.echo.playback.Queue.StreamableTrack -import dev.brahmkshatriya.echo.ui.player.LifeCycleListAdapter -import dev.brahmkshatriya.echo.ui.player.PlayerTrackAdapter -import dev.brahmkshatriya.echo.utils.loadInto -import dev.brahmkshatriya.echo.utils.observe -import dev.brahmkshatriya.echo.utils.toTimeString -import kotlinx.coroutines.flow.Flow - -class PlaylistAdapter( - private val current: Flow?, - private val callback: Callback -) : LifeCycleListAdapter(PlayerTrackAdapter.DiffCallback) { - - open class Callback { - open fun onItemClicked(position: Int) {} - open fun onItemClosedClicked(position: Int) {} - open fun onDragHandleTouched(viewHolder: RecyclerView.ViewHolder) {} - } - - override fun inflateCallback( - inflater: LayoutInflater, - container: ViewGroup? - ) = ItemPlaylistItemBinding.inflate(inflater, container, false) - - @SuppressLint("ClickableViewAccessibility") - override fun Holder.onBind(position: Int) { - val item = getItem(position) - val track = item.unloaded - binding.playlistItemTitle.text = track.title - track.cover.loadInto(binding.playlistItemImageView, R.drawable.art_music) - var subtitle = "" - track.duration?.toTimeString()?.let { - subtitle += it - } - track.artists.joinToString(", ") { it.name }.let { - if (it.isNotBlank()) subtitle += if (subtitle.isNotBlank()) " • $it" else it - } - binding.playlistItemAuthor.isVisible = subtitle.isNotEmpty() - binding.playlistItemAuthor.text = subtitle - - binding.playlistItemClose.setOnClickListener { - callback.onItemClosedClicked(bindingAdapterPosition) - } - - binding.playlistItemDragHandle.setOnTouchListener { _, event -> - if (event.actionMasked != MotionEvent.ACTION_DOWN) return@setOnTouchListener false - callback.onDragHandleTouched(this) - true - } - - binding.root.setOnClickListener { - callback.onItemClicked(bindingAdapterPosition) - } - - current?.let { currentFlow -> - observe(currentFlow) { - binding.playlistCurrentItem.isVisible = it == position - } - } ?: { binding.playlistCurrentItem.isVisible = false } - } - - -} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/exception/ExceptionFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/exception/ExceptionFragment.kt index 336516e5..e435fb3a 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/exception/ExceptionFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/exception/ExceptionFragment.kt @@ -14,6 +14,9 @@ import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.exceptions.LoginRequiredException import dev.brahmkshatriya.echo.common.exceptions.UnauthorizedException import dev.brahmkshatriya.echo.databinding.FragmentExceptionBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.audioStreamIndex +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.utils.autoCleared import dev.brahmkshatriya.echo.utils.getSerial import dev.brahmkshatriya.echo.utils.onAppBarChangeListener @@ -99,8 +102,9 @@ class ExceptionFragment : Fragment() { @Suppress("UnusedReceiverParameter") fun Context.getDetails(throwable: Throwable) = when (throwable) { is PlayerViewModel.PlayerException -> """ -Current : ${throwable.currentAudio.toString()} -Stream : ${throwable.streamableTrack.toString()} +Client : ${throwable.mediaItem?.clientId} +Track : ${throwable.mediaItem?.track} +Stream : ${throwable.mediaItem?.run { track.audioStreamables.getOrNull(audioStreamIndex) }} ${throwable.cause.stackTraceToString()} """.trimIndent() diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/AlbumHeaderAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/AlbumHeaderAdapter.kt index a60ad3f1..66c6e964 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/AlbumHeaderAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/AlbumHeaderAdapter.kt @@ -43,7 +43,6 @@ class AlbumHeaderAdapter( fun onRadioClicked(album: Album) } - //TODO Fix this, make it override fun getItemViewType(position: Int) = if (_album == null) 0 else 1 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt index 24356c2f..28f138f9 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemBottomSheet.kt @@ -144,7 +144,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { else null, if (client is RadioClient) ItemAction.Resource(R.drawable.ic_radio, R.string.radio) { - playerViewModel.radio(clientId, item.album) + playerViewModel.radio(clientId, item) } else null, ) + item.album.artists.map { @@ -158,7 +158,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { listOfNotNull( if (client is RadioClient) ItemAction.Resource(R.drawable.ic_radio, R.string.radio) { - playerViewModel.radio(clientId, item.playlist) + playerViewModel.radio(clientId, item) } else null, if (client is LibraryClient) @@ -187,7 +187,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { if (item is EchoMediaItem.Profile.ArtistItem) listOfNotNull( if (client is RadioClient) ItemAction.Resource(R.drawable.ic_radio, R.string.radio) { - playerViewModel.radio(clientId, item.artist) + playerViewModel.radio(clientId, item) } else null, if (client is ArtistFollowClient) @@ -240,7 +240,7 @@ class ItemBottomSheet : BottomSheetDialogFragment() { else null, if (client is RadioClient) ItemAction.Resource(R.drawable.ic_radio, R.string.radio) { - playerViewModel.radio(clientId, item.track) + playerViewModel.radio(clientId, item) } else null, item.track.album?.let { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemFragment.kt index 81a7475c..2afe5150 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/item/ItemFragment.kt @@ -162,7 +162,8 @@ class ItemFragment : Fragment() { override fun onPlayClicked(album: Album) = playerVM.play(clientId, album.toMediaItem(), 0) - override fun onRadioClicked(album: Album) = playerVM.radio(clientId, album) + override fun onRadioClicked(album: Album) = + playerVM.radio(clientId, album.toMediaItem()) } ) @@ -171,7 +172,8 @@ class ItemFragment : Fragment() { override fun onPlayClicked(list: Playlist) = playerVM.play(clientId, list.toMediaItem(), 0) - override fun onRadioClicked(list: Playlist) = playerVM.radio(clientId, list) + override fun onRadioClicked(list: Playlist) = + playerVM.radio(clientId, list.toMediaItem()) } ) @@ -180,7 +182,8 @@ class ItemFragment : Fragment() { artist: Artist, subscribe: Boolean, adapter: ArtistHeaderAdapter ) = Unit - override fun onRadioClicked(artist: Artist) = playerVM.radio(clientId, artist) + override fun onRadioClicked(artist: Artist) = + playerVM.radio(clientId, artist.toMediaItem()) }) fun concatAdapter(item: EchoMediaItem): ConcatAdapter { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsFragment.kt index 6b747bcc..d15d0138 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsFragment.kt @@ -19,15 +19,14 @@ import com.google.android.material.behavior.HideBottomViewOnScrollBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.clients.LyricsSearchClient +import dev.brahmkshatriya.echo.common.models.ExtensionType import dev.brahmkshatriya.echo.common.models.ImageHolder.Companion.toImageHolder import dev.brahmkshatriya.echo.common.models.Lyric import dev.brahmkshatriya.echo.common.models.Lyrics import dev.brahmkshatriya.echo.databinding.FragmentLyricsBinding import dev.brahmkshatriya.echo.databinding.ItemLyricsItemBinding -import dev.brahmkshatriya.echo.common.models.ExtensionType import dev.brahmkshatriya.echo.ui.extension.ExtensionsListBottomSheet import dev.brahmkshatriya.echo.utils.autoCleared -import dev.brahmkshatriya.echo.utils.emit import dev.brahmkshatriya.echo.utils.loadWith import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel @@ -96,7 +95,7 @@ class LyricsFragment : Fragment() { var currentLyric: Lyric? = null var lyricAdapter: LyricAdapter? = null - val smoothScroller = CenterSmoothScroller(binding.lyricsRecyclerView) + val layoutManager = binding.lyricsRecyclerView.layoutManager as LinearLayoutManager fun updateLyrics(current: Long) { if ((currentLyric?.endTime ?: 0) < current || current <= 0) { @@ -108,15 +107,18 @@ class LyricsFragment : Fragment() { lyricAdapter?.submitList(list) val currentIndex = list.indexOfLast { it.first } .takeIf { it != -1 } ?: return + + val smoothScroller = CenterSmoothScroller(binding.lyricsRecyclerView) smoothScroller.targetPosition = currentIndex layoutManager.startSmoothScroll(smoothScroller) + binding.appBarLayout.setExpanded(false) slideDown() } } lyricAdapter = LyricAdapter { lyric -> currentLyric = null - emit(playerVM.seekTo) { lyric.startTime } + playerVM.seekTo(lyric.startTime) updateLyrics(lyric.startTime) } binding.lyricsRecyclerView.adapter = lyricAdapter diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsViewModel.kt index 97a08c88..6e9c281d 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/lyrics/LyricsViewModel.kt @@ -2,6 +2,7 @@ package dev.brahmkshatriya.echo.ui.lyrics import android.content.SharedPreferences import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem import androidx.paging.PagingData import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.common.clients.LyricsClient @@ -9,7 +10,9 @@ import dev.brahmkshatriya.echo.common.clients.LyricsSearchClient import dev.brahmkshatriya.echo.common.helpers.PagedData import dev.brahmkshatriya.echo.common.models.Lyric import dev.brahmkshatriya.echo.common.models.Lyrics -import dev.brahmkshatriya.echo.playback.Queue +import dev.brahmkshatriya.echo.playback.Current +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.plugger.LyricsExtension import dev.brahmkshatriya.echo.plugger.MusicExtension import dev.brahmkshatriya.echo.plugger.getExtension @@ -19,18 +22,16 @@ import dev.brahmkshatriya.echo.utils.mapState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LyricsViewModel @Inject constructor( - private val global: Queue, private val settings: SharedPreferences, private val extensionListFlow: MutableStateFlow?>, private val lyricsListFlow: MutableStateFlow?>, + private val currentMediaFlow: MutableStateFlow, throwableFlow: MutableSharedFlow ) : ClientSelectionViewModel(throwableFlow) { @@ -43,13 +44,12 @@ class LyricsViewModel @Inject constructor( override val currentFlow = currentExtension.mapState { it?.metadata?.id } override fun onClientSelected(clientId: String) { - println("$clientId selected") onLyricsClientSelected(lyricsExtensionList.getExtension(clientId)) } - private suspend fun update(){ + private suspend fun update() { extensionListFlow.first { it != null } - val trackExtension = global.current?.clientId?.let { id -> + val trackExtension = currentMediaFlow.value?.mediaItem?.clientId?.let { id -> val extension = extensionListFlow.getExtension(id) val client = extension?.client if (client !is LyricsClient) return@let null @@ -62,15 +62,13 @@ class LyricsViewModel @Inject constructor( val extension = lyricsExtensionList.value.find { it.metadata.id == id } ?: trackExtension - println("update called") onLyricsClientSelected(extension) } override fun onInitialize() { viewModelScope.launch { update() - global.currentIndexFlow.map { global.current }.distinctUntilChanged().collect { _ -> - println("current changed") + currentMediaFlow.collect { update() } } @@ -78,10 +76,9 @@ class LyricsViewModel @Inject constructor( private fun onLyricsClientSelected(extension: LyricsExtension?) { currentExtension.value = extension - println("onLyricsClientSelected: $extension") currentLyrics.value = null settings.edit().putString(LAST_LYRICS_KEY, extension?.metadata?.id).apply() - val streamableTrack = global.current ?: return + val streamableTrack = currentMediaFlow.value?.mediaItem ?: return viewModelScope.launch(Dispatchers.IO) { val data = onTrackChange(streamableTrack) if (data != null) { @@ -106,10 +103,10 @@ class LyricsViewModel @Inject constructor( return tryWith { client.searchLyrics(query) } } - private suspend fun onTrackChange(streamableTrack: Queue.StreamableTrack): PagedData? { + private suspend fun onTrackChange(mediaItem: MediaItem): PagedData? { val client = currentExtension.value?.client ?: return null - val track = streamableTrack.loaded ?: streamableTrack.onLoad.first() - return tryWith { client.searchTrackLyrics(streamableTrack.clientId, track) } + val track = mediaItem.track + return tryWith { client.searchTrackLyrics(mediaItem.clientId, track) } } fun search(query: String?) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaContainerLoadingAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaContainerLoadingAdapter.kt index 5a28ce29..138845f9 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaContainerLoadingAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/media/MediaContainerLoadingAdapter.kt @@ -78,7 +78,6 @@ class MediaContainerLoadingAdapter(val listener: Listener? = null) : override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadViewHolder { val inflater = LayoutInflater.from(parent.context) - println("creating state view type for : $loadState") return LoadViewHolder( when (getStateViewType(loadState)) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt index 58d63df8..1869c780 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerFragment.kt @@ -28,7 +28,7 @@ import dev.brahmkshatriya.echo.viewmodels.SnackBar.Companion.createSnack import dev.brahmkshatriya.echo.viewmodels.UiViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.isLandscape import dev.brahmkshatriya.echo.viewmodels.UiViewModel.Companion.setupPlayerInfoBehavior -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max @@ -77,11 +77,8 @@ class PlayerFragment : Fragment() { ) binding.viewPager.registerOnUserPageChangeCallback { position, userInitiated -> - if (viewModel.currentIndex != position && userInitiated) - lifecycleScope.launch { - delay(300) - viewModel.audioIndexFlow.emit(position) - } + if (viewModel.currentFlow.value?.index != position && userInitiated) + lifecycleScope.launch { viewModel.play(position) } } binding.viewPager.getChildAt(0).run { @@ -90,8 +87,10 @@ class PlayerFragment : Fragment() { overScrollMode = View.OVER_SCROLL_NEVER } - observe(viewModel.listChangeFlow) { - if (it.isEmpty()) { + val combined = viewModel.run { currentFlow.combine(listUpdateFlow) { it, _ -> it } } + observe(combined) { + val list = viewModel.list + if (list.isEmpty()) { emit(uiViewModel.changeInfoState) { STATE_COLLAPSED } emit(uiViewModel.changePlayerState) { STATE_HIDDEN } } else { @@ -100,9 +99,8 @@ class PlayerFragment : Fragment() { emit(uiViewModel.changeInfoState) { STATE_COLLAPSED } } } - - adapter.submitList(it) - val index = viewModel.currentIndex + adapter.submitList(list) + val index = it?.index ?: -1 val smooth = abs(index - binding.viewPager.currentItem) <= 1 binding.viewPager.setCurrentItem(index, smooth) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerQueueFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerQueueFragment.kt index 4ad834b0..d78860e4 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerQueueFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerQueueFragment.kt @@ -6,18 +6,20 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED import dev.brahmkshatriya.echo.databinding.FragmentPlaylistBinding -import dev.brahmkshatriya.echo.ui.editplaylist.PlaylistAdapter +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.Radio import dev.brahmkshatriya.echo.utils.autoCleared import dev.brahmkshatriya.echo.utils.dpToPx -import dev.brahmkshatriya.echo.utils.emit import dev.brahmkshatriya.echo.utils.observe import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel +import kotlinx.coroutines.flow.combine class PlayerQueueFragment : Fragment() { @@ -36,6 +38,7 @@ class PlayerQueueFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + var queueAdapter: PlaylistAdapter? = null val callback = object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.START @@ -45,6 +48,9 @@ class PlayerQueueFragment : Fragment() { viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { + if (viewHolder.bindingAdapter != queueAdapter) return false + if (target.bindingAdapter != queueAdapter) return false + val fromPos = viewHolder.bindingAdapterPosition val toPos = target.bindingAdapterPosition viewModel.moveQueueItems(toPos, fromPos) @@ -55,16 +61,26 @@ class PlayerQueueFragment : Fragment() { val pos = viewHolder.bindingAdapterPosition viewModel.removeQueueItem(pos) } + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + return if (viewHolder.bindingAdapter == queueAdapter) makeMovementFlags( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.START + ) else 0 + } } val touchHelper = ItemTouchHelper(callback) - val adapter = PlaylistAdapter(viewModel.currentFlow, object : PlaylistAdapter.Callback() { + queueAdapter = PlaylistAdapter(object : PlaylistAdapter.Callback() { override fun onDragHandleTouched(viewHolder: RecyclerView.ViewHolder) { touchHelper.startDrag(viewHolder) } override fun onItemClicked(position: Int) { - emit(viewModel.audioIndexFlow) { position } + viewModel.play(position) } override fun onItemClosedClicked(position: Int) { @@ -72,16 +88,36 @@ class PlayerQueueFragment : Fragment() { } }) - binding.root.adapter = adapter + val radioAdapter = PlaylistAdapter(object : PlaylistAdapter.Callback() { + override fun onItemClicked(position: Int) { + viewModel.radioPlay(position) + } + }, true) + + val radioLoaderAdapter = PlaylistAdapter.Loader() + binding.root.adapter = ConcatAdapter(queueAdapter, radioLoaderAdapter, radioAdapter) touchHelper.attachToRecyclerView(binding.root) - observe(viewModel.listChangeFlow) { - adapter.submitList(it) + val combined = viewModel.listUpdateFlow.combine(viewModel.currentFlow) { _, current -> + val currentIndex = current?.index + viewModel.list.mapIndexed { index, mediaItem -> + if (currentIndex == index) true to current.mediaItem + else false to mediaItem + } } + observe(combined) { queueAdapter.submitList(it) } + observe(viewModel.radioStateFlow) { state -> + radioLoaderAdapter.setLoading(state is Radio.State.Loading) + val list = if (state is Radio.State.Loaded) state.tracks.drop(state.played + 1).map { + false to MediaItemUtils.build(null, it, state.clientId, null) + } else emptyList() + radioAdapter.submitList(list) + } + val manager = binding.root.layoutManager as LinearLayoutManager val offset = 24.dpToPx(requireContext()) observe(uiViewModel.changeInfoState) { - val index = viewModel.currentIndex + val index = viewModel.currentFlow.value?.index ?: -1 if (it == STATE_EXPANDED && index != -1) manager.scrollToPositionWithOffset(index, offset) } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt index 16831cf5..b2c0f463 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerTrackAdapter.kt @@ -16,6 +16,7 @@ import androidx.core.view.updatePaddingRelative import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem import androidx.media3.common.Player.REPEAT_MODE_ALL import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.media3.common.Player.REPEAT_MODE_ONE @@ -29,11 +30,13 @@ import dev.brahmkshatriya.echo.R import dev.brahmkshatriya.echo.common.clients.LibraryClient import dev.brahmkshatriya.echo.common.models.EchoMediaItem import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem -import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.ItemPlayerCollapsedBinding import dev.brahmkshatriya.echo.databinding.ItemPlayerControlsBinding import dev.brahmkshatriya.echo.databinding.ItemPlayerTrackBinding -import dev.brahmkshatriya.echo.playback.Queue.StreamableTrack +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.context +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.plugger.getExtension import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.defaultPlayerColors import dev.brahmkshatriya.echo.ui.player.PlayerColors.Companion.getColorsFrom @@ -56,7 +59,7 @@ import kotlin.math.min class PlayerTrackAdapter( val fragment: Fragment, private val listener: Listener -) : LifeCycleListAdapter(DiffCallback) { +) : LifeCycleListAdapter(DiffCallback) { interface Listener { fun onMoreClicked(clientId: String?, item: EchoMediaItem, loaded: Boolean) @@ -66,28 +69,27 @@ class PlayerTrackAdapter( private val viewModel by fragment.activityViewModels() private val uiViewModel by fragment.activityViewModels() - object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: StreamableTrack, newItem: StreamableTrack) = - oldItem.unloaded.id == newItem.unloaded.id + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MediaItem, newItem: MediaItem) = + oldItem.mediaId == newItem.mediaId + + override fun areContentsTheSame(oldItem: MediaItem, newItem: MediaItem) = + oldItem == newItem - override fun areContentsTheSame(oldItem: StreamableTrack, newItem: StreamableTrack) = - true } override fun inflateCallback(inflater: LayoutInflater, container: ViewGroup?) = ItemPlayerTrackBinding.inflate(inflater, container, false) - override fun Holder.onBind(position: Int) { + override fun Holder.onBind(position: Int) { val item = getItem(position) ?: return - val client = item.clientId - val track = item.current - binding.applyTrackDetails(client, item, track) - observe(item.onLoad) { - binding.applyTrackDetails(client, item, item.unloaded) - } + val clientId = item.clientId + + binding.applyTrackDetails(clientId, item) + lifecycleScope.launch { - val bitmap = track.cover?.loadBitmap(binding.root.context) + val bitmap = item.track.cover?.loadBitmap(binding.root.context) val colors = binding.root.context.getPlayerColors(bitmap) binding.root.setBackgroundColor(colors.background) binding.bgGradient.imageTintList = ColorStateList.valueOf(colors.background) @@ -135,12 +137,29 @@ class PlayerTrackAdapter( block: (T?) -> Unit ) { observe(flow) { - if (viewModel.current?.unloaded?.id == track.id) + if (viewModel.currentFlow.value?.index == bindingAdapterPosition) block(it) else block(null) } } + val client = viewModel.extensionListFlow.getExtension(clientId)?.client + val isLibrary = client is LibraryClient + val likeListener = + if (isLibrary) CheckBoxListener { viewModel.likeTrack(it) } + else null + + binding.playerControls.trackHeart.run { + isVisible = isLibrary + likeListener?.let { addOnCheckedStateChangedListener(it) } + } + + observeCurrent(viewModel.isLiked) { + likeListener?.enabled = false + binding.playerControls.trackHeart.isChecked = it ?: false + likeListener?.enabled = true + } + binding.playerControls.seekBar.apply { addOnChangeListener { _, value, fromUser -> if (fromUser) @@ -149,7 +168,7 @@ class PlayerTrackAdapter( addOnSliderTouchListener(object : Slider.OnSliderTouchListener { override fun onStartTrackingTouch(slider: Slider) = Unit override fun onStopTrackingTouch(slider: Slider) = - emit(viewModel.seekTo) { slider.value.toLong() } + viewModel.seekTo(slider.value.toLong()) }) } observeCurrent(viewModel.progress) { @@ -180,7 +199,7 @@ class PlayerTrackAdapter( } observeCurrent(viewModel.totalDuration) { - val duration = it ?: track.duration?.toInt() ?: return@observeCurrent + val duration = it ?: item.track.duration?.toInt() ?: return@observeCurrent binding.collapsedContainer.run { collapsedSeekBar.max = duration collapsedBuffer.max = duration @@ -214,7 +233,7 @@ class PlayerTrackAdapter( binding.playerControls.trackNext.isEnabled = it } binding.playerControls.trackNext.setOnClickListener { - emit(viewModel.seekToNext) + viewModel.seekToNext() it as MaterialButton (it.icon as Animatable).start() } @@ -222,14 +241,14 @@ class PlayerTrackAdapter( binding.playerControls.trackPrevious.isEnabled = it } binding.playerControls.trackPrevious.setOnClickListener { - emit(viewModel.seekToPrevious) + viewModel.seekToPrevious() it as MaterialButton (it.icon as Animatable).start() } val shuffleListener = viewModel.shuffleListener binding.playerControls.trackShuffle.addOnCheckedStateChangedListener(shuffleListener) - observe(viewModel.shuffle) { + observe(viewModel.shuffleMode) { shuffleListener.enabled = false binding.playerControls.trackShuffle.isChecked = it shuffleListener.enabled = true @@ -251,7 +270,7 @@ class PlayerTrackAdapter( (icon as Animatable).start() } binding.playerControls.trackRepeat.setOnClickListener { - val mode = when (viewModel.repeat.value) { + val mode = when (viewModel.repeatMode.value) { REPEAT_MODE_OFF -> REPEAT_MODE_ALL REPEAT_MODE_ALL -> REPEAT_MODE_ONE else -> REPEAT_MODE_OFF @@ -260,37 +279,19 @@ class PlayerTrackAdapter( viewModel.onRepeat(mode) } - observe(viewModel.repeat) { + observe(viewModel.repeatMode) { viewModel.repeatEnabled = false changeRepeatDrawable(it) viewModel.repeatEnabled = true } - - val extensionClient = viewModel.extensionListFlow.getExtension(item.clientId)?.client - binding.playerControls.trackHeart.run { - if (extensionClient is LibraryClient) { - isChecked = item.liked - val likeListener = CheckBoxListener { - viewModel.likeTrack(item, it) - } - addOnCheckedStateChangedListener(likeListener) - observeCurrent(viewModel.onLiked) { - likeListener.enabled = false - isChecked = it ?: track.liked - likeListener.enabled = true - } - isVisible = true - } else isVisible = false - } } private fun ItemPlayerTrackBinding.applyTrackDetails( - client: String?, - streamableTrack: StreamableTrack?, - oldTrack: Track? = null + client: String, + item: MediaItem, ) { - val track = streamableTrack?.current - track?.cover.loadWith(expandedTrackCover, oldTrack?.cover) { + val track = item.track + track.cover.loadWith(expandedTrackCover) { collapsedContainer.collapsedTrackCover.load(it) Glide.with(bgImage).load(it) .apply( @@ -302,21 +303,21 @@ class PlayerTrackAdapter( } collapsedContainer.run { - collapsedTrackArtist.text = track?.artists?.joinToString(", ") { it.name } - collapsedTrackTitle.text = track?.title + collapsedTrackArtist.text = track.artists.joinToString(", ") { it.name } + collapsedTrackTitle.text = track.title collapsedTrackTitle.isSelected = true collapsedTrackTitle.setHorizontallyScrolling(true) } playerControls.run { - trackTitle.text = track?.title + trackTitle.text = track.title trackTitle.isSelected = true trackTitle.setHorizontallyScrolling(true) - val artists = track?.artists - val artistNames = artists?.joinToString(", ") { it.name } ?: "" + val artists = track.artists + val artistNames = artists.joinToString(", ") { it.name } val spannableString = SpannableString(artistNames) - artists?.forEach { artist -> + artists.forEach { artist -> val start = artistNames.indexOf(artist.name) val end = start + artist.name.length val clickableSpan = PlayerItemSpan( @@ -330,20 +331,17 @@ class PlayerTrackAdapter( } expandedToolbar.run { - val itemContext = streamableTrack?.context + val itemContext = item.context title = if (itemContext != null) context.getString(R.string.playing_from) else null subtitle = itemContext?.title setOnMenuItemClickListener { - val streamable = streamableTrack - ?: return@setOnMenuItemClickListener false when (it.itemId) { R.id.menu_more -> { - val loaded = streamable.loaded?.toMediaItem() - val unloaded = streamable.unloaded.toMediaItem() - listener.onMoreClicked(client, loaded ?: unloaded, loaded != null) + listener.onMoreClicked(client, track.toMediaItem(), item.isLoaded) true } + else -> false } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerUiListener.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerUiListener.kt new file mode 100644 index 00000000..77fd8965 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlayerUiListener.kt @@ -0,0 +1,111 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.viewModelScope +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.ExoPlayer +import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel +import kotlinx.coroutines.launch + +class PlayerUiListener( + val player: Player, + val viewModel: PlayerViewModel +) : Player.Listener { + + init { + updateList() + with(viewModel) { + isPlaying.value = player.isPlaying + buffering.value = player.playbackState == Player.STATE_BUFFERING + shuffleMode.value = player.shuffleModeEnabled + repeatMode.value = player.repeatMode + } + updateNavigation() + } + + private fun updateList() = viewModel.run { + list = (0 until player.mediaItemCount).map { player.getMediaItemAt(it) } + viewModelScope.launch { + listUpdateFlow.emit(Unit) + } + } + + private fun updateNavigation() { + viewModel.nextEnabled.value = player.hasNextMediaItem() + viewModel.previousEnabled.value = player.currentMediaItemIndex >= 0 + } + + private val delay = 500L + private val threshold = 0.2f + private val updateProgressRunnable = Runnable { updateProgress() } + private val handler = Handler(Looper.getMainLooper()).also { + it.post(updateProgressRunnable) + } + private fun updateProgress() { + viewModel.progress.value = + player.currentPosition.toInt() to player.bufferedPosition.toInt() + viewModel.totalDuration.value = player.duration.toInt() + + handler.removeCallbacks(updateProgressRunnable) + val playbackState = player.playbackState + if (playbackState != ExoPlayer.STATE_IDLE && playbackState != ExoPlayer.STATE_ENDED) { + var delayMs: Long + if (player.playWhenReady && playbackState == ExoPlayer.STATE_READY) { + delayMs = delay - player.currentPosition % delay + if (delayMs < delay * threshold) { + delayMs += delay + } + } else { + delayMs = delay + } + handler.postDelayed(updateProgressRunnable, delayMs) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> + viewModel.buffering.value = true + + Player.STATE_READY -> { + viewModel.buffering.value = false + } + + else -> Unit + } + updateProgress() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + viewModel.isPlaying.value = isPlaying + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int + ) { + updateNavigation() + updateProgress() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + updateList() + updateNavigation() + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + viewModel.shuffleMode.value = shuffleModeEnabled + updateList() + } + + override fun onRepeatModeChanged(repeatMode: Int) { + updateNavigation() + viewModel.repeatMode.value = repeatMode + } + + override fun onPlayerError(error: PlaybackException) { + viewModel.createException(error) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt new file mode 100644 index 00000000..25892de6 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/PlaylistAdapter.kt @@ -0,0 +1,106 @@ +package dev.brahmkshatriya.echo.ui.player + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.media3.common.MediaItem +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.databinding.ItemPlaylistItemBinding +import dev.brahmkshatriya.echo.databinding.SkeletonItemQueueBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.isLoaded +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track +import dev.brahmkshatriya.echo.utils.loadInto +import dev.brahmkshatriya.echo.utils.toTimeString + +class PlaylistAdapter( + private val callback: Callback, + private val inactive: Boolean = false +) : LifeCycleListAdapter, ItemPlaylistItemBinding>(DiffCallback) { + + object DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame( + oldItem: Pair, + newItem: Pair + ) = oldItem.second.mediaId == newItem.second.mediaId + + override fun areContentsTheSame( + oldItem: Pair, + newItem: Pair + ) = oldItem == newItem && oldItem.second.isLoaded == newItem.second.isLoaded + + } + + open class Callback { + open fun onItemClicked(position: Int) {} + open fun onItemClosedClicked(position: Int) {} + open fun onDragHandleTouched(viewHolder: RecyclerView.ViewHolder) {} + } + + override fun inflateCallback( + inflater: LayoutInflater, + container: ViewGroup? + ) = ItemPlaylistItemBinding.inflate(inflater, container, false) + + @SuppressLint("ClickableViewAccessibility") + override fun Holder, ItemPlaylistItemBinding>.onBind(position: Int) { + val (isCurrent, item) = getItem(position) + val track = item.track + + binding.playlistItem.alpha = if (inactive) 0.5f else 1f + + binding.playlistItemTitle.text = track.title + track.cover.loadInto(binding.playlistItemImageView, R.drawable.art_music) + var subtitle = "" + track.duration?.toTimeString()?.let { + subtitle += it + } + track.artists.joinToString(", ") { it.name }.let { + if (it.isNotBlank()) subtitle += if (subtitle.isNotBlank()) " • $it" else it + } + binding.playlistItemAuthor.isVisible = subtitle.isNotEmpty() + binding.playlistItemAuthor.text = subtitle + + binding.playlistItemClose.isVisible = !inactive + binding.playlistItemClose.setOnClickListener { + callback.onItemClosedClicked(bindingAdapterPosition) + } + + binding.playlistItemDragImg.isVisible = !inactive + binding.playlistItemDragHandle.setOnTouchListener { _, event -> + if (event.actionMasked != MotionEvent.ACTION_DOWN) return@setOnTouchListener false + callback.onDragHandleTouched(this) + true + } + + binding.root.setOnClickListener { + callback.onItemClicked(bindingAdapterPosition) + } + + binding.playlistCurrentItem.isVisible = isCurrent + binding.playlistProgressBar.isVisible = isCurrent && !item.isLoaded + } + + class Loader : RecyclerView.Adapter() { + inner class ViewHolder(binding: SkeletonItemQueueBinding) : + RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = SkeletonItemQueueBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + private var loading = false + override fun getItemCount() = if (loading) 1 else 0 + override fun onBindViewHolder(holder: ViewHolder, position: Int) {} + fun setLoading(loading: Boolean) { + if (this.loading == loading) return + this.loading = loading + if (loading) notifyItemInserted(0) else notifyItemRemoved(0) + } + } +} diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/TrackDetailsFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/TrackDetailsFragment.kt index 8cf55f53..d7a5cfe5 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/player/TrackDetailsFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/player/TrackDetailsFragment.kt @@ -13,6 +13,8 @@ import dev.brahmkshatriya.echo.common.clients.TrackClient import dev.brahmkshatriya.echo.common.models.MediaItemsContainer import dev.brahmkshatriya.echo.common.models.Track import dev.brahmkshatriya.echo.databinding.FragmentTrackDetailsBinding +import dev.brahmkshatriya.echo.playback.MediaItemUtils.clientId +import dev.brahmkshatriya.echo.playback.MediaItemUtils.track import dev.brahmkshatriya.echo.plugger.MusicExtension import dev.brahmkshatriya.echo.plugger.getExtension import dev.brahmkshatriya.echo.ui.media.MediaClickListener @@ -25,7 +27,6 @@ import dev.brahmkshatriya.echo.viewmodels.PlayerViewModel import dev.brahmkshatriya.echo.viewmodels.UiViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -52,11 +53,9 @@ class TrackDetailsFragment : Fragment() { binding.root.adapter = adapter.withLoaders() observe(playerViewModel.currentFlow) { - val streamableTrack = playerViewModel.current - streamableTrack?.clientId ?: return@observe - val track = streamableTrack.loaded ?: streamableTrack.onLoad.first() - adapter.clientId = streamableTrack.clientId - viewModel.load(streamableTrack.clientId, track) + val (_, item) = it ?: return@observe + adapter.clientId = item.clientId + viewModel.load(item.clientId, item.track) } observe(viewModel.itemsFlow) { diff --git a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt index a35979c8..c36ff651 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/ui/settings/AudioFragment.kt @@ -2,6 +2,7 @@ package dev.brahmkshatriya.echo.ui.settings import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.media.audiofx.AudioEffect import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts @@ -10,6 +11,7 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import dev.brahmkshatriya.echo.R +import dev.brahmkshatriya.echo.common.models.Streamable import dev.brahmkshatriya.echo.utils.MaterialListPreference class AudioFragment : BaseSettingsFragment() { @@ -118,9 +120,21 @@ class AudioFragment : BaseSettingsFragment() { const val CLOSE_PLAYER = "close_player_when_app_closes" const val SKIP_SILENCE = "skip_silence" const val AUTO_START_RADIO = "auto_start_radio" - const val STREAM_QUALITY = "stream_quality" const val EQUALIZER = "equalizer" + + const val STREAM_QUALITY = "stream_quality" val streamQualities = arrayOf("highest", "medium", "lowest") + + fun selectStreamIndex(settings: SharedPreferences, audioStreamables: List) = + audioStreamables.indexOf(selectStream(settings, audioStreamables)) + + fun selectStream(settings: SharedPreferences, streamables: List) = + when (settings.getString(STREAM_QUALITY, "lowest")) { + "highest" -> streamables.maxByOrNull { it.quality } + "medium" -> streamables.sortedBy { it.quality }.getOrNull(streamables.size / 2) + "lowest" -> streamables.minByOrNull { it.quality } + else -> streamables.firstOrNull() + } } } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/Cache.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/Cache.kt index 8cc424fe..d3c78815 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/utils/Cache.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/Cache.kt @@ -15,7 +15,7 @@ fun Context.getFromCache( val fileName = id.hashCode().toString() val cacheDir = cacheDir(this, folderName) val file = File(cacheDir, fileName) - return if (file.exists()) tryWith { + return if (file.exists()) tryWith(false) { val bytes = FileInputStream(file).use { it.readBytes() } val parcel = Parcel.obtain() parcel.unmarshall(bytes, 0, bytes.size) @@ -43,11 +43,11 @@ inline fun Context.getFromCache( ) = getFromCache(id, folderName ?: T::class.java.simpleName) { creator.createFromParcel(it) } inline fun Context.saveToCache( - id: String, value: T, folderName: String? = null -) = saveToCache(id, folderName ?: T::class.java.simpleName) { value.writeToParcel(it, 0) } + id: String, value: T?, folderName: String? = null +) = saveToCache(id, folderName ?: T::class.java.simpleName) { value?.writeToParcel(it, 0) } inline fun Context.saveToCache( - id: String, value: List, folderName: String? = null + id: String, value: List, folderName: String? = null ) = saveToCache(id, folderName ?: T::class.java.simpleName) { it.writeTypedList(value) } inline fun Context.getListFromCache( diff --git a/app/src/main/java/dev/brahmkshatriya/echo/utils/PauseCountDown.kt b/app/src/main/java/dev/brahmkshatriya/echo/utils/PauseCountDown.kt new file mode 100644 index 00000000..b81bffa4 --- /dev/null +++ b/app/src/main/java/dev/brahmkshatriya/echo/utils/PauseCountDown.kt @@ -0,0 +1,49 @@ +package dev.brahmkshatriya.echo.utils + +import android.os.CountDownTimer + +abstract class PauseCountDown( + private var millisInFuture: Long, private var interval: Long = 1000 +) { + private lateinit var countDownTimer: CountDownTimer + private var remainingTime: Long = 0 + private var isTimerPaused: Boolean = true + + init { + this.remainingTime = millisInFuture + } + + @Synchronized + fun start() { + if (isTimerPaused) { + countDownTimer = object : CountDownTimer(remainingTime, interval) { + override fun onFinish() { + onTimerFinish() + reset() + } + + override fun onTick(millisUntilFinished: Long) { + remainingTime = millisUntilFinished + onTimerTick(millisUntilFinished) + } + + }.apply { start() } + isTimerPaused = false + } + } + + fun pause() { + if (!isTimerPaused) countDownTimer.cancel() + isTimerPaused = true + } + + fun reset() { + countDownTimer.cancel() + remainingTime = millisInFuture + isTimerPaused = true + } + + abstract fun onTimerTick(millisUntilFinished: Long) + abstract fun onTimerFinish() + +} \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/LoginUserViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/LoginUserViewModel.kt index 836444bc..efb5eb32 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/LoginUserViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/LoginUserViewModel.kt @@ -112,7 +112,6 @@ class LoginUserViewModel @Inject constructor( ) = withContext(Dispatchers.IO) { if (client !is LoginClient) return@withContext val user = userDao.getCurrentUser(id) - println("$id user : $user") val success = tryWith(throwableFlow) { withTimeout(TIMEOUT) { client.onSetLoginUser(user?.toUser()) } } diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt index 05aeb40c..e7f1b722 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/PlayerViewModel.kt @@ -1,95 +1,97 @@ package dev.brahmkshatriya.echo.viewmodels +import android.app.Application import android.content.SharedPreferences -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem import androidx.media3.common.ThumbRating +import androidx.media3.session.LibraryResult.RESULT_SUCCESS import androidx.media3.session.MediaBrowser -import androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.common.clients.AlbumClient import dev.brahmkshatriya.echo.common.clients.PlaylistClient -import dev.brahmkshatriya.echo.common.clients.RadioClient -import dev.brahmkshatriya.echo.common.models.Album -import dev.brahmkshatriya.echo.common.models.Artist import dev.brahmkshatriya.echo.common.models.EchoMediaItem -import dev.brahmkshatriya.echo.common.models.Playlist -import dev.brahmkshatriya.echo.common.models.StreamableAudio +import dev.brahmkshatriya.echo.common.models.EchoMediaItem.Companion.toMediaItem import dev.brahmkshatriya.echo.common.models.Track -import dev.brahmkshatriya.echo.playback.PlayerListener -import dev.brahmkshatriya.echo.playback.Queue -import dev.brahmkshatriya.echo.playback.recoverQueue +import dev.brahmkshatriya.echo.playback.Current +import dev.brahmkshatriya.echo.playback.MediaItemUtils +import dev.brahmkshatriya.echo.playback.PlayerCommands.radioCommand +import dev.brahmkshatriya.echo.playback.Radio import dev.brahmkshatriya.echo.plugger.MusicExtension import dev.brahmkshatriya.echo.plugger.getExtension import dev.brahmkshatriya.echo.ui.player.CheckBoxListener -import dev.brahmkshatriya.echo.ui.settings.AudioFragment.AudioPreference.Companion.KEEP_QUEUE +import dev.brahmkshatriya.echo.ui.player.PlayerUiListener import dev.brahmkshatriya.echo.utils.getSerial import dev.brahmkshatriya.echo.utils.listenFuture -import dev.brahmkshatriya.echo.utils.observe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel class PlayerViewModel @Inject constructor( - private val global: Queue, val settings: SharedPreferences, val extensionListFlow: MutableStateFlow?>, + val app: Application, + val currentFlow: MutableStateFlow, + val radioStateFlow: MutableStateFlow, throwableFlow: MutableSharedFlow, - private val listener: PlayerListener, ) : CatchingViewModel(throwableFlow) { - override fun onInitialize() { - listener.setViewModel(this) + private var browser: MediaBrowser? = null + private fun withBrowser(block: (MediaBrowser) -> Unit) { + val browser = browser + if (browser != null) viewModelScope.launch(Dispatchers.Main) { + tryWith { block(browser) } + } + else viewModelScope.launch { + throwableFlow.emit(IllegalStateException("Browser not connected")) + } } - val playPause = MutableSharedFlow() val playPauseListener = CheckBoxListener { - viewModelScope.launch { playPause.emit(it) } + withBrowser { browser -> if (it) browser.play() else browser.pause() } } - val shuffle = global.shuffle val shuffleListener = CheckBoxListener { - viewModelScope.launch { shuffle.emit(it) } + withBrowser { browser -> browser.shuffleModeEnabled = it } } - val repeat = global.repeat var repeatEnabled = false fun onRepeat(it: Int) { - if (repeatEnabled) viewModelScope.launch { repeat.emit(it) } - } - - val seekTo = MutableSharedFlow() - val seekToPrevious = MutableSharedFlow() - val seekToNext = MutableSharedFlow() - - val likeTrack = MutableSharedFlow>() - - fun clearQueue() { - viewModelScope.launch { - global.clearQueue() + if (repeatEnabled) withBrowser { browser -> + browser.repeatMode = it } } - fun moveQueueItems(new: Int, old: Int) { - viewModelScope.launch { - global.moveTrack(old, new) + fun seekTo(position: Long) = withBrowser { it.seekTo(position) } + fun seekToPrevious() = withBrowser { it.seekToPrevious() } + fun seekToNext() = withBrowser { it.seekToNext() } + fun likeTrack(isLiked: Boolean) = withBrowser { + val old = this.isLiked.value + this.isLiked.value = isLiked + val future = it.setRating(ThumbRating(isLiked)) + app.listenFuture(future) { sessionResult -> + val result = sessionResult.getOrThrow() + if (result.resultCode != RESULT_SUCCESS) { + val exception = result.extras.getSerial("error") + ?: Exception("Error : ${result.resultCode}") + createException(exception) + this.isLiked.value = old + } + this.isLiked.value = result.extras.getBoolean("liked") } } - fun removeQueueItem(index: Int) { - viewModelScope.launch { - global.removeTrack(index) - } - } + fun play(position: Int) = withBrowser { it.seekToDefaultPosition(position) } + + fun clearQueue() = withBrowser { it.clearMediaItems() } + fun moveQueueItems(new: Int, old: Int) = withBrowser { it.moveMediaItem(old, new) } + fun removeQueueItem(index: Int) = withBrowser { it.removeMediaItem(index) } - val audioIndexFlow = MutableSharedFlow() fun play(clientId: String, track: Track, playIndex: Int? = null) = play(clientId, null, listOf(track), playIndex) @@ -100,14 +102,14 @@ class PlayerViewModel @Inject constructor( when (lists) { is EchoMediaItem.Lists.AlbumItem -> { if (client is AlbumClient) tryWith { - client.loadTracks(lists.album).loadFirst() + client.loadTracks(lists.album).loadAll() } else null } is EchoMediaItem.Lists.PlaylistItem -> { if (client is PlaylistClient) tryWith { - client.loadTracks(lists.playlist).loadFirst() + client.loadTracks(lists.playlist).loadAll() } else null } @@ -131,10 +133,13 @@ class PlayerViewModel @Inject constructor( tracks: List, playIndex: Int? = null ) { - viewModelScope.launch { - global.clearQueue() - val pos = global.addTracks(clientId, context, tracks).first - playIndex?.let { audioIndexFlow.emit(pos + it) } + withBrowser { + val mediaItems = tracks.map { track -> + MediaItemUtils.build(settings, track, clientId, context) + } + it.setMediaItems(mediaItems, playIndex ?: 0, 0) + it.prepare() + it.playWhenReady = true } } @@ -150,111 +155,68 @@ class PlayerViewModel @Inject constructor( addToQueue(clientId, lists, tracks, end) } - private fun addToQueue( clientId: String, context: EchoMediaItem?, tracks: List, end: Boolean - ) { - viewModelScope.launch { - val index = if (end) global.queue.size else 1 - global.addTracks(clientId, context, tracks, index) + ) = withBrowser { + val mediaItems = tracks.map { track -> + MediaItemUtils.build(settings, track, clientId, context) } + val index = if (end) it.mediaItemCount else 1 + it.addMediaItems(index, mediaItems) + it.prepare() } - private fun playRadio(clientId: String, block: suspend RadioClient.() -> Playlist) { - val extension = extensionListFlow.getExtension(clientId) - viewModelScope.launch(Dispatchers.IO) { - val position = listener.radio(extension) { block(this) } - position?.let { audioIndexFlow.emit(it) } + fun radio(clientId: String, item: EchoMediaItem) { + withBrowser { + it.sendCustomCommand(radioCommand, bundleOf("clientId" to clientId, "item" to item)) } } - val onLiked = global.onLiked - fun likeTrack(track: Queue.StreamableTrack, isLiked: Boolean) = viewModelScope.launch { - likeTrack.emit(track.unloaded.id to isLiked) + fun radioPlay(subIndex: Int) { + val state = radioStateFlow.value + if (state !is Radio.State.Loaded) return + state.run { + val index = played + subIndex + 1 + val trackList = tracks.take(index + 1).drop(played + 1).ifEmpty { null } ?: return + radioStateFlow.value = state.copy(played = index) + addToQueue(clientId, playlist.toMediaItem(), trackList, true) + withBrowser { play(it.mediaItemCount - 1) } + } } - - fun radio(clientId: String, track: Track) = playRadio(clientId) { radio(track) } - fun radio(clientId: String, album: Album) = playRadio(clientId) { radio(album) } - fun radio(clientId: String, artist: Artist) = playRadio(clientId) { radio(artist) } - fun radio(clientId: String, playlist: Playlist) = playRadio(clientId) { radio(playlist) } - - companion object { - fun AppCompatActivity.connectBrowserToUI( + fun connectBrowserToUI( browser: MediaBrowser, viewModel: PlayerViewModel ) { - viewModel.initialize() - observe(viewModel.playPause) { - if (it) browser.play() else browser.pause() - } - observe(viewModel.seekToPrevious) { - browser.seekToPrevious() - browser.playWhenReady = true - } - observe(viewModel.seekToNext) { - browser.seekToNext() - browser.playWhenReady = true - } - observe(viewModel.audioIndexFlow) { - if (it >= 0) { - browser.seekToDefaultPosition(it) - browser.playWhenReady = true - } - } - observe(viewModel.seekTo) { - browser.seekTo(it) - } - - observe(viewModel.likeTrack) { (mediaId, isLiked) -> - val track = viewModel.global.getTrack(mediaId) ?: return@observe - if (track.liked == isLiked) return@observe - - val future = browser.setRating(mediaId, ThumbRating(isLiked)) - listenFuture(future) { - val result = it.getOrThrow() - if (result.resultCode == RESULT_ERROR_UNKNOWN) { - val exception = result.extras.getSerial("error")!! - viewModel.createException(exception) - } - } - } - - val keepQueue = viewModel.settings.getBoolean(KEEP_QUEUE, true) - if (keepQueue && browser.mediaItemCount == 0) - browser.sendCustomCommand(recoverQueue, Bundle.EMPTY) + viewModel.browser = browser + browser.addListener(PlayerUiListener(browser, viewModel)) } } fun createException(exception: Throwable) { - viewModelScope.launch { - val streamableTrack = global.current - val currentAudio = global.currentAudioFlow.value - throwableFlow.emit( - PlayerException(exception.cause ?: exception, streamableTrack, currentAudio) - ) + withBrowser { + viewModelScope.launch { + throwableFlow.emit( + PlayerException(exception.cause ?: exception, it.currentMediaItem) + ) + } } } data class PlayerException( override val cause: Throwable, - val streamableTrack: Queue.StreamableTrack?, - val currentAudio: StreamableAudio? + val mediaItem: MediaItem? ) : Throwable(cause.message) + var list: List = listOf() - val current get() = global.current - val currentIndex get() = global.currentIndex - - val currentFlow = - merge(MutableStateFlow(Unit), global.currentIndexFlow).map { global.currentIndex } - - val listChangeFlow = merge(MutableStateFlow(Unit), global.updateFlow).map { global.queue } + val listUpdateFlow = MutableSharedFlow() + val isLiked = MutableStateFlow(false) val progress = MutableStateFlow(0 to 0) val totalDuration = MutableStateFlow(null) @@ -262,4 +224,6 @@ class PlayerViewModel @Inject constructor( val isPlaying = MutableStateFlow(false) val nextEnabled = MutableStateFlow(false) val previousEnabled = MutableStateFlow(false) + val repeatMode = MutableStateFlow(0) + val shuffleMode = MutableStateFlow(false) } \ No newline at end of file diff --git a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/UiViewModel.kt b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/UiViewModel.kt index 242fe11b..faef4205 100644 --- a/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/UiViewModel.kt +++ b/app/src/main/java/dev/brahmkshatriya/echo/viewmodels/UiViewModel.kt @@ -24,7 +24,7 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_SETTLING import dagger.hilt.android.lifecycle.HiltViewModel import dev.brahmkshatriya.echo.R -import dev.brahmkshatriya.echo.playback.Queue +import dev.brahmkshatriya.echo.playback.Current import dev.brahmkshatriya.echo.ui.settings.LookFragment import dev.brahmkshatriya.echo.utils.animateTranslation import dev.brahmkshatriya.echo.utils.dpToPx @@ -33,6 +33,7 @@ import dev.brahmkshatriya.echo.utils.observe import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.max @@ -40,7 +41,7 @@ import kotlin.math.max @HiltViewModel class UiViewModel @Inject constructor( private val settings: SharedPreferences, - global : Queue + currentFlow: MutableStateFlow, ) : ViewModel() { data class Insets( @@ -113,7 +114,7 @@ class UiViewModel @Inject constructor( val fromNotification = MutableStateFlow(false) val playerSheetState = MutableStateFlow( - if(global.queue.isEmpty()) STATE_HIDDEN else STATE_COLLAPSED + if (currentFlow.value == null) STATE_HIDDEN else STATE_COLLAPSED ) val infoSheetState = MutableStateFlow(STATE_COLLAPSED) val changePlayerState = MutableSharedFlow() @@ -281,11 +282,14 @@ class UiViewModel @Inject constructor( }) viewModel.run { - observe(fromNotification) { - if (!it) return@observe - fromNotification.value = false - emit(changePlayerState) { STATE_EXPANDED } - emit(changeInfoState) { STATE_COLLAPSED } + viewModelScope.launch { + changePlayerState.first() + observe(fromNotification) { + if (!it) return@observe + fromNotification.value = false + emit(changePlayerState) { STATE_EXPANDED } + emit(changeInfoState) { STATE_COLLAPSED } + } } } } diff --git a/app/src/main/res/layout/item_playlist_item.xml b/app/src/main/res/layout/item_playlist_item.xml index a9653fe5..c1abe89b 100644 --- a/app/src/main/res/layout/item_playlist_item.xml +++ b/app/src/main/res/layout/item_playlist_item.xml @@ -3,8 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:background="?echoBackground" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:background="?echoBackground"> - + android:layout_height="match_parent"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/skeleton_item_playlist.xml b/app/src/main/res/layout/skeleton_item_playlist.xml new file mode 100644 index 00000000..ff247372 --- /dev/null +++ b/app/src/main/res/layout/skeleton_item_playlist.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/skeleton_item_queue.xml b/app/src/main/res/layout/skeleton_item_queue.xml new file mode 100644 index 00000000..887b4093 --- /dev/null +++ b/app/src/main/res/layout/skeleton_item_queue.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 140bafcb..720d963c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -188,6 +188,7 @@ %1$s (Disabled) Enabled Disabled + Radio Playlist is Empty Highest Medium diff --git a/common/src/main/java/dev/brahmkshatriya/echo/common/models/EchoMediaItem.kt b/common/src/main/java/dev/brahmkshatriya/echo/common/models/EchoMediaItem.kt index 24dd5c8e..82b53add 100644 --- a/common/src/main/java/dev/brahmkshatriya/echo/common/models/EchoMediaItem.kt +++ b/common/src/main/java/dev/brahmkshatriya/echo/common/models/EchoMediaItem.kt @@ -1,11 +1,13 @@ package dev.brahmkshatriya.echo.common.models +import android.os.Parcel import android.os.Parcelable -import dev.brahmkshatriya.echo.common.helpers.PagedData import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.parcelableCreator -@Parcelize sealed class EchoMediaItem : Parcelable { + + @Parcelize data class TrackItem(val track: Track) : EchoMediaItem() @Parcelize @@ -31,14 +33,28 @@ sealed class EchoMediaItem : Parcelable { fun Album.toMediaItem() = Lists.AlbumItem(this) fun Artist.toMediaItem() = Profile.ArtistItem(this) fun User.toMediaItem() = Profile.UserItem(this) - fun Playlist.toMediaItem() = Lists.PlaylistItem(this) + val creator = object : Parcelable.Creator { + + inline fun create(source: Parcel?) = runCatching { + parcelableCreator().createFromParcel(source)!! + }.getOrNull() - fun List.toMediaItemsContainer( - title: String, subtitle: String? = null, more: PagedData? = null - ) = MediaItemsContainer.Category(title, this, subtitle, more) + override fun createFromParcel(source: Parcel?): EchoMediaItem { + return create(source) + ?: create(source) + ?: create(source) + ?: create(source) + ?: create(source) + ?: throw IllegalArgumentException("Unknown parcelable type") + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } } fun toMediaItemsContainer() = MediaItemsContainer.Item(