mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
parse Subsonic dates without DatatypeFactory, reset list scroll on option change, fix search bar over the keyboard
This commit is contained in:
@@ -7,8 +7,6 @@ import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import org.w3c.dom.Document
|
||||
import org.w3c.dom.Element
|
||||
import javax.xml.datatype.DatatypeConfigurationException
|
||||
import javax.xml.datatype.DatatypeFactory
|
||||
|
||||
object SubsonicXml {
|
||||
fun artists(
|
||||
@@ -130,22 +128,46 @@ private fun Element.longAttr(name: String): Long? = getAttribute(name).takeIf {
|
||||
private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||
|
||||
internal object IsoDate {
|
||||
private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val factory: DatatypeFactory? =
|
||||
try {
|
||||
DatatypeFactory.newInstance()
|
||||
} catch (_: DatatypeConfigurationException) {
|
||||
null
|
||||
}
|
||||
private val pattern =
|
||||
Regex(
|
||||
"^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2}):(\\d{2})(?:\\.\\d+)?\\s*(Z|[+-]\\d{2}:?\\d{2})?)?$",
|
||||
)
|
||||
|
||||
fun parse(value: String): Long? {
|
||||
val factory = factory ?: return null
|
||||
return try {
|
||||
val zoned = if (timezone.containsMatchIn(value)) value else value + "Z"
|
||||
factory.newXMLGregorianCalendar(zoned).toGregorianCalendar().timeInMillis / 1000
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
val match = pattern.matchEntire(value.trim()) ?: return null
|
||||
val year = match.groupValues[1].toIntOrNull() ?: return null
|
||||
val month = match.groupValues[2].toIntOrNull() ?: return null
|
||||
val day = match.groupValues[3].toIntOrNull() ?: return null
|
||||
val hour = match.groupValues[4].toIntOrNull() ?: 0
|
||||
val minute = match.groupValues[5].toIntOrNull() ?: 0
|
||||
val second = match.groupValues[6].toIntOrNull() ?: 0
|
||||
if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) {
|
||||
return null
|
||||
}
|
||||
val days = daysFromCivil(year, month, day)
|
||||
return days * 86400L + hour * 3600L + minute * 60L + second - offsetSeconds(match.groupValues[7])
|
||||
}
|
||||
|
||||
private fun offsetSeconds(zone: String): Long {
|
||||
if (zone.isEmpty() || zone.equals("Z", ignoreCase = true)) return 0
|
||||
val digits = zone.drop(1).replace(":", "")
|
||||
if (digits.length != 4) return 0
|
||||
val hours = digits.take(2).toLongOrNull() ?: return 0
|
||||
val minutes = digits.drop(2).toLongOrNull() ?: return 0
|
||||
val sign = if (zone[0] == '-') -1L else 1L
|
||||
return sign * (hours * 3600 + minutes * 60)
|
||||
}
|
||||
|
||||
private fun daysFromCivil(
|
||||
year: Int,
|
||||
month: Int,
|
||||
day: Int,
|
||||
): Long {
|
||||
val y = if (month <= 2) year - 1 else year
|
||||
val era = (if (y >= 0) y else y - 399) / 400
|
||||
val yearOfEra = y - era * 400
|
||||
val dayOfYear = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
|
||||
val dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
|
||||
return era * 146097L + dayOfEra - 719468L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -147,7 +148,7 @@ private fun MainNavigation() {
|
||||
val navBarInset = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().imePadding()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
modifier =
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -60,6 +59,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -327,54 +327,56 @@ fun LibraryScreen(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
) { page ->
|
||||
when (LibraryTab.entries[page]) {
|
||||
LibraryTab.Albums -> {
|
||||
AlbumsContent(
|
||||
albums,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onAlbumClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
key(resetKey) {
|
||||
when (LibraryTab.entries[page]) {
|
||||
LibraryTab.Albums -> {
|
||||
AlbumsContent(
|
||||
albums,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onAlbumClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Artists -> {
|
||||
ArtistsContent(
|
||||
artists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onArtistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
LibraryTab.Artists -> {
|
||||
ArtistsContent(
|
||||
artists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onArtistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Songs -> {
|
||||
SongsContent(
|
||||
songs,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onSongClick,
|
||||
playingSongId,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
LibraryTab.Songs -> {
|
||||
SongsContent(
|
||||
songs,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onSongClick,
|
||||
playingSongId,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Playlists -> {
|
||||
PlaylistsContent(
|
||||
playlists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onPlaylistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
LibraryTab.Playlists -> {
|
||||
PlaylistsContent(
|
||||
playlists,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onPlaylistClick,
|
||||
filtered = filtersActive,
|
||||
onClearFilters = onClearFilters,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +395,6 @@ fun LibraryScreen(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,6 +220,19 @@ class SyncServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresStarredAndAddedTimestamps() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source = FakeMusicSource(albums = listOf(album("al1").copy(created = 1234, starred = 5678)))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val stored = db.libraryDao().album(1, "al1").first()
|
||||
assertEquals(1234L, stored?.created)
|
||||
assertEquals(5678L, stored?.starred)
|
||||
}
|
||||
|
||||
private suspend fun <T : Any> PagingSource<Int, T>.allRows(): List<T> {
|
||||
val page = load(PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false))
|
||||
return (page as PagingSource.LoadResult.Page).data
|
||||
|
||||
Reference in New Issue
Block a user