diff --git a/.phrasey/schema.toml b/.phrasey/schema.toml index 8611ec2d..be232708 100644 --- a/.phrasey/schema.toml +++ b/.phrasey/schema.toml @@ -680,3 +680,6 @@ name = "Pink" [[keys]] name = "Rose" + +[[keys]] +name = "MediaFolders" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac52591e..1384dd2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.android.app) alias(libs.plugins.android.kotlin) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) } android { @@ -63,15 +65,17 @@ android { buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() - } - packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } } + + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } dependencies { @@ -84,12 +88,16 @@ dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.core) implementation(libs.core.splashscreen) + implementation(libs.documentfile) implementation(libs.fuzzywuzzy) implementation(libs.jaudiotagger) + implementation(libs.kotlinx.serialization.json) implementation(libs.lifecycle.runtime) implementation(libs.media) implementation(libs.okhttp3) debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) + + testImplementation(libs.junit.jupiter) } diff --git a/app/src/main/java/io/github/zyrouge/metaphony/Artwork.kt b/app/src/main/java/io/github/zyrouge/metaphony/Artwork.kt new file mode 100644 index 00000000..c9942faa --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/Artwork.kt @@ -0,0 +1,37 @@ +package io.github.zyrouge.metaphony + +data class Artwork( + val format: Format, + val data: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Artwork + if (format != other.format) return false + if (!data.contentEquals(other.data)) return false + return true + } + + override fun hashCode(): Int { + var result = format.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + + enum class Format(val extension: String, val mimeType: String) { + Jpeg("jpg", "image/jpg"), + Png("png", "image/png"), + Gif("gif", "image/gif"), + Unknown("", ""); + + companion object { + fun fromMimeType(value: String) = when (value) { + Jpeg.mimeType, "image/jpeg" -> Jpeg + Png.mimeType -> Png + Gif.mimeType -> Gif + else -> Unknown + } + } + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/Metadata.kt b/app/src/main/java/io/github/zyrouge/metaphony/Metadata.kt new file mode 100644 index 00000000..ed011fb7 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/Metadata.kt @@ -0,0 +1,38 @@ +package io.github.zyrouge.metaphony + +import io.github.zyrouge.metaphony.flac.Flac +import io.github.zyrouge.metaphony.mp3.Mp3 +import io.github.zyrouge.metaphony.mpeg4.Mpeg4 +import io.github.zyrouge.metaphony.ogg.Ogg +import java.io.InputStream +import java.time.LocalDate + +interface Metadata { + val title: String? + val artists: Set + val album: String? + val albumArtists: Set + val composer: Set + val genres: Set + val year: Int? + val date: LocalDate? + val trackNumber: Int? + val trackTotal: Int? + val discNumber: Int? + val discTotal: Int? + val lyrics: String? + val comments: Set + val artworks: List + + companion object { + fun read(input: InputStream, mimeType: String): Metadata? { + return when (mimeType) { + "audio/flac" -> Flac.read(input) + "audio/mpeg" -> Mp3.read(input) + "audio/mp4" -> Mpeg4.read(input) + "audio/ogg" -> Ogg.read(input) + else -> null + } + } + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/flac/Flac.kt b/app/src/main/java/io/github/zyrouge/metaphony/flac/Flac.kt new file mode 100644 index 00000000..d0dfcfda --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/flac/Flac.kt @@ -0,0 +1,43 @@ +package io.github.zyrouge.metaphony.flac + +import io.github.zyrouge.metaphony.utils.xDecodeToUInt +import io.github.zyrouge.metaphony.utils.xReadByte +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xReadString +import io.github.zyrouge.metaphony.utils.xSkipBytes +import io.github.zyrouge.metaphony.vorbis.VorbisMetadata +import io.github.zyrouge.metaphony.vorbis.readVorbisComments +import io.github.zyrouge.metaphony.vorbis.readVorbisPicture +import java.io.InputStream +import kotlin.experimental.and + +object Flac { + fun read(input: InputStream): VorbisMetadata { + val flac = input.xReadString(4) + if (flac != "fLaC") { + throw Exception("Missing 'fLaC' header") + } + val builder = VorbisMetadata.Builder() + while (true) { + val last = readFlacBlock(input, builder) + if (last) break + } + return builder.done() + } + + private fun readFlacBlock( + input: InputStream, + builder: VorbisMetadata.Builder, + ): Boolean { + val blockHeader = input.xReadByte() + val last = (blockHeader and 0x80.toByte()) == 0x80.toByte() + val blockId = blockHeader and 0x7f.toByte() + val blockLen = input.xReadInt(3) + when (blockId.xDecodeToUInt()) { + 4 -> builder.readVorbisComments(input) + 6 -> builder.readVorbisPicture(input) + else -> input.xSkipBytes(blockLen) + } + return last + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2FrameFlags.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2FrameFlags.kt new file mode 100644 index 00000000..7fd90005 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2FrameFlags.kt @@ -0,0 +1,51 @@ +package io.github.zyrouge.metaphony.id3v2 + +import io.github.zyrouge.metaphony.utils.xBitSetAt +import io.github.zyrouge.metaphony.utils.xReadByte +import io.github.zyrouge.metaphony.utils.xSkipBytes +import java.io.InputStream + +internal data class ID3v2FrameFlags( + val flagsSize: Int, + val compression: Boolean, + val encryption: Boolean, + val unsynchronization: Boolean, + val dataLengthIndicator: Boolean, +) { + companion object { + internal fun readID3v2FrameFlags( + input: InputStream, + version: ID3v2Version, + ): ID3v2FrameFlags? { + return when (version) { + ID3v2Version.V3 -> readID3v2r3FrameFlags(input) + ID3v2Version.V4 -> readID3v2r4FrameFlags(input) + else -> null + } + } + + private fun readID3v2r3FrameFlags(input: InputStream): ID3v2FrameFlags { + input.xSkipBytes(1) + val format = input.xReadByte() + return ID3v2FrameFlags( + flagsSize = 2, + compression = format.xBitSetAt(7), + encryption = format.xBitSetAt(6), + unsynchronization = false, + dataLengthIndicator = false, + ) + } + + private fun readID3v2r4FrameFlags(input: InputStream): ID3v2FrameFlags { + input.xSkipBytes(1) + val format = input.xReadByte() + return ID3v2FrameFlags( + flagsSize = 2, + compression = format.xBitSetAt(3), + encryption = format.xBitSetAt(2), + unsynchronization = format.xBitSetAt(1), + dataLengthIndicator = format.xBitSetAt(0), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2FrameHeader.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2FrameHeader.kt new file mode 100644 index 00000000..8461b625 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2FrameHeader.kt @@ -0,0 +1,54 @@ +package io.github.zyrouge.metaphony.id3v2 + +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xReadString +import java.io.InputStream + +internal data class ID3v2FrameHeader( + val name: String, + val size: Int, + val headerSize: Int, +) { + companion object { + internal fun readID3v2FrameHeader( + input: InputStream, + version: ID3v2Version, + ): ID3v2FrameHeader { + return when (version) { + ID3v2Version.V2 -> readID3v2r2FrameHeader(input) + ID3v2Version.V3 -> readID3v2r3FrameHeader(input) + ID3v2Version.V4 -> readID3v2r4FrameHeader(input) + } + } + + private fun readID3v2r2FrameHeader(input: InputStream): ID3v2FrameHeader { + val name = input.xReadString(3) + val size = input.xReadInt(3) + return ID3v2FrameHeader( + name = name, + size = size, + headerSize = 6, + ) + } + + private fun readID3v2r3FrameHeader(input: InputStream): ID3v2FrameHeader { + val name = input.xReadString(4) + val size = input.xReadInt(4) + return ID3v2FrameHeader( + name = name, + size = size, + headerSize = 8, + ) + } + + private fun readID3v2r4FrameHeader(input: InputStream): ID3v2FrameHeader { + val name = input.xReadString(4) + val size = input.xReadInt(4, 7) + return ID3v2FrameHeader( + name = name, + size = size, + headerSize = 8, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Frames.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Frames.kt new file mode 100644 index 00000000..502276e7 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Frames.kt @@ -0,0 +1,182 @@ +package io.github.zyrouge.metaphony.id3v2 + +import io.github.zyrouge.metaphony.Artwork +import io.github.zyrouge.metaphony.utils.xReadBytes +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xSkipBytes +import io.github.zyrouge.metaphony.utils.xSlice +import io.github.zyrouge.metaphony.utils.xSplit +import java.io.InputStream + +object ID3v2Frames { + private const val TEXT_ENCODING_ISO_8859 = 0.toByte() + private const val TEXT_ENCODING_UTF_16 = 1.toByte() + private const val TEXT_ENCODING_UTF_16_BE = 2.toByte() + private const val TEXT_ENCODING_UTF_8 = 3.toByte() + + private const val ZERO_BYTE = 0.toByte() + private val SINGLE_ZERO_DELIMITER = byteArrayOf(ZERO_BYTE) + private val DOUBLE_ZERO_DELIMITER = byteArrayOf(ZERO_BYTE, ZERO_BYTE) + + internal val ZERO_BYTE_CHARACTER = String(SINGLE_ZERO_DELIMITER) + internal const val NULL_CHARACTER = 0.toChar() + + internal fun ID3v2Metadata.Builder.readID3v2Frames(input: InputStream) { + val header = Id3v2Header.readID3v2Header(input) + readID3v2Frames(input, header) + } + + private fun ID3v2Metadata.Builder.readID3v2Frames(input: InputStream, header: Id3v2Header) { + var offset = header.offset + while (offset < header.size) { + val frameHeader = ID3v2FrameHeader.readID3v2FrameHeader(input, header.version) + val frameFlags = ID3v2FrameFlags.readID3v2FrameFlags(input, header.version) + var size = frameHeader.size + if (size == 0) break + offset += frameHeader.headerSize + size + frameFlags?.flagsSize?.let { offset += it } + if (frameFlags?.compression == true) { + when (header.version) { + ID3v2Version.V3 -> { + input.xSkipBytes(4) + size -= 4 + } + + ID3v2Version.V4 -> { + size = input.xReadInt(4, 7) + } + + else -> {} + } + } + if (frameFlags?.encryption == true) { + input.xSkipBytes(1) + size-- + } + val name = frameHeader.name + // NOTE: not everything is parsed, only some needed ones + when { + name == "TXXX" || name == "TXX" -> { + val data = input.xReadBytes(size) + readTextDescFrame(data, hasLanguage = false, hasEncodedText = true).let { + textDescFrames[it.description] = it + } + } + + name.firstOrNull() == 'T' -> { + val data = input.xReadBytes(size) + textFrames[name] = readTFrame(data) + } + + name == "WXXX" || name == "WXX" -> { + val data = input.xReadBytes(size) + readTextDescFrame(data, hasLanguage = false, hasEncodedText = false).let { + textDescFrames[it.description] = it + } + } + + name.firstOrNull() == 'W' -> { + val data = input.xReadBytes(size) + textFrames[name] = readWFrame(data) + } + + name == "COMM" || name == "COM" || name == "USLT" || name == "ULT" -> { + val data = input.xReadBytes(size) + readTextDescFrame(data, hasLanguage = true, hasEncodedText = true).let { + textDescFrames[it.description] = it + } + } + + name == "APIC" -> { + val data = input.xReadBytes(size) + pictureFrames.add(readAPICFrame(data)) + } + + name == "PIC" -> { + val data = input.xReadBytes(size) + pictureFrames.add(readPICFrame(data)) + } + + else -> input.xSkipBytes(size) + } + } + } + + data class ID3v2TextWithDescFrame( + val language: String?, + val description: String, + val text: String, + ) + + private fun readTextDescFrame( + data: ByteArray, + hasLanguage: Boolean, + hasEncodedText: Boolean, + ): ID3v2TextWithDescFrame { + var start = 1 + val encoding = data.first() + val delimiter = when (encoding) { + TEXT_ENCODING_UTF_16, TEXT_ENCODING_UTF_16_BE -> DOUBLE_ZERO_DELIMITER + else -> SINGLE_ZERO_DELIMITER + } + val language = when { + hasLanguage -> { + start += 3 + data.xSlice(to = 3).decodeToString() + } + + else -> null + } + val info = data.xSlice(start).xSplit(delimiter, 2) + val description = decodeText(encoding, info[0]) + val textEncoding = when { + hasEncodedText -> encoding + else -> TEXT_ENCODING_ISO_8859 + } + val text = decodeText(textEncoding, info[1]) + return ID3v2TextWithDescFrame( + language = language, + text = text, + description = description, + ) + } + + private fun readTFrame(data: ByteArray): Set { + if (data.isEmpty()) return emptySet() + val decoded = decodeText(data.first(), data.xSlice(1)) + val values = decoded.split(SINGLE_ZERO_DELIMITER.decodeToString()) + return values.filter { it.isNotBlank() }.toSet() + } + + private fun readWFrame(data: ByteArray) = readTFrame(byteArrayOf(0) + data) + + private fun readAPICFrame(data: ByteArray): Artwork { + val dataSplit = data.xSlice(1).xSplit(SINGLE_ZERO_DELIMITER, 3) + val mimeType = dataSplit.first().decodeToString() + val bytes = dataSplit.last() + return Artwork( + format = Artwork.Format.fromMimeType(mimeType), + data = bytes, + ) + } + + private fun readPICFrame(data: ByteArray): Artwork { + val mimeType = data.xSlice(1, 4).decodeToString() + val descSplit = data.xSlice(5).xSplit(SINGLE_ZERO_DELIMITER, 2) + val bytes = descSplit.last() + return Artwork( + format = Artwork.Format.fromMimeType(mimeType), + data = bytes, + ) + } + + private fun decodeText(encoding: Byte, data: ByteArray): String { + val charset = when (encoding) { + TEXT_ENCODING_UTF_16 -> Charsets.UTF_16 + TEXT_ENCODING_UTF_16_BE -> Charsets.UTF_16BE + TEXT_ENCODING_UTF_8 -> Charsets.UTF_8 + else -> Charsets.ISO_8859_1 + } + return String(data, charset) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Genres.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Genres.kt new file mode 100644 index 00000000..99d97364 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Genres.kt @@ -0,0 +1,57 @@ +package io.github.zyrouge.metaphony.id3v2 + +internal object ID3v2Genres { + private val id3v2Genres = listOf( + "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", + "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", + "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", + "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", + "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", + "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", + "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", + "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", + "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", + "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", + "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", + "Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes", "Trailer", + "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", + "Musical", "Rock & Roll", "Hard Rock", "Folk", "Folk-Rock", + "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival", + "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", + "Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", + "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", + "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", + "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", + "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", + "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall", + "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror", "Indie", + "Britpop", "Negerpunk", "Polsk Punk", "Beat", "Christian Gangsta Rap", + "Heavy Metal", "Black Metal", "Crossover", "Contemporary Christian", + "Christian Rock ", "Merengue", "Salsa", "Thrash Metal", "Anime", "JPop", + "Synthpop", + "Christmas", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", + "Chillout", "Downtempo", "Dub", "EBM", "Eclectic", "Electro", + "Electroclash", "Emo", "Experimental", "Garage", "Global", "IDM", + "Illbient", "Industro-Goth", "Jam Band", "Krautrock", "Leftfield", "Lounge", + "Math Rock", "New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance", + "Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical", "Audiobook", + "Audio Theatre", "Neue Deutsche Welle", "Podcast", "Indie Rock", "G-Funk", "Dubstep", + "Garage Rock", "Psybient", + ) + + private val id3v2GenreRegex = Regex.fromLiteral("""\d+""") + + private fun parseIDv2Genre(value: String): Set { + if (value[0] == '(') { + val matches = id3v2GenreRegex.findAll(value) + return matches.mapNotNull { id3v2Genres.getOrNull(it.value.toInt()) }.toSet() + } + value.toIntOrNull()?.let { + val genre = id3v2Genres.getOrNull(it) + return setOfNotNull(genre) + } + return value.split(ID3v2Frames.NULL_CHARACTER).toSet() + } + + fun parseIDv2Genre(values: Set) = values.flatMap { parseIDv2Genre(it) }.toSet() +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Metadata.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Metadata.kt new file mode 100644 index 00000000..23b9e2c8 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Metadata.kt @@ -0,0 +1,76 @@ +package io.github.zyrouge.metaphony.id3v2 + +import io.github.zyrouge.metaphony.Artwork +import io.github.zyrouge.metaphony.Metadata +import io.github.zyrouge.metaphony.utils.xDateToLocalDate +import io.github.zyrouge.metaphony.utils.xIntAfterSlash +import io.github.zyrouge.metaphony.utils.xIntBeforeSlash +import java.time.LocalDate + +data class ID3v2Metadata( + internal val rawTextDescFrames: Map, + internal val rawTextFrames: Map>, + override val artworks: List, +) : Metadata { + override val title: String? get() = textFrameSingle("TT2") ?: textFrameSingle("TIT2") + override val artists: Set + get() = textFrameMultipleOrNull("TP1") ?: textFrameMultiple("TPE1") + override val album: String? get() = textFrameSingle("TAL") ?: textFrameSingle("TALB") + override val albumArtists: Set + get() = textFrameMultipleOrNull("TP2") ?: textFrameMultiple("TPE2") + override val composer: Set + get() = textFrameMultipleOrNull("TCM") ?: textFrameMultiple("TCOM") + override val genres: Set + get() = parseGenres() + override val year: Int? get() = date?.year + override val trackNumber: Int? + get() = (textFrameSingle("TRK") ?: textFrameSingle("TRCK"))?.let { + it.xIntBeforeSlash() ?: it.toIntOrNull() + } + override val trackTotal: Int? + get() = (textFrameSingle("TRK") ?: textFrameSingle("TRCK"))?.xIntAfterSlash() + override val discNumber: Int? + get() = (textFrameSingle("TPA") ?: textFrameSingle("TPOS"))?.let { + it.xIntBeforeSlash() ?: it.toIntOrNull() + } + override val discTotal: Int? + get() = (textFrameSingle("TPA") ?: textFrameSingle("TPOS"))?.xIntAfterSlash() + override val lyrics: String? get() = textFrameSingle("lyrics") ?: textFrameSingle("lyrics-xxx") + override val comments: Set + get() = textFrameMultipleOrNull("COM") ?: textFrameMultiple("COMM") + + override val date: LocalDate? + get() { + val raw = textFrameSingle("TDRL") ?: return null + if (raw.isEmpty()) return null + return raw.xDateToLocalDate() + } + + internal fun textFrameSingle(name: String) = rawTextFrames[name]?.firstOrNull() + internal fun textFrameMultiple(name: String) = textFrameMultipleOrNull(name) ?: setOf() + internal fun textFrameMultipleOrNull(name: String) = rawTextFrames[name] + + internal fun parseGenres(): Set { + val values = textFrameMultipleOrNull("TCO") + ?: textFrameMultipleOrNull("TCON") + ?: rawTextDescFrames.values.mapNotNull { + when { + it.description.lowercase() == "genre" -> it.text.split(ID3v2Frames.ZERO_BYTE_CHARACTER) + else -> null + } + }.flatten().filter { it.isNotBlank() }.toSet() + return ID3v2Genres.parseIDv2Genre(values) + } + + internal data class Builder( + val textDescFrames: MutableMap = mutableMapOf(), + val pictureFrames: MutableList = mutableListOf(), + val textFrames: MutableMap> = mutableMapOf(), + ) { + fun done() = ID3v2Metadata( + rawTextDescFrames = textDescFrames, + rawTextFrames = textFrames, + artworks = pictureFrames, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Version.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Version.kt new file mode 100644 index 00000000..5dd46b78 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/ID3v2Version.kt @@ -0,0 +1,7 @@ +package io.github.zyrouge.metaphony.id3v2 + +internal enum class ID3v2Version { + V2, + V3, + V4, +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/id3v2/Id3v2Header.kt b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/Id3v2Header.kt new file mode 100644 index 00000000..15dec013 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/id3v2/Id3v2Header.kt @@ -0,0 +1,52 @@ +package io.github.zyrouge.metaphony.id3v2 + +import io.github.zyrouge.metaphony.utils.xBitSetAt +import io.github.zyrouge.metaphony.utils.xReadByte +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xReadString +import io.github.zyrouge.metaphony.utils.xSkipBytes +import java.io.InputStream + +internal data class Id3v2Header( + val version: ID3v2Version, + val size: Int, + val unsynchronization: Boolean, + val offset: Int, +) { + companion object { + internal fun readID3v2Header(input: InputStream): Id3v2Header { + val marker = input.xReadString(3) + if (marker != "ID3") { + throw Exception("Missing marker") + } + val rawVersion = input.xReadInt(1) + val version = when (rawVersion) { + 2 -> ID3v2Version.V2 + 3 -> ID3v2Version.V3 + 4 -> ID3v2Version.V4 + else -> throw Exception("Invalid version") + } + input.xSkipBytes(1) + val flags = input.xReadByte() + val unsynchronization = flags.xBitSetAt(7) + val extendedHeader = flags.xBitSetAt(6) + val size = input.xReadInt(4, 7) + var offset = 10 + if (extendedHeader) { + val extendedHeaderSize = when (version) { + ID3v2Version.V2 -> 0 + ID3v2Version.V3 -> input.xReadInt(4) + ID3v2Version.V4 -> input.xReadInt(4, 7) - 4 + } + input.xSkipBytes(extendedHeaderSize) + offset += extendedHeaderSize + } + return Id3v2Header( + version = version, + size = size, + unsynchronization = unsynchronization, + offset = offset, + ) + } + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/mp3/Mp3.kt b/app/src/main/java/io/github/zyrouge/metaphony/mp3/Mp3.kt new file mode 100644 index 00000000..8a3def38 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/mp3/Mp3.kt @@ -0,0 +1,13 @@ +package io.github.zyrouge.metaphony.mp3 + +import io.github.zyrouge.metaphony.id3v2.ID3v2Frames.readID3v2Frames +import io.github.zyrouge.metaphony.id3v2.ID3v2Metadata +import java.io.InputStream + +object Mp3 { + fun read(input: InputStream): ID3v2Metadata { + val builder = ID3v2Metadata.Builder() + builder.readID3v2Frames(input) + return builder.done() + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4.kt b/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4.kt new file mode 100644 index 00000000..2f07d863 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4.kt @@ -0,0 +1,12 @@ +package io.github.zyrouge.metaphony.mpeg4 + +import io.github.zyrouge.metaphony.mpeg4.Mpeg4Atoms.readAtoms +import java.io.InputStream + +object Mpeg4 { + fun read(input: InputStream): Mpeg4Metadata { + val builder = Mpeg4Metadata.Builder() + builder.readAtoms(input) + return builder.done() + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4Atoms.kt b/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4Atoms.kt new file mode 100644 index 00000000..e51deee7 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4Atoms.kt @@ -0,0 +1,129 @@ +package io.github.zyrouge.metaphony.mpeg4 + +import io.github.zyrouge.metaphony.Artwork +import io.github.zyrouge.metaphony.utils.xAvailable +import io.github.zyrouge.metaphony.utils.xDecodeToInt +import io.github.zyrouge.metaphony.utils.xDecodeToUInt +import io.github.zyrouge.metaphony.utils.xRead32bitBigEndian +import io.github.zyrouge.metaphony.utils.xReadBytes +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xReadString +import io.github.zyrouge.metaphony.utils.xSkipBytes +import io.github.zyrouge.metaphony.utils.xSlice +import java.io.InputStream + +object Mpeg4Atoms { + private const val ATOM_BINARY_TYPE = 0 + private const val ATOM_TEXT_TYPE = 1 + private const val ATOM_JPEG_TYPE = 13 + private const val ATOM_PNG_TYPE = 14 + private const val ATOM_UNIT8_TYPE = 21 + + // Source: https://atomicparsley.sourceforge.net/mpeg-4files.html + private val atomNames = mapOf( + "©alb" to "album", + "©art" to "artist", + "aART" to "album_artist", + "©cmt" to "comment", + "©day" to "year", + "©nam" to "title", + "©gen" to "genre", + "gnre" to "genre", + "trkn" to "track", + "disk" to "disc", + "©wrt" to "composer", + "covr" to "picture", + "©lyr" to "lyrics", + ) + + private val dnsAtomNames = mapOf( + "com.apple.iTunes:ARTISTS" to "artist", + ) + + internal fun Mpeg4Metadata.Builder.readAtoms(input: InputStream) { + while (input.xAvailable()) { + var (name, size) = readAtomHeader(input) + if (name == "meta") { + input.xSkipBytes(4) + readAtoms(input) + return + } + if (name == "moov" || name == "udta" || name == "ilst") { + readAtoms(input) + return + } + var canRead = false + if (name == "----") { + val (_, meanSize) = readAtomHeader(input) + input.xSkipBytes(meanSize - 8) + val (_, subSize) = readAtomHeader(input) + input.xSkipBytes(4) + val subName = input.xReadString(subSize - 12) + name = dnsAtomNames[subName.substring(5)] ?: subName + size -= meanSize + subSize + canRead = true + } + atomNames[name]?.let { + name = it + canRead = true + } + if (!canRead) { + input.xSkipBytes(size - 8) + continue + } + readAtomData(input, name, size - 8) + } + } + + private fun readAtomHeader(input: InputStream): Pair { + val size = input.xRead32bitBigEndian() + val name = String(input.xReadBytes(4), Charsets.ISO_8859_1) + return name to size + } + + private fun Mpeg4Metadata.Builder.readAtomData( + input: InputStream, + name: String, + size: Int, + ) { + input.xSkipBytes(8) + val contentType = input.xReadInt(4) + input.xSkipBytes(4) + val data = input.xReadBytes(size - 16) + when (contentType) { + ATOM_BINARY_TYPE -> { + if (name == "track" || name == "disc") { + uint8Atoms[name] = data[3].xDecodeToUInt() + uint8Atoms["${name}_total"] = data[5].xDecodeToUInt() + } + } + + ATOM_TEXT_TYPE -> { + val value = data.decodeToString() + stringAtoms.compute(name) { _, old -> + old?.let { it + value } ?: setOf(value) + } + } + + ATOM_UNIT8_TYPE -> { + uint8Atoms[name] = data.xSlice(2).xDecodeToInt() + } + + ATOM_JPEG_TYPE -> { + val artwork = Artwork( + format = Artwork.Format.fromMimeType("image/jpeg"), + data = data, + ) + pictureAtoms.add(artwork) + } + + ATOM_PNG_TYPE -> { + val artwork = Artwork( + format = Artwork.Format.fromMimeType("image/png"), + data = data, + ) + pictureAtoms.add(artwork) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4Metadata.kt b/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4Metadata.kt new file mode 100644 index 00000000..1aa066ed --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/mpeg4/Mpeg4Metadata.kt @@ -0,0 +1,53 @@ +package io.github.zyrouge.metaphony.mpeg4 + +import io.github.zyrouge.metaphony.Artwork +import io.github.zyrouge.metaphony.Metadata +import io.github.zyrouge.metaphony.utils.xDateToLocalDate +import java.time.LocalDate + +data class Mpeg4Metadata( + internal val rawStringAtoms: Map>, + internal val rawUint8Atoms: Map, + internal val rawPictureAtoms: List, +) : Metadata { + override val title: String? get() = stringAtomSingle("title") + override val artists: Set get() = stringAtomMultiple("artist") + override val album: String? get() = stringAtomSingle("album") + override val albumArtists: Set get() = stringAtomMultiple("album_artist") + override val composer: Set get() = stringAtomMultiple("composer") + override val genres: Set get() = stringAtomMultiple("genre") + override val year: Int? get() = date?.year + override val trackNumber: Int? get() = uint8Atom("track") + override val trackTotal: Int? get() = uint8Atom("track_total") + override val discNumber: Int? get() = uint8Atom("disc") + override val discTotal: Int? get() = uint8Atom("disc_total") + override val lyrics: String? get() = stringAtomSingle("lyrics") + override val comments: Set get() = stringAtomMultiple("comment") + override val artworks: List get() = rawPictureAtoms + + override val date: LocalDate? + get() { + val raw = stringAtomSingle("year") ?: return null + if (raw.isEmpty()) return null + return raw.xDateToLocalDate() + } + + internal fun stringAtomSingle(name: String) = rawStringAtoms[name]?.firstOrNull() + internal fun stringAtomMultiple(name: String) = rawStringAtoms[name] ?: setOf() + internal fun uint8Atom(name: String) = rawUint8Atoms[name] + + internal data class Builder( + val stringAtoms: MutableMap> = mutableMapOf(), + val uint8Atoms: MutableMap = mutableMapOf(), + val pictureAtoms: MutableList = mutableListOf(), + ) { + fun done() = Mpeg4Metadata( + rawStringAtoms = stringAtoms, + rawUint8Atoms = uint8Atoms, + rawPictureAtoms = pictureAtoms, + ) + } +} + + + diff --git a/app/src/main/java/io/github/zyrouge/metaphony/ogg/Ogg.kt b/app/src/main/java/io/github/zyrouge/metaphony/ogg/Ogg.kt new file mode 100644 index 00000000..28731f7f --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/ogg/Ogg.kt @@ -0,0 +1,27 @@ +package io.github.zyrouge.metaphony.ogg + +import io.github.zyrouge.metaphony.utils.xAvailable +import io.github.zyrouge.metaphony.utils.xSkipBytes +import io.github.zyrouge.metaphony.utils.xStartsWith +import io.github.zyrouge.metaphony.vorbis.VorbisMetadata +import io.github.zyrouge.metaphony.vorbis.readVorbisComments +import java.io.InputStream + +object Ogg { + private val VORBIS_PREFIX = byteArrayOf(3, 118, 111, 114, 98, 105, 115) + + fun read(input: InputStream): VorbisMetadata { + val builder = VorbisMetadata.Builder() + while (input.xAvailable()) { + val packet = OggPacket.readOggPacket(input) + if (packet.xStartsWith(VORBIS_PREFIX)) { + packet.inputStream().use { packetStream -> + packetStream.xSkipBytes(VORBIS_PREFIX.size) + builder.readVorbisComments(packetStream) + } + } + } + return builder.done() + } +} + diff --git a/app/src/main/java/io/github/zyrouge/metaphony/ogg/OggPacket.kt b/app/src/main/java/io/github/zyrouge/metaphony/ogg/OggPacket.kt new file mode 100644 index 00000000..5fe31545 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/ogg/OggPacket.kt @@ -0,0 +1,32 @@ +package io.github.zyrouge.metaphony.ogg + +import io.github.zyrouge.metaphony.utils.xReadBytes +import io.github.zyrouge.metaphony.utils.xReadInt +import java.io.InputStream + +object OggPacket { + internal fun readOggPacket(input: InputStream): ByteArray { + var packet = ByteArray(0) + var isContinuing = false + while (true) { + val header = OggPageHeader.readOggPageHeader(input) + if (isContinuing && !header.isContinuation) { + throw Exception("Expected continuation page") + } + var partialLen = 0 + var lastSegmentLen = 0 + for (i in 0 until header.segments) { + val segmentLen = input.xReadInt(1) + partialLen += segmentLen + lastSegmentLen = segmentLen + } + val partial = input.xReadBytes(partialLen) + packet += partial + if (header.isLastPage || lastSegmentLen != 255) { + break + } + isContinuing = true + } + return packet + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/metaphony/ogg/OggPageHeader.kt b/app/src/main/java/io/github/zyrouge/metaphony/ogg/OggPageHeader.kt new file mode 100644 index 00000000..ae031ddc --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/ogg/OggPageHeader.kt @@ -0,0 +1,36 @@ +package io.github.zyrouge.metaphony.ogg + +import io.github.zyrouge.metaphony.utils.xReadByte +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xReadString +import io.github.zyrouge.metaphony.utils.xSkipBytes +import java.io.InputStream + +internal data class OggPageHeader( + val segments: Int, + val isContinuation: Boolean, + val isFirstPage: Boolean, + val isLastPage: Boolean, +) { + companion object { + internal fun readOggPageHeader(input: InputStream): OggPageHeader { + val marker = input.xReadString(4) + if (marker != "OggS") { + throw Exception("Missing synchronisation marker") + } + val version = input.xReadByte() + if (version != 0.toByte()) { + throw Exception("Invalid version") + } + val flags = input.xReadInt(1) + input.xSkipBytes(20) + val segments = input.xReadInt(1) + return OggPageHeader( + segments = segments, + isContinuation = flags == 1, + isFirstPage = flags == 2, + isLastPage = flags == 4, + ) + } + } +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/utils/ByteArrayUtils.kt b/app/src/main/java/io/github/zyrouge/metaphony/utils/ByteArrayUtils.kt new file mode 100644 index 00000000..cec1c28f --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/utils/ByteArrayUtils.kt @@ -0,0 +1,49 @@ +package io.github.zyrouge.metaphony.utils + +internal fun ByteArray.xDecodeToInt(bitSize: Int = 8) = fold(0) { value, x -> + (value shl bitSize) or x.xDecodeToUInt() +} + +internal fun ByteArray.xSlice(from: Int = 0, to: Int = size) = copyOfRange(from, to) + +internal fun ByteArray.xStartsWith(prefix: ByteArray): Boolean { + if (size < prefix.size) return false + for ((i, x) in prefix.withIndex()) { + if (this[i] != x) return false + } + return true +} + +internal fun ByteArray.xIndexOf(delimiter: ByteArray, start: Int = 0): Int { + if (delimiter.isEmpty()) { + throw NotImplementedError("Expected non-zero sized delimiter") + } + for (i in start until size - delimiter.size + 1) { + val matched = delimiter.withIndex().all { + it.value == this[i + it.index] + } + if (matched) return i + } + return -1 +} + +internal fun ByteArray.xSplit(delimiter: ByteArray, limit: Int = -1): List { + if (limit == 0 || limit < -1) { + throw NotImplementedError("Expected limit to be greater than 0 or -1") + } + val values = mutableListOf() + var start = 0 + while (values.size < limit) { + var end = xIndexOf(delimiter, start) + if (end == -1) { + values.add(xSlice(start)) + break + } + if (values.size + 1 == limit) { + end = size + } + values.add(xSlice(start, end)) + start = end + delimiter.size + } + return values +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/utils/ByteUtils.kt b/app/src/main/java/io/github/zyrouge/metaphony/utils/ByteUtils.kt new file mode 100644 index 00000000..67d619f0 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/utils/ByteUtils.kt @@ -0,0 +1,7 @@ +package io.github.zyrouge.metaphony.utils + +internal fun Byte.xDecodeToUInt() = toUByte().toInt() + +internal fun Byte.xBitSetAt(n: Int): Boolean { + return (toInt() shr n) and 1 == 1 +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/utils/InputStreamUtils.kt b/app/src/main/java/io/github/zyrouge/metaphony/utils/InputStreamUtils.kt new file mode 100644 index 00000000..dd3ec176 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/utils/InputStreamUtils.kt @@ -0,0 +1,33 @@ +package io.github.zyrouge.metaphony.utils + +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal fun InputStream.xSkipBytes(n: Int) { + if (n < 0) { + throw Exception("Cannot skip negative count") + } + skip(n.toLong()) +} + +internal fun InputStream.xReadBytes(n: Int): ByteArray { + if (n < 0) { + throw Exception("Cannot read negative count") + } + val bytes = ByteArray(n) + read(bytes, 0, n) + return bytes +} + +internal fun InputStream.xReadByte(): Byte = xReadBytes(1).first() +internal fun InputStream.xReadString(n: Int) = xReadBytes(n).decodeToString() +internal fun InputStream.xReadInt(n: Int, bitSize: Int = 8) = xReadBytes(n).xDecodeToInt(bitSize) +internal fun InputStream.xRead32bitLittleEndian() = xReadOrderedInt(ByteOrder.LITTLE_ENDIAN) +internal fun InputStream.xRead32bitBigEndian() = xReadOrderedInt(ByteOrder.BIG_ENDIAN) +internal fun InputStream.xAvailable() = available() > 0 + +internal fun InputStream.xReadOrderedInt(order: ByteOrder): Int { + val bytes = xReadBytes(4) + return ByteBuffer.wrap(bytes).order(order).getInt() +} diff --git a/app/src/main/java/io/github/zyrouge/metaphony/utils/StringUtils.kt b/app/src/main/java/io/github/zyrouge/metaphony/utils/StringUtils.kt new file mode 100644 index 00000000..788c5d96 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/utils/StringUtils.kt @@ -0,0 +1,25 @@ +package io.github.zyrouge.metaphony.utils + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +internal fun String.xDateToLocalDate(): LocalDate { + val format = when (length) { + 4 -> DateTimeFormatter.ofPattern("yyyy") + 7 -> DateTimeFormatter.ofPattern("yyyy-MM") + 10 -> DateTimeFormatter.ofPattern("yyyy-MM-dd") + else -> DateTimeFormatter.ISO_LOCAL_DATE + } + return LocalDate.parse(this, format) +} + +private fun parseSlashSeparatedNumbers(value: String): Pair? { + val split = value.split("/") +// if (split.size == 2) { +// return split[0].toInt() to split[1].toInt() +// } + return null +} + +internal fun String.xIntBeforeSlash() = parseSlashSeparatedNumbers(this)?.first +internal fun String.xIntAfterSlash() = parseSlashSeparatedNumbers(this)?.second diff --git a/app/src/main/java/io/github/zyrouge/metaphony/vorbis/Vorbis.kt b/app/src/main/java/io/github/zyrouge/metaphony/vorbis/Vorbis.kt new file mode 100644 index 00000000..d3c7efa4 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/metaphony/vorbis/Vorbis.kt @@ -0,0 +1,114 @@ +package io.github.zyrouge.metaphony.vorbis + +import io.github.zyrouge.metaphony.Artwork +import io.github.zyrouge.metaphony.Metadata +import io.github.zyrouge.metaphony.utils.xDateToLocalDate +import io.github.zyrouge.metaphony.utils.xIntAfterSlash +import io.github.zyrouge.metaphony.utils.xIntBeforeSlash +import io.github.zyrouge.metaphony.utils.xRead32bitLittleEndian +import io.github.zyrouge.metaphony.utils.xReadBytes +import io.github.zyrouge.metaphony.utils.xReadInt +import io.github.zyrouge.metaphony.utils.xReadString +import io.github.zyrouge.metaphony.utils.xSkipBytes +import java.io.InputStream +import java.time.LocalDate +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class VorbisMetadata( + private val rawComments: Map>, + override val artworks: List, +) : Metadata { + override val title: String? get() = fieldSingle("title") + override val artists: Set get() = fieldMultiple("artist") + override val album: String? get() = fieldSingle("album") + override val albumArtists: Set + get() = fieldMultipleOrNull("albumartist") ?: fieldMultiple("album_artist") + override val composer: Set + get() = fieldMultipleOrNull("composer") ?: fieldMultipleOrNull("performer") + ?: fieldMultiple("artist") + override val genres: Set get() = fieldMultiple("genre") + override val year: Int? get() = fieldSingle("year")?.toInt() ?: date?.year + override val trackNumber: Int? + get() = fieldSingle("tracknumber")?.let { + it.xIntBeforeSlash() ?: it.toInt() + } + override val trackTotal: Int? + get() = fieldSingle("tracknumber")?.xIntAfterSlash() ?: fieldSingle("tracktotal")?.toInt() + override val discNumber: Int? + get() = fieldSingle("discnumber")?.let { + it.xIntBeforeSlash() ?: it.toInt() + } + override val discTotal: Int? + get() = fieldSingle("discnumber")?.xIntAfterSlash() ?: fieldSingle("disctotal")?.toInt() + override val lyrics: String? get() = fieldSingle("lyrics") ?: fieldSingle("lyrics-xxx") + override val comments: Set get() = fieldMultiple("comment") + + override val date: LocalDate? + get() { + val raw = fieldSingle("date") ?: return null + if (raw.isEmpty()) return null + return raw.xDateToLocalDate() + } + + private fun fieldSingle(name: String) = rawComments[name]?.firstOrNull() + private fun fieldMultiple(name: String) = fieldMultipleOrNull(name) ?: setOf() + private fun fieldMultipleOrNull(name: String) = rawComments[name] + + internal data class Builder( + val comments: MutableMap> = mutableMapOf(), + val pictures: MutableList = mutableListOf(), + ) { + fun done() = VorbisMetadata(comments, pictures) + } +} + +@OptIn(ExperimentalEncodingApi::class) +internal fun VorbisMetadata.Builder.readVorbisComments(input: InputStream) { + val vendorLen = input.xRead32bitLittleEndian() + input.xSkipBytes(vendorLen) + val commentsLen = input.xRead32bitLittleEndian() + for (i in 0 until commentsLen) { + val rawLen = input.xRead32bitLittleEndian() + val raw = input.xReadString(rawLen) + val (name, value) = parseVorbisComment(raw) + when (name) { + "metadata_block_picture" -> { + Base64.decode(value).inputStream().use { pictureStream -> + readVorbisPicture(pictureStream) + } + } + + else -> comments.compute(name) { _, existing -> + existing?.let { it + value } ?: setOf(value) + } + } + } +} + +private fun parseVorbisComment(raw: String): Pair { + val split = raw.split("=", limit = 2) + if (split.size != 2) { + throw Exception("Vorbis comment does not contain '='") + } + return split[0].lowercase() to split[1] +} + +internal fun VorbisMetadata.Builder.readVorbisPicture(input: InputStream) { + // skip picture type + input.xSkipBytes(4) + val mimeLen = input.xReadInt(4) + val mime = input.xReadString(mimeLen) + val format = Artwork.Format.fromMimeType(mime) + val descLen = input.xReadInt(4) + input.xSkipBytes(descLen) + // skip width, height, color depth, color used + input.xSkipBytes(4) + input.xSkipBytes(4) + input.xSkipBytes(4) + input.xSkipBytes(4) + val imageLen = input.xReadInt(4) + val image = input.xReadBytes(imageLen) + val artwork = Artwork(format = format, data = image) + pictures.add(artwork) +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/Settings.kt b/app/src/main/java/io/github/zyrouge/symphony/services/Settings.kt index c1a37bd9..25154b3d 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/Settings.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/Settings.kt @@ -2,6 +2,7 @@ package io.github.zyrouge.symphony.services import android.content.Context import android.content.SharedPreferences +import android.net.Uri import android.os.Environment import androidx.core.content.edit import io.github.zyrouge.symphony.Symphony @@ -84,6 +85,7 @@ object SettingsKeys { const val artistTagSeparators = "artist_tag_separators" const val genreTagSeparators = "genre_tag_separators" const val miniPlayerTextMarquee = "mini_player_text_marquee" + const val mediaFolders = "media_folders" } object SettingsDefaults { @@ -322,7 +324,10 @@ class SettingsManager(private val symphony: Symphony) { private val _miniPlayerTextMarquee = MutableStateFlow(getMiniPlayerTextMarquee()) val miniPlayerTextMarquee = _miniPlayerTextMarquee.asStateFlow() - fun getThemeMode() = getSharedPreferences().getString(SettingsKeys.themeMode, null) + private val _mediaFolders = MutableStateFlow(getMediaFolders()) + val mediaFolders = _mediaFolders.asStateFlow() + + private fun getThemeMode() = getSharedPreferences().getString(SettingsKeys.themeMode, null) ?.let { ThemeMode.valueOf(it) } ?: SettingsDefaults.themeMode @@ -333,7 +338,8 @@ class SettingsManager(private val symphony: Symphony) { _themeMode.updateUsingValue(getThemeMode()) } - fun getLanguage() = getSharedPreferences().getString(SettingsKeys.language, null) + private fun getLanguage() = getSharedPreferences().getString(SettingsKeys.language, null) + fun setLanguage(language: String?) { getSharedPreferences().edit { putString(SettingsKeys.language, language) @@ -341,7 +347,7 @@ class SettingsManager(private val symphony: Symphony) { _language.updateUsingValue(getLanguage()) } - fun getUseMaterialYou() = getSharedPreferences().getBoolean( + private fun getUseMaterialYou() = getSharedPreferences().getBoolean( SettingsKeys.useMaterialYou, SettingsDefaults.useMaterialYou, ) @@ -353,7 +359,7 @@ class SettingsManager(private val symphony: Symphony) { _useMaterialYou.updateUsingValue(getUseMaterialYou()) } - fun getLastUsedSongsSortBy() = getSharedPreferences() + private fun getLastUsedSongsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedSongsSortBy, null) ?: SettingsDefaults.lastUsedSongSortBy @@ -364,7 +370,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedSongsSortBy.updateUsingValue(getLastUsedSongsSortBy()) } - fun getLastUsedSongsSortReverse() = + private fun getLastUsedSongsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedSongsSortReverse, false) fun setLastUsedSongsSortReverse(reverse: Boolean) { @@ -374,7 +380,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedSongsSortReverse.updateUsingValue(getLastUsedSongsSortReverse()) } - fun getLastUsedArtistsSortBy() = getSharedPreferences() + private fun getLastUsedArtistsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedArtistsSortBy, null) ?: SettingsDefaults.lastUsedArtistsSortBy @@ -385,7 +391,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedArtistsSortBy.updateUsingValue(getLastUsedArtistsSortBy()) } - fun getLastUsedArtistsSortReverse() = + private fun getLastUsedArtistsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedArtistsSortReverse, false) fun setLastUsedArtistsSortReverse(reverse: Boolean) { @@ -395,7 +401,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedArtistsSortReverse.updateUsingValue(getLastUsedArtistsSortReverse()) } - fun getLastUsedAlbumArtistsSortBy() = getSharedPreferences() + private fun getLastUsedAlbumArtistsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedAlbumArtistsSortBy, null) ?: SettingsDefaults.lastUsedAlbumArtistsSortBy @@ -406,7 +412,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedAlbumArtistsSortBy.updateUsingValue(getLastUsedAlbumArtistsSortBy()) } - fun getLastUsedAlbumArtistsSortReverse() = + private fun getLastUsedAlbumArtistsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedAlbumArtistsSortReverse, false) fun setLastUsedAlbumArtistsSortReverse(reverse: Boolean) { @@ -416,7 +422,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedAlbumArtistsSortReverse.updateUsingValue(getLastUsedAlbumArtistsSortReverse()) } - fun getLastUsedAlbumsSortBy() = getSharedPreferences() + private fun getLastUsedAlbumsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedAlbumsSortBy, null) ?: SettingsDefaults.lastUsedAlbumsSortBy @@ -427,7 +433,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedAlbumsSortBy.updateUsingValue(getLastUsedAlbumsSortBy()) } - fun getLastUsedAlbumsSortReverse() = + private fun getLastUsedAlbumsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedAlbumsSortReverse, false) fun setLastUsedAlbumsSortReverse(reverse: Boolean) { @@ -437,7 +443,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedAlbumsSortReverse.updateUsingValue(getLastUsedAlbumsSortReverse()) } - fun getLastUsedGenresSortBy() = getSharedPreferences() + private fun getLastUsedGenresSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedGenresSortBy, null) ?: SettingsDefaults.lastUsedGenresSortBy @@ -448,7 +454,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedGenresSortBy.updateUsingValue(getLastUsedGenresSortBy()) } - fun getLastUsedGenresSortReverse() = + private fun getLastUsedGenresSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedGenresSortReverse, false) fun setLastUsedGenresSortReverse(reverse: Boolean) { @@ -458,7 +464,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedGenresSortReverse.updateUsingValue(getLastUsedGenresSortReverse()) } - fun getLastUsedBrowserSortBy() = getSharedPreferences() + private fun getLastUsedBrowserSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedBrowserSortBy, null) ?: SettingsDefaults.lastUsedBrowserSortBy @@ -469,7 +475,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedBrowserSortBy.updateUsingValue(getLastUsedBrowserSortBy()) } - fun getLastUsedBrowserSortReverse() = + private fun getLastUsedBrowserSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedBrowserSortReverse, false) fun setLastUsedBrowserSortReverse(reverse: Boolean) { @@ -479,7 +485,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedBrowserSortReverse.updateUsingValue(getLastUsedBrowserSortReverse()) } - fun getLastUsedBrowserPath() = + private fun getLastUsedBrowserPath() = getSharedPreferences().getString(SettingsKeys.lastUsedBrowserPath, null) ?.split("/")?.toList() @@ -490,7 +496,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedBrowserPath.updateUsingValue(getLastUsedBrowserPath()) } - fun getLastUsedPlaylistsSortBy() = getSharedPreferences() + private fun getLastUsedPlaylistsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedPlaylistsSortBy, null) ?: SettingsDefaults.lastUsedPlaylistsSortBy @@ -501,7 +507,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedPlaylistsSortBy.updateUsingValue(getLastUsedPlaylistsSortBy()) } - fun getLastUsedPlaylistsSortReverse() = + private fun getLastUsedPlaylistsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedPlaylistsSortReverse, false) fun setLastUsedPlaylistsSortReverse(reverse: Boolean) { @@ -511,7 +517,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedPlaylistsSortReverse.updateUsingValue(getLastUsedPlaylistsSortReverse()) } - fun getLastUsedPlaylistSongsSortBy() = getSharedPreferences() + private fun getLastUsedPlaylistSongsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedPlaylistSongsSortBy, null) ?: SettingsDefaults.lastUsedPlaylistSongsSortBy @@ -522,7 +528,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedPlaylistSongsSortBy.updateUsingValue(getLastUsedPlaylistSongsSortBy()) } - fun getLastUsedPlaylistSongsSortReverse() = + private fun getLastUsedPlaylistSongsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedPlaylistSongsSortReverse, false) fun setLastUsedPlaylistSongsSortReverse(reverse: Boolean) { @@ -532,7 +538,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedPlaylistSongsSortReverse.updateUsingValue(getLastUsedPlaylistSongsSortReverse()) } - fun getLastUsedAlbumSongsSortBy() = getSharedPreferences() + private fun getLastUsedAlbumSongsSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedAlbumSongsSortBy, null) ?: SettingsDefaults.lastUsedAlbumSongsSortBy @@ -543,7 +549,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedAlbumSongsSortBy.updateUsingValue(getLastUsedAlbumSongsSortBy()) } - fun getLastUsedAlbumSongsSortReverse() = + private fun getLastUsedAlbumSongsSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedAlbumSongsSortReverse, false) fun setLastUsedAlbumSongsSortReverse(reverse: Boolean) { @@ -553,7 +559,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedAlbumSongsSortReverse.updateUsingValue(getLastUsedAlbumSongsSortReverse()) } - fun getLastUsedTreePathSortBy() = getSharedPreferences() + private fun getLastUsedTreePathSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedTreePathSortBy, null) ?: SettingsDefaults.lastUsedTreePathSortBy @@ -564,7 +570,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedTreePathSortBy.updateUsingValue(getLastUsedTreePathSortBy()) } - fun getLastUsedTreePathSortReverse() = + private fun getLastUsedTreePathSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedTreePathSortReverse, false) fun setLastUsedTreePathSortReverse(reverse: Boolean) { @@ -574,7 +580,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedTreePathSortReverse.updateUsingValue(getLastUsedTreePathSortReverse()) } - fun getLastUsedFoldersSortBy() = getSharedPreferences() + private fun getLastUsedFoldersSortBy() = getSharedPreferences() .getEnum(SettingsKeys.lastUsedFoldersSortBy, null) ?: SettingsDefaults.lastUsedFoldersSortBy @@ -585,7 +591,7 @@ class SettingsManager(private val symphony: Symphony) { _lastUsedFoldersSortBy.updateUsingValue(getLastUsedFoldersSortBy()) } - fun getLastUsedFoldersSortReverse() = + private fun getLastUsedFoldersSortReverse() = getSharedPreferences().getBoolean(SettingsKeys.lastUsedFoldersSortReverse, false) fun setLastUsedFoldersSortReverse(reverse: Boolean) { @@ -605,7 +611,7 @@ class SettingsManager(private val symphony: Symphony) { } } - fun getHomeLastTab() = getSharedPreferences() + private fun getHomeLastTab() = getSharedPreferences() .getEnum(SettingsKeys.homeLastTab, null) ?: HomePages.Songs @@ -616,7 +622,7 @@ class SettingsManager(private val symphony: Symphony) { _homeLastTab.updateUsingValue(getHomeLastTab()) } - fun getLastDisabledTreePaths(): List = getSharedPreferences() + private fun getLastDisabledTreePaths(): List = getSharedPreferences() .getStringSet(SettingsKeys.lastDisabledTreePaths, null) ?.toList() ?: emptyList() @@ -627,7 +633,7 @@ class SettingsManager(private val symphony: Symphony) { _lastDisabledTreePaths.updateUsingValue(getLastDisabledTreePaths()) } - fun getSongsFilterPattern() = + private fun getSongsFilterPattern() = getSharedPreferences().getString(SettingsKeys.songsFilterPattern, null) fun setSongsFilterPattern(value: String?) { @@ -637,7 +643,7 @@ class SettingsManager(private val symphony: Symphony) { _songsFilterPattern.updateUsingValue(getSongsFilterPattern()) } - fun getCheckForUpdates() = getSharedPreferences().getBoolean( + private fun getCheckForUpdates() = getSharedPreferences().getBoolean( SettingsKeys.checkForUpdates, SettingsDefaults.checkForUpdates, ) @@ -649,7 +655,7 @@ class SettingsManager(private val symphony: Symphony) { _checkForUpdates.updateUsingValue(getCheckForUpdates()) } - fun getFadePlayback() = getSharedPreferences().getBoolean( + private fun getFadePlayback() = getSharedPreferences().getBoolean( SettingsKeys.fadePlayback, SettingsDefaults.fadePlayback, ) @@ -661,7 +667,7 @@ class SettingsManager(private val symphony: Symphony) { _fadePlayback.updateUsingValue(getFadePlayback()) } - fun getRequireAudioFocus() = getSharedPreferences().getBoolean( + private fun getRequireAudioFocus() = getSharedPreferences().getBoolean( SettingsKeys.requireAudioFocus, SettingsDefaults.requireAudioFocus, ) @@ -673,7 +679,7 @@ class SettingsManager(private val symphony: Symphony) { _requireAudioFocus.updateUsingValue(getRequireAudioFocus()) } - fun getIgnoreAudioFocusLoss() = getSharedPreferences().getBoolean( + private fun getIgnoreAudioFocusLoss() = getSharedPreferences().getBoolean( SettingsKeys.ignoreAudioFocusLoss, SettingsDefaults.ignoreAudioFocusLoss, ) @@ -685,7 +691,7 @@ class SettingsManager(private val symphony: Symphony) { _ignoreAudioFocusLoss.updateUsingValue(getIgnoreAudioFocusLoss()) } - fun getPlayOnHeadphonesConnect() = getSharedPreferences().getBoolean( + private fun getPlayOnHeadphonesConnect() = getSharedPreferences().getBoolean( SettingsKeys.playOnHeadphonesConnect, SettingsDefaults.playOnHeadphonesConnect, ) @@ -697,7 +703,7 @@ class SettingsManager(private val symphony: Symphony) { _playOnHeadphonesConnect.updateUsingValue(getPlayOnHeadphonesConnect()) } - fun getPauseOnHeadphonesDisconnect() = getSharedPreferences().getBoolean( + private fun getPauseOnHeadphonesDisconnect() = getSharedPreferences().getBoolean( SettingsKeys.pauseOnHeadphonesDisconnect, SettingsDefaults.pauseOnHeadphonesDisconnect, ) @@ -709,7 +715,8 @@ class SettingsManager(private val symphony: Symphony) { _pauseOnHeadphonesDisconnect.updateUsingValue(getPauseOnHeadphonesDisconnect()) } - fun getPrimaryColor() = getSharedPreferences().getString(SettingsKeys.primaryColor, null) + private fun getPrimaryColor() = + getSharedPreferences().getString(SettingsKeys.primaryColor, null) fun setPrimaryColor(value: String) { getSharedPreferences().edit { @@ -718,7 +725,7 @@ class SettingsManager(private val symphony: Symphony) { _primaryColor.updateUsingValue(getPrimaryColor()) } - fun getFadePlaybackDuration() = getSharedPreferences().getFloat( + private fun getFadePlaybackDuration() = getSharedPreferences().getFloat( SettingsKeys.fadePlaybackDuration, SettingsDefaults.fadePlaybackDuration, ) @@ -730,7 +737,7 @@ class SettingsManager(private val symphony: Symphony) { _fadePlaybackDuration.updateUsingValue(getFadePlaybackDuration()) } - fun getHomeTabs() = getSharedPreferences() + private fun getHomeTabs() = getSharedPreferences() .getString(SettingsKeys.homeTabs, null) ?.split(",") ?.mapNotNull { parseEnumValue(it) } @@ -747,7 +754,7 @@ class SettingsManager(private val symphony: Symphony) { } } - fun getHomePageBottomBarLabelVisibility() = getSharedPreferences() + private fun getHomePageBottomBarLabelVisibility() = getSharedPreferences() .getEnum(SettingsKeys.homePageBottomBarLabelVisibility, null) ?: SettingsDefaults.homePageBottomBarLabelVisibility @@ -758,7 +765,7 @@ class SettingsManager(private val symphony: Symphony) { _homePageBottomBarLabelVisibility.updateUsingValue(getHomePageBottomBarLabelVisibility()) } - fun getForYouContents() = getSharedPreferences() + private fun getForYouContents() = getSharedPreferences() .getString(SettingsKeys.forYouContents, null) ?.split(",") ?.mapNotNull { parseEnumValue(it) } @@ -772,7 +779,7 @@ class SettingsManager(private val symphony: Symphony) { _forYouContents.updateUsingValue(getForYouContents()) } - fun getBlacklistFolders(): Set = getSharedPreferences() + private fun getBlacklistFolders(): Set = getSharedPreferences() .getStringSet(SettingsKeys.blacklistFolders, null) ?: SettingsDefaults.blacklistFolders @@ -783,7 +790,7 @@ class SettingsManager(private val symphony: Symphony) { _blacklistFolders.updateUsingValue(getBlacklistFolders()) } - fun getWhitelistFolders(): Set = getSharedPreferences() + private fun getWhitelistFolders(): Set = getSharedPreferences() .getStringSet(SettingsKeys.whitelistFolders, null) ?: SettingsDefaults.whitelistFolders @@ -794,7 +801,7 @@ class SettingsManager(private val symphony: Symphony) { _whitelistFolders.updateUsingValue(getWhitelistFolders()) } - fun getReadIntroductoryMessage() = getSharedPreferences().getBoolean( + private fun getReadIntroductoryMessage() = getSharedPreferences().getBoolean( SettingsKeys.readIntroductoryMessage, SettingsDefaults.readIntroductoryMessage, ) @@ -806,7 +813,7 @@ class SettingsManager(private val symphony: Symphony) { _readIntroductoryMessage.updateUsingValue(getReadIntroductoryMessage()) } - fun getNowPlayingAdditionalInfo() = getSharedPreferences().getBoolean( + private fun getNowPlayingAdditionalInfo() = getSharedPreferences().getBoolean( SettingsKeys.nowPlayingAdditionalInfo, SettingsDefaults.showNowPlayingAdditionalInfo, ) @@ -818,7 +825,7 @@ class SettingsManager(private val symphony: Symphony) { _nowPlayingAdditionalInfo.updateUsingValue(getNowPlayingAdditionalInfo()) } - fun getNowPlayingSeekControls() = getSharedPreferences().getBoolean( + private fun getNowPlayingSeekControls() = getSharedPreferences().getBoolean( SettingsKeys.nowPlayingSeekControls, SettingsDefaults.enableSeekControls, ) @@ -830,7 +837,7 @@ class SettingsManager(private val symphony: Symphony) { _nowPlayingSeekControls.updateUsingValue(getNowPlayingSeekControls()) } - fun getSeekBackDuration() = getSharedPreferences().getInt( + private fun getSeekBackDuration() = getSharedPreferences().getInt( SettingsKeys.seekBackDuration, SettingsDefaults.seekBackDuration, ) @@ -842,7 +849,7 @@ class SettingsManager(private val symphony: Symphony) { _seekBackDuration.updateUsingValue(getSeekBackDuration()) } - fun getSeekForwardDuration() = getSharedPreferences().getInt( + private fun getSeekForwardDuration() = getSharedPreferences().getInt( SettingsKeys.seekForwardDuration, SettingsDefaults.seekForwardDuration, ) @@ -854,7 +861,7 @@ class SettingsManager(private val symphony: Symphony) { _seekForwardDuration.updateUsingValue(getSeekForwardDuration()) } - fun getMiniPlayerTrackControls() = getSharedPreferences().getBoolean( + private fun getMiniPlayerTrackControls() = getSharedPreferences().getBoolean( SettingsKeys.miniPlayerTrackControls, SettingsDefaults.miniPlayerTrackControls, ) @@ -866,7 +873,7 @@ class SettingsManager(private val symphony: Symphony) { _miniPlayerTrackControls.updateUsingValue(getMiniPlayerTrackControls()) } - fun getMiniPlayerSeekControls() = getSharedPreferences().getBoolean( + private fun getMiniPlayerSeekControls() = getSharedPreferences().getBoolean( SettingsKeys.miniPlayerSeekControls, SettingsDefaults.miniPlayerSeekControls, ) @@ -878,7 +885,7 @@ class SettingsManager(private val symphony: Symphony) { _miniPlayerSeekControls.updateUsingValue(getMiniPlayerSeekControls()) } - fun getFontFamily() = getSharedPreferences().getString(SettingsKeys.fontFamily, null) + private fun getFontFamily() = getSharedPreferences().getString(SettingsKeys.fontFamily, null) fun setFontFamily(language: String) { getSharedPreferences().edit { putString(SettingsKeys.fontFamily, language) @@ -886,7 +893,7 @@ class SettingsManager(private val symphony: Symphony) { _fontFamily.updateUsingValue(getFontFamily()) } - fun getNowPlayingControlsLayout() = getSharedPreferences() + private fun getNowPlayingControlsLayout() = getSharedPreferences() .getEnum(SettingsKeys.nowPlayingControlsLayout, null) ?: SettingsDefaults.nowPlayingControlsLayout @@ -897,7 +904,7 @@ class SettingsManager(private val symphony: Symphony) { _nowPlayingControlsLayout.updateUsingValue(getNowPlayingControlsLayout()) } - fun getShowUpdateToast() = getSharedPreferences().getBoolean( + private fun getShowUpdateToast() = getSharedPreferences().getBoolean( SettingsKeys.showUpdateToast, SettingsDefaults.showUpdateToast, ) @@ -909,7 +916,7 @@ class SettingsManager(private val symphony: Symphony) { _showUpdateToast.updateUsingValue(getShowUpdateToast()) } - fun getFontScale() = getSharedPreferences().getFloat( + private fun getFontScale() = getSharedPreferences().getFloat( SettingsKeys.fontScale, SettingsDefaults.fontScale, ) @@ -921,7 +928,7 @@ class SettingsManager(private val symphony: Symphony) { _fontScale.updateUsingValue(getFontScale()) } - fun getContentScale() = getSharedPreferences().getFloat( + private fun getContentScale() = getSharedPreferences().getFloat( SettingsKeys.contentScale, SettingsDefaults.contentScale, ) @@ -933,7 +940,7 @@ class SettingsManager(private val symphony: Symphony) { _contentScale.updateUsingValue(getContentScale()) } - fun getNowPlayingLyricsLayout() = getSharedPreferences() + private fun getNowPlayingLyricsLayout() = getSharedPreferences() .getEnum(SettingsKeys.nowPlayingLyricsLayout, null) ?: SettingsDefaults.nowPlayingLyricsLayout @@ -944,7 +951,7 @@ class SettingsManager(private val symphony: Symphony) { _nowPlayingLyricsLayout.updateUsingValue(getNowPlayingLyricsLayout()) } - fun getArtistTagSeparators(): Set = getSharedPreferences() + private fun getArtistTagSeparators(): Set = getSharedPreferences() .getStringSet(SettingsKeys.artistTagSeparators, null) ?: SettingsDefaults.artistTagSeparators @@ -955,7 +962,7 @@ class SettingsManager(private val symphony: Symphony) { _artistTagSeparators.updateUsingValue(getArtistTagSeparators()) } - fun getGenreTagSeparators(): Set = getSharedPreferences() + private fun getGenreTagSeparators(): Set = getSharedPreferences() .getStringSet(SettingsKeys.genreTagSeparators, null) ?: SettingsDefaults.genreTagSeparators @@ -966,7 +973,7 @@ class SettingsManager(private val symphony: Symphony) { _genreTagSeparators.updateUsingValue(getGenreTagSeparators()) } - fun getMiniPlayerTextMarquee() = getSharedPreferences().getBoolean( + private fun getMiniPlayerTextMarquee() = getSharedPreferences().getBoolean( SettingsKeys.miniPlayerTextMarquee, SettingsDefaults.miniPlayerTextMarquee, ) @@ -978,6 +985,19 @@ class SettingsManager(private val symphony: Symphony) { _miniPlayerTextMarquee.updateUsingValue(getMiniPlayerTextMarquee()) } + private fun getMediaFolders(): Set = getSharedPreferences() + .getStringSet(SettingsKeys.mediaFolders, null) + ?.map { Uri.parse(it) } + ?.toSet() + ?: setOf() + + fun setMediaFolders(values: Set) { + getSharedPreferences().edit { + putStringSet(SettingsKeys.mediaFolders, values.map { it.toString() }.toSet()) + } + _mediaFolders.updateUsingValue(getMediaFolders()) + } + private fun getSharedPreferences() = symphony.applicationContext.getSharedPreferences( SettingsKeys.identifier, Context.MODE_PRIVATE, diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/database/ArtworkCache.kt b/app/src/main/java/io/github/zyrouge/symphony/services/database/ArtworkCache.kt new file mode 100644 index 00000000..1b24e278 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/services/database/ArtworkCache.kt @@ -0,0 +1,17 @@ +package io.github.zyrouge.symphony.services.database + +import io.github.zyrouge.symphony.Symphony +import io.github.zyrouge.symphony.services.database.adapters.FileTreeDatabaseAdapter +import java.nio.file.Paths + +class ArtworkCache(val symphony: Symphony) { + private val adapter = FileTreeDatabaseAdapter( + Paths + .get(symphony.applicationContext.dataDir.absolutePath, "covers") + .toFile() + ) + + fun get(key: String) = adapter.get(key) + fun all() = adapter.list() + fun clear() = adapter.clear() +} diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/database/Database.kt b/app/src/main/java/io/github/zyrouge/symphony/services/database/Database.kt index 9953f991..58590ffe 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/database/Database.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/database/Database.kt @@ -4,6 +4,7 @@ import io.github.zyrouge.symphony.Symphony class Database(symphony: Symphony) { val songCache = SongCache(symphony) + val artworkCache = ArtworkCache(symphony) val lyricsCache = LyricsCache(symphony) val playlists = PlaylistsBox(symphony) } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/database/LyricsCache.kt b/app/src/main/java/io/github/zyrouge/symphony/services/database/LyricsCache.kt index b4180806..15a840d0 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/database/LyricsCache.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/database/LyricsCache.kt @@ -1,29 +1,21 @@ package io.github.zyrouge.symphony.services.database import io.github.zyrouge.symphony.Symphony -import io.github.zyrouge.symphony.services.database.adapters.FileDatabaseAdapter -import org.json.JSONObject -import java.nio.file.Paths +import io.github.zyrouge.symphony.services.database.adapters.PersistentCacheDatabaseAdapter class LyricsCache(val symphony: Symphony) { - private val adapter = FileDatabaseAdapter( - Paths - .get(symphony.applicationContext.cacheDir.absolutePath, "lyrics_cache.json") - .toFile() + private val adapter = PersistentCacheDatabaseAdapter( + symphony.applicationContext, + "lyrics", + 1, + PersistentCacheDatabaseAdapter.Transformer.AsString() ) - fun read(): Map { - val content = adapter.read() - val output = mutableMapOf() - val parsed = JSONObject(content) - for (x in parsed.keys()) { - output[x] = parsed.getString(x) - } - return output - } - - fun update(value: Map) { - val json = JSONObject(value) - adapter.overwrite(json.toString()) - } + fun get(key: String) = adapter.get(key) + fun put(key: String, value: String) = adapter.put(key, value) + fun delete(key: String) = adapter.delete(key) + fun delete(keys: Collection) = adapter.delete(keys) + fun keys() = adapter.keys() + fun all() = adapter.all() + fun clear() = adapter.clear() } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/database/SongCache.kt b/app/src/main/java/io/github/zyrouge/symphony/services/database/SongCache.kt index b5f1ac2f..5fb3ccba 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/database/SongCache.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/database/SongCache.kt @@ -1,89 +1,27 @@ package io.github.zyrouge.symphony.services.database import io.github.zyrouge.symphony.Symphony -import io.github.zyrouge.symphony.services.database.adapters.FileDatabaseAdapter +import io.github.zyrouge.symphony.services.database.adapters.PersistentCacheDatabaseAdapter import io.github.zyrouge.symphony.services.groove.Song -import io.github.zyrouge.symphony.utils.getIntOrNull -import io.github.zyrouge.symphony.utils.getStringOrNull -import org.json.JSONObject -import java.nio.file.Paths class SongCache(val symphony: Symphony) { - data class Attributes( - val lastModified: Long, - val albumArtist: String?, - val bitrate: Int?, - val genre: String?, - val bitsPerSample: Int?, - val samplingRate: Int?, - val codec: String?, - ) { - fun toJSONObject() = JSONObject().apply { - put(LAST_MODIFIED, lastModified) - put(ALBUM_ARTIST, albumArtist) - put(BITRATE, bitrate) - put(GENRE, genre) - put(BITS_PER_SAMPLE, bitsPerSample) - put(SAMPLING_RATE, samplingRate) - put(CODEC, codec) - } - - companion object { - const val defaultSeparator = ";" - - private const val LAST_MODIFIED = "0" - private const val ALBUM_ARTIST = "1" - private const val BITRATE = "2" - private const val GENRE = "3" - private const val BITS_PER_SAMPLE = "4" - private const val SAMPLING_RATE = "5" - private const val CODEC = "6" - - fun fromJSONObject(json: JSONObject) = json.run { - Attributes( - lastModified = getLong(LAST_MODIFIED), - albumArtist = getStringOrNull(ALBUM_ARTIST), - bitrate = getIntOrNull(BITRATE), - genre = getStringOrNull(GENRE), - bitsPerSample = getIntOrNull(BITS_PER_SAMPLE), - samplingRate = getIntOrNull(SAMPLING_RATE), - codec = getStringOrNull(CODEC), - ) - } - - fun fromSong(song: Song) = Attributes( - lastModified = song.dateModified, - albumArtist = song.additional.albumArtists.joinToString(defaultSeparator), - bitrate = song.additional.bitrate, - genre = song.additional.genres.joinToString(defaultSeparator), - bitsPerSample = song.additional.bitsPerSample, - samplingRate = song.additional.samplingRate, - codec = song.additional.codec, - ) - } - } - - private val adapter = FileDatabaseAdapter( - Paths - .get(symphony.applicationContext.cacheDir.absolutePath, "song_cache.json") - .toFile() + private val adapter = PersistentCacheDatabaseAdapter( + symphony.applicationContext, + "songs", + 1, + SongTransformer() ) - fun read(): Map { - val content = adapter.read() - val output = mutableMapOf() - val parsed = JSONObject(content) - for (x in parsed.keys()) { - output[x.toLong()] = Attributes.fromJSONObject(parsed.getJSONObject(x)) - } - return output - } - - fun update(value: Map) { - val json = JSONObject() - value.forEach { (k, v) -> - json.put(k.toString(), v.toJSONObject()) - } - adapter.overwrite(json.toString()) + fun get(key: String) = adapter.get(key) + fun put(key: String, value: Song) = adapter.put(key, value) + fun delete(key: String) = adapter.delete(key) + fun delete(keys: Collection) = adapter.delete(keys) + fun keys() = adapter.keys() + fun all() = adapter.all() + fun clear() = adapter.clear() + + private class SongTransformer : PersistentCacheDatabaseAdapter.Transformer() { + override fun serialize(data: Song) = data.toJson() + override fun deserialize(data: String) = Song.fromJson(data) } } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/database/adapters/FileTreeDatabaseAdapter.kt b/app/src/main/java/io/github/zyrouge/symphony/services/database/adapters/FileTreeDatabaseAdapter.kt new file mode 100644 index 00000000..241f4191 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/services/database/adapters/FileTreeDatabaseAdapter.kt @@ -0,0 +1,18 @@ +package io.github.zyrouge.symphony.services.database.adapters + +import java.io.File + +class FileTreeDatabaseAdapter(val tree: File) { + init { + tree.mkdirs() + } + + fun get(name: String): File = File(tree, name) + + fun list(): List = tree.list()?.toList() ?: emptyList() + + fun clear() { + tree.deleteRecursively() + tree.mkdirs() + } +} diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/database/adapters/PersistentCacheDatabaseAdapter.kt b/app/src/main/java/io/github/zyrouge/symphony/services/database/adapters/PersistentCacheDatabaseAdapter.kt new file mode 100644 index 00000000..b4606840 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/services/database/adapters/PersistentCacheDatabaseAdapter.kt @@ -0,0 +1,133 @@ +package io.github.zyrouge.symphony.services.database.adapters + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class PersistentCacheDatabaseAdapter( + context: Context, + val name: String, + val version: Int, + private val transformer: Transformer, +) { + private val helper = Helper(context, name, version) + + fun get(key: String): T? { + val columns = arrayOf(COLUMN_VALUE) + val selection = "$COLUMN_KEY = ?" + val selectionArgs = arrayOf(key) + helper.readableDatabase + .query(name, columns, selection, selectionArgs, null, null, null) + .use { + val valueIndex = it.getColumnIndexOrThrow(COLUMN_VALUE) + if (!it.moveToNext()) { + return null + } + val rawValue = it.getString(valueIndex) + return transformer.deserialize(rawValue) + } + } + + fun put(key: String, value: T): Boolean { + val values = ContentValues().apply { + put(COLUMN_KEY, key) + put(COLUMN_VALUE, transformer.serialize(value)) + } + val conflict = SQLiteDatabase.CONFLICT_REPLACE + val rowId = helper.writableDatabase.insertWithOnConflict(name, null, values, conflict) + return rowId != -1L + } + + fun delete(key: String): Boolean { + val selection = "$COLUMN_KEY = ?" + val selectionArgs = arrayOf(key) + val count = helper.writableDatabase.delete(name, selection, selectionArgs) + return count == 1 + } + + fun delete(keys: Collection): Int { + if (keys.isEmpty()) { + return 0 + } + val selectionPlaceholder = "?, ".repeat(keys.size).let { + it.substring(0, it.length - 2) + } + val selection = "$COLUMN_KEY IN (${selectionPlaceholder})" + val selectionArgs = keys.toTypedArray() + val count = helper.writableDatabase.delete(name, selection, selectionArgs) + return count + } + + fun clear(): Int { + val count = helper.writableDatabase.delete(name, null, null) + return count + } + + fun keys(): List { + val keys = mutableListOf() + val columns = arrayOf(COLUMN_KEY) + helper.readableDatabase + .query(name, columns, null, null, null, null, null) + .use { + val keyIndex = it.getColumnIndexOrThrow(COLUMN_KEY) + while (it.moveToNext()) { + val key = it.getString(keyIndex) + keys.add(key) + } + } + return keys + } + + fun all(): Map { + val all = mutableMapOf() + val columns = arrayOf(COLUMN_KEY, COLUMN_VALUE) + helper.readableDatabase + .query(name, columns, null, null, null, null, null) + .use { + val keyIndex = it.getColumnIndexOrThrow(COLUMN_KEY) + val valueIndex = it.getColumnIndexOrThrow(COLUMN_VALUE) + while (it.moveToNext()) { + val key = it.getString(keyIndex) + val rawValue = it.getString(valueIndex) + val value = transformer.deserialize(rawValue) + all[key] = value + } + } + return all + } + + private class Helper(context: Context, val name: String, version: Int) : + SQLiteOpenHelper(context, name, null, version) { + override fun onCreate(db: SQLiteDatabase) { + val query = + "CREATE TABLE $name ($COLUMN_KEY TEXT PRIMARY KEY, $COLUMN_VALUE TEXT NOT NULL)" + db.execSQL(query) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + val query = "DROP TABLE $name" + db.execSQL(query) + onCreate(db) + } + + override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + onUpgrade(db, oldVersion, newVersion) + } + } + + abstract class Transformer { + abstract fun serialize(data: T): String + abstract fun deserialize(data: String): T + + class AsString : Transformer() { + override fun serialize(data: String) = data + override fun deserialize(data: String) = data + } + } + + companion object { + const val COLUMN_KEY = "key" + const val COLUMN_VALUE = "value" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Album.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Album.kt index 04ab0711..86a4d1ae 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Album.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Album.kt @@ -16,7 +16,7 @@ data class Album( fun getSongIds(symphony: Symphony) = symphony.groove.album.getSongIds(id) fun getSortedSongIds(symphony: Symphony) = symphony.groove.song.sort( getSongIds(symphony), - symphony.settings.getLastUsedAlbumSongsSortBy(), - symphony.settings.getLastUsedAlbumSongsSortReverse(), + symphony.settings.lastUsedAlbumSongsSortBy.value, + symphony.settings.lastUsedAlbumSongsSortReverse.value, ) } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtist.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtist.kt index d830d0fe..796cf3b3 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtist.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtist.kt @@ -15,8 +15,8 @@ data class AlbumArtist( fun getSongIds(symphony: Symphony) = symphony.groove.albumArtist.getSongIds(name) fun getSortedSongIds(symphony: Symphony) = symphony.groove.song.sort( getSongIds(symphony), - symphony.settings.getLastUsedSongsSortBy(), - symphony.settings.getLastUsedSongsSortReverse(), + symphony.settings.lastUsedSongsSortBy.value, + symphony.settings.lastUsedSongsSortReverse.value, ) fun getAlbumIds(symphony: Symphony) = symphony.groove.albumArtist.getAlbumIds(name) diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtistRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtistRepository.kt index 51ed2f8b..c8f29081 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtistRepository.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumArtistRepository.kt @@ -6,6 +6,7 @@ import io.github.zyrouge.symphony.ui.helpers.createHandyImageRequest import io.github.zyrouge.symphony.utils.ConcurrentSet import io.github.zyrouge.symphony.utils.FuzzySearchOption import io.github.zyrouge.symphony.utils.FuzzySearcher +import io.github.zyrouge.symphony.utils.concurrentSetOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -20,13 +21,13 @@ enum class AlbumArtistSortBy { class AlbumArtistRepository(private val symphony: Symphony) { private val cache = ConcurrentHashMap() - private val songIdsCache = ConcurrentHashMap>() + private val songIdsCache = ConcurrentHashMap>() private val albumIdsCache = ConcurrentHashMap>() private val searcher = FuzzySearcher( options = listOf(FuzzySearchOption({ v -> get(v)?.name?.let { compareString(it) } })) ) - val isUpdating get() = symphony.groove.mediaStore.isUpdating + val isUpdating get() = symphony.groove.exposer.isUpdating private val _all = MutableStateFlow>(emptyList()) val all = _all.asStateFlow() private val _count = MutableStateFlow(0) @@ -37,17 +38,15 @@ class AlbumArtistRepository(private val symphony: Symphony) { } internal fun onSong(song: Song) { - song.additional.albumArtists.forEach { albumArtist -> + song.albumArtists.forEach { albumArtist -> songIdsCache.compute(albumArtist) { _, value -> - value?.apply { add(song.id) } - ?: ConcurrentSet(song.id) + value?.apply { add(song.id) } ?: concurrentSetOf(song.id) } var nNumberOfAlbums = 0 symphony.groove.album.getIdFromSong(song)?.let { albumId -> albumIdsCache.compute(albumArtist) { _, value -> nNumberOfAlbums = (value?.size ?: 0) + 1 - value?.apply { add(albumId) } - ?: ConcurrentSet(albumId) + value?.apply { add(albumId) } ?: concurrentSetOf(albumId) } } cache.compute(albumArtist) { _, value -> diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumRepository.kt index 9d009c63..04f41728 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumRepository.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/AlbumRepository.kt @@ -6,6 +6,7 @@ import io.github.zyrouge.symphony.ui.helpers.createHandyImageRequest import io.github.zyrouge.symphony.utils.ConcurrentSet import io.github.zyrouge.symphony.utils.FuzzySearchOption import io.github.zyrouge.symphony.utils.FuzzySearcher +import io.github.zyrouge.symphony.utils.concurrentSetOf import io.github.zyrouge.symphony.utils.joinToStringIfNotEmpty import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,7 +22,7 @@ enum class AlbumSortBy { class AlbumRepository(private val symphony: Symphony) { private val cache = ConcurrentHashMap() - private val songIdsCache = ConcurrentHashMap>() + private val songIdsCache = ConcurrentHashMap>() private val searcher = FuzzySearcher( options = listOf( FuzzySearchOption({ v -> get(v)?.name?.let { compareString(it) } }, 3), @@ -29,7 +30,7 @@ class AlbumRepository(private val symphony: Symphony) { ) ) - val isUpdating get() = symphony.groove.mediaStore.isUpdating + val isUpdating get() = symphony.groove.exposer.isUpdating private val _all = MutableStateFlow>(emptyList()) val all = _all.asStateFlow() private val _count = MutableStateFlow(0) @@ -40,10 +41,9 @@ class AlbumRepository(private val symphony: Symphony) { } internal fun onSong(song: Song) { - val albumId = getIdFromSong(song) - if (albumId == null) return + val albumId = getIdFromSong(song) ?: return songIdsCache.compute(albumId) { _, value -> - value?.apply { add(song.id) } ?: ConcurrentSet(song.id) + value?.apply { add(song.id) } ?: concurrentSetOf(song.id) } cache.compute(albumId) { _, value -> value?.apply { @@ -77,7 +77,7 @@ class AlbumRepository(private val symphony: Symphony) { fun getIdFromSong(song: Song): String? { if (song.album == null) return null - val artists = song.additional.albumArtists.sorted().joinToString("-") + val artists = song.albumArtists.sorted().joinToString("-") return "${song.album}-${artists}-${song.year ?: 0}" } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Artist.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Artist.kt index 14d32592..99ed4447 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Artist.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Artist.kt @@ -15,8 +15,8 @@ data class Artist( fun getSongIds(symphony: Symphony) = symphony.groove.artist.getSongIds(name) fun getSortedSongIds(symphony: Symphony) = symphony.groove.song.sort( getSongIds(symphony), - symphony.settings.getLastUsedSongsSortBy(), - symphony.settings.getLastUsedSongsSortReverse(), + symphony.settings.lastUsedSongsSortBy.value, + symphony.settings.lastUsedSongsSortReverse.value, ) fun getAlbumIds(symphony: Symphony) = symphony.groove.albumArtist.getAlbumIds(name) diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/ArtistRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/ArtistRepository.kt index 1b125d92..92479853 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/ArtistRepository.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/ArtistRepository.kt @@ -6,6 +6,7 @@ import io.github.zyrouge.symphony.ui.helpers.createHandyImageRequest import io.github.zyrouge.symphony.utils.ConcurrentSet import io.github.zyrouge.symphony.utils.FuzzySearchOption import io.github.zyrouge.symphony.utils.FuzzySearcher +import io.github.zyrouge.symphony.utils.concurrentSetOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -20,13 +21,13 @@ enum class ArtistSortBy { class ArtistRepository(private val symphony: Symphony) { private val cache = ConcurrentHashMap() - private val songIdsCache = ConcurrentHashMap>() + private val songIdsCache = ConcurrentHashMap>() private val albumIdsCache = ConcurrentHashMap>() private val searcher = FuzzySearcher( options = listOf(FuzzySearchOption({ v -> get(v)?.name?.let { compareString(it) } })) ) - val isUpdating get() = symphony.groove.mediaStore.isUpdating + val isUpdating get() = symphony.groove.exposer.isUpdating private val _all = MutableStateFlow>(emptyList()) val all = _all.asStateFlow() private val _count = MutableStateFlow(0) @@ -39,15 +40,13 @@ class ArtistRepository(private val symphony: Symphony) { internal fun onSong(song: Song) { song.artists.forEach { artist -> songIdsCache.compute(artist) { _, value -> - value?.apply { add(song.id) } - ?: ConcurrentSet(song.id) + value?.apply { add(song.id) } ?: concurrentSetOf(song.id) } var nNumberOfAlbums = 0 symphony.groove.album.getIdFromSong(song)?.let { album -> albumIdsCache.compute(artist) { _, value -> nNumberOfAlbums = (value?.size ?: 0) + 1 - value?.apply { add(album) } - ?: ConcurrentSet(album) + value?.apply { add(album) } ?: concurrentSetOf(album) } } cache.compute(artist) { _, value -> diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Genre.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Genre.kt index 367d6293..36e03810 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Genre.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Genre.kt @@ -11,7 +11,7 @@ data class Genre( fun getSongIds(symphony: Symphony) = symphony.groove.genre.getSongIds(name) fun getSortedSongIds(symphony: Symphony) = symphony.groove.song.sort( getSongIds(symphony), - symphony.settings.getLastUsedSongsSortBy(), - symphony.settings.getLastUsedSongsSortReverse(), + symphony.settings.lastUsedSongsSortBy.value, + symphony.settings.lastUsedSongsSortReverse.value, ) } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/GenreRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/GenreRepository.kt index 33780540..0a07c819 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/GenreRepository.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/GenreRepository.kt @@ -4,6 +4,7 @@ import io.github.zyrouge.symphony.Symphony import io.github.zyrouge.symphony.utils.ConcurrentSet import io.github.zyrouge.symphony.utils.FuzzySearchOption import io.github.zyrouge.symphony.utils.FuzzySearcher +import io.github.zyrouge.symphony.utils.concurrentSetOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -17,12 +18,12 @@ enum class GenreSortBy { class GenreRepository(private val symphony: Symphony) { private val cache = ConcurrentHashMap() - private val songIdsCache = ConcurrentHashMap>() + private val songIdsCache = ConcurrentHashMap>() private val searcher = FuzzySearcher( options = listOf(FuzzySearchOption({ v -> get(v)?.name?.let { compareString(it) } })) ) - val isUpdating get() = symphony.groove.mediaStore.isUpdating + val isUpdating get() = symphony.groove.exposer.isUpdating private val _all = MutableStateFlow>(emptyList()) val all = _all.asStateFlow() private val _count = MutableStateFlow(0) @@ -33,10 +34,9 @@ class GenreRepository(private val symphony: Symphony) { } internal fun onSong(song: Song) { - song.additional.genres.forEach { genre -> + song.genres.forEach { genre -> songIdsCache.compute(genre) { _, value -> - value?.apply { add(song.id) } - ?: ConcurrentSet(song.id) + value?.apply { add(song.id) } ?: concurrentSetOf(song.id) } cache.compute(genre) { _, value -> value?.apply { diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/GrooveManager.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/GrooveManager.kt index 00cf8578..5b74b2a5 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/GrooveManager.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/GrooveManager.kt @@ -3,8 +3,6 @@ package io.github.zyrouge.symphony.services.groove import io.github.zyrouge.symphony.Symphony import io.github.zyrouge.symphony.SymphonyHooks import io.github.zyrouge.symphony.services.PermissionEvents -import io.github.zyrouge.symphony.utils.Eventer -import io.github.zyrouge.symphony.utils.dispatch import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,9 +23,8 @@ class GrooveManager(private val symphony: Symphony) : SymphonyHooks { val coroutineScope = CoroutineScope(Dispatchers.Default) var readyDeferred = CompletableDeferred() - val mediaStore = MediaStoreExposer(symphony) + val exposer = MediaExposer(symphony) val song = SongRepository(symphony) - val lyrics = LyricsRepository(symphony) val album = AlbumRepository(symphony) val artist = ArtistRepository(symphony) val albumArtist = AlbumArtistRepository(symphony) @@ -50,21 +47,19 @@ class GrooveManager(private val symphony: Symphony) : SymphonyHooks { private suspend fun fetch() { coroutineScope.launch { - mediaStore.fetch() - playlist.fetch() - lyrics.fetch() + exposer.fetch() +// playlist.fetch() }.join() } private suspend fun reset() { coroutineScope.launch { awaitAll( - async { mediaStore.reset() }, + async { exposer.reset() }, async { albumArtist.reset() }, async { album.reset() }, async { artist.reset() }, async { genre.reset() }, - async { lyrics.reset() }, async { playlist.reset() }, async { song.reset() }, ) @@ -83,26 +78,3 @@ class GrooveManager(private val symphony: Symphony) : SymphonyHooks { } } } - -class GrooveEventerRapidUpdateDispatcher( - val eventer: Eventer, - val maxCount: Int = 50, - val minTimeDiff: Int = 250, -) { - var count = 0 - var time = currentTime - - fun dispatch() { - if (count > maxCount && (currentTime - time) > minTimeDiff) { - eventer.dispatch() - count = 0 - time = System.currentTimeMillis() - return - } - count++ - } - - companion object { - private val currentTime: Long get() = System.currentTimeMillis() - } -} diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/LyricsRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/LyricsRepository.kt deleted file mode 100644 index dd4584e4..00000000 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/LyricsRepository.kt +++ /dev/null @@ -1,143 +0,0 @@ -package io.github.zyrouge.symphony.services.groove - -import android.content.ContentUris -import android.net.Uri -import android.provider.MediaStore -import io.github.zyrouge.symphony.Symphony -import io.github.zyrouge.symphony.utils.AudioTaggerX -import io.github.zyrouge.symphony.utils.CursorShorty -import io.github.zyrouge.symphony.utils.FileX -import io.github.zyrouge.symphony.utils.Logger -import io.github.zyrouge.symphony.utils.getColumnIndices -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedQueue - -private data class LrcFile(val id: Long, val dateModified: Long, val path: String) - -class LyricsRepository(private val symphony: Symphony) { - private val lrcFiles = ConcurrentHashMap() - private val compactCacheKeys = ConcurrentLinkedQueue() - val cache = ConcurrentHashMap() - - fun fetch() { - try { - kotlin - .runCatching { symphony.database.lyricsCache.read() } - .getOrNull() - ?.let { - compactCacheKeys.addAll(it.keys) - cache.putAll(it) - } - } catch (err: Exception) { - Logger.error("LyricsRepository", "loading cache failed", err) - } - try { - val cursor = symphony.applicationContext.contentResolver.query( - contentUri, - projectedColumns.toTypedArray(), - MediaStore.Files.FileColumns.MEDIA_TYPE + " = " + MediaStore.Files.FileColumns.MEDIA_TYPE_SUBTITLE, - null, - null - ) - cursor?.use { - val shorty = CursorShorty(it, it.getColumnIndices(projectedColumns)) - while (it.moveToNext()) { - val id = shorty.getLong(MediaStore.Files.FileColumns._ID) - val dateModified = shorty.getLong(MediaStore.Files.FileColumns.DATE_MODIFIED) - val path = shorty.getString(MediaStore.Files.FileColumns.DATA) - if (!path.endsWith(".lrc")) continue - lrcFiles[path] = LrcFile(id = id, dateModified = dateModified, path = path) - } - } - } catch (err: Exception) { - Logger.error("LyricsRepository", "mediastore failed", err) - } - } - - fun reset() { - cache.clear() - } - - suspend fun getLyrics(song: Song): String? { - val lrcFilePath = constructLrcPathFromSong(song) - lrcFiles[lrcFilePath]?.let { lrcFile -> - val cacheKey = constructCacheKey(lrcFile.id, lrcFile.dateModified, "lrc-") - cache[cacheKey]?.let { lyrics -> - hitCompactCache(cacheKey) - return lyrics - } - val uri = buildUri(lrcFile.id) - symphony.applicationContext.contentResolver - .openInputStream(uri) - ?.use { inputStream -> - val lyrics = String(inputStream.readBytes()) - cache[cacheKey] = lyrics - hitCompactCache(cacheKey) - symphony.database.lyricsCache.update(getCompactCache()) - return lyrics - } - } - val cacheKey = constructCacheKey(song.id, song.dateModified) - cache[cacheKey]?.let { lyrics -> - hitCompactCache(cacheKey) - return lyrics - } - try { - val outputFile = symphony.applicationContext.cacheDir - .toPath() - .resolve(song.filename) - .toFile() - FileX.ensureFile(outputFile) - symphony.applicationContext.contentResolver.openInputStream(song.uri) - ?.use { inputStream -> - outputFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - val lyrics = AudioTaggerX.getLyrics(outputFile) - outputFile.delete() - lyrics?.let { - cache[cacheKey] = lyrics - hitCompactCache(cacheKey) - symphony.database.lyricsCache.update(getCompactCache()) - } - return lyrics - } catch (err: Exception) { - Logger.error("LyricsRepository", "fetch lyrics failed", err) - } - return null - } - - private fun hitCompactCache(cacheKey: String) { - while (compactCacheKeys.size > MAX_CACHE_SIZE) { - compactCacheKeys.remove() - } - compactCacheKeys.add(cacheKey) - } - - private fun getCompactCache(): Map { - val output = mutableMapOf() - compactCacheKeys.forEach { cacheKey -> - cache[cacheKey]?.let { lyrics -> - output[cacheKey] = lyrics - } - } - return output - } - - companion object { - const val MAX_CACHE_SIZE = 50 - val lrcExtReplace = Regex("""\.[^.]+${'$'}""") - val contentUri: Uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) - val projectedColumns = listOf( - MediaStore.Files.FileColumns._ID, - MediaStore.Files.FileColumns.DATE_MODIFIED, - MediaStore.Files.FileColumns.DATA, - ) - - fun buildUri(id: Long): Uri = ContentUris.withAppendedId(contentUri, id) - fun constructLrcPathFromSong(song: Song) = song.path.replace(lrcExtReplace, ".lrc") - fun constructCacheKey(id: Long, dateModified: Long, prefix: String = "") = - "$prefix-$id-$dateModified" - } -} diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/MediaExposer.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/MediaExposer.kt new file mode 100644 index 00000000..fb81e293 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/MediaExposer.kt @@ -0,0 +1,202 @@ +package io.github.zyrouge.symphony.services.groove + +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.github.zyrouge.symphony.Symphony +import io.github.zyrouge.symphony.utils.Logger +import io.github.zyrouge.symphony.utils.concurrentSetOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +class MediaExposer(private val symphony: Symphony) { + internal val uris = ConcurrentHashMap() + var explorer = GrooveExplorer.Folder() + private val _isUpdating = MutableStateFlow(false) + val isUpdating = _isUpdating.asStateFlow() + + private fun emitUpdate(value: Boolean) { + _isUpdating.update { + value + } + symphony.groove.onMediaStoreUpdate(value) + } + + private inner class ScanCycle { + val songCache = ConcurrentHashMap(symphony.database.songCache.all()) + val songCacheUnused = concurrentSetOf(songCache.keys) + val artworkCacheUnused = concurrentSetOf(symphony.database.artworkCache.all()) + val lyricsCacheUnused = concurrentSetOf(symphony.database.lyricsCache.keys()) + } + + fun fetch() { + emitUpdate(true) + try { + val context = symphony.applicationContext + val folderUris = symphony.settings.mediaFolders.value + val permissions = Intent.FLAG_GRANT_READ_URI_PERMISSION + val cycle = ScanCycle() + runBlocking { + folderUris.map { x -> + async(Dispatchers.IO) { + context.contentResolver.takePersistableUriPermission(x, permissions) + DocumentFile.fromTreeUri(context, x)?.let { + scanMediaTree(cycle, it) + } + } + }.awaitAll() + } + trimCache(cycle) + } catch (err: Exception) { + Logger.error("MediaExposer", "fetch failed", err) + } + emitUpdate(false) + emitFinish() + } + + private suspend fun scanMediaTree(cycle: ScanCycle, file: DocumentFile) { + try { + val filter = MediaFilter( + symphony.settings.songsFilterPattern.value, + symphony.settings.blacklistFolders.value.toSortedSet(), + symphony.settings.whitelistFolders.value.toSortedSet() + ) + val path = file.name ?: return + if (!filter.isWhitelisted(path)) { + return + } + coroutineScope { + file.listFiles().toList().map { x -> + async(Dispatchers.IO) { + when { + x.isDirectory -> scanMediaTree(cycle, x) + x.isFile -> scanMediaFile(cycle, x) + } + } + }.awaitAll() + } + } catch (err: Exception) { + Logger.error("MediaExposer", "scan media tree failed", err) + } + } + + private suspend fun scanMediaFile(cycle: ScanCycle, file: DocumentFile) { + try { + val path = file.name!! + explorer.addRelativePath(GrooveExplorer.Path(path)) + val mimeType = file.type ?: return + when { + mimeType == "audio/x-mpegurl" -> scanM3UFile(cycle, file) + path.endsWith(".lrc") -> scanLrcFile(cycle, file) + mimeType.startsWith("audio/") -> scanAudioFile(cycle, file) + } + } catch (err: Exception) { + Logger.error("MediaExposer", "scan media file failed", err) + } + } + + private suspend fun scanAudioFile(cycle: ScanCycle, file: DocumentFile) { + val path = file.name!! + uris[path] = file.uri + val lastModified = file.lastModified() + val cached = cycle.songCache[path]?.takeIf { + it.dateModified == lastModified + && it.coverFile?.let { x -> cycle.artworkCacheUnused.contains(x) } ?: true + } + val song = cached ?: Song.parse(symphony, file) ?: return + if (cached == null) { + symphony.database.songCache.put(path, song) + } + cycle.songCacheUnused.remove(path) + song.coverFile?.let { cycle.artworkCacheUnused.remove(it) } + cycle.lyricsCacheUnused.remove(song.id) + withContext(Dispatchers.Main) { + emitSong(song) + } + } + + private suspend fun scanLrcFile(cycle: ScanCycle, file: DocumentFile) { + val path = file.name!! + uris[path] = file.uri + } + + private suspend fun scanM3UFile(cycle: ScanCycle, file: DocumentFile) { + val path = file.name!! + uris[path] = file.uri + } + + private fun trimCache(cycle: ScanCycle) { + try { + symphony.database.songCache.delete(cycle.songCacheUnused) + } catch (err: Exception) { + Logger.warn("MediaExposer", "trim song cache failed", err) + } + for (x in cycle.artworkCacheUnused) { + try { + symphony.database.artworkCache.get(x).delete() + } catch (err: Exception) { + Logger.warn("MediaExposer", "delete artwork cache file failed", err) + } + } + try { + symphony.database.lyricsCache.delete(cycle.lyricsCacheUnused) + } catch (err: Exception) { + Logger.warn("MediaExposer", "trim lyrics cache failed", err) + } + } + + fun reset() { + emitUpdate(true) + uris.clear() + explorer = GrooveExplorer.Folder() + emitUpdate(false) + } + + private fun emitSong(song: Song) { + symphony.groove.albumArtist.onSong(song) + symphony.groove.album.onSong(song) + symphony.groove.artist.onSong(song) + symphony.groove.genre.onSong(song) + symphony.groove.song.onSong(song) + } + + private fun emitFinish() { + symphony.groove.albumArtist.onFinish() + symphony.groove.album.onFinish() + symphony.groove.artist.onFinish() + symphony.groove.genre.onFinish() + symphony.groove.song.onFinish() + } + + private class MediaFilter( + pattern: String?, + private val blacklisted: Set, + private val whitelisted: Set, + ) { + private val regex = pattern?.let { Regex(it, RegexOption.IGNORE_CASE) } + + fun isWhitelisted(path: String): Boolean { + regex?.let { + if (!it.containsMatchIn(path)) { + return false + } + } + val bFilter = blacklisted.findLast { x -> path.startsWith(x) } + if (bFilter == null) { + return true + } + val wFilter = whitelisted.findLast { x -> + x.startsWith(bFilter) && path.startsWith(x) + } + return wFilter != null + } + } +} diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/MediaStoreExposer.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/MediaStoreExposer.kt deleted file mode 100644 index 867c9e30..00000000 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/MediaStoreExposer.kt +++ /dev/null @@ -1,153 +0,0 @@ -package io.github.zyrouge.symphony.services.groove - -import android.provider.MediaStore -import android.provider.MediaStore.Audio.AudioColumns -import io.github.zyrouge.symphony.Symphony -import io.github.zyrouge.symphony.services.database.SongCache -import io.github.zyrouge.symphony.utils.CursorShorty -import io.github.zyrouge.symphony.utils.Logger -import io.github.zyrouge.symphony.utils.getColumnIndices -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import java.util.concurrent.ConcurrentHashMap - -class MediaStoreExposer(private val symphony: Symphony) { - private val cache = ConcurrentHashMap() - - var explorer = GrooveExplorer.Folder() - private val _isUpdating = MutableStateFlow(false) - val isUpdating = _isUpdating.asStateFlow() - - private fun emitUpdate(value: Boolean) { - _isUpdating.update { - value - } - symphony.groove.onMediaStoreUpdate(value) - } - - fun fetch() { - emitUpdate(true) - try { - val cursor = symphony.applicationContext.contentResolver.query( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - projectedColumns.toTypedArray(), - MediaStore.Audio.Media.IS_MUSIC + " = 1", - null, - null - ) - cursor?.use { - val shorty = CursorShorty(it, it.getColumnIndices(projectedColumns)) - - val blacklisted = symphony.settings.blacklistFolders.value.toSortedSet() - val whitelisted = symphony.settings.whitelistFolders.value.toSortedSet() - val regex = symphony.settings.songsFilterPattern.value - ?.let { literal -> Regex(literal, RegexOption.IGNORE_CASE) } - - val additionalMetadataCache = kotlin - .runCatching { symphony.database.songCache.read() } - .getOrNull() - val nAdditionalMetadata = mutableMapOf() - - while (it.moveToNext()) { - val path = shorty.getString(AudioColumns.DATA) - explorer.addRelativePath(GrooveExplorer.Path(path)) - val isWhitelisted = true - .takeIf { regex?.containsMatchIn(path) ?: true } - .takeIf { - blacklisted - .find { x -> path.startsWith(x) } - ?.let { match -> - whitelisted.any { x -> - x.startsWith(match) && path.startsWith(x) - } - } ?: true - } ?: false - if (!isWhitelisted) { - continue - } - - kotlin - .runCatching { - Song.fromCursor(symphony, shorty) { id -> - additionalMetadataCache?.get(id) - } - } - .getOrNull() - ?.also { song -> - cache[song.id] = song - nAdditionalMetadata[song.id] = SongCache.Attributes.fromSong(song) - emitSong(song) - } - } - symphony.database.songCache.update(nAdditionalMetadata) - } - } catch (err: Exception) { - Logger.error("MediaStoreExposer", "fetch failed", err) - } - emitUpdate(false) - emitFinish() - } - - fun reset() { - emitUpdate(true) - explorer = GrooveExplorer.Folder() - cache.clear() - emitUpdate(false) - } - - private fun emitSong(song: Song) { - symphony.groove.albumArtist.onSong(song) - symphony.groove.album.onSong(song) - symphony.groove.artist.onSong(song) - symphony.groove.genre.onSong(song) - symphony.groove.song.onSong(song) - } - - private fun emitFinish() { - symphony.groove.albumArtist.onFinish() - symphony.groove.album.onFinish() - symphony.groove.artist.onFinish() - symphony.groove.genre.onFinish() - symphony.groove.song.onFinish() - } - - companion object { - val projectedColumns = listOf( - AudioColumns._ID, - AudioColumns.DATE_MODIFIED, - AudioColumns.TITLE, - AudioColumns.TRACK, - AudioColumns.YEAR, - AudioColumns.DURATION, - AudioColumns.ALBUM_ID, - AudioColumns.ALBUM, - AudioColumns.ARTIST_ID, - AudioColumns.ARTIST, - AudioColumns.COMPOSER, - AudioColumns.DATE_ADDED, - AudioColumns.SIZE, - AudioColumns.DATA - ) - - fun isWhitelisted( - path: String, - regex: Regex?, - blacklisted: List, - whitelisted: List, - ): Boolean { - regex?.let { - if (!it.containsMatchIn(path)) { - return false - } - } - return blacklisted - .find { x -> path.startsWith(x) } - ?.let { match -> - whitelisted.any { x -> - x.startsWith(match) && path.startsWith(x) - } - } ?: true - } - } -} diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Playlist.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Playlist.kt index 2ea45962..ad47d535 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Playlist.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Playlist.kt @@ -13,7 +13,7 @@ import org.json.JSONObject data class Playlist( val id: String, val title: String, - val songIds: List, + val songIds: List, val numberOfTracks: Int, val local: Local?, ) { @@ -47,8 +47,8 @@ data class Playlist( fun getSortedSongIds(symphony: Symphony) = symphony.groove.song.sort( songIds, - symphony.settings.getLastUsedPlaylistSongsSortBy(), - symphony.settings.getLastUsedPlaylistSongsSortReverse(), + symphony.settings.lastUsedPlaylistSongsSortBy.value, + symphony.settings.lastUsedPlaylistSongsSortReverse.value, ) fun isLocal() = local != null @@ -82,7 +82,7 @@ data class Playlist( fun fromJSONObject(serialized: JSONObject) = Playlist( id = serialized.getString(PLAYLIST_ID_KEY), title = serialized.getString(PLAYLIST_TITLE_KEY), - songIds = serialized.getJSONArray(PLAYLIST_SONGS_KEY).toList { getLong(it) }, + songIds = serialized.getJSONArray(PLAYLIST_SONGS_KEY).toList { getString(it) }, numberOfTracks = serialized.getInt(PLAYLIST_NUMBER_OF_TRACKS_KEY), local = null, ) @@ -94,7 +94,7 @@ data class Playlist( .openInputStream(local.uri) ?.use { String(it.readBytes()) } ?: "" val m3u = M3U.parse(content) - val songs = mutableListOf() + val songs = mutableListOf() m3u.entries.forEach { entry -> val resolvedPath = when { symphony.groove.song.pathCache.containsKey(entry.path) -> entry.path diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/PlaylistRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/PlaylistRepository.kt index 713155ee..4d5ba678 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/PlaylistRepository.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/PlaylistRepository.kt @@ -41,7 +41,7 @@ class PlaylistRepository(private val symphony: Symphony) { val all = _all.asStateFlow() private val _count = MutableStateFlow(0) val count = _count.asStateFlow() - private val _favorites = MutableStateFlow>(emptyList()) + private val _favorites = MutableStateFlow>(emptyList()) val favorites = _favorites.asStateFlow() private fun emitUpdate(value: Boolean) = _isUpdating.update { @@ -150,7 +150,7 @@ class PlaylistRepository(private val symphony: Symphony) { Playlist.fromM3U(symphony, local) }.getOrNull() - fun create(title: String, songIds: List) = Playlist( + fun create(title: String, songIds: List) = Playlist( id = generatePlaylistId(), title = title, songIds = songIds, @@ -178,7 +178,7 @@ class PlaylistRepository(private val symphony: Symphony) { save() } - suspend fun update(id: String, songIds: List) { + suspend fun update(id: String, songIds: List) { val old = get(id) ?: return val new = Playlist( id = id, @@ -198,13 +198,13 @@ class PlaylistRepository(private val symphony: Symphony) { save() } - fun isFavorite(songId: Long): Boolean { + fun isFavorite(songId: String): Boolean { val favorites = getFavorites() return favorites.songIds.contains(songId) } // NOTE: maybe we shouldn't use groove's coroutine scope? - fun favorite(songId: Long) { + fun favorite(songId: String) { val favorites = getFavorites() if (favorites.songIds.contains(songId)) return symphony.groove.coroutineScope.launch { @@ -212,7 +212,7 @@ class PlaylistRepository(private val symphony: Symphony) { } } - fun unfavorite(songId: Long) { + fun unfavorite(songId: String) { val favorites = getFavorites() if (!favorites.songIds.contains(songId)) return symphony.groove.coroutineScope.launch { diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Song.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Song.kt index 062fb5ae..7a0013e7 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/Song.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/Song.kt @@ -1,178 +1,204 @@ package io.github.zyrouge.symphony.services.groove -import android.content.ContentUris -import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.provider.MediaStore.Audio.AudioColumns import androidx.compose.runtime.Immutable +import androidx.documentfile.provider.DocumentFile +import io.github.zyrouge.metaphony.Artwork +import io.github.zyrouge.metaphony.Metadata import io.github.zyrouge.symphony.Symphony -import io.github.zyrouge.symphony.services.database.SongCache -import io.github.zyrouge.symphony.utils.CursorShorty +import io.github.zyrouge.symphony.utils.RelaxedJsonDecoder +import io.github.zyrouge.symphony.utils.UriSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.math.RoundingMode import kotlin.io.path.Path @Immutable +@Serializable data class Song( - val id: Long, + @SerialName(KEY_ID) + val id: String, + @SerialName(KEY_TITLE) val title: String, - val trackNumber: Int?, - val year: Int?, - val duration: Long, + @SerialName(KEY_ALBUM) val album: String?, + @SerialName(KEY_ARTISTS) val artists: Set, + @SerialName(KEY_COMPOSERS) val composers: Set, - val additional: AdditionalMetadata, - val dateAdded: Long, + @SerialName(KEY_ALBUM_ARTISTS) + val albumArtists: Set, + @SerialName(KEY_GENRES) + val genres: Set, + @SerialName(KEY_TRACK_NUMBER) + val trackNumber: Int?, + @SerialName(KEY_TRACK_TOTAL) + val trackTotal: Int?, + @SerialName(KEY_DISC_NUMBER) + val discNumber: Int?, + @SerialName(KEY_DISC_TOTAL) + val discTotal: Int?, + @SerialName(KEY_YEAR) + val year: Int?, + @SerialName(KEY_DURATION) + val duration: Long, + @SerialName(KEY_BITRATE) + val bitrate: Int?, + @SerialName(KEY_BITS_PER_SAMPLE) + val bitsPerSample: Int?, + @SerialName(KEY_SAMPLING_RATE) + val samplingRate: Int?, + @SerialName(KEY_CODEC) + val codec: String?, + @SerialName(KEY_DATE_MODIFIED) val dateModified: Long, + @SerialName(KEY_SIZE) val size: Long, + @SerialName(KEY_COVER_FILE) + val coverFile: String?, + @SerialName(KEY_URI) + @Serializable(UriSerializer::class) + val uri: Uri, + @SerialName(KEY_PATH) val path: String, ) { - @Immutable - data class AdditionalMetadata( - val albumArtists: Set, - val genres: Set, - val bitrate: Int?, - val bitsPerSample: Int?, - val samplingRate: Int?, - val codec: String?, - ) { - val bitrateK: Int? get() = bitrate?.let { it / 1000 } - val samplingRateK: Float? - get() = samplingRate?.let { - (it.toFloat() / 1000) - .toBigDecimal() - .setScale(1, RoundingMode.CEILING) - .toFloat() - } - - fun toSamplingInfoString(symphony: Symphony): String? { - val values = mutableListOf() - codec?.let { values.add(it) } - bitsPerSample?.let { - values.add(symphony.t.XBit(it.toString())) - } - bitrateK?.let { - values.add(symphony.t.XKbps(it.toString())) - } - samplingRateK?.let { - values.add(symphony.t.XKHz(it.toString())) - } - return when { - values.isNotEmpty() -> values.joinToString(", ") - else -> null - } + val bitrateK: Int? get() = bitrate?.let { it / 1000 } + val samplingRateK: Float? + get() = samplingRate?.let { + (it.toFloat() / 1000) + .toBigDecimal() + .setScale(1, RoundingMode.CEILING) + .toFloat() } - companion object { - fun fromSongCacheAttributes( - symphony: Symphony, - attributes: SongCache.Attributes, - ): AdditionalMetadata { - val artistSeparators = symphony.settings.artistTagSeparators.value - val genreSeparators = symphony.settings.genreTagSeparators.value - return AdditionalMetadata( - albumArtists = attributes.albumArtist - ?.let { parseMultiValue(it, artistSeparators) } - ?: setOf(), - bitrate = attributes.bitrate, - genres = attributes.genre - ?.let { parseMultiValue(it, genreSeparators) } - ?: setOf(), - bitsPerSample = attributes.bitsPerSample, - samplingRate = attributes.samplingRate, - codec = attributes.codec, - ) - } - - fun fetch(symphony: Symphony, id: Long): AdditionalMetadata { - var albumArtist: String? = null - var bitrate: Int? = null - var genre: String? = null - var bitsPerSample: Int? = null - var samplingRate: Int? = null - var codec: String? = null - kotlin.runCatching { - val retriever = MediaMetadataRetriever() - retriever.runCatching { - setDataSource(symphony.applicationContext, buildUri(id)) - albumArtist = - extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST) - bitrate = extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) - ?.toInt() - genre = extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - bitsPerSample = - extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITS_PER_SAMPLE) - ?.toInt() - samplingRate = - extractMetadata(MediaMetadataRetriever.METADATA_KEY_SAMPLERATE) - ?.toInt() - } - codec = extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) - ?.let { prettyMimetype(it) } - retriever.release() - } - } - val artistSeparators = symphony.settings.artistTagSeparators.value - val genreSeparators = symphony.settings.genreTagSeparators.value - return AdditionalMetadata( - albumArtists = albumArtist - ?.let { parseMultiValue(it, artistSeparators) } - ?: setOf(), - bitrate = bitrate, - genres = genre - ?.let { parseMultiValue(it, genreSeparators) } - ?: setOf(), - bitsPerSample = bitsPerSample, - samplingRate = samplingRate, - codec = codec, - ) - } - } - } - val filename = Path(path).fileName.toString() - val uri: Uri get() = buildUri(id) fun createArtworkImageRequest(symphony: Symphony) = symphony.groove.song.createArtworkImageRequest(id) + fun toSamplingInfoString(symphony: Symphony): String? { + val values = mutableListOf() + codec?.let { values.add(it) } + bitsPerSample?.let { + values.add(symphony.t.XBit(it.toString())) + } + bitrateK?.let { + values.add(symphony.t.XKbps(it.toString())) + } + samplingRateK?.let { + values.add(symphony.t.XKHz(it.toString())) + } + return when { + values.isNotEmpty() -> values.joinToString(", ") + else -> null + } + } + + fun toJson() = Json.encodeToString(this) + companion object { - fun buildUri(id: Long) = ContentUris.withAppendedId( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - id, - ) + const val KEY_TITLE = "0" + const val KEY_ALBUM = "1" + const val KEY_ARTISTS = "2" + const val KEY_COMPOSERS = "3" + const val KEY_ALBUM_ARTISTS = "4" + const val KEY_GENRES = "5" + const val KEY_TRACK_NUMBER = "6" + const val KEY_TRACK_TOTAL = "7" + const val KEY_DISC_NUMBER = "8" + const val KEY_DISC_TOTAL = "9" + const val KEY_YEAR = "10" + const val KEY_DURATION = "11" + const val KEY_BITRATE = "12" + const val KEY_BITS_PER_SAMPLE = "13" + const val KEY_SAMPLING_RATE = "14" + const val KEY_CODEC = "15" + const val KEY_DATE_MODIFIED = "17" + const val KEY_SIZE = "18" + const val KEY_URI = "19" + const val KEY_PATH = "20" + const val KEY_ID = "21" + const val KEY_COVER_FILE = "22" + + fun fromJson(json: String) = RelaxedJsonDecoder.decodeFromString(json) - fun fromCursor( - symphony: Symphony, - shorty: CursorShorty, - fetchCachedAttributes: (Long) -> SongCache.Attributes?, - ): Song { - val artistSeparators = symphony.settings.artistTagSeparators.value - val id = shorty.getLong(AudioColumns._ID) - val dateModified = shorty.getLong(AudioColumns.DATE_MODIFIED) +// fun fromCursor( +// symphony: Symphony, +// shorty: CursorShorty, +// fetchCachedAttributes: (Long) -> SongCache.Attributes?, +// ): Song { +// val artistSeparators = symphony.settings.artistTagSeparators.value +// val id = shorty.getLong(AudioColumns._ID) +// val dateModified = shorty.getLong(AudioColumns.DATE_MODIFIED) +// return Song( +// id = id, +// title = shorty.getString(AudioColumns.TITLE), +// trackNumber = shorty.getIntNullable(AudioColumns.TRACK)?.takeIf { it > 0 }, +// year = shorty.getIntNullable(AudioColumns.YEAR)?.takeIf { it > 0 }, +// duration = shorty.getLong(AudioColumns.DURATION), +// album = shorty.getStringNullable(AudioColumns.ALBUM), +// artists = shorty.getStringNullable(AudioColumns.ARTIST) +// ?.let { parseMultiValue(it, artistSeparators) } ?: setOf(), +// composers = shorty.getStringNullable(AudioColumns.COMPOSER) +// ?.let { parseMultiValue(it, artistSeparators) } ?: setOf(), +// dateAdded = shorty.getLong(AudioColumns.DATE_ADDED), +// dateModified = dateModified, +// size = shorty.getLong(AudioColumns.SIZE), +// path = shorty.getString(AudioColumns.DATA), +// additional = fetchCachedAttributes(id) +// ?.takeIf { it.lastModified == dateModified } +// ?.runCatching { AdditionalMetadata.fromSongCacheAttributes(symphony, this) } +// ?.getOrNull() +// ?: AdditionalMetadata.fetch(symphony, id), +// ) +// } + + fun parse(symphony: Symphony, file: DocumentFile): Song? { + val path = file.name!! + val mimeType = file.type!! + val uri = file.uri + val metadata = symphony.applicationContext.contentResolver.openInputStream(uri) + ?.use { Metadata.read(it, mimeType) } + ?: return null + val id = symphony.groove.song.idGenerator.next() + val coverFile = metadata.artworks.firstOrNull()?.let { + when (it.format) { + Artwork.Format.Unknown -> null + else -> { + val name = "$id.${it.format.extension}" + symphony.database.artworkCache.get(name).writeBytes(it.data) + name + } + } + } + metadata.lyrics?.let { + symphony.database.lyricsCache.put(id, it) + } return Song( id = id, - title = shorty.getString(AudioColumns.TITLE), - trackNumber = shorty.getIntNullable(AudioColumns.TRACK)?.takeIf { it > 0 }, - year = shorty.getIntNullable(AudioColumns.YEAR)?.takeIf { it > 0 }, - duration = shorty.getLong(AudioColumns.DURATION), - album = shorty.getStringNullable(AudioColumns.ALBUM), - artists = shorty.getStringNullable(AudioColumns.ARTIST) - ?.let { parseMultiValue(it, artistSeparators) } ?: setOf(), - composers = shorty.getStringNullable(AudioColumns.COMPOSER) - ?.let { parseMultiValue(it, artistSeparators) } ?: setOf(), - dateAdded = shorty.getLong(AudioColumns.DATE_ADDED), - dateModified = dateModified, - size = shorty.getLong(AudioColumns.SIZE), - path = shorty.getString(AudioColumns.DATA), - additional = fetchCachedAttributes(id) - ?.takeIf { it.lastModified == dateModified } - ?.runCatching { AdditionalMetadata.fromSongCacheAttributes(symphony, this) } - ?.getOrNull() - ?: AdditionalMetadata.fetch(symphony, id), + title = metadata.title ?: path, // TODO + album = metadata.album, + artists = metadata.artists, + composers = metadata.composer, + albumArtists = metadata.albumArtists, + genres = metadata.genres, + trackNumber = metadata.trackNumber, + trackTotal = metadata.trackTotal, + discNumber = metadata.discNumber, + discTotal = metadata.discTotal, + year = metadata.year, + duration = -1, // TODO + bitrate = -1, // TODO + bitsPerSample = -1, // TODO + samplingRate = -1, // TODO + codec = "", // TODO + dateModified = file.lastModified(), + size = file.length(), + coverFile = coverFile, + uri = uri, + path = path, ) } @@ -183,7 +209,9 @@ data class Song( fun prettyMimetype(mimetype: String): String? { val codec = mimetype.lowercase().replaceFirst("audio/", "") - if (codec.isBlank()) return null + if (codec.isBlank()) { + return null + } return prettyCodecs[codec] ?: codec.uppercase() } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/groove/SongRepository.kt b/app/src/main/java/io/github/zyrouge/symphony/services/groove/SongRepository.kt index 26cac64e..8b354554 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/groove/SongRepository.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/groove/SongRepository.kt @@ -1,17 +1,21 @@ package io.github.zyrouge.symphony.services.groove import android.net.Uri -import android.provider.MediaStore +import androidx.core.net.toUri import io.github.zyrouge.symphony.Symphony import io.github.zyrouge.symphony.ui.helpers.Assets import io.github.zyrouge.symphony.ui.helpers.createHandyImageRequest import io.github.zyrouge.symphony.utils.FuzzySearchOption import io.github.zyrouge.symphony.utils.FuzzySearcher +import io.github.zyrouge.symphony.utils.Logger +import io.github.zyrouge.symphony.utils.TimeBasedIncrementalKeyGenerator import io.github.zyrouge.symphony.utils.joinToStringIfNotEmpty import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.Path +import kotlin.io.path.nameWithoutExtension enum class SongSortBy { CUSTOM, @@ -19,7 +23,6 @@ enum class SongSortBy { ARTIST, ALBUM, DURATION, - DATE_ADDED, DATE_MODIFIED, COMPOSER, ALBUM_ARTIST, @@ -29,9 +32,10 @@ enum class SongSortBy { } class SongRepository(private val symphony: Symphony) { - private val cache = ConcurrentHashMap() - internal val pathCache = ConcurrentHashMap() - private val searcher = FuzzySearcher( + private val cache = ConcurrentHashMap() + internal val pathCache = ConcurrentHashMap() + internal val idGenerator = TimeBasedIncrementalKeyGenerator() + private val searcher = FuzzySearcher( options = listOf( FuzzySearchOption({ v -> get(v)?.title?.let { compareString(it) } }, 3), FuzzySearchOption({ v -> get(v)?.filename?.let { compareString(it) } }, 2), @@ -40,8 +44,8 @@ class SongRepository(private val symphony: Symphony) { ) ) - val isUpdating get() = symphony.groove.mediaStore.isUpdating - private val _all = MutableStateFlow>(emptyList()) + val isUpdating get() = symphony.groove.exposer.isUpdating + private val _all = MutableStateFlow>(emptyList()) val all = _all.asStateFlow() private val _count = MutableStateFlow(0) val count = _count.asStateFlow() @@ -81,20 +85,19 @@ class SongRepository(private val symphony: Symphony) { emitCount() } - fun search(songIds: List, terms: String, limit: Int = 7) = searcher + fun search(songIds: List, terms: String, limit: Int = 7) = searcher .search(terms, songIds, maxLength = limit) - fun sort(songIds: List, by: SongSortBy, reverse: Boolean): List { + fun sort(songIds: List, by: SongSortBy, reverse: Boolean): List { val sorted = when (by) { SongSortBy.CUSTOM -> songIds SongSortBy.TITLE -> songIds.sortedBy { get(it)?.title } SongSortBy.ARTIST -> songIds.sortedBy { get(it)?.artists?.joinToStringIfNotEmpty() } SongSortBy.ALBUM -> songIds.sortedBy { get(it)?.album } SongSortBy.DURATION -> songIds.sortedBy { get(it)?.duration } - SongSortBy.DATE_ADDED -> songIds.sortedBy { get(it)?.dateAdded } SongSortBy.DATE_MODIFIED -> songIds.sortedBy { get(it)?.dateModified } SongSortBy.COMPOSER -> songIds.sortedBy { get(it)?.composers?.joinToStringIfNotEmpty() } - SongSortBy.ALBUM_ARTIST -> songIds.sortedBy { get(it)?.additional?.albumArtists?.joinToStringIfNotEmpty() } + SongSortBy.ALBUM_ARTIST -> songIds.sortedBy { get(it)?.albumArtists?.joinToStringIfNotEmpty() } SongSortBy.YEAR -> songIds.sortedBy { get(it)?.year } SongSortBy.FILENAME -> songIds.sortedBy { get(it)?.filename } SongSortBy.TRACK_NUMBER -> songIds.sortedBy { get(it)?.trackNumber } @@ -106,22 +109,36 @@ class SongRepository(private val symphony: Symphony) { fun ids() = cache.keys.toList() fun values() = cache.values.toList() - fun get(id: Long) = cache[id] - fun get(ids: List) = ids.mapNotNull { get(it) } + fun get(id: String) = cache[id] + fun get(ids: List) = ids.mapNotNull { get(it) } - fun getArtworkUri(songId: Long): Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - .buildUpon() - .run { - appendPath(songId.toString()) - appendPath("albumart") - build() - } + fun getArtworkUri(songId: String): Uri = get(songId)?.coverFile + ?.let { symphony.database.artworkCache.get(it) }?.toUri() + ?: getDefaultArtworkUri() fun getDefaultArtworkUri() = Assets.getPlaceholderUri(symphony) - fun createArtworkImageRequest(songId: Long) = createHandyImageRequest( + fun createArtworkImageRequest(songId: String) = createHandyImageRequest( symphony.applicationContext, image = getArtworkUri(songId), fallback = Assets.getPlaceholderId(symphony), ) + + suspend fun getLyrics(song: Song): String? { + try { + val lrcFilePath = Path(song.path).nameWithoutExtension + ".lrc" + symphony.groove.exposer.uris[lrcFilePath]?.let { uri -> + symphony.applicationContext.contentResolver + .openInputStream(uri) + ?.use { inputStream -> + val lyrics = String(inputStream.readBytes()) + return lyrics + } + } + return symphony.database.lyricsCache.get(song.id) + } catch (err: Exception) { + Logger.error("LyricsRepository", "fetch lyrics failed", err) + } + return null + } } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/radio/Radio.kt b/app/src/main/java/io/github/zyrouge/symphony/services/radio/Radio.kt index 6877175c..56ec6743 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/radio/Radio.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/radio/Radio.kt @@ -365,8 +365,8 @@ class Radio(private val symphony: Symphony) : SymphonyHooks { symphony.settings.getPreviousSongQueue()?.let { previous -> var currentSongIndex = previous.currentSongIndex var playedDuration = previous.playedDuration - val originalQueue = mutableListOf() - val currentQueue = mutableListOf() + val originalQueue = mutableListOf() + val currentQueue = mutableListOf() previous.originalQueue.forEach { songId -> if (symphony.groove.song.get(songId) != null) { originalQueue.add(songId) diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioArtworkCacher.kt b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioArtworkCacher.kt index 6acd7cc4..59e75ba9 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioArtworkCacher.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioArtworkCacher.kt @@ -10,7 +10,7 @@ import io.github.zyrouge.symphony.ui.helpers.Assets class RadioArtworkCacher(val symphony: Symphony) { private var default: Bitmap? = null - private var cached = mutableMapOf() + private var cached = mutableMapOf() private val cacheLimit = 3 suspend fun getArtwork(song: Song): Bitmap { @@ -34,7 +34,7 @@ class RadioArtworkCacher(val symphony: Symphony) { } } - private fun updateCache(key: Long, value: Bitmap) { + private fun updateCache(key: String, value: Bitmap) { if (!cached.containsKey(key) && cached.size >= cacheLimit) { cached.remove(cached.keys.first()) } diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioObservatory.kt b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioObservatory.kt index c9c83a53..b807aa86 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioObservatory.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioObservatory.kt @@ -16,7 +16,7 @@ class RadioObservatory(private val symphony: Symphony) { val playbackPosition = _playbackPosition.asStateFlow() private val _queueIndex = MutableStateFlow(-1) val queueIndex = _queueIndex.asStateFlow() - private val _queue = MutableStateFlow>(emptyList()) + private val _queue = MutableStateFlow>(emptyList()) val queue = _queue.asStateFlow() private val _loopMode = MutableStateFlow(RadioLoopMode.None) val loopMode = _loopMode.asStateFlow() diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioQueue.kt b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioQueue.kt index 8023b15a..79e2899b 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioQueue.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioQueue.kt @@ -14,8 +14,8 @@ enum class RadioLoopMode { } class RadioQueue(private val symphony: Symphony) { - val originalQueue = ConcurrentList() - val currentQueue = ConcurrentList() + val originalQueue = ConcurrentList() + val currentQueue = ConcurrentList() var currentSongIndex = -1 set(value) { @@ -35,7 +35,7 @@ class RadioQueue(private val symphony: Symphony) { symphony.radio.onUpdate.dispatch(RadioEvents.LoopModeChanged) } - val currentSongId: Long? + val currentSongId: String? get() = getSongIdAt(currentSongIndex) fun hasSongAt(index: Int) = index > -1 && index < currentQueue.size @@ -49,7 +49,7 @@ class RadioQueue(private val symphony: Symphony) { } fun add( - songIds: List, + songIds: List, index: Int? = null, options: Radio.PlayOptions = Radio.PlayOptions(), ) { @@ -67,7 +67,7 @@ class RadioQueue(private val symphony: Symphony) { } fun add( - songId: Long, + songId: String, index: Int? = null, options: Radio.PlayOptions = Radio.PlayOptions(), ) = add(listOf(songId), index, options) @@ -144,8 +144,8 @@ class RadioQueue(private val symphony: Symphony) { data class Serialized( val currentSongIndex: Int, val playedDuration: Long, - val originalQueue: List, - val currentQueue: List, + val originalQueue: List, + val currentQueue: List, val shuffled: Boolean, ) { fun serialize() = @@ -173,8 +173,8 @@ class RadioQueue(private val symphony: Symphony) { return Serialized( currentSongIndex = semi[0].toInt(), playedDuration = semi[1].toLong(), - originalQueue = semi[2].split(",").map { it.toLong() }, - currentQueue = semi[3].split(",").map { it.toLong() }, + originalQueue = semi[2].split(","), + currentQueue = semi[3].split(","), shuffled = semi[4].toBoolean(), ) } catch (_: Exception) { diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioSession.kt b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioSession.kt index 8a5ff5e0..66bf6d23 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioSession.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioSession.kt @@ -36,7 +36,7 @@ class RadioSession(val symphony: Symphony) { ) val notification = RadioNotification(symphony) - private var currentSongId: Long? = null + private var currentSongId: String? = null private var receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { intent?.action?.let { action -> diff --git a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioShorty.kt b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioShorty.kt index e91bc72f..4d3c12b7 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioShorty.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/services/radio/RadioShorty.kt @@ -52,23 +52,23 @@ class RadioShorty(private val symphony: Symphony) { } fun playQueue( - songIds: List, + songIds: List, options: Radio.PlayOptions = Radio.PlayOptions(), shuffle: Boolean = false, ) { symphony.radio.stop(ended = false) if (songIds.isEmpty()) return symphony.radio.queue.add( - songIds, - options = options.run { - copy(index = if (shuffle) Random.nextInt(songIds.size) else options.index) - } + songIds, + options = options.run { + copy(index = if (shuffle) Random.nextInt(songIds.size) else options.index) + } ) symphony.radio.queue.setShuffleMode(shuffle) } fun playQueue( - songId: Long, + songId: String, options: Radio.PlayOptions = Radio.PlayOptions(), shuffle: Boolean = false, ) = playQueue(listOf(songId), options = options, shuffle = shuffle) diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/AddToPlaylistDialog.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/AddToPlaylistDialog.kt index 5102f741..1d70e00b 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/AddToPlaylistDialog.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/AddToPlaylistDialog.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch @Composable fun AddToPlaylistDialog( context: ViewContext, - songIds: List, + songIds: List, onDismissRequest: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/GenericSongListDropdown.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/GenericSongListDropdown.kt index 03a9e152..f269f1b8 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/GenericSongListDropdown.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/GenericSongListDropdown.kt @@ -17,7 +17,7 @@ import io.github.zyrouge.symphony.ui.helpers.ViewContext @Composable fun GenericSongListDropdown( context: ViewContext, - songIds: List, + songIds: List, expanded: Boolean, onDismissRequest: () -> Unit, ) { diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/LyricsText.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/LyricsText.kt index 0619040d..520af872 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/LyricsText.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/LyricsText.kt @@ -51,7 +51,7 @@ fun LyricsText( } } var lyricsState by remember { mutableIntStateOf(0) } - var lyricsSongId by remember { mutableStateOf(null) } + var lyricsSongId by remember { mutableStateOf(null) } var lyrics by remember { mutableStateOf(null) } LaunchedEffect(LocalContext.current) { @@ -70,7 +70,7 @@ fun LyricsText( lyricsSongId = song?.id coroutineScope.launch { lyrics = song?.let { song -> - context.symphony.groove.lyrics.getLyrics(song)?.let { + context.symphony.groove.song.getLyrics(song)?.let { TimedContent.fromLyrics(it) } } diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/NewPlaylistDialog.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/NewPlaylistDialog.kt index c8712478..4a4d6f4c 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/NewPlaylistDialog.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/NewPlaylistDialog.kt @@ -28,13 +28,13 @@ import io.github.zyrouge.symphony.ui.helpers.ViewContext @Composable fun NewPlaylistDialog( context: ViewContext, - initialSongIds: List = listOf(), + initialSongIds: List = listOf(), onDone: (Playlist) -> Unit, onDismissRequest: () -> Unit, ) { var input by remember { mutableStateOf("") } var showSongsPicker by remember { mutableStateOf(false) } - val songIds = remember { mutableStateListOf(*initialSongIds.toTypedArray()) } + val songIds = remember { mutableStateListOf(*initialSongIds.toTypedArray()) } val songIdsImmutable = songIds.toList() val focusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/PlaylistManageSongsDialog.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/PlaylistManageSongsDialog.kt index 17b314d8..df9282a7 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/PlaylistManageSongsDialog.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/PlaylistManageSongsDialog.kt @@ -38,8 +38,8 @@ import io.github.zyrouge.symphony.ui.helpers.ViewContext @Composable fun PlaylistManageSongsDialog( context: ViewContext, - selectedSongIds: List, - onDone: (List) -> Unit, + selectedSongIds: List, + onDone: (List) -> Unit, ) { val allSongIds by context.symphony.groove.song.all.collectAsState() val nSelectedSongIds = remember { selectedSongIds.toMutableStateList() } diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongCard.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongCard.kt index fbbb295f..720bca4c 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongCard.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongCard.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle @@ -54,7 +53,6 @@ import io.github.zyrouge.symphony.ui.helpers.Routes import io.github.zyrouge.symphony.ui.helpers.ViewContext import io.github.zyrouge.symphony.utils.Logger -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SongCard( context: ViewContext, @@ -278,7 +276,7 @@ fun SongDropdownMenu( } ) } - song.additional.albumArtists.forEach { albumArtist -> + song.albumArtists.forEach { albumArtist -> DropdownMenuItem( leadingIcon = { Icon(Icons.Filled.Person, null) diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongExplorerList.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongExplorerList.kt index 64238adc..0c8d0171 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongExplorerList.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongExplorerList.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -57,12 +56,11 @@ import io.github.zyrouge.symphony.utils.wrapInViewContext private data class SongExplorerResult( val folders: List, - val files: Map, + val files: Map, ) private const val SongFolderContentType = "folder" -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SongExplorerList( context: ViewContext, @@ -280,17 +278,17 @@ fun SongExplorerList( private data class GrooveExplorerCategorizedData( val folders: List, - val files: Map, + val files: Map, ) private fun GrooveExplorer.Folder.categorizedChildren(): GrooveExplorerCategorizedData { val folders = mutableListOf() - val files = mutableMapOf() + val files = mutableMapOf() children.values.forEach { entity -> when (entity) { is GrooveExplorer.Folder -> folders.add(entity) is GrooveExplorer.File -> { - files[entity.data as Long] = entity + files[entity.data as String] = entity } } } @@ -299,7 +297,7 @@ private fun GrooveExplorer.Folder.categorizedChildren(): GrooveExplorerCategoriz private fun GrooveExplorer.Folder.childrenAsSongIds() = children.values.mapNotNull { entity -> when (entity) { - is GrooveExplorer.File -> entity.data as Long + is GrooveExplorer.File -> entity.data as String else -> null } } diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongInformationDialog.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongInformationDialog.kt index b6d3d024..1e8d0129 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongInformationDialog.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongInformationDialog.kt @@ -34,9 +34,9 @@ fun SongInformationDialog(context: ViewContext, song: Song, onDismissRequest: () } } } - if (song.additional.albumArtists.isNotEmpty()) { + if (song.albumArtists.isNotEmpty()) { InformationKeyValue(context.symphony.t.AlbumArtist) { - LongPressCopyableAndTappableText(context, song.additional.albumArtists) { + LongPressCopyableAndTappableText(context, song.albumArtists) { onDismissRequest() context.navController.navigate(Routes.AlbumArtist.build(it)) } @@ -59,9 +59,9 @@ fun SongInformationDialog(context: ViewContext, song: Song, onDismissRequest: () } } } - if (song.additional.genres.isNotEmpty()) { + if (song.genres.isNotEmpty()) { InformationKeyValue(context.symphony.t.Genre) { - LongPressCopyableAndTappableText(context, song.additional.genres) { + LongPressCopyableAndTappableText(context, song.genres) { onDismissRequest() context.navController.navigate(Routes.Genre.build(it)) } @@ -89,22 +89,22 @@ fun SongInformationDialog(context: ViewContext, song: Song, onDismissRequest: () InformationKeyValue(context.symphony.t.Duration) { LongPressCopyableText(context, DurationFormatter.formatMs(song.duration)) } - song.additional.codec?.let { + song.codec?.let { InformationKeyValue(context.symphony.t.Codec) { LongPressCopyableText(context, it) } } - song.additional.bitrateK?.let { + song.bitrateK?.let { InformationKeyValue(context.symphony.t.Bitrate) { LongPressCopyableText(context, context.symphony.t.XKbps(it.toString())) } } - song.additional.bitsPerSample?.let { + song.bitsPerSample?.let { InformationKeyValue(context.symphony.t.BitDepth) { LongPressCopyableText(context, context.symphony.t.XBit(it.toString())) } } - song.additional.samplingRateK?.let { + song.samplingRateK?.let { InformationKeyValue(context.symphony.t.SamplingRate) { LongPressCopyableText(context, context.symphony.t.XKHz(it.toString())) } @@ -118,17 +118,10 @@ fun SongInformationDialog(context: ViewContext, song: Song, onDismissRequest: () InformationKeyValue(context.symphony.t.Size) { LongPressCopyableText(context, "${round((song.size / 1024 / 1024).toDouble())} MB") } - InformationKeyValue(context.symphony.t.DateAdded) { - LongPressCopyableText( - context, - SimpleDateFormat.getInstance().format(Date(song.dateAdded * 1000)), - ) - } InformationKeyValue(context.symphony.t.LastModified) { LongPressCopyableText( context, - SimpleDateFormat.getInstance() - .format(Date(song.dateModified * 1000)), + SimpleDateFormat.getInstance().format(Date(song.dateModified * 1000)), ) } }, diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongList.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongList.kt index 75ab657b..df5dde20 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongList.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongList.kt @@ -31,7 +31,7 @@ enum class SongListType { @Composable fun SongList( context: ViewContext, - songIds: List, + songIds: List, songsCount: Int? = null, leadingContent: (LazyListScope.() -> Unit)? = null, trailingContent: (LazyListScope.() -> Unit)? = null, @@ -131,7 +131,6 @@ fun SongSortBy.label(context: ViewContext) = when (this) { SongSortBy.ARTIST -> context.symphony.t.Artist SongSortBy.ALBUM -> context.symphony.t.Album SongSortBy.DURATION -> context.symphony.t.Duration - SongSortBy.DATE_ADDED -> context.symphony.t.DateAdded SongSortBy.DATE_MODIFIED -> context.symphony.t.LastModified SongSortBy.COMPOSER -> context.symphony.t.Composer SongSortBy.ALBUM_ARTIST -> context.symphony.t.AlbumArtist diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongTreeList.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongTreeList.kt index 50d8185f..f6451db8 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongTreeList.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/components/SongTreeList.kt @@ -64,7 +64,7 @@ import io.github.zyrouge.symphony.ui.helpers.ViewContext @Composable fun SongTreeList( context: ViewContext, - songIds: List, + songIds: List, songsCount: Int? = null, initialDisabled: List, onDisable: ((List) -> Unit), @@ -157,8 +157,8 @@ fun SongTreeList( @Composable fun SongTreeListContent( context: ViewContext, - tree: Map>, - songIds: List, + tree: Map>, + songIds: List, disabled: List, togglePath: (String) -> Unit, ) { @@ -522,9 +522,9 @@ fun PathSortBy.label(context: ViewContext) = when (this) { private fun createLinearTree( context: ViewContext, - songIds: List, -): Map> { - val result = mutableMapOf>() + songIds: List, +): Map> { + val result = mutableMapOf>() songIds.forEach { songId -> val song = context.symphony.groove.song.get(songId) ?: return@forEach val parsedPath = GrooveExplorer.Path(song.path) diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/helpers/Assets.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/helpers/Assets.kt index 71513114..c10b8228 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/helpers/Assets.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/helpers/Assets.kt @@ -13,13 +13,13 @@ object Assets { val placeholderDarkId = R.raw.placeholder_dark val placeholderLightId = R.raw.placeholder_light - fun getPlaceholderId(isLight: Boolean = false) = when { + private fun getPlaceholderId(isLight: Boolean = false) = when { isLight -> placeholderLightId else -> placeholderDarkId } - fun getPlaceholderId(symphony: Symphony) = Assets.getPlaceholderId( - isLight = symphony.settings.getThemeMode().toColorSchemeMode(symphony).isLight(), + fun getPlaceholderId(symphony: Symphony) = getPlaceholderId( + isLight = symphony.settings.themeMode.value.toColorSchemeMode(symphony).isLight(), ) fun getPlaceholderUri(symphony: Symphony) = buildUriOfResource( diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/Search.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/Search.kt index 54135a7e..6ed2eb91 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/view/Search.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/Search.kt @@ -72,7 +72,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private data class SearchResult( - val songIds: List, + val songIds: List, val artistNames: List, val albumIds: List, val albumArtistNames: List, @@ -99,7 +99,7 @@ fun SearchView(context: ViewContext, initialChip: GrooveKinds?) { currentTermsRoutine = coroutineScope.launch { withContext(Dispatchers.Default) { delay(250) - val songIds = mutableListOf() + val songIds = mutableListOf() val artistNames = mutableListOf() val albumIds = mutableListOf() val albumArtistNames = mutableListOf() diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/Settings.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/Settings.kt index f5639c93..7a4a43d8 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/view/Settings.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/Settings.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.HeadsetOff import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PhotoSizeSelectLarge @@ -83,8 +84,9 @@ import io.github.zyrouge.symphony.ui.theme.ThemeMode import io.github.zyrouge.symphony.ui.view.home.ForYou import io.github.zyrouge.symphony.ui.view.settings.SettingsFloatInputTile import io.github.zyrouge.symphony.ui.view.settings.SettingsLinkTile -import io.github.zyrouge.symphony.ui.view.settings.SettingsMultiFolderTile +import io.github.zyrouge.symphony.ui.view.settings.SettingsMultiGrooveFolderTile import io.github.zyrouge.symphony.ui.view.settings.SettingsMultiOptionTile +import io.github.zyrouge.symphony.ui.view.settings.SettingsMultiSystemFolderTile import io.github.zyrouge.symphony.ui.view.settings.SettingsMultiTextOptionTile import io.github.zyrouge.symphony.ui.view.settings.SettingsOptionTile import io.github.zyrouge.symphony.ui.view.settings.SettingsSideHeading @@ -139,6 +141,7 @@ fun SettingsView(context: ViewContext) { val showUpdateToast by context.symphony.settings.showUpdateToast.collectAsState() val fontScale by context.symphony.settings.fontScale.collectAsState() val contentScale by context.symphony.settings.contentScale.collectAsState() + val mediaFolders by context.symphony.settings.mediaFolders.collectAsState() val refetchLibrary = { context.symphony.radio.stop() @@ -187,6 +190,11 @@ fun SettingsView(context: ViewContext) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { val contentColor = MaterialTheme.colorScheme.onPrimary + val seekDurationRange = 3f..60f + val defaultSongsFilterPattern = ".*" + val isLatestVersion = + AppMeta.latestVersion == null || AppMeta.latestVersion == AppMeta.version + Box( modifier = Modifier .fillMaxWidth() @@ -493,7 +501,6 @@ fun SettingsView(context: ViewContext) { context.symphony.settings.setPauseOnHeadphonesDisconnect(value) } ) - val seekDurationRange = 3f..60f SettingsSliderTile( context, icon = { @@ -638,7 +645,20 @@ fun SettingsView(context: ViewContext) { ) HorizontalDivider() SettingsSideHeading(context.symphony.t.Groove) - val defaultSongsFilterPattern = ".*" + SettingsMultiSystemFolderTile( + context, + icon = { + Icon(Icons.Filled.LibraryMusic, null) + }, + title = { + Text(context.symphony.t.MediaFolders) + }, + initialValues = mediaFolders, + onChange = { values -> + context.symphony.settings.setMediaFolders(values) + refetchLibrary() + } + ) SettingsTextInputTile( context, icon = { @@ -661,7 +681,7 @@ fun SettingsView(context: ViewContext) { refetchLibrary() } ) - SettingsMultiFolderTile( + SettingsMultiGrooveFolderTile( context, icon = { Icon(Icons.Filled.RuleFolder, null) @@ -669,14 +689,14 @@ fun SettingsView(context: ViewContext) { title = { Text(context.symphony.t.BlacklistFolders) }, - explorer = context.symphony.groove.mediaStore.explorer, + explorer = context.symphony.groove.exposer.explorer, initialValues = blacklistFolders, onChange = { values -> context.symphony.settings.setBlacklistFolders(values) refetchLibrary() } ) - SettingsMultiFolderTile( + SettingsMultiGrooveFolderTile( context, icon = { Icon(Icons.Filled.RuleFolder, null) @@ -684,7 +704,7 @@ fun SettingsView(context: ViewContext) { title = { Text(context.symphony.t.WhitelistFolders) }, - explorer = context.symphony.groove.mediaStore.explorer, + explorer = context.symphony.groove.exposer.explorer, initialValues = whitelistFolders, onChange = { values -> context.symphony.settings.setWhitelistFolders(values) @@ -728,7 +748,7 @@ fun SettingsView(context: ViewContext) { }, onClick = { coroutineScope.launch { - context.symphony.database.songCache.update(emptyMap()) + context.symphony.database.songCache.clear() refetchLibrary() snackbarHostState.showSnackbar( context.symphony.t.SongCacheCleared, @@ -797,9 +817,6 @@ fun SettingsView(context: ViewContext) { ) HorizontalDivider() SettingsSideHeading(context.symphony.t.About) - val isLatestVersion = AppMeta.latestVersion - ?.let { it == AppMeta.version } - ?: true SettingsSimpleTile( icon = { Icon(Icons.Filled.MusicNote, null) diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/Folders.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/Folders.kt index 32b96e32..74ef08b0 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/Folders.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/Folders.kt @@ -101,7 +101,7 @@ fun FoldersView(context: ViewContext) { derivedStateOf { folder.children.values.mapNotNull { when (it) { - is GrooveExplorer.File -> it.data as Long + is GrooveExplorer.File -> it.data as String else -> null } } @@ -300,21 +300,21 @@ private fun FolderTile( private fun GrooveExplorer.Folder.createArtworkImageRequest(context: ViewContext) = children.values .find { it is GrooveExplorer.File } ?.let { - val songId = (it as GrooveExplorer.File).data as Long + val songId = (it as GrooveExplorer.File).data as String context.symphony.groove.song.createArtworkImageRequest(songId) } ?: Assets.createPlaceholderImageRequest(context.symphony) -private fun GrooveExplorer.Folder.getSortedSongIds(context: ViewContext): List { +private fun GrooveExplorer.Folder.getSortedSongIds(context: ViewContext): List { val songIds = children.values.mapNotNull { when (it) { - is GrooveExplorer.File -> it.data as Long + is GrooveExplorer.File -> it.data as String else -> null } } return context.symphony.groove.song.sort( songIds, - context.symphony.settings.getLastUsedSongsSortBy(), - context.symphony.settings.getLastUsedSongsSortReverse(), + context.symphony.settings.lastUsedSongsSortBy.value, + context.symphony.settings.lastUsedSongsSortReverse.value, ) } diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/ForYou.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/ForYou.kt index f6555448..8952238a 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/ForYou.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/home/ForYou.kt @@ -91,7 +91,7 @@ fun ForYouView(context: ViewContext) { runIfOrDefault(!songsIsUpdating, listOf()) { context.symphony.groove.song.sort( songIds.toList(), - SongSortBy.DATE_ADDED, + SongSortBy.DATE_MODIFIED, true ) } diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/nowPlaying/BodyContent.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/nowPlaying/BodyContent.kt index 68b01403..f4df6226 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/view/nowPlaying/BodyContent.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/nowPlaying/BodyContent.kt @@ -30,7 +30,6 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -60,7 +59,7 @@ import io.github.zyrouge.symphony.ui.view.NowPlayingControlsLayout import io.github.zyrouge.symphony.ui.view.NowPlayingData import io.github.zyrouge.symphony.utils.DurationFormatter -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable fun NowPlayingBodyContent(context: ViewContext, data: NowPlayingData) { val favoriteSongIds by context.symphony.groove.playlist.favorites.collectAsState() @@ -110,7 +109,7 @@ fun NowPlayingBodyContent(context: ViewContext, data: NowPlayingData) { } } if (data.showSongAdditionalInfo) { - targetStateSong.additional.toSamplingInfoString(context.symphony)?.let { + targetStateSong.toSamplingInfoString(context.symphony)?.let { val localContentColor = LocalContentColor.current Text( it, diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiFolderTile.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiGrooveFolderTile.kt similarity index 99% rename from app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiFolderTile.kt rename to app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiGrooveFolderTile.kt index bd5cb2aa..52adff56 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiFolderTile.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiGrooveFolderTile.kt @@ -47,9 +47,8 @@ import io.github.zyrouge.symphony.ui.helpers.navigateToFolder private const val SettingsFolderContentType = "folder" -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsMultiFolderTile( +fun SettingsMultiGrooveFolderTile( context: ViewContext, icon: @Composable () -> Unit, title: @Composable () -> Unit, diff --git a/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiSystemFolderTile.kt b/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiSystemFolderTile.kt new file mode 100644 index 00000000..34936c7e --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/ui/view/settings/MultiSystemFolderTile.kt @@ -0,0 +1,296 @@ +package io.github.zyrouge.symphony.ui.view.settings + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import io.github.zyrouge.symphony.services.groove.GrooveExplorer +import io.github.zyrouge.symphony.ui.components.ScaffoldDialog +import io.github.zyrouge.symphony.ui.components.ScaffoldDialogDefaults +import io.github.zyrouge.symphony.ui.components.SubtleCaptionText +import io.github.zyrouge.symphony.ui.components.drawScrollBar +import io.github.zyrouge.symphony.ui.helpers.ViewContext +import io.github.zyrouge.symphony.ui.helpers.navigateToFolder + +private const val SettingsFolderContentType = "folder" + +@Composable +fun SettingsMultiSystemFolderTile( + context: ViewContext, + icon: @Composable () -> Unit, + title: @Composable () -> Unit, + initialValues: Set, + onChange: (Set) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + + Card( + colors = SettingsTileDefaults.cardColors(), + onClick = { + showDialog = !showDialog + } + ) { + ListItem( + colors = SettingsTileDefaults.listItemColors(), + leadingContent = { icon() }, + headlineContent = { title() }, + supportingContent = { + Text(context.symphony.t.XFolders(initialValues.size.toString())) + }, + ) + } + + if (showDialog) { + val values = remember { initialValues.toMutableStateList() } + val pickFolderLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + uri?.let { _ -> values.add(uri) } + } + + // TODO: workaround for dialog resize bug + // https://issuetracker.google.com/issues/221643630 + key(values.size) { + ScaffoldDialog( + onDismissRequest = { + onChange(values.toSet()) + showDialog = false + }, + title = title, + contentHeight = ScaffoldDialogDefaults.PreferredMaxHeight, + content = { + val lazyListState = rememberLazyListState() + + LazyColumn( + state = lazyListState, + modifier = Modifier + .padding(top = 8.dp) + .drawScrollBar(lazyListState), + ) { + itemsIndexed(values) { i, x -> + Card( + colors = SettingsTileDefaults.cardColors(), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + onClick = {}, + ) { + Row( + modifier = Modifier.padding( + start = 20.dp, + end = 8.dp, + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text(x.toString(), modifier = Modifier.weight(1f)) + IconButton(onClick = { values.removeAt(i) }) { + Icon(Icons.Filled.Delete, null) + } + } + } + } + } + }, + actions = { + TextButton( + onClick = { + pickFolderLauncher.launch(null) + } + ) { + Text(context.symphony.t.AddFolder) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = { + showDialog = false + } + ) { + Text(context.symphony.t.Cancel) + } + TextButton( + onClick = { + onChange(values.toSet()) + showDialog = false + } + ) { + Text(context.symphony.t.Done) + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsFolderTilePickerDialog( + context: ViewContext, + explorer: GrooveExplorer.Folder, + onSelect: (List?) -> Unit, +) { + var currentFolder by remember { mutableStateOf(explorer) } + val sortedEntities by remember(currentFolder) { + derivedStateOf { + currentFolder.children.values.mapNotNull { entity -> + when (entity) { + is GrooveExplorer.Folder -> entity + else -> null + } + } + } + } + val currentPath by remember(currentFolder) { + derivedStateOf { currentFolder.pathParts } + } + val currentPathScrollState = rememberScrollState() + + LaunchedEffect(LocalContext.current) { + snapshotFlow { currentPath }.collect { + currentPathScrollState.animateScrollTo(Int.MAX_VALUE) + } + } + + ScaffoldDialog( + onDismissRequest = { + when { + currentFolder.parent != null -> currentFolder.parent?.let { currentFolder = it } + else -> onSelect(null) + } + }, + title = { + Text(context.symphony.t.PickFolder) + }, + topBar = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(currentPathScrollState) + .padding(12.dp, 8.dp), + ) { + currentPath.mapIndexed { i, basename -> + Text( + basename, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { + explorer + .navigateToFolder(currentPath.subList(1, i + 1)) + ?.let { currentFolder = it } + } + .padding(8.dp, 4.dp), + ) + if (i != currentPath.size - 1) { + Text( + "/", + modifier = Modifier + .padding(4.dp, 0.dp) + .alpha(0.3f), + ) + } + } + } + }, + contentHeight = ScaffoldDialogDefaults.PreferredMaxHeight, + content = { + when { + sortedEntities.isEmpty() -> Box( + modifier = Modifier.fillMaxHeight() + ) { + SubtleCaptionText(context.symphony.t.NoFoldersFound) + } + + else -> { + val lazyListState = rememberLazyListState() + + LazyColumn( + state = lazyListState, + modifier = Modifier.drawScrollBar(lazyListState), + ) { + items( + sortedEntities, + key = { it.basename }, + contentType = { SettingsFolderContentType } + ) { folder -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + onClick = { + currentFolder = folder + } + ) { + Row( + modifier = Modifier.padding(20.dp, 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.Folder, + null, + modifier = Modifier.size(32.dp), + ) + Spacer(modifier = Modifier.width(20.dp)) + Column { + Text(folder.basename) + Text( + context.symphony.t.XFolders( + folder.countChildrenFolders().toString() + ), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + } + } + }, + actions = { + TextButton(onClick = { onSelect(null) }) { + Text(context.symphony.t.Cancel) + } + TextButton(onClick = { onSelect(currentPath) }) { + Text(context.symphony.t.Done) + } + }, + ) +} + +private fun GrooveExplorer.Folder.countChildrenFolders() = children.values + .count { it is GrooveExplorer.Folder } diff --git a/app/src/main/java/io/github/zyrouge/symphony/utils/JSON.kt b/app/src/main/java/io/github/zyrouge/symphony/utils/JSON.kt index 4af96214..df02f9cf 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/utils/JSON.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/utils/JSON.kt @@ -3,8 +3,28 @@ package io.github.zyrouge.symphony.utils import org.json.JSONArray import org.json.JSONObject + fun JSONObject.getStringOrNull(key: String): String? = if (has(key)) getString(key) else null fun JSONObject.getIntOrNull(key: String): Int? = if (has(key)) getInt(key) else null -fun JSONArray.toList(fn: JSONArray.(Int) -> T) = List(length()) { i -> fn.invoke(this, i) } +fun JSONObject.getLongOrNull(key: String): Long? = if (has(key)) getLong(key) else null + +fun JSONObject.getJSONArrayOrNull(key: String): JSONArray? = + if (has(key)) getJSONArray(key) else null + +private typealias _ArrayCollector = JSONArray.(Int) -> T + +fun > JSONArray.collectInto(into: V, fn: _ArrayCollector): V { + for (i in 0 until length()) { + into.add(fn.invoke(this, i)) + } + return into +} + +fun JSONArray.toList(fn: _ArrayCollector): List = collectInto(mutableListOf()) { fn(it) } + +fun JSONArray.toSet(fn: _ArrayCollector): Set = collectInto(mutableSetOf()) { fn(it) } + +fun JSONObject.getStringOrEmptySet(key: String) = + getJSONArrayOrNull(key)?.toSet { getString(it) } ?: emptySet() diff --git a/app/src/main/java/io/github/zyrouge/symphony/utils/KeyGenerator.kt b/app/src/main/java/io/github/zyrouge/symphony/utils/KeyGenerator.kt new file mode 100644 index 00000000..9d20c74e --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/utils/KeyGenerator.kt @@ -0,0 +1,16 @@ +package io.github.zyrouge.symphony.utils + +class TimeBasedIncrementalKeyGenerator(private var i: Int = 0, private var time: Long = 0) { + fun next(): String { + synchronized(this) { + val now = System.currentTimeMillis() + if (now == time) { + i = 0 + time = now + } else { + i++ + } + return "$now.$i" + } + } +} diff --git a/app/src/main/java/io/github/zyrouge/symphony/utils/List.kt b/app/src/main/java/io/github/zyrouge/symphony/utils/List.kt index 8282c535..dfe4705a 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/utils/List.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/utils/List.kt @@ -35,8 +35,6 @@ fun List.mutate(fn: MutableList.() -> Unit): List { return out } -fun List.joinToStringIfNotEmpty() = if (isNotEmpty()) joinToString() else null - class ConcurrentList : MutableList { private val list = mutableListOf() private val lock = ReentrantReadWriteLock() diff --git a/app/src/main/java/io/github/zyrouge/symphony/utils/Serializable.kt b/app/src/main/java/io/github/zyrouge/symphony/utils/Serializable.kt new file mode 100644 index 00000000..ad1ac820 --- /dev/null +++ b/app/src/main/java/io/github/zyrouge/symphony/utils/Serializable.kt @@ -0,0 +1,19 @@ +package io.github.zyrouge.symphony.utils + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json + +val RelaxedJsonDecoder = Json { + ignoreUnknownKeys = true +} + +object UriSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Uri) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString()) +} diff --git a/app/src/main/java/io/github/zyrouge/symphony/utils/Set.kt b/app/src/main/java/io/github/zyrouge/symphony/utils/Set.kt index 4f6c0eb7..037f1d49 100644 --- a/app/src/main/java/io/github/zyrouge/symphony/utils/Set.kt +++ b/app/src/main/java/io/github/zyrouge/symphony/utils/Set.kt @@ -1,25 +1,13 @@ package io.github.zyrouge.symphony.utils -import java.util.Collections import java.util.concurrent.ConcurrentHashMap fun Set.joinToStringIfNotEmpty() = if (isNotEmpty()) joinToString() else null -class ConcurrentSet(vararg elements: T) : MutableSet { - private val set = Collections.newSetFromMap(ConcurrentHashMap()).apply { - addAll(elements) - } +typealias ConcurrentSet = ConcurrentHashMap.KeySetView - override val size: Int get() = set.size - override fun contains(element: T) = set.contains(element) - override fun containsAll(elements: Collection) = set.containsAll(elements) - override fun isEmpty() = set.isEmpty() - override fun add(element: T) = set.add(element) - override fun addAll(elements: Collection) = set.addAll(elements) - override fun clear() = set.clear() - override fun remove(element: T) = set.remove(element) - override fun removeAll(elements: Collection) = set.removeAll(elements.toSet()) - override fun retainAll(elements: Collection) = set.retainAll(elements.toSet()) - override fun iterator() = set.iterator() -} +fun concurrentSetOf(vararg elements: T): ConcurrentSet = + ConcurrentHashMap.newKeySet().apply { addAll(elements) } +fun concurrentSetOf(elements: Collection): ConcurrentSet = + ConcurrentHashMap.newKeySet().apply { addAll(elements) } diff --git a/app/src/test/java/io/github/zyrouge/metaphony/FlacTest.kt b/app/src/test/java/io/github/zyrouge/metaphony/FlacTest.kt new file mode 100644 index 00000000..7c0e6c2c --- /dev/null +++ b/app/src/test/java/io/github/zyrouge/metaphony/FlacTest.kt @@ -0,0 +1,24 @@ +package io.github.zyrouge.metaphony + +import io.github.zyrouge.metaphony.flac.Flac +import org.junit.jupiter.api.Test + +class FlacTest { + @Test + fun parse() { + val klassLoader = object {}.javaClass.classLoader!! + val stream = klassLoader.getResourceAsStream("audio.flac")!! + val metadata = Flac.read(stream) + stream.close() + println(metadata) +// metadata.artwork?.let { +// val ext = it.mimeType.split("/")[1] +// val file = File( +// klass.getResource(".")!!.path, +// "flac-gen-pic.$ext" +// ) +// file.writeBytes(it.data) +// println("done! ${file.path}") +// } + } +} diff --git a/app/src/test/java/io/github/zyrouge/metaphony/ID3v2Test.kt b/app/src/test/java/io/github/zyrouge/metaphony/ID3v2Test.kt new file mode 100644 index 00000000..4a9c5b71 --- /dev/null +++ b/app/src/test/java/io/github/zyrouge/metaphony/ID3v2Test.kt @@ -0,0 +1,46 @@ +import io.github.zyrouge.metaphony.mp3.Mp3 +import org.junit.jupiter.api.Test + +class ID3v2Test { + @Test + fun parse() { + parseID3v23() + parseID3v24() + } + + @Test + fun parseID3v23() { + val klassLoader = object {}.javaClass.classLoader!! + val stream = klassLoader.getResourceAsStream("audio-id3v2.3.mp3")!! + val metadata = Mp3.read(stream) + stream.close() + println(metadata.genres) +// metadata.artwork?.let { +// val ext = it.mimeType.split("/")[1] +// val file = File( +// klass.getResource(".")!!.path, +// "flac-gen-pic.$ext" +// ) +// file.writeBytes(it.data) +// println("done! ${file.path}") +// } + } + + @Test + fun parseID3v24() { + val klassLoader = object {}.javaClass.classLoader!! + val stream = klassLoader.getResourceAsStream("audio-id3v2.4.mp3")!! + val metadata = Mp3.read(stream) + stream.close() + println(metadata.genres) +// metadata.artwork?.let { +// val ext = it.mimeType.split("/")[1] +// val file = File( +// klass.getResource(".")!!.path, +// "flac-gen-pic.$ext" +// ) +// file.writeBytes(it.data) +// println("done! ${file.path}") +// } + } +} diff --git a/app/src/test/java/io/github/zyrouge/metaphony/Mp3Test.kt b/app/src/test/java/io/github/zyrouge/metaphony/Mp3Test.kt new file mode 100644 index 00000000..9a3e72c2 --- /dev/null +++ b/app/src/test/java/io/github/zyrouge/metaphony/Mp3Test.kt @@ -0,0 +1,31 @@ +package io.github.zyrouge.metaphony + +import io.github.zyrouge.metaphony.mp3.Mp3 +import org.junit.jupiter.api.Test + +class Mp3Test { + @Test + fun parse() { + val klassLoader = object {}.javaClass.classLoader!! +// val stream = klassLoader.getResourceAsStream("audio-id3v2.4.mp3")!! + val metadata = klassLoader + .getResourceAsStream("5SOS - Ghost of You.mp3")!! + .use { Mp3.read(it) } + println(metadata.title) + println(metadata.artists) + println(metadata.album) + println(metadata.genres) + println(metadata.artworks.firstOrNull()?.format) +// println(metadata.rawUint8Atoms) +// println(metadata.rawPictureAtoms[0]) +// metadata.artworks.firstOrNull()?.let { +// val ext = it.format.name.lowercase() +// val file = File( +// klass.getResource(".")!!.path, +// "flac-gen-pic.$ext" +// ) +// file.writeBytes(it.data) +// println("done! ${file.path}") +// } + } +} diff --git a/app/src/test/java/io/github/zyrouge/metaphony/Mpeg4Test.kt b/app/src/test/java/io/github/zyrouge/metaphony/Mpeg4Test.kt new file mode 100644 index 00000000..6be82d84 --- /dev/null +++ b/app/src/test/java/io/github/zyrouge/metaphony/Mpeg4Test.kt @@ -0,0 +1,24 @@ +import io.github.zyrouge.metaphony.mpeg4.Mpeg4 +import org.junit.jupiter.api.Test + +class Mpeg4Test { + @Test + fun parse() { + val klassLoader = object {}.javaClass.classLoader!! + val stream = klassLoader.getResourceAsStream("audio-empty-2.m4a")!! + val metadata = Mpeg4.read(stream) + stream.close() + println(metadata.artists) +// println(metadata.rawUint8Atoms) +// println(metadata.rawPictureAtoms[0]) +// metadata.artworks.firstOrNull()?.let { +// val ext = it.format.name.lowercase() +// val file = File( +// klass.getResource(".")!!.path, +// "flac-gen-pic.$ext" +// ) +// file.writeBytes(it.data) +// println("done! ${file.path}") +// } + } +} diff --git a/app/src/test/java/io/github/zyrouge/metaphony/OggTest.kt b/app/src/test/java/io/github/zyrouge/metaphony/OggTest.kt new file mode 100644 index 00000000..92a88b62 --- /dev/null +++ b/app/src/test/java/io/github/zyrouge/metaphony/OggTest.kt @@ -0,0 +1,26 @@ +import io.github.zyrouge.metaphony.ogg.Ogg +import org.junit.jupiter.api.Test + +class OggTest { + @Test + fun parse() { + val klassLoader = object {}.javaClass.classLoader!! + val stream = klassLoader.getResourceAsStream("audio-empty.ogg")!! + val metadata = Ogg.read(stream) + stream.close() + println(metadata.trackNumber) + println(metadata.trackTotal) +// println(metadata.artists) +// println(metadata.rawUint8Atoms) +// println(metadata.rawPictureAtoms[0]) +// metadata.artworks.firstOrNull()?.let { +// val ext = it.format.name.lowercase() +// val file = File( +// klass.getResource(".")!!.path, +// "flac-gen-pic.$ext" +// ) +// file.writeBytes(it.data) +// println("done! ${file.path}") +// } + } +} diff --git a/app/src/test/resources/audio-id3v2.3.mp3 b/app/src/test/resources/audio-id3v2.3.mp3 new file mode 100644 index 00000000..0cb5c10e Binary files /dev/null and b/app/src/test/resources/audio-id3v2.3.mp3 differ diff --git a/app/src/test/resources/audio-id3v2.4.mp3 b/app/src/test/resources/audio-id3v2.4.mp3 new file mode 100644 index 00000000..5d2c9424 Binary files /dev/null and b/app/src/test/resources/audio-id3v2.4.mp3 differ diff --git a/app/src/test/resources/audio.flac b/app/src/test/resources/audio.flac new file mode 100644 index 00000000..379f9209 Binary files /dev/null and b/app/src/test/resources/audio.flac differ diff --git a/app/src/test/resources/audio.m4a b/app/src/test/resources/audio.m4a new file mode 100644 index 00000000..2a97cd0a Binary files /dev/null and b/app/src/test/resources/audio.m4a differ diff --git a/app/src/test/resources/audio.mp3 b/app/src/test/resources/audio.mp3 new file mode 100644 index 00000000..a8802753 Binary files /dev/null and b/app/src/test/resources/audio.mp3 differ diff --git a/app/src/test/resources/audio.ogg b/app/src/test/resources/audio.ogg new file mode 100644 index 00000000..9e4ae8a0 Binary files /dev/null and b/app/src/test/resources/audio.ogg differ diff --git a/build.gradle.kts b/build.gradle.kts index 39870705..0b2c9a5f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,6 @@ plugins { alias(libs.plugins.android.app) apply false alias(libs.plugins.android.kotlin) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.serialization) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca1394d9..29043584 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,24 +2,28 @@ activity-compose = "1.8.2" coil = "2.6.0" compose = "1.6.6" -compose-compiler = "1.5.12" compose-material = "1.6.6" compose-material3 = "1.3.0-alpha05" compose-navigation = "2.7.7" core = "1.13.0" core-splashscreen = "1.0.1" +documentfile = "1.0.1" fuzzywuzzy = "1.4.0" jaudiotagger = "3.0.1" +junit-jupiter = "5.11.0" +kotlinx-serialization-json = "1.7.3" lifecycle-runtime = "2.7.0" media = "1.7.0" okhttp3 = "4.12.0" android-gradle-plugin = "8.3.2" -kotlin-gradle-plugin = "1.9.23" +kotlin-gradle-plugin = "2.0.20" +kotlin-serialization-plugin = "2.0.20" compile-sdk = "34" min-sdk = "28" target-sdk = "34" +testng = "6.9.6" [libraries] activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } @@ -33,8 +37,11 @@ compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", versi compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" } core = { group = "androidx.core", name = "core-ktx", version.ref = "core" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" } +documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } fuzzywuzzy = { group = "me.xdrop", name = "fuzzywuzzy", version.ref = "fuzzywuzzy" } jaudiotagger = { group = "net.jthink", name = "jaudiotagger", version.ref = "jaudiotagger" } +junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit-jupiter" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } media = { group = "androidx.media", name = "media", version.ref = "media" } okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" } @@ -42,3 +49,5 @@ okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okht [plugins] android-app = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-gradle-plugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-gradle-plugin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" } diff --git a/i18n/en.toml b/i18n/en.toml index 3f02e5dd..6e87c391 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -228,3 +228,4 @@ Purple = "Purple" Fuchsia = "Fuchsia" Pink = "Pink" Rose = "Rose" +MediaFolders = "Media folders" diff --git a/package-lock.json b/package-lock.json index 4cccf25f..7f9e6422 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,386 +11,426 @@ "devDependencies": { "@types/archiver": "^6.0.2", "@types/fs-extra": "^11.0.4", - "@types/node": "^20.12.7", + "@types/node": "^22.5.0", "@zyrouge/phrasey-json": "^1.0.3", "@zyrouge/phrasey-locales-builder": "^1.1.10", "@zyrouge/phrasey-toml": "^1.0.3", "archiver": "^7.0.1", "fs-extra": "^11.2.0", "phrasey": "^2.0.26", - "picocolors": "^1.0.0", - "prettier": "^3.2.5", + "picocolors": "^1.0.1", + "prettier": "^3.3.3", "prettier-plugin-toml": "^2.0.1", - "tsx": "^4.7.2", - "typescript": "^5.4.5" + "tsx": "^4.18.0", + "typescript": "^5.5.4" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.10.tgz", - "integrity": "sha512-Q+mk96KJ+FZ30h9fsJl+67IjNJm3x2eX+GBWGmocAKgzp27cowCOOqSdscX80s0SpdFXZnIv/+1xD1EctFx96Q==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.10.tgz", - "integrity": "sha512-7W0bK7qfkw1fc2viBfrtAEkDKHatYfHzr/jKAHNr9BvkYDXPcC6bodtm8AyLJNNuqClLNaeTLuwURt4PRT9d7w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.10.tgz", - "integrity": "sha512-1X4CClKhDgC3by7k8aOWZeBXQX8dHT5QAMCAQDArCLaYfkppoARvh0fit3X2Qs+MXDngKcHv6XXyQCpY0hkK1Q==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.10.tgz", - "integrity": "sha512-O/nO/g+/7NlitUxETkUv/IvADKuZXyH4BHf/g/7laqKC4i/7whLpB0gvpPc2zpF0q9Q6FXS3TS75QHac9MvVWw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.10.tgz", - "integrity": "sha512-YSRRs2zOpwypck+6GL3wGXx2gNP7DXzetmo5pHXLrY/VIMsS59yKfjPizQ4lLt5vEI80M41gjm2BxrGZ5U+VMA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.10.tgz", - "integrity": "sha512-alfGtT+IEICKtNE54hbvPg13xGBe4GkVxyGWtzr+yHO7HIiRJppPDhOKq3zstTcVf8msXb/t4eavW3jCDpMSmA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.10.tgz", - "integrity": "sha512-dMtk1wc7FSH8CCkE854GyGuNKCewlh+7heYP/sclpOG6Cectzk14qdUIY5CrKDbkA/OczXq9WesqnPl09mj5dg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.10.tgz", - "integrity": "sha512-G5UPPspryHu1T3uX8WiOEUa6q6OlQh6gNl4CO4Iw5PS+Kg5bVggVFehzXBJY6X6RSOMS8iXDv2330VzaObm4Ag==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.10.tgz", - "integrity": "sha512-j6gUW5aAaPgD416Hk9FHxn27On28H4eVI9rJ4az7oCGTFW48+LcgNDBN+9f8rKZz7EEowo889CPKyeaD0iw9Kg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.10.tgz", - "integrity": "sha512-QxaouHWZ+2KWEj7cGJmvTIHVALfhpGxo3WLmlYfJ+dA5fJB6lDEIg+oe/0//FuyVHuS3l79/wyBxbHr0NgtxJQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.10.tgz", - "integrity": "sha512-4ub1YwXxYjj9h1UIZs2hYbnTZBtenPw5NfXCRgEkGb0b6OJ2gpkMvDqRDYIDRjRdWSe/TBiZltm3Y3Q8SN1xNg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.10.tgz", - "integrity": "sha512-lo3I9k+mbEKoxtoIbM0yC/MZ1i2wM0cIeOejlVdZ3D86LAcFXFRdeuZmh91QJvUTW51bOK5W2BznGNIl4+mDaA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.10.tgz", - "integrity": "sha512-J4gH3zhHNbdZN0Bcr1QUGVNkHTdpijgx5VMxeetSk6ntdt+vR1DqGmHxQYHRmNb77tP6GVvD+K0NyO4xjd7y4A==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.10.tgz", - "integrity": "sha512-tgT/7u+QhV6ge8wFMzaklOY7KqiyitgT1AUHMApau32ZlvTB/+efeCtMk4eXS+uEymYK249JsoiklZN64xt6oQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.10.tgz", - "integrity": "sha512-0f/spw0PfBMZBNqtKe5FLzBDGo0SKZKvMl5PHYQr3+eiSscfJ96XEknCe+JoOayybWUFQbcJTrk946i3j9uYZA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.10.tgz", - "integrity": "sha512-pZFe0OeskMHzHa9U38g+z8Yx5FNCLFtUnJtQMpwhS+r4S566aK2ci3t4NCP4tjt6d5j5uo4h7tExZMjeKoehAA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.10.tgz", - "integrity": "sha512-SpYNEqg/6pZYoc+1zLCjVOYvxfZVZj6w0KROZ3Fje/QrM3nfvT2llI+wmKSrWuX6wmZeTapbarvuNNK/qepSgA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.10.tgz", - "integrity": "sha512-ACbZ0vXy9zksNArWlk2c38NdKg25+L9pr/mVaj9SUq6lHZu/35nx2xnQVRGLrC1KKQqJKRIB0q8GspiHI3J80Q==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.10.tgz", - "integrity": "sha512-PxcgvjdSjtgPMiPQrM3pwSaG4kGphP+bLSb+cihuP0LYdZv1epbAIecHVl5sD3npkfYBZ0ZnOjR878I7MdJDFg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.10.tgz", - "integrity": "sha512-ZkIOtrRL8SEJjr+VHjmW0znkPs+oJXhlJbNwfI37rvgeMtk3sxOQevXPXjmAPZPigVTncvFqLMd+uV0IBSEzqA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.10.tgz", - "integrity": "sha512-+Sa4oTDbpBfGpl3Hn3XiUe4f8TU2JF7aX8cOfqFYMMjXp6ma6NJDztl5FDG8Ezx0OjwGikIHw+iA54YLDNNVfw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.10.tgz", - "integrity": "sha512-EOGVLK1oWMBXgfttJdPHDTiivYSjX6jDNaATeNOaCOFEVcfMjtbx7WVQwPSE1eIfCp/CaSF2nSrDtzc4I9f8TQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.10.tgz", - "integrity": "sha512-whqLG6Sc70AbU73fFYvuYzaE4MNMBIlR1Y/IrUeOXFrWHxBEjjbZaQ3IXIQS8wJdAzue2GwYZCjOrgrU1oUHoA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@iarna/toml": { @@ -532,12 +572,13 @@ } }, "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "22.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", + "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/readdir-glob": { @@ -737,12 +778,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -946,41 +988,43 @@ } }, "node_modules/esbuild": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.10.tgz", - "integrity": "sha512-S1Y27QGt/snkNYrRcswgRFqZjaTG5a5xM3EQo97uNBnH505pdzSNe/HLBq1v0RO7iK/ngdbhJB6mDAp0OK+iUA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.10", - "@esbuild/android-arm": "0.19.10", - "@esbuild/android-arm64": "0.19.10", - "@esbuild/android-x64": "0.19.10", - "@esbuild/darwin-arm64": "0.19.10", - "@esbuild/darwin-x64": "0.19.10", - "@esbuild/freebsd-arm64": "0.19.10", - "@esbuild/freebsd-x64": "0.19.10", - "@esbuild/linux-arm": "0.19.10", - "@esbuild/linux-arm64": "0.19.10", - "@esbuild/linux-ia32": "0.19.10", - "@esbuild/linux-loong64": "0.19.10", - "@esbuild/linux-mips64el": "0.19.10", - "@esbuild/linux-ppc64": "0.19.10", - "@esbuild/linux-riscv64": "0.19.10", - "@esbuild/linux-s390x": "0.19.10", - "@esbuild/linux-x64": "0.19.10", - "@esbuild/netbsd-x64": "0.19.10", - "@esbuild/openbsd-x64": "0.19.10", - "@esbuild/sunos-x64": "0.19.10", - "@esbuild/win32-arm64": "0.19.10", - "@esbuild/win32-ia32": "0.19.10", - "@esbuild/win32-x64": "0.19.10" + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" } }, "node_modules/event-target-shim": { @@ -1033,10 +1077,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -1089,10 +1134,11 @@ } }, "node_modules/get-tsconfig": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", - "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.6.tgz", + "integrity": "sha512-ZAqrLlu18NbDdRaHq+AKXzAmqIUPswPWKUchfytdAjiRFnCe5ojG2bstg6mRiZabkKfCoL/e98pbBELIV/YCeA==", "dev": true, + "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -1213,6 +1259,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -1338,12 +1385,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -1429,10 +1477,11 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -1447,10 +1496,11 @@ } }, "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -1574,6 +1624,7 @@ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -1792,6 +1843,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -1800,13 +1852,14 @@ } }, "node_modules/tsx": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.2.tgz", - "integrity": "sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.18.0.tgz", + "integrity": "sha512-a1jaKBSVQkd6yEc1/NI7G6yHFfefIcuf3QJST7ZEyn4oQnxLYrZR5uZAM8UrwUa3Ge8suiZHcNS1gNrEvmobqg==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "~0.19.10", - "get-tsconfig": "^4.7.2" + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" }, "bin": { "tsx": "dist/cli.mjs" @@ -1819,10 +1872,11 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1832,10 +1886,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.0", @@ -1999,163 +2054,170 @@ }, "dependencies": { "@esbuild/aix-ppc64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.10.tgz", - "integrity": "sha512-Q+mk96KJ+FZ30h9fsJl+67IjNJm3x2eX+GBWGmocAKgzp27cowCOOqSdscX80s0SpdFXZnIv/+1xD1EctFx96Q==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.10.tgz", - "integrity": "sha512-7W0bK7qfkw1fc2viBfrtAEkDKHatYfHzr/jKAHNr9BvkYDXPcC6bodtm8AyLJNNuqClLNaeTLuwURt4PRT9d7w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.10.tgz", - "integrity": "sha512-1X4CClKhDgC3by7k8aOWZeBXQX8dHT5QAMCAQDArCLaYfkppoARvh0fit3X2Qs+MXDngKcHv6XXyQCpY0hkK1Q==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.10.tgz", - "integrity": "sha512-O/nO/g+/7NlitUxETkUv/IvADKuZXyH4BHf/g/7laqKC4i/7whLpB0gvpPc2zpF0q9Q6FXS3TS75QHac9MvVWw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.10.tgz", - "integrity": "sha512-YSRRs2zOpwypck+6GL3wGXx2gNP7DXzetmo5pHXLrY/VIMsS59yKfjPizQ4lLt5vEI80M41gjm2BxrGZ5U+VMA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.10.tgz", - "integrity": "sha512-alfGtT+IEICKtNE54hbvPg13xGBe4GkVxyGWtzr+yHO7HIiRJppPDhOKq3zstTcVf8msXb/t4eavW3jCDpMSmA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.10.tgz", - "integrity": "sha512-dMtk1wc7FSH8CCkE854GyGuNKCewlh+7heYP/sclpOG6Cectzk14qdUIY5CrKDbkA/OczXq9WesqnPl09mj5dg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.10.tgz", - "integrity": "sha512-G5UPPspryHu1T3uX8WiOEUa6q6OlQh6gNl4CO4Iw5PS+Kg5bVggVFehzXBJY6X6RSOMS8iXDv2330VzaObm4Ag==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.10.tgz", - "integrity": "sha512-j6gUW5aAaPgD416Hk9FHxn27On28H4eVI9rJ4az7oCGTFW48+LcgNDBN+9f8rKZz7EEowo889CPKyeaD0iw9Kg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.10.tgz", - "integrity": "sha512-QxaouHWZ+2KWEj7cGJmvTIHVALfhpGxo3WLmlYfJ+dA5fJB6lDEIg+oe/0//FuyVHuS3l79/wyBxbHr0NgtxJQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.10.tgz", - "integrity": "sha512-4ub1YwXxYjj9h1UIZs2hYbnTZBtenPw5NfXCRgEkGb0b6OJ2gpkMvDqRDYIDRjRdWSe/TBiZltm3Y3Q8SN1xNg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.10.tgz", - "integrity": "sha512-lo3I9k+mbEKoxtoIbM0yC/MZ1i2wM0cIeOejlVdZ3D86LAcFXFRdeuZmh91QJvUTW51bOK5W2BznGNIl4+mDaA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.10.tgz", - "integrity": "sha512-J4gH3zhHNbdZN0Bcr1QUGVNkHTdpijgx5VMxeetSk6ntdt+vR1DqGmHxQYHRmNb77tP6GVvD+K0NyO4xjd7y4A==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.10.tgz", - "integrity": "sha512-tgT/7u+QhV6ge8wFMzaklOY7KqiyitgT1AUHMApau32ZlvTB/+efeCtMk4eXS+uEymYK249JsoiklZN64xt6oQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.10.tgz", - "integrity": "sha512-0f/spw0PfBMZBNqtKe5FLzBDGo0SKZKvMl5PHYQr3+eiSscfJ96XEknCe+JoOayybWUFQbcJTrk946i3j9uYZA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.10.tgz", - "integrity": "sha512-pZFe0OeskMHzHa9U38g+z8Yx5FNCLFtUnJtQMpwhS+r4S566aK2ci3t4NCP4tjt6d5j5uo4h7tExZMjeKoehAA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.10.tgz", - "integrity": "sha512-SpYNEqg/6pZYoc+1zLCjVOYvxfZVZj6w0KROZ3Fje/QrM3nfvT2llI+wmKSrWuX6wmZeTapbarvuNNK/qepSgA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.10.tgz", - "integrity": "sha512-ACbZ0vXy9zksNArWlk2c38NdKg25+L9pr/mVaj9SUq6lHZu/35nx2xnQVRGLrC1KKQqJKRIB0q8GspiHI3J80Q==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.10.tgz", - "integrity": "sha512-PxcgvjdSjtgPMiPQrM3pwSaG4kGphP+bLSb+cihuP0LYdZv1epbAIecHVl5sD3npkfYBZ0ZnOjR878I7MdJDFg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.10.tgz", - "integrity": "sha512-ZkIOtrRL8SEJjr+VHjmW0znkPs+oJXhlJbNwfI37rvgeMtk3sxOQevXPXjmAPZPigVTncvFqLMd+uV0IBSEzqA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.10.tgz", - "integrity": "sha512-+Sa4oTDbpBfGpl3Hn3XiUe4f8TU2JF7aX8cOfqFYMMjXp6ma6NJDztl5FDG8Ezx0OjwGikIHw+iA54YLDNNVfw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.10.tgz", - "integrity": "sha512-EOGVLK1oWMBXgfttJdPHDTiivYSjX6jDNaATeNOaCOFEVcfMjtbx7WVQwPSE1eIfCp/CaSF2nSrDtzc4I9f8TQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.10.tgz", - "integrity": "sha512-whqLG6Sc70AbU73fFYvuYzaE4MNMBIlR1Y/IrUeOXFrWHxBEjjbZaQ3IXIQS8wJdAzue2GwYZCjOrgrU1oUHoA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", "dev": true, "optional": true }, @@ -2273,12 +2335,12 @@ } }, "@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "22.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", + "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", "dev": true, "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "@types/readdir-glob": { @@ -2437,12 +2499,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "buffer": { @@ -2587,34 +2649,35 @@ } }, "esbuild": { - "version": "0.19.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.10.tgz", - "integrity": "sha512-S1Y27QGt/snkNYrRcswgRFqZjaTG5a5xM3EQo97uNBnH505pdzSNe/HLBq1v0RO7iK/ngdbhJB6mDAp0OK+iUA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", "dev": true, "requires": { - "@esbuild/aix-ppc64": "0.19.10", - "@esbuild/android-arm": "0.19.10", - "@esbuild/android-arm64": "0.19.10", - "@esbuild/android-x64": "0.19.10", - "@esbuild/darwin-arm64": "0.19.10", - "@esbuild/darwin-x64": "0.19.10", - "@esbuild/freebsd-arm64": "0.19.10", - "@esbuild/freebsd-x64": "0.19.10", - "@esbuild/linux-arm": "0.19.10", - "@esbuild/linux-arm64": "0.19.10", - "@esbuild/linux-ia32": "0.19.10", - "@esbuild/linux-loong64": "0.19.10", - "@esbuild/linux-mips64el": "0.19.10", - "@esbuild/linux-ppc64": "0.19.10", - "@esbuild/linux-riscv64": "0.19.10", - "@esbuild/linux-s390x": "0.19.10", - "@esbuild/linux-x64": "0.19.10", - "@esbuild/netbsd-x64": "0.19.10", - "@esbuild/openbsd-x64": "0.19.10", - "@esbuild/sunos-x64": "0.19.10", - "@esbuild/win32-arm64": "0.19.10", - "@esbuild/win32-ia32": "0.19.10", - "@esbuild/win32-x64": "0.19.10" + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" } }, "event-target-shim": { @@ -2658,9 +2721,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -2695,9 +2758,9 @@ "optional": true }, "get-tsconfig": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", - "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.6.tgz", + "integrity": "sha512-ZAqrLlu18NbDdRaHq+AKXzAmqIUPswPWKUchfytdAjiRFnCe5ojG2bstg6mRiZabkKfCoL/e98pbBELIV/YCeA==", "dev": true, "requires": { "resolve-pkg-maps": "^1.0.0" @@ -2877,12 +2940,12 @@ "dev": true }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" } }, @@ -2941,9 +3004,9 @@ } }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "picomatch": { @@ -2953,9 +3016,9 @@ "dev": true }, "prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true }, "prettier-plugin-toml": { @@ -3187,26 +3250,26 @@ } }, "tsx": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.2.tgz", - "integrity": "sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.18.0.tgz", + "integrity": "sha512-a1jaKBSVQkd6yEc1/NI7G6yHFfefIcuf3QJST7ZEyn4oQnxLYrZR5uZAM8UrwUa3Ge8suiZHcNS1gNrEvmobqg==", "dev": true, "requires": { - "esbuild": "~0.19.10", + "esbuild": "~0.23.0", "fsevents": "~2.3.3", - "get-tsconfig": "^4.7.2" + "get-tsconfig": "^4.7.5" } }, "typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true }, "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, "universalify": { diff --git a/package.json b/package.json index 4a81c86a..b92d8146 100644 --- a/package.json +++ b/package.json @@ -26,17 +26,17 @@ "devDependencies": { "@types/archiver": "^6.0.2", "@types/fs-extra": "^11.0.4", - "@types/node": "^20.12.7", + "@types/node": "^22.5.0", "@zyrouge/phrasey-json": "^1.0.3", "@zyrouge/phrasey-locales-builder": "^1.1.10", "@zyrouge/phrasey-toml": "^1.0.3", "archiver": "^7.0.1", "fs-extra": "^11.2.0", "phrasey": "^2.0.26", - "picocolors": "^1.0.0", - "prettier": "^3.2.5", + "picocolors": "^1.0.1", + "prettier": "^3.3.3", "prettier-plugin-toml": "^2.0.1", - "tsx": "^4.7.2", - "typescript": "^5.4.5" + "tsx": "^4.18.0", + "typescript": "^5.5.4" } }