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" />