Twelve: View activity

Change-Id: Ief90418e500f9c83237f6bcfe7fc0c0c393979ae
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ecde999..0361f73 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -59,6 +59,38 @@
 
         </activity>
 
+        <activity
+            android:name=".ViewActivity"
+            android:excludeFromRecents="true"
+            android:exported="true"
+            android:theme="@style/Theme.Twelve.Dialog">
+
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <action android:name="android.provider.action.REVIEW" />
+                <action android:name="android.provider.action.REVIEW_SECURE" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:mimeType="application/itunes" />
+                <data android:mimeType="application/ogg" />
+                <data android:mimeType="application/vnd.apple.mpegurl" />
+                <data android:mimeType="application/vnd.ms-sstr+xml" />
+                <data android:mimeType="application/x-mpegurl" />
+                <data android:mimeType="application/x-ogg" />
+                <data android:mimeType="audio/*" />
+                <data android:mimeType="vnd.android.cursor.item/audio" />
+
+                <data android:scheme="content" />
+                <data android:scheme="file" />
+                <data android:scheme="http" />
+                <data android:scheme="https" />
+                <data android:scheme="rtsp" />
+            </intent-filter>
+
+        </activity>
+
         <service
             android:name=".services.PlaybackService"
             android:exported="true"
diff --git a/app/src/main/java/org/lineageos/twelve/ViewActivity.kt b/app/src/main/java/org/lineageos/twelve/ViewActivity.kt
new file mode 100644
index 0000000..089214a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ViewActivity.kt
@@ -0,0 +1,397 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve
+
+import android.animation.ValueAnimator
+import android.content.Intent
+import android.icu.text.DecimalFormat
+import android.icu.text.DecimalFormatSymbols
+import android.os.Bundle
+import android.util.Log
+import android.view.animation.LinearInterpolator
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.animation.doOnEnd
+import androidx.core.animation.doOnStart
+import androidx.core.util.Consumer
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.media3.common.Player
+import coil3.load
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.slider.Slider
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.models.MediaType
+import org.lineageos.twelve.models.RepeatMode
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.utils.TimestampFormatter
+import org.lineageos.twelve.viewmodels.IntentsViewModel
+import org.lineageos.twelve.viewmodels.LocalPlayerViewModel
+import java.util.Locale
+import kotlin.math.roundToLong
+
+/**
+ * An activity used to handle view intents.
+ */
+class ViewActivity : AppCompatActivity(R.layout.activity_view) {
+    // View models
+    private val intentsViewModel by viewModels<IntentsViewModel>()
+    private val localPlayerViewModel by viewModels<LocalPlayerViewModel>()
+
+    // Views
+    private val albumTitleTextView by lazy { findViewById<TextView>(R.id.albumTitleTextView) }
+    private val artistNameTextView by lazy { findViewById<TextView>(R.id.artistNameTextView) }
+    private val audioTitleTextView by lazy { findViewById<TextView>(R.id.audioTitleTextView) }
+    private val currentTimestampTextView by lazy { findViewById<TextView>(R.id.currentTimestampTextView) }
+    private val dummyThumbnailImageView by lazy { findViewById<ImageView>(R.id.dummyThumbnailImageView) }
+    private val durationTimestampTextView by lazy { findViewById<TextView>(R.id.durationTimestampTextView) }
+    private val nextTrackMaterialButton by lazy { findViewById<MaterialButton>(R.id.nextTrackMaterialButton) }
+    private val playPauseMaterialButton by lazy { findViewById<MaterialButton>(R.id.playPauseMaterialButton) }
+    private val playbackSpeedMaterialButton by lazy { findViewById<MaterialButton>(R.id.playbackSpeedMaterialButton) }
+    private val previousTrackMaterialButton by lazy { findViewById<MaterialButton>(R.id.previousTrackMaterialButton) }
+    private val progressSlider by lazy { findViewById<Slider>(R.id.progressSlider) }
+    private val repeatMarkerImageView by lazy { findViewById<ImageView>(R.id.repeatMarkerImageView) }
+    private val repeatMaterialButton by lazy { findViewById<MaterialButton>(R.id.repeatMaterialButton) }
+    private val shuffleMarkerImageView by lazy { findViewById<ImageView>(R.id.shuffleMarkerImageView) }
+    private val shuffleMaterialButton by lazy { findViewById<MaterialButton>(R.id.shuffleMaterialButton) }
+    private val thumbnailImageView by lazy { findViewById<ImageView>(R.id.thumbnailImageView) }
+
+    // Progress slider state
+    private var isProgressSliderDragging = false
+    private var animator: ValueAnimator? = null
+
+    // Intents
+    private val intentListener = Consumer<Intent> { intentsViewModel.onIntent(it) }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        // Audio information
+        audioTitleTextView.isSelected = true
+        artistNameTextView.isSelected = true
+        albumTitleTextView.isSelected = true
+
+        // Media controls
+        progressSlider.setLabelFormatter {
+            TimestampFormatter.formatTimestampMillis(it)
+        }
+        progressSlider.addOnSliderTouchListener(
+            object : Slider.OnSliderTouchListener {
+                override fun onStartTrackingTouch(slider: Slider) {
+                    isProgressSliderDragging = true
+                    animator?.cancel()
+                }
+
+                override fun onStopTrackingTouch(slider: Slider) {
+                    isProgressSliderDragging = false
+                    localPlayerViewModel.seekToPosition(slider.value.roundToLong())
+                }
+            }
+        )
+
+        playPauseMaterialButton.setOnClickListener {
+            localPlayerViewModel.togglePlayPause()
+        }
+
+        playbackSpeedMaterialButton.setOnClickListener {
+            localPlayerViewModel.shufflePlaybackSpeed()
+        }
+
+        repeatMaterialButton.setOnClickListener {
+            localPlayerViewModel.toggleRepeatMode()
+        }
+
+        shuffleMaterialButton.setOnClickListener {
+            localPlayerViewModel.toggleShuffleMode()
+        }
+
+        previousTrackMaterialButton.setOnClickListener {
+            localPlayerViewModel.seekToPrevious()
+        }
+
+        nextTrackMaterialButton.setOnClickListener {
+            localPlayerViewModel.seekToNext()
+        }
+
+        intentsViewModel.onIntent(intent)
+        addOnNewIntentListener(intentListener)
+
+        lifecycleScope.launch {
+            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch {
+                    localPlayerViewModel.mediaMetadata.collectLatest { mediaMetadata ->
+                        mediaMetadata.albumTitle?.also {
+                            if (albumTitleTextView.text != it) {
+                                albumTitleTextView.text = it
+                            }
+                            albumTitleTextView.isVisible = true
+                        } ?: run {
+                            albumTitleTextView.isVisible = false
+                        }
+
+                        mediaMetadata.artist?.also {
+                            if (artistNameTextView.text != it) {
+                                artistNameTextView.text = it
+                            }
+                            artistNameTextView.isVisible = true
+                        } ?: run {
+                            artistNameTextView.isVisible = false
+                        }
+
+                        mediaMetadata.title?.also {
+                            if (audioTitleTextView.text != it) {
+                                audioTitleTextView.text = it
+                            }
+                            audioTitleTextView.isVisible = true
+                        } ?: run {
+                            audioTitleTextView.isVisible = false
+                        }
+                    }
+                }
+
+                launch {
+                    localPlayerViewModel.isPlaying.collectLatest { isPlaying ->
+                        playPauseMaterialButton.setIconResource(
+                            when (isPlaying) {
+                                true -> R.drawable.ic_pause
+                                false -> R.drawable.ic_play_arrow
+                            }
+                        )
+                    }
+                }
+
+                launch {
+                    localPlayerViewModel.mediaArtwork.collectLatest {
+                        when (it) {
+                            is RequestStatus.Loading -> {
+                                // Do nothing
+                            }
+
+                            is RequestStatus.Success -> {
+                                it.data?.bitmap?.let { bitmap ->
+                                    thumbnailImageView.load(bitmap)
+                                    thumbnailImageView.isVisible = true
+                                    dummyThumbnailImageView.isVisible = false
+                                } ?: it.data?.uri?.let { uri ->
+                                    thumbnailImageView.load(uri)
+                                    thumbnailImageView.isVisible = true
+                                    dummyThumbnailImageView.isVisible = false
+                                } ?: run {
+                                    thumbnailImageView.isVisible = false
+                                    dummyThumbnailImageView.isVisible = true
+                                }
+                            }
+
+                            is RequestStatus.Error -> {
+                                Log.e(LOG_TAG, "Failed to load artwork")
+                                dummyThumbnailImageView.isVisible = true
+                                thumbnailImageView.isVisible = false
+                            }
+                        }
+                    }
+                }
+
+                launch {
+                    // Restart animation based on this value being changed
+                    var oldValue = 0f
+
+                    localPlayerViewModel.durationCurrentPositionMs.collectLatest { durationCurrentPositionMs ->
+                        val (durationMs, currentPositionMs, playbackSpeed) =
+                            durationCurrentPositionMs.let {
+                                Triple(
+                                    it.first ?: 0L,
+                                    it.second ?: 0L,
+                                    it.third
+                                )
+                            }
+
+                        // We want to lose ms precision with the slider
+                        val durationSecs = durationMs / 1000
+                        val currentPositionSecs = currentPositionMs / 1000
+
+                        val newValueTo = (durationSecs * 1000).toFloat().takeIf { it > 0 } ?: 1f
+                        val newValue = (currentPositionSecs * 1000).toFloat()
+
+                        val valueToChanged = progressSlider.valueTo != newValueTo
+                        val valueChanged = oldValue != newValue
+
+                        // Only +1s should be animated
+                        val shouldBeAnimated = (newValue - oldValue) == 1000f
+
+                        val newAnimator = ValueAnimator.ofFloat(
+                            progressSlider.value, newValue
+                        ).apply {
+                            interpolator = LinearInterpolator()
+                            duration = 1000 / playbackSpeed.roundToLong()
+                            doOnStart {
+                                // Update valueTo at the start of the animation
+                                if (progressSlider.valueTo != newValueTo) {
+                                    progressSlider.valueTo = newValueTo
+                                }
+                            }
+                            addUpdateListener {
+                                progressSlider.value = (it.animatedValue as Float)
+                            }
+                        }
+
+                        oldValue = newValue
+
+                        /**
+                         * Update only if:
+                         * - The value changed and the user isn't dragging the slider
+                         * - valueTo changed
+                         */
+                        if ((!isProgressSliderDragging && valueChanged) || valueToChanged) {
+                            val afterOldAnimatorEnded = {
+                                if (shouldBeAnimated) {
+                                    animator = newAnimator
+                                    newAnimator.start()
+                                } else {
+                                    animator = null
+                                    // Update both valueTo and value
+                                    progressSlider.valueTo = newValueTo
+                                    progressSlider.value = newValue
+                                }
+                            }
+
+                            animator?.also { oldAnimator ->
+                                // Start the new animation right after old one finishes
+                                oldAnimator.doOnEnd {
+                                    afterOldAnimatorEnded()
+                                }
+
+                                if (oldAnimator.isRunning) {
+                                    oldAnimator.cancel()
+                                } else {
+                                    oldAnimator.end()
+                                }
+                            } ?: run {
+                                // This is the first animation
+                                afterOldAnimatorEnded()
+                            }
+                        }
+
+                        currentTimestampTextView.text = TimestampFormatter.formatTimestampMillis(
+                            currentPositionMs
+                        )
+                        durationTimestampTextView.text = TimestampFormatter.formatTimestampMillis(
+                            durationMs
+                        )
+                    }
+                }
+
+                launch {
+                    localPlayerViewModel.playbackParameters.collectLatest {
+                        it?.also {
+                            playbackSpeedMaterialButton.text = getString(
+                                R.string.playback_speed_format,
+                                playbackSpeedFormatter.format(it.speed),
+                            )
+                        }
+                    }
+                }
+
+                launch {
+                    localPlayerViewModel.repeatMode.collectLatest {
+                        repeatMaterialButton.setIconResource(
+                            when (it) {
+                                RepeatMode.NONE,
+                                RepeatMode.ALL -> R.drawable.ic_repeat
+
+                                RepeatMode.ONE -> R.drawable.ic_repeat_one
+                            }
+                        )
+                        repeatMarkerImageView.isVisible = it != RepeatMode.NONE
+                    }
+                }
+
+                launch {
+                    localPlayerViewModel.shuffleMode.collectLatest { shuffleModeEnabled ->
+                        shuffleMarkerImageView.isVisible = shuffleModeEnabled
+                    }
+                }
+
+                launch {
+                    intentsViewModel.parsedIntent.collectLatest { parsedIntent ->
+                        parsedIntent?.handle {
+                            if (it.action != IntentsViewModel.ParsedIntent.Action.VIEW) {
+                                Log.e(LOG_TAG, "Cannot handle action ${it.action}")
+                                finish()
+                                return@handle
+                            }
+
+                            if (it.contents.isEmpty()) {
+                                Log.e(LOG_TAG, "No content to play")
+                                finish()
+                                return@handle
+                            }
+
+                            val contentType = it.contents.first().type
+                            if (contentType != MediaType.AUDIO) {
+                                Log.e(LOG_TAG, "Cannot handle content type $contentType")
+                                finish()
+                                return@handle
+                            }
+
+                            if (it.contents.any { content -> content.type != contentType }) {
+                                Log.e(LOG_TAG, "All contents must have the same type")
+                                finish()
+                                return@handle
+                            }
+
+                            localPlayerViewModel.setMediaUris(
+                                it.contents.map { content -> content.uri }
+                            )
+                        }
+                    }
+                }
+
+                launch {
+                    localPlayerViewModel.availableCommands.collectLatest {
+                        it?.let {
+                            playPauseMaterialButton.isEnabled = it.contains(
+                                Player.COMMAND_PLAY_PAUSE
+                            )
+
+                            playbackSpeedMaterialButton.isEnabled = it.contains(
+                                Player.COMMAND_SET_SPEED_AND_PITCH
+                            )
+
+                            shuffleMaterialButton.isEnabled = it.contains(
+                                Player.COMMAND_SET_SHUFFLE_MODE
+                            )
+
+                            repeatMaterialButton.isEnabled = it.contains(
+                                Player.COMMAND_SET_REPEAT_MODE
+                            )
+
+                            previousTrackMaterialButton.isEnabled = it.contains(
+                                Player.COMMAND_SEEK_TO_PREVIOUS
+                            )
+
+                            nextTrackMaterialButton.isEnabled = it.contains(
+                                Player.COMMAND_SEEK_TO_NEXT
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    companion object {
+        private val LOG_TAG = ViewActivity::class.simpleName!!
+
+        private val decimalFormatSymbols = DecimalFormatSymbols(Locale.ROOT)
+
+        private val playbackSpeedFormatter = DecimalFormat("0.#", decimalFormatSymbols)
+    }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/LocalPlayerViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/LocalPlayerViewModel.kt
new file mode 100644
index 0000000..4c820d0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/LocalPlayerViewModel.kt
@@ -0,0 +1,238 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import android.graphics.BitmapFactory
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import androidx.media3.exoplayer.ExoPlayer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.ext.applicationContext
+import org.lineageos.twelve.ext.availableCommandsFlow
+import org.lineageos.twelve.ext.isPlayingFlow
+import org.lineageos.twelve.ext.mediaMetadataFlow
+import org.lineageos.twelve.ext.next
+import org.lineageos.twelve.ext.playbackParametersFlow
+import org.lineageos.twelve.ext.playbackStateFlow
+import org.lineageos.twelve.ext.repeatModeFlow
+import org.lineageos.twelve.ext.shuffleModeFlow
+import org.lineageos.twelve.ext.typedRepeatMode
+import org.lineageos.twelve.models.PlaybackState
+import org.lineageos.twelve.models.RepeatMode
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.models.Thumbnail
+
+/**
+ * A view model useful to playback stuff locally (not in the playback service).
+ */
+class LocalPlayerViewModel(application: Application) : AndroidViewModel(application) {
+    enum class PlaybackSpeed(val value: Float) {
+        ONE(1f),
+        ONE_POINT_FIVE(1.5f),
+        TWO(2f),
+        ZERO_POINT_FIVE(0.5f);
+
+        companion object {
+            fun fromValue(value: Float) = entries.firstOrNull {
+                it.value == value
+            }
+        }
+    }
+
+    // ExoPlayer
+    private val exoPlayer = ExoPlayer.Builder(applicationContext)
+        .setAudioAttributes(
+            AudioAttributes.Builder()
+                .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+                .setUsage(C.USAGE_MEDIA)
+                .build(),
+            true
+        )
+        .setHandleAudioBecomingNoisy(true)
+        .build()
+
+    val mediaMetadata = exoPlayer.mediaMetadataFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = MediaMetadata.EMPTY
+        )
+
+    val playbackState = exoPlayer.playbackStateFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = null
+        )
+
+    val isPlaying = exoPlayer.isPlayingFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = false
+        )
+
+    val shuffleMode = exoPlayer.shuffleModeFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = false
+        )
+
+    val repeatMode = exoPlayer.repeatModeFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = RepeatMode.NONE
+        )
+
+    val mediaArtwork = combine(
+        mediaMetadata,
+        playbackState,
+    ) { mediaMetadata, playbackState ->
+        when (playbackState) {
+            PlaybackState.BUFFERING -> RequestStatus.Loading()
+
+            else -> RequestStatus.Success<_, Nothing>(
+                mediaMetadata.artworkUri?.let {
+                    Thumbnail(uri = it)
+                } ?: mediaMetadata.artworkData?.let {
+                    BitmapFactory.decodeByteArray(it, 0, it.size)?.let { bitmap ->
+                        Thumbnail(bitmap = bitmap)
+                    }
+                }
+            )
+        }
+    }
+        .flowOn(Dispatchers.IO)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = RequestStatus.Loading()
+        )
+
+    val durationCurrentPositionMs = flow {
+        while (true) {
+            val duration = exoPlayer.duration.takeIf { it != C.TIME_UNSET }
+            emit(
+                Triple(
+                    duration,
+                    duration?.let { exoPlayer.currentPosition },
+                    exoPlayer.playbackParameters.speed,
+                )
+            )
+            delay(200)
+        }
+    }
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = Triple(null, null, 1f)
+        )
+
+    val playbackParameters = exoPlayer.playbackParametersFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = null
+        )
+
+    val availableCommands = exoPlayer.availableCommandsFlow()
+        .flowOn(Dispatchers.Main)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = null
+        )
+
+    override fun onCleared() {
+        exoPlayer.release()
+
+        super.onCleared()
+    }
+
+    fun setMediaUris(uris: Iterable<Uri>) {
+        exoPlayer.apply {
+            setMediaItems(
+                uris.map {
+                    MediaItem.fromUri(it)
+                }
+            )
+            prepare()
+            play()
+        }
+    }
+
+    fun togglePlayPause() {
+        exoPlayer.apply {
+            if (playWhenReady) {
+                pause()
+            } else {
+                play()
+            }
+        }
+    }
+
+    fun shufflePlaybackSpeed() {
+        val playbackSpeed = PlaybackSpeed.fromValue(
+            exoPlayer.playbackParameters.speed
+        ) ?: PlaybackSpeed.ONE
+
+        exoPlayer.setPlaybackSpeed(playbackSpeed.next().value)
+    }
+
+    fun seekToPosition(positionMs: Long) {
+        exoPlayer.seekTo(positionMs)
+    }
+
+    fun toggleShuffleMode() {
+        exoPlayer.apply {
+            shuffleModeEnabled = shuffleModeEnabled.not()
+        }
+    }
+
+    fun toggleRepeatMode() {
+        exoPlayer.apply {
+            typedRepeatMode = typedRepeatMode.next()
+        }
+    }
+
+    fun seekToPrevious() {
+        exoPlayer.apply {
+            val currentMediaItemIndex = currentMediaItemIndex
+            seekToPrevious()
+            if (this.currentMediaItemIndex < currentMediaItemIndex) {
+                play()
+            }
+        }
+    }
+
+    fun seekToNext() {
+        exoPlayer.apply {
+            seekToNext()
+            play()
+        }
+    }
+}
diff --git a/app/src/main/res/layout/activity_view.xml b/app/src/main/res/layout/activity_view.xml
new file mode 100644
index 0000000..992be07
--- /dev/null
+++ b/app/src/main/res/layout/activity_view.xml
@@ -0,0 +1,214 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    style="@style/Widget.Material3.CardView.Filled"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    app:cardBackgroundColor="?attr/colorSurface"
+    app:cardCornerRadius="28dp"
+    app:contentPadding="24dp">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical">
+
+        <com.google.android.material.card.MaterialCardView
+            style="@style/Widget.Material3.CardView.Filled"
+            android:layout_width="56dp"
+            android:layout_height="56dp"
+            android:layout_marginBottom="8dp"
+            app:cardBackgroundColor="?attr/colorSecondaryContainer"
+            app:cardCornerRadius="8dp">
+
+            <ImageView
+                android:id="@+id/dummyThumbnailImageView"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:padding="8dp"
+                android:src="@drawable/ic_music_note"
+                app:tint="?attr/colorOnSecondaryContainer" />
+
+            <ImageView
+                android:id="@+id/thumbnailImageView"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:visibility="gone" />
+
+        </com.google.android.material.card.MaterialCardView>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/audioTitleTextView"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:ellipsize="marquee"
+                android:marqueeRepeatLimit="marquee_forever"
+                android:maxLines="1"
+                android:singleLine="true"
+                android:textAppearance="?attr/textAppearanceHeadlineSmall"
+                android:textColor="?attr/colorOnSurface"
+                tools:text="Tokyo Story" />
+
+            <TextView
+                android:id="@+id/artistNameTextView"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:ellipsize="marquee"
+                android:marqueeRepeatLimit="marquee_forever"
+                android:maxLines="1"
+                android:singleLine="true"
+                android:textAppearance="?attr/textAppearanceLabelLarge"
+                android:textColor="?attr/colorOnSurface"
+                tools:text="Ryuichi Sakamoto" />
+
+            <TextView
+                android:id="@+id/albumTitleTextView"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:ellipsize="marquee"
+                android:marqueeRepeatLimit="marquee_forever"
+                android:maxLines="1"
+                android:singleLine="true"
+                android:textAppearance="?attr/textAppearanceLabelLarge"
+                android:textColor="?attr/colorOnSurface"
+                tools:text="Sweet Revenge" />
+
+        </LinearLayout>
+
+        <com.google.android.material.slider.Slider
+            android:id="@+id/progressSlider"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="8dp" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginHorizontal="8dp"
+            android:gravity="center"
+            android:orientation="horizontal">
+
+            <TextView
+                android:id="@+id/currentTimestampTextView"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textAppearance="?attr/textAppearanceLabelMedium"
+                android:textColor="?attr/colorOnSurface"
+                tools:text="0:13" />
+
+            <Space
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="1" />
+
+            <TextView
+                android:id="@+id/durationTimestampTextView"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textAppearance="?attr/textAppearanceLabelMedium"
+                android:textColor="?attr/colorOnSurface"
+                tools:text="1:16" />
+
+        </LinearLayout>
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="8dp"
+            android:orientation="horizontal">
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/playPauseMaterialButton"
+                style="@style/Widget.Material3.Button.IconButton.Filled"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                app:icon="@drawable/ic_play_arrow" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/playbackSpeedMaterialButton"
+                style="@style/Widget.Material3.Button.IconButton"
+                android:layout_width="48dp"
+                android:layout_height="48dp"
+                android:padding="0dp"
+                tools:text="1×" />
+
+            <Space
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="1" />
+
+            <FrameLayout
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content">
+
+                <ImageView
+                    android:id="@+id/shuffleMarkerImageView"
+                    android:layout_width="8dp"
+                    android:layout_height="4dp"
+                    android:layout_gravity="bottom|center_horizontal"
+                    android:importantForAccessibility="no"
+                    android:src="@drawable/now_playing_marker"
+                    android:visibility="gone"
+                    app:tint="?attr/colorPrimary" />
+
+                <com.google.android.material.button.MaterialButton
+                    android:id="@+id/shuffleMaterialButton"
+                    style="@style/Widget.Material3.Button.IconButton"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    app:icon="@drawable/ic_shuffle" />
+
+            </FrameLayout>
+
+            <FrameLayout
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content">
+
+                <ImageView
+                    android:id="@+id/repeatMarkerImageView"
+                    android:layout_width="8dp"
+                    android:layout_height="4dp"
+                    android:layout_gravity="bottom|center_horizontal"
+                    android:importantForAccessibility="no"
+                    android:src="@drawable/now_playing_marker"
+                    android:visibility="gone"
+                    app:tint="?attr/colorPrimary" />
+
+                <com.google.android.material.button.MaterialButton
+                    android:id="@+id/repeatMaterialButton"
+                    style="@style/Widget.Material3.Button.IconButton"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    app:icon="@drawable/ic_repeat" />
+
+            </FrameLayout>
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/previousTrackMaterialButton"
+                style="@style/Widget.Material3.Button.IconButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                app:icon="@drawable/ic_skip_previous" />
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/nextTrackMaterialButton"
+                style="@style/Widget.Material3.Button.IconButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                app:icon="@drawable/ic_skip_next" />
+
+        </LinearLayout>
+
+    </LinearLayout>
+
+</com.google.android.material.card.MaterialCardView>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 19c6712..28eec09 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -7,6 +7,14 @@
     <!-- Application theme -->
     <style name="Theme.Twelve" parent="Theme.Material3.DayNight.NoActionBar" />
 
+    <style name="Theme.Twelve.Dialog" parent="Theme.Material3.DayNight.Dialog.Alert">
+        <item name="android:windowActionBar">false</item>
+        <item name="android:windowBackground">@android:color/transparent</item>
+        <item name="android:windowNoTitle">true</item>
+        <item name="windowActionBar">false</item>
+        <item name="windowNoTitle">true</item>
+    </style>
+
     <!-- No elements styles -->
     <style name="Theme.Twelve.NoElements" />