Merge branch '3.5.0' into dev
4
.github/workflows/android.yml
vendored
|
@ -2,9 +2,9 @@ name: Android CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "dev" ]
|
branches: []
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "dev" ]
|
branches: []
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
13
CHANGELOG.md
|
@ -8,10 +8,16 @@
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Album covers are now loaded on a per-song basis
|
- Album covers are now loaded on a per-song basis
|
||||||
- Correctly interpret MP4 sort tags
|
- MP4 sort tags are now correctly interpreted
|
||||||
|
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
|
||||||
|
- M3U paths are now interpreted both as relative and absolute regardless of the format
|
||||||
|
- Added support for M3U paths starting with /storage/
|
||||||
|
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
- Fixed repeat mode not restoring on startup
|
- Fixed repeat mode not restoring on startup
|
||||||
|
- Fixed rewinding not occuring when skipping back at the beginning of the queue if
|
||||||
|
rewind before skipping was turned off
|
||||||
|
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
|
||||||
|
|
||||||
#### What's Changed
|
#### What's Changed
|
||||||
- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
|
- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
|
||||||
|
@ -20,6 +26,11 @@
|
||||||
#### dev -> dev1 changes
|
#### dev -> dev1 changes
|
||||||
- Re-added ability to open app from clicking on notification
|
- Re-added ability to open app from clicking on notification
|
||||||
- Removed tasker plugin
|
- Removed tasker plugin
|
||||||
|
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
|
||||||
|
- M3U paths are now interpreted both as relative and absolute regardless of the format
|
||||||
|
- Added support for M3U paths starting with /storage/
|
||||||
|
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
|
||||||
|
- Made album cover keying more efficient at the cost of resillients
|
||||||
|
|
||||||
## 3.4.3
|
## 3.4.3
|
||||||
|
|
||||||
|
|
|
@ -157,8 +157,8 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractQualityCover(cover: Cover.Embedded) =
|
private suspend fun extractQualityCover(cover: Cover.Embedded) =
|
||||||
extractAospMetadataCover(cover)
|
extractExoplayerCover(cover)
|
||||||
?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover)
|
?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
|
||||||
|
|
||||||
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
|
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
|
||||||
MediaMetadataRetriever().run {
|
MediaMetadataRetriever().run {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import android.graphics.ColorMatrixColorFilter
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
@Suppress("UNUSED")
|
||||||
fun Bitmap.dHash(hashSize: Int = 16): String {
|
fun Bitmap.dHash(hashSize: Int = 16): String {
|
||||||
// Step 1: Resize the bitmap to a fixed size
|
// Step 1: Resize the bitmap to a fixed size
|
||||||
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
|
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
|
||||||
|
|
|
@ -164,7 +164,10 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
||||||
val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath)
|
val songs =
|
||||||
|
importedPlaylist.paths.mapNotNull {
|
||||||
|
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
|
||||||
|
}
|
||||||
|
|
||||||
if (songs.isEmpty()) {
|
if (songs.isEmpty()) {
|
||||||
logE("No songs found")
|
logE("No songs found")
|
||||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||||
|
|
||||||
@Database(entities = [CachedSong::class], version = 45, exportSchema = false)
|
@Database(entities = [CachedSong::class], version = 46, exportSchema = false)
|
||||||
abstract class CacheDatabase : RoomDatabase() {
|
abstract class CacheDatabase : RoomDatabase() {
|
||||||
abstract fun cachedSongsDao(): CachedSongsDao
|
abstract fun cachedSongsDao(): CachedSongsDao
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean)
|
||||||
* @see ExternalPlaylistManager
|
* @see ExternalPlaylistManager
|
||||||
* @see M3U
|
* @see M3U
|
||||||
*/
|
*/
|
||||||
data class ImportedPlaylist(val name: String?, val paths: List<Path>)
|
data class ImportedPlaylist(val name: String?, val paths: List<PossiblePaths>)
|
||||||
|
|
||||||
|
typealias PossiblePaths = List<Path>
|
||||||
|
|
||||||
class ExternalPlaylistManagerImpl
|
class ExternalPlaylistManagerImpl
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
@ -29,9 +29,12 @@ import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.fs.Components
|
import org.oxycblt.auxio.music.fs.Components
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
|
import org.oxycblt.auxio.music.fs.Volume
|
||||||
|
import org.oxycblt.auxio.music.fs.VolumeManager
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.logE
|
import org.oxycblt.auxio.util.logE
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal M3U file format implementation.
|
* Minimal M3U file format implementation.
|
||||||
|
@ -72,10 +75,16 @@ interface M3U {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U {
|
class M3UImpl
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val volumeManager: VolumeManager
|
||||||
|
) : M3U {
|
||||||
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
|
override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? {
|
||||||
|
val volumes = volumeManager.getVolumes()
|
||||||
val reader = BufferedReader(InputStreamReader(stream))
|
val reader = BufferedReader(InputStreamReader(stream))
|
||||||
val paths = mutableListOf<Path>()
|
val paths = mutableListOf<PossiblePaths>()
|
||||||
var name: String? = null
|
var name: String? = null
|
||||||
|
|
||||||
consumeFile@ while (true) {
|
consumeFile@ while (true) {
|
||||||
|
@ -112,39 +121,13 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is basically no formal specification of file paths in M3U, and it differs
|
// There is basically no formal specification of file paths in M3U, and it differs
|
||||||
// based on the US that generated it. These are the paths though that I assume most
|
// based on the programs that generated it. I more or less have to consider any possible
|
||||||
// programs will generate.
|
// interpretation as valid.
|
||||||
val components =
|
val interpretations = interpretPath(path)
|
||||||
when {
|
val possibilities =
|
||||||
path.startsWith('/') -> {
|
interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) }
|
||||||
// Unix absolute path. Note that we still assume this absolute path is in
|
|
||||||
// the same volume as the M3U file. There's no sane way to map the volume
|
|
||||||
// to the phone's volumes, so this is the only thing we can do.
|
|
||||||
Components.parseUnix(path)
|
|
||||||
}
|
|
||||||
path.startsWith("./") -> {
|
|
||||||
// Unix relative path, resolve it
|
|
||||||
Components.parseUnix(path).absoluteTo(workingDirectory.components)
|
|
||||||
}
|
|
||||||
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> {
|
|
||||||
// Windows absolute path, we should get rid of the volume prefix, but
|
|
||||||
// otherwise
|
|
||||||
// the rest should be fine. Again, we have to disregard what the volume
|
|
||||||
// actually
|
|
||||||
// is since there's no sane way to map it to the phone's volumes.
|
|
||||||
Components.parseWindows(path.substring(2))
|
|
||||||
}
|
|
||||||
path.startsWith(".\\") -> {
|
|
||||||
// Windows relative path, we need to remove the .\\ prefix
|
|
||||||
Components.parseWindows(path).absoluteTo(workingDirectory.components)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// No clue, parse by all separators and assume it's relative.
|
|
||||||
Components.parseAny(path).absoluteTo(workingDirectory.components)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
paths.add(Path(workingDirectory.volume, components))
|
paths.add(possibilities)
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (paths.isNotEmpty()) {
|
return if (paths.isNotEmpty()) {
|
||||||
|
@ -155,6 +138,44 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class InterpretedPath(val components: Components, val likelyAbsolute: Boolean)
|
||||||
|
|
||||||
|
private fun interpretPath(path: String): List<InterpretedPath> =
|
||||||
|
when {
|
||||||
|
path.startsWith('/') -> listOf(InterpretedPath(Components.parseUnix(path), true))
|
||||||
|
path.startsWith("./") -> listOf(InterpretedPath(Components.parseUnix(path), false))
|
||||||
|
path.matches(WINDOWS_VOLUME_PREFIX_REGEX) ->
|
||||||
|
listOf(InterpretedPath(Components.parseWindows(path.substring(2)), true))
|
||||||
|
path.startsWith("\\") -> listOf(InterpretedPath(Components.parseWindows(path), true))
|
||||||
|
path.startsWith(".\\") -> listOf(InterpretedPath(Components.parseWindows(path), false))
|
||||||
|
else ->
|
||||||
|
listOf(
|
||||||
|
InterpretedPath(Components.parseUnix(path), false),
|
||||||
|
InterpretedPath(Components.parseWindows(path), true))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expandInterpretation(
|
||||||
|
path: InterpretedPath,
|
||||||
|
workingDirectory: Path,
|
||||||
|
volumes: List<Volume>
|
||||||
|
): List<Path> {
|
||||||
|
val absoluteInterpretation = Path(workingDirectory.volume, path.components)
|
||||||
|
val relativeInterpretation =
|
||||||
|
Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components))
|
||||||
|
val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true }
|
||||||
|
val volumeInterpretation =
|
||||||
|
volumeExactMatch?.let {
|
||||||
|
val components =
|
||||||
|
unlikelyToBeNull(volumeExactMatch.components).containing(path.components)
|
||||||
|
Path(volumeExactMatch, components)
|
||||||
|
}
|
||||||
|
return if (path.likelyAbsolute) {
|
||||||
|
listOfNotNull(volumeInterpretation, absoluteInterpretation, relativeInterpretation)
|
||||||
|
} else {
|
||||||
|
listOfNotNull(relativeInterpretation, volumeInterpretation, absoluteInterpretation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun write(
|
override fun write(
|
||||||
playlist: Playlist,
|
playlist: Playlist,
|
||||||
outputStream: OutputStream,
|
outputStream: OutputStream,
|
||||||
|
|
|
@ -158,6 +158,8 @@ value class Components private constructor(val components: List<String>) {
|
||||||
return components == other.components.take(components.size)
|
return components == other.components.take(components.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun containing(other: Components) = Components(other.components.drop(components.size))
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Parses a path string into a [Components] instance by the unix path separator (/).
|
* Parses a path string into a [Components] instance by the unix path separator (/).
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.music.metadata
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import androidx.core.text.isDigitsOnly
|
import androidx.core.text.isDigitsOnly
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.exoplayer.MetadataRetriever
|
import androidx.media3.exoplayer.MetadataRetriever
|
||||||
|
@ -26,8 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource
|
||||||
import androidx.media3.exoplayer.source.TrackGroupArray
|
import androidx.media3.exoplayer.source.TrackGroupArray
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.image.extractor.CoverExtractor
|
import org.oxycblt.auxio.image.extractor.CoverExtractor
|
||||||
import org.oxycblt.auxio.image.extractor.dHash
|
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.auxio.music.fs.toAudioUri
|
import org.oxycblt.auxio.music.fs.toAudioUri
|
||||||
import org.oxycblt.auxio.music.info.Date
|
import org.oxycblt.auxio.music.info.Date
|
||||||
|
@ -106,10 +105,24 @@ private class TagWorkerImpl(
|
||||||
populateWithId3v2(textTags.id3v2)
|
populateWithId3v2(textTags.id3v2)
|
||||||
populateWithVorbis(textTags.vorbis)
|
populateWithVorbis(textTags.vorbis)
|
||||||
|
|
||||||
val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata)
|
coverExtractor.findCoverDataInMetadata(metadata)?.use {
|
||||||
val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
|
val available = it.available()
|
||||||
rawSong.coverPerceptualHash = bitmap?.dHash()
|
val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
|
||||||
bitmap?.recycle()
|
it.skip(skip)
|
||||||
|
val bytes = ByteArray(COVER_KEY_SAMPLE)
|
||||||
|
it.read(bytes)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString()
|
||||||
|
|
||||||
|
rawSong.coverPerceptualHash = byteString
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIONAL: Nicer cover art keying using an actual perceptual hash
|
||||||
|
// Really bad idea if you have big cover arts. Okay idea if you have different
|
||||||
|
// formats for the same cover art.
|
||||||
|
// val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) }
|
||||||
|
// rawSong.coverPerceptualHash = bitmap?.dHash()
|
||||||
|
// bitmap?.recycle()
|
||||||
|
|
||||||
// OPUS base gain interpretation code: This is likely not needed, as the media player
|
// OPUS base gain interpretation code: This is likely not needed, as the media player
|
||||||
// should be using the base gain already. Uncomment if that's not the case.
|
// should be using the base gain already. Uncomment if that's not the case.
|
||||||
|
@ -140,7 +153,9 @@ private class TagWorkerImpl(
|
||||||
|
|
||||||
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
|
(textFrames["TXXX:musicbrainz release track id"]
|
||||||
|
?: textFrames["TXXX:musicbrainz_releasetrackid"])
|
||||||
|
?.let { rawSong.musicBrainzId = it.first() }
|
||||||
textFrames["TIT2"]?.let { rawSong.name = it.first() }
|
textFrames["TIT2"]?.let { rawSong.name = it.first() }
|
||||||
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
|
textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
|
||||||
|
|
||||||
|
@ -170,7 +185,9 @@ private class TagWorkerImpl(
|
||||||
?.let { rawSong.date = it }
|
?.let { rawSong.date = it }
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
|
(textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let {
|
||||||
|
rawSong.albumMusicBrainzId = it.first()
|
||||||
|
}
|
||||||
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
|
textFrames["TALB"]?.let { rawSong.albumName = it.first() }
|
||||||
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
|
textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
|
||||||
(textFrames["TXXX:musicbrainz album type"]
|
(textFrames["TXXX:musicbrainz album type"]
|
||||||
|
@ -180,7 +197,9 @@ private class TagWorkerImpl(
|
||||||
?.let { rawSong.releaseTypes = it }
|
?.let { rawSong.releaseTypes = it }
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
|
(textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let {
|
||||||
|
rawSong.artistMusicBrainzIds = it
|
||||||
|
}
|
||||||
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
|
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
|
||||||
(textFrames["TXXX:artistssort"]
|
(textFrames["TXXX:artistssort"]
|
||||||
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
|
?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"]
|
||||||
|
@ -188,9 +207,9 @@ private class TagWorkerImpl(
|
||||||
?.let { rawSong.artistSortNames = it }
|
?.let { rawSong.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
textFrames["TXXX:musicbrainz album artist id"]?.let {
|
(textFrames["TXXX:musicbrainz album artist id"]
|
||||||
rawSong.albumArtistMusicBrainzIds = it
|
?: textFrames["TXXX:musicbrainz_albumartistid"])
|
||||||
}
|
?.let { rawSong.albumArtistMusicBrainzIds = it }
|
||||||
(textFrames["TXXX:albumartists"]
|
(textFrames["TXXX:albumartists"]
|
||||||
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
|
?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"]
|
||||||
?: textFrames["TPE2"])
|
?: textFrames["TPE2"])
|
||||||
|
@ -261,7 +280,9 @@ private class TagWorkerImpl(
|
||||||
|
|
||||||
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
|
(comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let {
|
||||||
|
rawSong.musicBrainzId = it.first()
|
||||||
|
}
|
||||||
comments["title"]?.let { rawSong.name = it.first() }
|
comments["title"]?.let { rawSong.name = it.first() }
|
||||||
comments["titlesort"]?.let { rawSong.sortName = it.first() }
|
comments["titlesort"]?.let { rawSong.sortName = it.first() }
|
||||||
|
|
||||||
|
@ -290,20 +311,28 @@ private class TagWorkerImpl(
|
||||||
?.let { rawSong.date = it }
|
?.let { rawSong.date = it }
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
|
(comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let {
|
||||||
|
rawSong.albumMusicBrainzId = it.first()
|
||||||
|
}
|
||||||
comments["album"]?.let { rawSong.albumName = it.first() }
|
comments["album"]?.let { rawSong.albumName = it.first() }
|
||||||
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
|
comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
|
||||||
comments["releasetype"]?.let { rawSong.releaseTypes = it }
|
(comments["releasetype"] ?: comments["musicbrainz album type"])?.let {
|
||||||
|
rawSong.releaseTypes = it
|
||||||
|
}
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
|
(comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let {
|
||||||
|
rawSong.artistMusicBrainzIds = it
|
||||||
|
}
|
||||||
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
(comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
|
||||||
(comments["artistssort"]
|
(comments["artistssort"]
|
||||||
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
|
?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"])
|
||||||
?.let { rawSong.artistSortNames = it }
|
?.let { rawSong.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
|
(comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let {
|
||||||
|
rawSong.albumArtistMusicBrainzIds = it
|
||||||
|
}
|
||||||
(comments["albumartists"]
|
(comments["albumartists"]
|
||||||
?: comments["album_artists"] ?: comments["album artists"]
|
?: comments["album_artists"] ?: comments["album artists"]
|
||||||
?: comments["albumartist"])
|
?: comments["albumartist"])
|
||||||
|
@ -360,6 +389,8 @@ private class TagWorkerImpl(
|
||||||
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
|
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
val COVER_KEY_SAMPLE = 32
|
||||||
|
|
||||||
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
|
||||||
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
val COMPILATION_RELEASE_TYPES = listOf("compilation")
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,9 @@
|
||||||
package org.oxycblt.auxio.music.service
|
package org.oxycblt.auxio.music.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.session.MediaSession.ControllerInfo
|
import androidx.media3.session.MediaSession.ControllerInfo
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
@ -29,6 +32,7 @@ import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.ListSettings
|
import org.oxycblt.auxio.list.ListSettings
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
@ -57,8 +61,6 @@ constructor(
|
||||||
private var invalidator: Invalidator? = null
|
private var invalidator: Invalidator? = null
|
||||||
|
|
||||||
interface Invalidator {
|
interface Invalidator {
|
||||||
data class ParentId(val id: String, val itemCount: Int)
|
|
||||||
|
|
||||||
fun invalidate(ids: Map<String, Int>)
|
fun invalidate(ids: Map<String, Int>)
|
||||||
|
|
||||||
fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
|
fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
|
||||||
|
@ -213,27 +215,41 @@ constructor(
|
||||||
return when (val item = musicRepository.find(uid)) {
|
return when (val item = musicRepository.find(uid)) {
|
||||||
is Album -> {
|
is Album -> {
|
||||||
val songs = listSettings.albumSongSort.songs(item.songs)
|
val songs = listSettings.albumSongSort.songs(item.songs)
|
||||||
songs.map { it.toMediaItem(context, item) }
|
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||||
}
|
}
|
||||||
is Artist -> {
|
is Artist -> {
|
||||||
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
|
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
|
||||||
val songs = listSettings.artistSongSort.songs(item.songs)
|
val songs = listSettings.artistSongSort.songs(item.songs)
|
||||||
albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) }
|
albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
|
||||||
|
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||||
}
|
}
|
||||||
is Genre -> {
|
is Genre -> {
|
||||||
val artists = GENRE_ARTISTS_SORT.artists(item.artists)
|
val artists = GENRE_ARTISTS_SORT.artists(item.artists)
|
||||||
val songs = listSettings.genreSongSort.songs(item.songs)
|
val songs = listSettings.genreSongSort.songs(item.songs)
|
||||||
artists.map { it.toMediaItem(context) } +
|
artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
|
||||||
songs.map { it.toMediaItem(context, null) }
|
songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
|
||||||
}
|
}
|
||||||
is Playlist -> {
|
is Playlist -> {
|
||||||
item.songs.map { it.toMediaItem(context, item) }
|
item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||||
}
|
}
|
||||||
is Song,
|
is Song,
|
||||||
null -> return null
|
null -> return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
|
||||||
|
val oldExtras = mediaMetadata.extras ?: Bundle()
|
||||||
|
val newExtras =
|
||||||
|
Bundle(oldExtras).apply {
|
||||||
|
putString(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||||
|
context.getString(res))
|
||||||
|
}
|
||||||
|
return buildUpon()
|
||||||
|
.setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
private fun getCategorySize(
|
private fun getCategorySize(
|
||||||
category: MediaSessionUID.Category,
|
category: MediaSessionUID.Category,
|
||||||
musicRepository: MusicRepository
|
musicRepository: MusicRepository
|
||||||
|
|
|
@ -19,10 +19,15 @@
|
||||||
package org.oxycblt.auxio.music.service
|
package org.oxycblt.auxio.music.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
@ -37,14 +42,27 @@ import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
|
|
||||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
||||||
|
// TODO: Make custom overflow menu for compat
|
||||||
|
val style =
|
||||||
|
Bundle().apply {
|
||||||
|
putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
||||||
|
}
|
||||||
val metadata =
|
val metadata =
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle(context.getString(nameRes))
|
.setTitle(context.getString(nameRes))
|
||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setMediaType(mediaType)
|
.setMediaType(mediaType)
|
||||||
.build()
|
.setExtras(style)
|
||||||
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build()
|
if (bitmapRes != null) {
|
||||||
|
val data = ByteArrayOutputStream()
|
||||||
|
BitmapFactory.decodeResource(context.resources, bitmapRes)
|
||||||
|
.compress(Bitmap.CompressFormat.PNG, 100, data)
|
||||||
|
metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON)
|
||||||
|
}
|
||||||
|
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
||||||
|
@ -103,7 +121,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
|
||||||
.setReleaseMonth(dates?.min?.month)
|
.setReleaseMonth(dates?.min?.month)
|
||||||
.setReleaseDay(dates?.min?.day)
|
.setReleaseDay(dates?.min?.day)
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||||
.setIsPlayable(true)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||||
|
@ -133,7 +151,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
|
||||||
context.getString(R.string.def_song_count)
|
context.getString(R.string.def_song_count)
|
||||||
}))
|
}))
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||||
.setIsPlayable(true)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setGenre(genres.resolveNames(context))
|
.setGenre(genres.resolveNames(context))
|
||||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||||
|
@ -157,7 +175,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
|
||||||
context.getString(R.string.def_song_count)
|
context.getString(R.string.def_song_count)
|
||||||
})
|
})
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
||||||
.setIsPlayable(true)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||||
|
@ -180,7 +198,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
|
||||||
context.getString(R.string.def_song_count)
|
context.getString(R.string.def_song_count)
|
||||||
})
|
})
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||||
.setIsPlayable(true)
|
.setIsPlayable(false)
|
||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.setArtworkUri(cover?.single?.mediaStoreCoverUri)
|
.setArtworkUri(cover?.single?.mediaStoreCoverUri)
|
||||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||||
|
@ -205,14 +223,38 @@ fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface MediaSessionUID {
|
sealed interface MediaSessionUID {
|
||||||
enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) :
|
enum class Category(
|
||||||
MediaSessionUID {
|
val id: String,
|
||||||
ROOT("root", R.string.info_app_name, null),
|
@StringRes val nameRes: Int,
|
||||||
SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC),
|
@DrawableRes val bitmapRes: Int?,
|
||||||
ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
val mediaType: Int?
|
||||||
ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
) : MediaSessionUID {
|
||||||
GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
ROOT("root", R.string.info_app_name, null, null),
|
||||||
PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
SONGS(
|
||||||
|
"songs",
|
||||||
|
R.string.lbl_songs,
|
||||||
|
R.drawable.ic_song_bitmap_24,
|
||||||
|
MediaMetadata.MEDIA_TYPE_MUSIC),
|
||||||
|
ALBUMS(
|
||||||
|
"albums",
|
||||||
|
R.string.lbl_albums,
|
||||||
|
R.drawable.ic_album_bitmap_24,
|
||||||
|
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||||
|
ARTISTS(
|
||||||
|
"artists",
|
||||||
|
R.string.lbl_artists,
|
||||||
|
R.drawable.ic_artist_bitmap_24,
|
||||||
|
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||||
|
GENRES(
|
||||||
|
"genres",
|
||||||
|
R.string.lbl_genres,
|
||||||
|
R.drawable.ic_genre_bitmap_24,
|
||||||
|
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
||||||
|
PLAYLISTS(
|
||||||
|
"playlists",
|
||||||
|
R.string.lbl_playlists,
|
||||||
|
R.drawable.ic_playlist_bitmap_24,
|
||||||
|
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
||||||
|
|
||||||
override fun toString() = "$ID_CATEGORY:$id"
|
override fun toString() = "$ID_CATEGORY:$id"
|
||||||
|
|
||||||
|
|
|
@ -270,8 +270,10 @@ class ExoPlaybackStateHolder(
|
||||||
override fun prev() {
|
override fun prev() {
|
||||||
if (playbackSettings.rewindWithPrev) {
|
if (playbackSettings.rewindWithPrev) {
|
||||||
player.seekToPrevious()
|
player.seekToPrevious()
|
||||||
} else {
|
} else if (player.hasPreviousMediaItem()) {
|
||||||
player.seekToPreviousMediaItem()
|
player.seekToPreviousMediaItem()
|
||||||
|
} else {
|
||||||
|
player.seekTo(0)
|
||||||
}
|
}
|
||||||
if (!playbackSettings.rememberPause) {
|
if (!playbackSettings.rememberPause) {
|
||||||
player.play()
|
player.play()
|
||||||
|
@ -365,17 +367,28 @@ class ExoPlaybackStateHolder(
|
||||||
rawQueue: RawQueue,
|
rawQueue: RawQueue,
|
||||||
ack: StateAck.NewPlayback?
|
ack: StateAck.NewPlayback?
|
||||||
) {
|
) {
|
||||||
this.parent = parent
|
logD("Applying saved state")
|
||||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
var sendEvent = false
|
||||||
if (rawQueue.isShuffled) {
|
if (this.parent != parent) {
|
||||||
player.shuffleModeEnabled = true
|
this.parent = parent
|
||||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
sendEvent = true
|
||||||
} else {
|
}
|
||||||
player.shuffleModeEnabled = false
|
if (rawQueue != resolveQueue()) {
|
||||||
|
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
||||||
|
if (rawQueue.isShuffled) {
|
||||||
|
player.shuffleModeEnabled = true
|
||||||
|
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||||
|
} else {
|
||||||
|
player.shuffleModeEnabled = false
|
||||||
|
}
|
||||||
|
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
|
||||||
|
player.prepare()
|
||||||
|
player.pause()
|
||||||
|
sendEvent = true
|
||||||
|
}
|
||||||
|
if (sendEvent) {
|
||||||
|
ack?.let { playbackManager.ack(this, it) }
|
||||||
}
|
}
|
||||||
player.seekTo(rawQueue.heapIndex, C.TIME_UNSET)
|
|
||||||
player.prepare()
|
|
||||||
ack?.let { playbackManager.ack(this, it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun endSession() {
|
override fun endSession() {
|
||||||
|
|
|
@ -173,6 +173,15 @@ class MediaSessionPlayer(
|
||||||
playbackManager.repeatMode(appRepeatMode)
|
playbackManager.repeatMode(appRepeatMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun seekToDefaultPosition(mediaItemIndex: Int) {
|
||||||
|
val indices = unscrambleQueueIndices()
|
||||||
|
val fakeIndex = indices.indexOf(mediaItemIndex)
|
||||||
|
if (fakeIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playbackManager.goto(fakeIndex)
|
||||||
|
}
|
||||||
|
|
||||||
override fun seekToNext() = playbackManager.next()
|
override fun seekToNext() = playbackManager.next()
|
||||||
|
|
||||||
override fun seekToNextMediaItem() = playbackManager.next()
|
override fun seekToNextMediaItem() = playbackManager.next()
|
||||||
|
@ -183,18 +192,9 @@ class MediaSessionPlayer(
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs)
|
override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs)
|
||||||
|
|
||||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed()
|
||||||
val indices = unscrambleQueueIndices()
|
|
||||||
val fakeIndex = indices.indexOf(mediaItemIndex)
|
override fun seekToDefaultPosition() = notAllowed()
|
||||||
if (fakeIndex < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
playbackManager.goto(fakeIndex)
|
|
||||||
if (positionMs == C.TIME_UNSET) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
playbackManager.seekTo(positionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||||
|
@ -278,10 +278,6 @@ class MediaSessionPlayer(
|
||||||
|
|
||||||
override fun setPlaybackSpeed(speed: Float) = notAllowed()
|
override fun setPlaybackSpeed(speed: Float) = notAllowed()
|
||||||
|
|
||||||
override fun seekToDefaultPosition() = notAllowed()
|
|
||||||
|
|
||||||
override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed()
|
|
||||||
|
|
||||||
override fun seekForward() = notAllowed()
|
override fun seekForward() = notAllowed()
|
||||||
|
|
||||||
override fun seekBack() = notAllowed()
|
override fun seekBack() = notAllowed()
|
||||||
|
|
|
@ -129,7 +129,7 @@ constructor(
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
waitJob.cancel()
|
waitJob.cancel()
|
||||||
mediaSession.release()
|
mediaItemBrowser.release()
|
||||||
actionHandler.release()
|
actionHandler.release()
|
||||||
exoHolder.release()
|
exoHolder.release()
|
||||||
playbackManager.removeListener(this)
|
playbackManager.removeListener(this)
|
||||||
|
|
|
@ -49,13 +49,15 @@ constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val playbackManager: PlaybackStateManager,
|
private val playbackManager: PlaybackStateManager,
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
private val systemReceiver: SystemPlaybackReceiver
|
private val widgetComponent: WidgetComponent
|
||||||
) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
|
) : PlaybackStateManager.Listener, PlaybackSettings.Listener {
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
fun onCustomLayoutChanged(layout: List<CommandButton>)
|
fun onCustomLayoutChanged(layout: List<CommandButton>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val systemReceiver =
|
||||||
|
SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent)
|
||||||
private var callback: Callback? = null
|
private var callback: Callback? = null
|
||||||
|
|
||||||
fun attach(callback: Callback) {
|
fun attach(callback: Callback) {
|
||||||
|
@ -71,6 +73,7 @@ constructor(
|
||||||
playbackManager.removeListener(this)
|
playbackManager.removeListener(this)
|
||||||
playbackSettings.unregisterListener(this)
|
playbackSettings.unregisterListener(this)
|
||||||
context.unregisterReceiver(systemReceiver)
|
context.unregisterReceiver(systemReceiver)
|
||||||
|
widgetComponent.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withCommands(commands: SessionCommands) =
|
fun withCommands(commands: SessionCommands) =
|
||||||
|
@ -180,12 +183,10 @@ object PlaybackActions {
|
||||||
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
|
* A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an
|
||||||
* active [IntentFilter] to be registered.
|
* active [IntentFilter] to be registered.
|
||||||
*/
|
*/
|
||||||
class SystemPlaybackReceiver
|
class SystemPlaybackReceiver(
|
||||||
@Inject
|
private val playbackManager: PlaybackStateManager,
|
||||||
constructor(
|
private val playbackSettings: PlaybackSettings,
|
||||||
val playbackManager: PlaybackStateManager,
|
private val widgetComponent: WidgetComponent
|
||||||
val playbackSettings: PlaybackSettings,
|
|
||||||
val widgetComponent: WidgetComponent
|
|
||||||
) : BroadcastReceiver() {
|
) : BroadcastReceiver() {
|
||||||
private var initialHeadsetPlugEventHandled = false
|
private var initialHeadsetPlugEventHandled = false
|
||||||
|
|
||||||
|
|
|
@ -91,7 +91,8 @@ constructor(
|
||||||
override val shuffled: Boolean
|
override val shuffled: Boolean
|
||||||
) : PlaybackCommand
|
) : PlaybackCommand
|
||||||
|
|
||||||
override fun song(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle)
|
override fun song(song: Song, shuffle: ShuffleMode) =
|
||||||
|
newCommand(song, null, listOf(song), shuffle)
|
||||||
|
|
||||||
override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle)
|
override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle)
|
||||||
|
|
||||||
|
@ -105,7 +106,7 @@ constructor(
|
||||||
newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle)
|
newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle)
|
||||||
|
|
||||||
override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) =
|
override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) =
|
||||||
newCommand(song, playlist, playlist.songs, listSettings.playlistSort, shuffle)
|
newCommand(song, playlist, playlist.songs, shuffle)
|
||||||
|
|
||||||
override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle)
|
override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle)
|
||||||
|
|
||||||
|
|
|
@ -795,15 +795,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
||||||
index
|
index
|
||||||
})
|
})
|
||||||
|
|
||||||
// Valid state where something needs to be played, direct the stateholder to apply
|
stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback)
|
||||||
// this new state.
|
stateHolder.seekTo(savedState.positionMs)
|
||||||
val oldStateMirror = stateMirror
|
|
||||||
if (oldStateMirror.rawQueue != rawQueue) {
|
|
||||||
logD("Queue changed, must reload player")
|
|
||||||
stateHolder.playing(false)
|
|
||||||
stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback)
|
|
||||||
stateHolder.seekTo(savedState.positionMs)
|
|
||||||
}
|
|
||||||
stateHolder.repeatMode(savedState.repeatMode)
|
stateHolder.repeatMode(savedState.repeatMode)
|
||||||
|
|
||||||
isInitialized = true
|
isInitialized = true
|
||||||
|
|
|
@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.oxycblt.auxio.BuildConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper around [StateFlow] exposing a one-time consumable event.
|
* A wrapper around [StateFlow] exposing a one-time consumable event.
|
||||||
|
@ -166,7 +167,13 @@ suspend fun <E> SendChannel<E>.sendWithTimeout(element: E, timeout: Long = DEFAU
|
||||||
try {
|
try {
|
||||||
withTimeout(timeout) { send(element) }
|
withTimeout(timeout) { send(element) }
|
||||||
} catch (e: TimeoutCancellationException) {
|
} catch (e: TimeoutCancellationException) {
|
||||||
throw TimeoutException("Timed out sending element $element to channel: $e")
|
logE("Failed to send element to channel $e in ${timeout}ms.")
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
throw TimeoutException("Timed out sending element to channel: $e")
|
||||||
|
} else {
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
send(element)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,7 +210,13 @@ suspend fun <E> ReceiveChannel<E>.forEachWithTimeout(
|
||||||
subsequent = true
|
subsequent = true
|
||||||
}
|
}
|
||||||
} catch (e: TimeoutCancellationException) {
|
} catch (e: TimeoutCancellationException) {
|
||||||
throw TimeoutException("Timed out receiving element from channel: $e")
|
logE("Failed to send element to channel $e in ${timeout}ms.")
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
throw TimeoutException("Timed out sending element to channel: $e")
|
||||||
|
} else {
|
||||||
|
logE(e.stackTraceToString())
|
||||||
|
handler()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 676 B |
BIN
app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 531 B |
BIN
app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 432 B |
BIN
app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 259 B |
BIN
app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 291 B |
BIN
app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 375 B |
BIN
app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 313 B |
BIN
app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 241 B |
BIN
app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 182 B |
BIN
app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 175 B |
BIN
app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 894 B |
BIN
app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 642 B |
BIN
app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 422 B |
BIN
app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 328 B |
BIN
app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 326 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 812 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 559 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 560 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png
Normal file
After Width: | Height: | Size: 784 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png
Normal file
After Width: | Height: | Size: 672 B |
BIN
app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png
Normal file
After Width: | Height: | Size: 687 B |