mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
Album and artist context menus gain an Info dialog covering the plural album artists, sort names and the play and download details the screens do not show. Multiple artist links collapse into one ellipsised entry that opens the artist chooser instead of listing each artist. Sort lists can now prefer regular names over sort names, or only use sort names for names without Latin letters. The new plain-name indexes keep those orders out of a temp B-tree. Also carry plural artists through the Media3 queue so Now Playing and the mini player keep them, drop the album artist tap ripple, and show the aggregate download progress in the tab bar where offline mode used to sit.
334 lines
12 KiB
Kotlin
334 lines
12 KiB
Kotlin
package com.subtracks.playback
|
|
|
|
import android.os.Bundle
|
|
import androidx.media3.common.C
|
|
import androidx.media3.common.Format
|
|
import androidx.media3.common.MediaItem
|
|
import androidx.media3.common.MediaItem.ClippingConfiguration
|
|
import androidx.media3.common.MediaMetadata
|
|
import androidx.media3.common.PlaybackException
|
|
import androidx.media3.common.Player
|
|
import androidx.media3.common.util.UnstableApi
|
|
import androidx.media3.session.MediaController
|
|
import com.subtracks.R
|
|
import com.subtracks.UiMessage
|
|
import com.subtracks.data.model.ArtistRef
|
|
import com.subtracks.data.model.ArtistRefsCodec
|
|
import com.subtracks.data.model.AudioEncoding
|
|
import com.subtracks.data.repo.DownloadRepository
|
|
import com.subtracks.data.repo.SourceRepository
|
|
import com.subtracks.log.Log
|
|
|
|
class Media3PlayerHandle(
|
|
private val controller: MediaController,
|
|
private val sourceRepository: SourceRepository,
|
|
private val downloads: DownloadRepository,
|
|
) : PlayerHandle {
|
|
override val itemCount: Int get() = controller.mediaItemCount
|
|
|
|
override val currentIndex: Int get() = controller.currentMediaItemIndex
|
|
|
|
override val isIdle: Boolean get() = controller.playbackState == Player.STATE_IDLE
|
|
|
|
override val isEnded: Boolean get() = controller.playbackState == Player.STATE_ENDED
|
|
|
|
override val isBuffering: Boolean get() = controller.playbackState == Player.STATE_BUFFERING
|
|
|
|
override val isPlaying: Boolean get() = controller.isPlaying
|
|
|
|
override val playWhenReady: Boolean get() = controller.playWhenReady
|
|
|
|
override val durationMs: Long
|
|
get() = controller.duration.takeIf { it != C.TIME_UNSET } ?: 0
|
|
|
|
override val currentPositionMs: Long get() = controller.currentPosition.coerceAtLeast(0)
|
|
|
|
override val currentItem: QueueItem? get() = controller.currentMediaItem?.let(::toQueueItem)
|
|
|
|
@get:UnstableApi
|
|
override val audioEncoding: AudioEncoding?
|
|
get() =
|
|
controller.currentTracks.groups
|
|
.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }
|
|
?.let { group ->
|
|
(0 until group.length)
|
|
.firstOrNull { group.isTrackSelected(it) }
|
|
?.let(group::getTrackFormat)
|
|
}?.let { format ->
|
|
AudioEncoding(
|
|
mimeType = format.sampleMimeType,
|
|
bitrate = format.bitrate.takeIf { it != Format.NO_VALUE },
|
|
sampleRate = format.sampleRate.takeIf { it != Format.NO_VALUE },
|
|
channels = format.channelCount.takeIf { it != Format.NO_VALUE },
|
|
)
|
|
}
|
|
|
|
override fun setWindow(
|
|
items: List<QueueItem>,
|
|
startIndex: Int,
|
|
startPositionMs: Long,
|
|
) = controller.setMediaItems(items.map(::toMediaItem), startIndex, startPositionMs)
|
|
|
|
override fun addFirst(item: QueueItem) = controller.addMediaItems(0, listOf(toMediaItem(item)))
|
|
|
|
override fun addLast(item: QueueItem) = controller.addMediaItems(controller.mediaItemCount, listOf(toMediaItem(item)))
|
|
|
|
override fun insertAt(
|
|
index: Int,
|
|
item: QueueItem,
|
|
) = controller.addMediaItems(index, listOf(toMediaItem(item)))
|
|
|
|
override fun removeFirst() = controller.removeMediaItem(0)
|
|
|
|
override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1)
|
|
|
|
override fun removeAt(index: Int) = controller.removeMediaItem(index)
|
|
|
|
override fun move(
|
|
from: Int,
|
|
to: Int,
|
|
) = controller.moveMediaItem(from, to)
|
|
|
|
override fun prepare() = controller.prepare()
|
|
|
|
override fun play() = controller.play()
|
|
|
|
override fun pause() = controller.pause()
|
|
|
|
override fun stop() = controller.stop()
|
|
|
|
override fun clear() = controller.clearMediaItems()
|
|
|
|
override fun itemUris(): List<String?> =
|
|
(0 until controller.mediaItemCount).map {
|
|
controller
|
|
.getMediaItemAt(it)
|
|
.localConfiguration
|
|
?.uri
|
|
?.toString()
|
|
}
|
|
|
|
override fun replaceItem(
|
|
index: Int,
|
|
item: QueueItem,
|
|
) = controller.replaceMediaItem(index, toMediaItem(item))
|
|
|
|
override fun seekToIndex(index: Int) {
|
|
Log.d(
|
|
"playback",
|
|
"seekToIndex($index) position=${controller.currentPosition} duration=${duration(
|
|
controller.duration,
|
|
)} state=${controller.playbackState}",
|
|
)
|
|
controller.seekTo(index, 0)
|
|
}
|
|
|
|
override fun seekTo(positionMs: Long) {
|
|
Log.d(
|
|
"playback",
|
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
|
)
|
|
controller.seekTo(positionMs)
|
|
}
|
|
|
|
override fun setRepeatOne(enabled: Boolean) {
|
|
controller.repeatMode = if (enabled) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
|
|
}
|
|
|
|
override fun addListener(listener: PlayerHandle.Listener) {
|
|
controller.addListener(
|
|
object : Player.Listener {
|
|
override fun onMediaItemTransition(
|
|
mediaItem: MediaItem?,
|
|
reason: Int,
|
|
) {
|
|
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
|
listener.onTransition()
|
|
}
|
|
|
|
override fun onEvents(
|
|
player: Player,
|
|
events: Player.Events,
|
|
) = listener.onEvents()
|
|
|
|
override fun onPlayerError(error: PlaybackException) {
|
|
Log.w(
|
|
"playback",
|
|
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
|
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
|
error,
|
|
)
|
|
listener.onError(playbackErrorMessage(error))
|
|
}
|
|
|
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
Log.d(
|
|
"playback",
|
|
"state=$playbackState position=${controller.currentPosition} " +
|
|
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
|
)
|
|
if (playbackState == Player.STATE_ENDED) {
|
|
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
private fun describe(mediaItem: MediaItem?): String {
|
|
if (mediaItem == null) return "item=none"
|
|
val metadata = mediaItem.mediaMetadata
|
|
val artist =
|
|
metadata.artist
|
|
?.toString()
|
|
?.takeIf { it.isNotBlank() }
|
|
?.let { " artist=\"$it\"" }
|
|
.orEmpty()
|
|
val album =
|
|
metadata.albumTitle
|
|
?.toString()
|
|
?.takeIf { it.isNotBlank() }
|
|
?.let { " album=\"$it\"" }
|
|
.orEmpty()
|
|
val source =
|
|
when (mediaItem.localConfiguration?.uri?.scheme) {
|
|
"file", "content" -> "file"
|
|
"http", "https" -> "http"
|
|
null -> "none"
|
|
else -> "other"
|
|
}
|
|
return "item id=${mediaItem.mediaId} title=\"${metadata.title?.toString().orEmpty()}\"$artist$album source=$source"
|
|
}
|
|
|
|
private fun duration(value: Long): String = if (value == C.TIME_UNSET) "unset" else "${value}ms"
|
|
|
|
private fun transitionReason(reason: Int): String =
|
|
when (reason) {
|
|
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat"
|
|
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
|
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
|
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist"
|
|
else -> reason.toString()
|
|
}
|
|
|
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
|
val quality = sourceRepository.quality.value
|
|
return queueMediaItem(
|
|
item = item,
|
|
local = downloads.localUri(item.id),
|
|
stream = sourceRepository.streamUri(item.id, item.durationMs, quality),
|
|
transcode = quality.transcodes,
|
|
)
|
|
}
|
|
|
|
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
|
QueueItem(
|
|
id = mediaItem.mediaId,
|
|
title =
|
|
mediaItem.mediaMetadata.title
|
|
?.toString()
|
|
.orEmpty(),
|
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
|
artists = mediaItem.artistRefs(),
|
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
|
durationMs =
|
|
mediaItem.mediaMetadata.extras
|
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
|
?.getLong(EXTRA_DURATION_MS),
|
|
replayGain = mediaItem.replayGain(),
|
|
)
|
|
}
|
|
|
|
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
|
mediaMetadata.extras
|
|
?.getString(EXTRA_ARTISTS)
|
|
?.let(ArtistRefsCodec::decode)
|
|
.orEmpty()
|
|
|
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
|
private const val EXTRA_ARTISTS = "artists"
|
|
private const val EXTRA_DURATION_MS = "durationMs"
|
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
|
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
|
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
|
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
|
|
|
internal fun MediaItem?.replayGain(): ReplayGain {
|
|
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
|
return ReplayGain(
|
|
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
|
|
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
|
|
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
|
|
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
|
|
)
|
|
}
|
|
|
|
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
|
|
|
|
private fun Bundle.putGain(
|
|
key: String,
|
|
value: Float?,
|
|
) {
|
|
if (value != null) putFloat(key, value)
|
|
}
|
|
|
|
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
|
|
|
|
internal fun queueMediaItem(
|
|
item: QueueItem,
|
|
local: String?,
|
|
stream: String?,
|
|
transcode: Boolean,
|
|
): MediaItem {
|
|
val metadata =
|
|
MediaMetadata
|
|
.Builder()
|
|
.setTitle(item.title)
|
|
.setArtist(item.artist)
|
|
.setAlbumTitle(item.album)
|
|
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
|
|
.setDurationMs(item.durationMs)
|
|
.setIsBrowsable(false)
|
|
.setIsPlayable(true)
|
|
.setExtras(
|
|
Bundle().apply {
|
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
|
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
|
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
|
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
|
},
|
|
).build()
|
|
val builder =
|
|
MediaItem
|
|
.Builder()
|
|
.setMediaId(item.id)
|
|
.setUri(local ?: stream)
|
|
.setMediaMetadata(metadata)
|
|
if (local == null && transcode) {
|
|
item.durationMs?.takeIf { it > 0 }?.let { duration ->
|
|
builder.setClippingConfiguration(
|
|
ClippingConfiguration.Builder().setEndPositionMs(duration).build(),
|
|
)
|
|
}
|
|
}
|
|
return builder.build()
|
|
}
|
|
|
|
internal fun playbackErrorMessage(error: PlaybackException): UiMessage =
|
|
when (error.errorCode) {
|
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
|
-> UiMessage(R.string.playback_error_network)
|
|
|
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> UiMessage(R.string.playback_error_refused)
|
|
|
|
else -> UiMessage(R.string.playback_error, listOf(error.errorCodeName))
|
|
}
|