ui: finish the library info dialogs and add playlist info

Sort names and titles are only shown when they differ from the regular
name, so artists without a distinct sort name no longer repeat the name.

Track info uses plural labels for both the artists and their sort name
when the track has more than one artist, and singular labels otherwise.
Added and last played sit together in album info.

Playlists gain an Info dialog with owner, sharing, song count, length,
creation and update dates. Owner and the public flag are synced from
getPlaylists, so a schema migration adds both columns.
This commit is contained in:
austinried
2026-10-21 13:54:37 +00:00
parent 8052e1db76
commit f46f43b6cd
15 changed files with 2367 additions and 11 deletions
@@ -745,6 +745,27 @@ class SubsonicXmlTest {
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
}
@Test
fun mapsPlaylistOwnerAndSharing() {
val playlists =
parse(
"""
<subsonic-response status="ok">
<playlists>
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
</playlists>
</subsonic-response>
""".trimIndent(),
tag = "playlist",
create = { SubsonicXml.playlist(1, it) },
)
assertEquals("austin", playlists[0].owner)
assertTrue(playlists[0].shared)
assertEquals(false, playlists[1].shared)
}
private class CountingInputStream(
input: InputStream,
) : FilterInputStream(input) {
@@ -258,6 +258,19 @@ class SyncServiceTest {
assertEquals(5678L, stored?.starred)
}
@Test
fun syncStoresPlaylistOwnerAndSharing() =
runTest {
insertSource()
val source = FakeMusicSource(playlists = listOf(playlist("p1").copy(owner = "austin", shared = true)))
SyncService(db, source).sync()
val stored = db.libraryDao().playlist(1, "p1").first()
assertEquals("austin", stored?.owner)
assertEquals(true, stored?.shared)
}
@Test
fun syncStoresSongGenresInOrder() =
runTest {
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -83,4 +84,35 @@ class ItemContextMenuInfoTest {
assertEquals("ar1", (opened as? MenuTarget.Artist)?.artist?.id)
}
@Test
fun aPlaylistMenuOpensItsInfo() {
var opened: MenuTarget? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target =
MenuTarget.Playlist(
Playlist(
sourceId = 1,
id = "pl1",
name = "Playlist",
comment = null,
coverArt = null,
songCount = 1,
created = 0,
),
),
actions = ItemActions(),
onDismiss = {},
onInfo = { opened = it },
)
}
}
composeRule.onNodeWithText("Info").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("pl1", (opened as? MenuTarget.Playlist)?.playlist?.id)
}
}
@@ -8,6 +8,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
@@ -83,4 +85,212 @@ class LibraryInfoDialogTest {
composeRule.onNodeWithText("4").assertIsDisplayed()
composeRule.onNodeWithText("40").assertIsDisplayed()
}
@Test
fun anArtistHidesASortNameThatMatchesItsName() {
composeRule.setContent {
SubtracksTheme {
ArtistInfoDialog(
artist =
Artist(
sourceId = 1,
id = "ar1",
name = "The Artist",
albumCount = 1,
starred = null,
sortName = "the artist",
),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Sort name").assertDoesNotExist()
}
@Test
fun anAlbumHidesSortNamesThatMatchTheRegularNames() {
composeRule.setContent {
SubtracksTheme {
AlbumInfoDialog(
album =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
sortName = "album",
albumArtistSort = "artist",
),
download = null,
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
composeRule.onNodeWithText("Album artist sort").assertDoesNotExist()
}
@Test
fun anAlbumsDatesAreListedTogether() {
composeRule.setContent {
SubtracksTheme {
AlbumInfoDialog(
album =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 86400,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
playCount = 3,
played = 172800,
),
download = null,
onDismiss = {},
)
}
}
val added = composeRule.onNodeWithText("Added").fetchSemanticsNode().boundsInRoot
val lastPlayed = composeRule.onNodeWithText("Last played").fetchSemanticsNode().boundsInRoot
val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot
assertTrue("last played must follow added", lastPlayed.top >= added.bottom)
assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom)
}
@Test
fun aPlaylistShowsOwnerSharingLengthAndDates() {
composeRule.setContent {
SubtracksTheme {
PlaylistInfoDialog(
playlist =
Playlist(
sourceId = 1,
id = "pl1",
name = "Mix",
comment = null,
coverArt = null,
songCount = 12,
created = 1600000000,
changed = 1700000000,
duration = 3725,
owner = "austin",
shared = true,
),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Owner").assertIsDisplayed()
composeRule.onNodeWithText("austin").assertIsDisplayed()
composeRule.onNodeWithText("Shared").assertIsDisplayed()
composeRule.onNodeWithText("Yes").assertIsDisplayed()
composeRule.onNodeWithText("12").assertIsDisplayed()
composeRule.onNodeWithText("1:02:05").assertIsDisplayed()
composeRule.onNodeWithText("Created").assertIsDisplayed()
composeRule.onNodeWithText("Updated").assertIsDisplayed()
}
@Test
fun aSingleArtistTrackUsesSingularLabels() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song = song(artists = listOf(ArtistRef("ar1", "Alpha")), artistSort = "Beta"),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artist").assertIsDisplayed()
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
composeRule.onNodeWithText("Artists").assertDoesNotExist()
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
}
@Test
fun aMixedArtistTrackUsesPluralLabelsForBothRows() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song =
song(
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
artistSort = "Alpha",
),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artists").assertIsDisplayed()
composeRule.onNodeWithText("Artists sort").assertIsDisplayed()
composeRule.onNodeWithText("Artist").assertDoesNotExist()
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
}
@Test
fun aTrackHidesATitleAndArtistSortThatMatchTheRegularNames() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song = song(artists = listOf(ArtistRef("ar1", "Artist")), sortName = "song", artistSort = "artist"),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
}
private fun song(
artists: List<ArtistRef>,
sortName: String = "Song",
artistSort: String = "Artist",
) = Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = artists.first().id,
title = "Song",
album = "Album",
artist = artists.first().name,
duration = 200,
track = 1,
disc = 1,
starred = null,
artists = artists,
sortName = sortName,
artistSort = artistSort,
)
}