From 4960c6ec837d8664090a8ecdf47b8b984ed3b80c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:43:47 +0000 Subject: [PATCH 1/4] Browse Clementine's internet services Clementine can now be browsed like its Internet sidebar (SomaFM, Radio Browser, Jamendo, Subsonic, saved radio streams...), and put what's there on the playlist as a drag from the sidebar does. This adds an Internet screen, after the Library, shown when Clementine says it can be browsed (SERVER_FEATURE_BROWSE), as the iOS remote does. The top level lists the services, with their own icons. Tapping a node with children opens it; back goes up and asks for that level again, so Clementine keeps it up to date. Tapping a track or stream plays it, or adds it while Clementine is playing, as tapping a song in the library does. A long press on anything that can go on the playlist offers Play now, Play next, Add to playlist and Replace playlist; an opened album or playlist has Play and Add to playlist buttons, with how many items it has. Services that need setting up say so with Clementine's message. Large levels come in pages of up to 500, asked for as the last row shows. Node ids last only as long as the connection, so each new one starts again from the services, and answers from an earlier one are ignored. A node that's gone goes back up a level. The protocol file gains Clementine's browse messages. The store screenshots add the Internet screen's top level, as 10_internet. Release-note: Browse Clementine's internet services, such as radio stations, Jamendo and Subsonic, and play from them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- README.md | 1 + .../clementineremote/StoreScreenshots.java | 11 + .../backend/RemoteRepository.kt | 52 +++ .../backend/pb/ClementineMessageFactory.java | 34 ++ .../clementineremote/ui/browse/BrowseList.kt | 33 +- .../ui/internet/InternetBrowser.kt | 292 ++++++++++++++ .../ui/internet/InternetScreen.kt | 320 +++++++++++++++ .../ui/internet/InternetViewModel.kt | 102 +++++ .../clementineremote/ui/shell/AppShell.kt | 35 +- .../main/proto/remotecontrolmessages.proto | 139 +++++++ app/src/main/res/drawable/ic_auto_awesome.xml | 11 + app/src/main/res/drawable/ic_folder.xml | 11 + app/src/main/res/drawable/ic_public.xml | 11 + app/src/main/res/drawable/ic_sensors.xml | 11 + app/src/main/res/values/strings.xml | 14 + .../backend/RemoteRepositoryTest.kt | 36 ++ .../pb/ClementineMessageFactoryTest.java | 25 ++ .../ui/internet/InternetBrowserTest.kt | 376 ++++++++++++++++++ .../ui/internet/InternetScreenTest.kt | 230 +++++++++++ .../clementineremote/ui/shell/AppShellTest.kt | 37 ++ 20 files changed, 1765 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/de/qspool/clementineremote/ui/internet/InternetBrowser.kt create mode 100644 app/src/main/java/de/qspool/clementineremote/ui/internet/InternetScreen.kt create mode 100644 app/src/main/java/de/qspool/clementineremote/ui/internet/InternetViewModel.kt create mode 100644 app/src/main/res/drawable/ic_auto_awesome.xml create mode 100644 app/src/main/res/drawable/ic_folder.xml create mode 100644 app/src/main/res/drawable/ic_public.xml create mode 100644 app/src/main/res/drawable/ic_sensors.xml create mode 100644 app/src/test/java/de/qspool/clementineremote/ui/internet/InternetBrowserTest.kt create mode 100644 app/src/test/java/de/qspool/clementineremote/ui/internet/InternetScreenTest.kt diff --git a/README.md b/README.md index 3416c4b1..bd79d039 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ __All Features:__ * Control player * Download songs from Clementine to your phone * Browse your library +* Browse Clementine's internet services (radio stations, Jamendo, Subsonic, ...) and play from them * Search for songs * Displays the cover art * Read the lyrics diff --git a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java index f431e6c9..c96e4749 100644 --- a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java +++ b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java @@ -273,6 +273,12 @@ public void takeScreenshots() { waitFor(By.textStartsWith("Nocturne in")); screenshot("2_library_album"); + // Clementine's internet services, at the top level. + navigateTo("navInternet"); + waitFor(tag("internetNode")); + SystemClock.sleep(SETTLE_MILLIS); + screenshot("10_internet"); + navigateTo("navSearch"); search("Gymnopédie"); // Songs matched by title are listed straight away. @@ -299,6 +305,11 @@ public void takeScreenshots() { SystemClock.sleep(SETTLE_MILLIS); screenshot("dark_2_library"); + navigateTo("navInternet"); + waitFor(tag("internetNode")); + SystemClock.sleep(SETTLE_MILLIS); + screenshot("dark_10_internet"); + navigateTo("navQueue"); waitFor(By.text("Clair de lune").hasAncestor(tag("queueSongs"))); screenshot("dark_3_playlist"); diff --git a/app/src/main/java/de/qspool/clementineremote/backend/RemoteRepository.kt b/app/src/main/java/de/qspool/clementineremote/backend/RemoteRepository.kt index bb11ba40..2bd63cc4 100644 --- a/app/src/main/java/de/qspool/clementineremote/backend/RemoteRepository.kt +++ b/app/src/main/java/de/qspool/clementineremote/backend/RemoteRepository.kt @@ -8,11 +8,17 @@ import de.qspool.clementineremote.backend.listener.PlayerConnectionListener import de.qspool.clementineremote.backend.pb.ClementineMessage import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.OutputState +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowse +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowseAdd import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ServerFeature import de.qspool.clementineremote.backend.streaming.ThisRenderer import de.qspool.clementineremote.backend.player.MySong +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow /** @@ -60,6 +66,28 @@ object RemoteRepository { val active: Output? get() = outputs.firstOrNull { it.active } } + /** Whether Clementine's Internet sidebar can be browsed, on which connection. */ + data class Browsing( + /** Whether this Clementine can be browsed (REQUEST_BROWSE). */ + val supported: Boolean = false, + /** + * Counts connections (Clementine's INFO, sent on each). Node ids are valid only on the + * connection that gave them. + */ + val connection: Int = 0, + ) + + /** An answer to browsing Clementine's Internet sidebar, and the connection it came on. */ + sealed interface BrowseMessage { + val connection: Int + + /** A node's children, asked for or changed. */ + data class Browse(override val connection: Int, val response: ResponseBrowse) : BrowseMessage + + /** How putting nodes on the playlist went. */ + data class AddResult(override val connection: Int, val response: ResponseBrowseAdd) : BrowseMessage + } + /** The output id of Clementine's own computer. */ const val LOCAL_OUTPUT = "local" @@ -89,6 +117,21 @@ object RemoteRepository { @JvmStatic val outputs: StateFlow = _outputs.asStateFlow() + private val _browsing = MutableStateFlow(Browsing()) + + /** Whether Clementine's Internet sidebar can be browsed; a new connection each INFO. */ + @JvmStatic + val browsing: StateFlow = _browsing.asStateFlow() + + private val _browseMessages = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Clementine's answers to browsing its Internet sidebar, in the order they came. */ + @JvmStatic + val browseMessages: SharedFlow = _browseMessages.asSharedFlow() + /** Whether Clementine plays on this phone (remote streaming). */ @JvmStatic fun isPlayingHere(): Boolean = _outputs.value.active?.isThisPhone == true @@ -123,8 +166,17 @@ object RemoteRepository { if (supported) { send(ClementineMessage.getMessage(MsgType.REQUEST_OUTPUTS)) } + _browsing.value = Browsing( + supported = ServerFeature.SERVER_FEATURE_BROWSE in + message.message.responseClementineInfo.featuresList, + connection = _browsing.value.connection + 1, + ) refresh() } + MsgType.BROWSE -> _browseMessages.tryEmit( + BrowseMessage.Browse(_browsing.value.connection, message.message.responseBrowse)) + MsgType.BROWSE_ADD_RESULT -> _browseMessages.tryEmit( + BrowseMessage.AddResult(_browsing.value.connection, message.message.responseBrowseAdd)) MsgType.OUTPUTS -> _outputs.value = _outputs.value.copy( outputs = message.message.responseOutputs.outputsList.map { Output( diff --git a/app/src/main/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactory.java b/app/src/main/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactory.java index 77eff8c5..10e403aa 100644 --- a/app/src/main/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactory.java +++ b/app/src/main/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactory.java @@ -18,12 +18,16 @@ package de.qspool.clementineremote.backend.pb; import java.util.LinkedList; +import java.util.List; import de.qspool.clementineremote.App; +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.DownloadItem; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Message; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Repeat; +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RequestBrowse; +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RequestBrowseAdd; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RequestChangeSong; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RequestConnect; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RendererCapabilities; @@ -386,4 +390,34 @@ public static ClementineMessage buildSetOutput(String outputId) { msg.setRequestSetOutput(RequestSetOutput.newBuilder().setOutputId(outputId)); return new ClementineMessage(msg); } + + /** + * Asks for the children of a node of Clementine's Internet sidebar, from offset on, as many + * as Clementine sends at once. Clementine keeps sending them as they change, until another + * node is asked for. + * + * @param nodeId the node, or empty for the services + */ + public static ClementineMessage buildBrowse(String nodeId, int offset) { + Message.Builder msg = ClementineMessage.getMessageBuilder(MsgType.REQUEST_BROWSE); + RequestBrowse.Builder request = RequestBrowse.newBuilder(); + if (nodeId != null && !nodeId.isEmpty()) { + request.setNodeId(nodeId); + } + if (offset > 0) { + request.setOffset(offset); + } + msg.setRequestBrowse(request); + return new ClementineMessage(msg); + } + + /** + * Puts nodes of Clementine's Internet sidebar on its current playlist, as dragging them there + * does: appended, played, queued to play next, or replacing the playlist. + */ + public static ClementineMessage buildBrowseAdd(List nodeIds, BrowseAddAction action) { + Message.Builder msg = ClementineMessage.getMessageBuilder(MsgType.REQUEST_BROWSE_ADD); + msg.setRequestBrowseAdd(RequestBrowseAdd.newBuilder().addAllNodeIds(nodeIds).setAction(action)); + return new ClementineMessage(msg); + } } diff --git a/app/src/main/java/de/qspool/clementineremote/ui/browse/BrowseList.kt b/app/src/main/java/de/qspool/clementineremote/ui/browse/BrowseList.kt index 75d99047..e1f7c70a 100644 --- a/app/src/main/java/de/qspool/clementineremote/ui/browse/BrowseList.kt +++ b/app/src/main/java/de/qspool/clementineremote/ui/browse/BrowseList.kt @@ -1,5 +1,6 @@ package de.qspool.clementineremote.ui.browse +import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -31,6 +32,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -112,23 +114,32 @@ internal fun BrowseRow( @Composable private fun Leading(kind: ItemKind, item: SongSelectItem) { val icon = remember(item) { item.icon?.asImageBitmap() } - val shape = if (kind == ItemKind.SONG) RoundedCornerShape(8.dp) else CircleShape - val size = if (kind == ItemKind.SONG) 48.dp else 40.dp - val background = if (kind == ItemKind.SONG) { + val drawable = when (kind) { + ItemKind.ARTIST -> R.drawable.ic_person + ItemKind.ALBUM, ItemKind.YEAR -> R.drawable.ic_album + ItemKind.SOURCE, ItemKind.GENRE, ItemKind.SONG -> R.drawable.ic_music_note + } + BrowseTile(drawable, playable = kind == ItemKind.SONG, image = icon) +} + +/** + * A row's tile: a rounded square for something that plays (a song), a circle for what groups + * others. It shows [image], if there is one, or else [icon]. + */ +@Composable +internal fun BrowseTile(@DrawableRes icon: Int, playable: Boolean, image: ImageBitmap? = null) { + val shape = if (playable) RoundedCornerShape(8.dp) else CircleShape + val size = if (playable) 48.dp else 40.dp + val background = if (playable) { MaterialTheme.colorScheme.surfaceContainerHighest } else { MaterialTheme.colorScheme.secondaryContainer } Box(Modifier.size(size).clip(shape).background(background), contentAlignment = Alignment.Center) { - if (icon != null) { - Image(icon, contentDescription = null, modifier = Modifier.size(24.dp)) + if (image != null) { + Image(image, contentDescription = null, modifier = Modifier.size(24.dp)) } else { - val drawable = when (kind) { - ItemKind.ARTIST -> R.drawable.ic_person - ItemKind.ALBUM, ItemKind.YEAR -> R.drawable.ic_album - ItemKind.SOURCE, ItemKind.GENRE, ItemKind.SONG -> R.drawable.ic_music_note - } - Icon(painterResource(drawable), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icon(painterResource(icon), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } } diff --git a/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetBrowser.kt b/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetBrowser.kt new file mode 100644 index 00000000..f791ad43 --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetBrowser.kt @@ -0,0 +1,292 @@ +package de.qspool.clementineremote.ui.internet + +import com.google.protobuf.ByteString +import de.qspool.clementineremote.backend.pb.ClementineMessage +import de.qspool.clementineremote.backend.pb.ClementineMessageFactory +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddResult +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseChildren +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseNode +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseNodeKind +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowsePlayability +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseState +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowse +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowseAdd + +/** What a node of Clementine's Internet sidebar is, for its icon. */ +enum class NodeKind { SERVICE, FOLDER, TRACK, STREAM, SMART_PLAYLIST } + +/** A node of the tree Clementine's Internet sidebar shows: a service, or something below one. */ +data class InternetNode( + /** Clementine's id for it, valid on this connection only. */ + val id: String, + val title: String, + /** A second line, such as a track's artist; null when there's none. */ + val subtitle: String? = null, + val kind: NodeKind = NodeKind.FOLDER, + /** Whether it opens, to the nodes below it. */ + val hasChildren: Boolean = false, + /** Whether it can go on the playlist. */ + val addable: Boolean = false, + /** A service's own icon, as a PNG. */ + val icon: ByteString? = null, +) { + companion object { + fun of(node: BrowseNode) = InternetNode( + id = node.nodeId, + title = node.title, + subtitle = node.subtitle.takeIf { node.hasSubtitle() && it.isNotBlank() }, + kind = when (node.kind) { + BrowseNodeKind.BROWSE_NODE_KIND_SERVICE -> NodeKind.SERVICE + BrowseNodeKind.BROWSE_NODE_KIND_TRACK -> NodeKind.TRACK + BrowseNodeKind.BROWSE_NODE_KIND_STREAM -> NodeKind.STREAM + BrowseNodeKind.BROWSE_NODE_KIND_SMART_PLAYLIST -> NodeKind.SMART_PLAYLIST + else -> NodeKind.FOLDER + }, + hasChildren = node.children == BrowseChildren.BROWSE_CHILDREN_SOME, + addable = node.playability == BrowsePlayability.BROWSE_PLAYABILITY_ADDABLE, + icon = node.iconPng.takeIf { node.hasIconPng() && it.size() > 0 }, + ) + } +} + +/** Where a level's nodes stand. */ +enum class LevelStatus { + /** Still loading: before Clementine's first answer, or while it loads more. */ + LOADING, + READY, + + /** The service has to be set up on the computer first. */ + NEEDS_SETUP, +} + +/** A level of the tree: the services, or the nodes below one that was opened. */ +data class InternetLevel( + /** The node opened; null for the services. */ + val opened: InternetNode? = null, + val status: LevelStatus = LevelStatus.LOADING, + /** The nodes loaded so far, from the first. */ + val nodes: List = emptyList(), + /** How many nodes there are in all. */ + val totalCount: Int = 0, + /** With [LevelStatus.NEEDS_SETUP], Clementine's sentence saying how. */ + val message: String? = null, +) { + /** Clementine's id for this level: empty for the services. */ + val id: String get() = opened?.id.orEmpty() + + /** Whether there are more nodes than those loaded. */ + val hasMore: Boolean get() = totalCount > nodes.size +} + +/** What tapping a node does. */ +sealed interface TapAction { + /** Opens it, to the nodes below it. */ + data object Open : TapAction + + /** Puts it on the playlist. */ + data class Add(val action: BrowseAddAction) : TapAction + + /** Nothing: it neither opens nor can go on the playlist. */ + data object None : TapAction +} + +/** What to tell the user after putting something on the playlist. */ +enum class AddMessage { + /** Added to the end of the playlist. */ + ADDED, + + /** Queued to play next. */ + PLAYING_NEXT, + + /** Clementine couldn't put it on the playlist. */ + NOT_PLAYABLE, + + /** It's no longer in Clementine. */ + GONE, +} + +/** + * Browses Clementine's Internet sidebar, level by level, over the connection: asks Clementine + * for a level's nodes ([send]), and follows its answers. Clementine keeps sending the level last + * asked for as it changes, so going back up asks for the level shown again. Node ids last only as + * long as the connection: a new one ([reset]) starts again from the services. + * + * Not thread safe: its methods are called on one thread, the main thread in the app. + */ +class InternetBrowser(private val send: (ClementineMessage) -> Unit) { + + /** The levels opened, from the services down; the last is shown. Never empty. */ + var levels: List = listOf(InternetLevel()) + private set + + val shown: InternetLevel get() = levels.last() + + /** Whether Clementine can be browsed. */ + var supported: Boolean = false + private set + + /** The offset of the next page asked for the level shown, until it comes. */ + private var pageAsked: Int? = null + + /** What was put on the playlist, and how, until Clementine says how it went. */ + private val adding = ArrayDeque, BrowseAddAction>>() + + /** + * A new connection, to a Clementine that can be browsed or not: forgets every id, and shows + * the services, asking for them if it can. + */ + fun reset(supported: Boolean) { + this.supported = supported + levels = listOf(InternetLevel()) + pageAsked = null + adding.clear() + if (supported) { + ask(shown) + } + } + + /** Asks for the level shown again, from its first node, keeping what's shown meanwhile. */ + fun refresh() { + if (supported) { + pageAsked = null + ask(shown) + } + } + + /** Opens [node] if it has nodes below it; it shows loading until Clementine answers. */ + fun open(node: InternetNode) { + if (!node.hasChildren) { + return + } + levels = levels + InternetLevel(opened = node) + pageAsked = null + ask(shown) + } + + /** Goes up a level, asking for it again; false at the services. */ + fun back(): Boolean { + if (levels.size <= 1) { + return false + } + levels = levels.dropLast(1) + pageAsked = null + ask(shown) + return true + } + + /** The last node loaded is shown: asks for the next page, if there's more and it isn't asked for yet. */ + fun loadMore() { + val level = shown + if (!level.hasMore || pageAsked == level.nodes.size) { + return + } + pageAsked = level.nodes.size + ask(level, offset = level.nodes.size) + } + + /** Taps [node]: opens it, or puts it on the playlist, or nothing (see [tapAction]). */ + fun tap(node: InternetNode, playing: Boolean) { + when (val action = tapAction(node, playing)) { + TapAction.Open -> open(node) + is TapAction.Add -> add(node, action.action) + TapAction.None -> {} + } + } + + /** Puts [node] (and everything below it) on the playlist, as [action] says. */ + fun add(node: InternetNode, action: BrowseAddAction) { + if (!node.addable) { + return + } + val ids = listOf(node.id) + adding.addLast(ids to action) + send(ClementineMessageFactory.buildBrowseAdd(ids, action)) + } + + /** Clementine's answer to a request for a level, or the level it watches, changed. */ + fun onBrowse(response: ResponseBrowse) { + val level = shown + // Answers for a level no longer shown: Clementine watches only the last asked for. + if (response.nodeId != level.id) { + return + } + if (response.state == BrowseState.BROWSE_STATE_GONE) { + if (!back()) { + // The services never go; ask for them again, all the same. + refresh() + } + return + } + if (pageAsked == response.offset) { + pageAsked = null + } + val status = when (response.state) { + BrowseState.BROWSE_STATE_LOADING -> LevelStatus.LOADING + BrowseState.BROWSE_STATE_NEEDS_SETUP -> LevelStatus.NEEDS_SETUP + else -> LevelStatus.READY + } + val nodes = merge(level.nodes, response.offset, response.nodesList.map(InternetNode::of), response.totalCount) + ?: return + levels = levels.dropLast(1) + level.copy( + status = status, + nodes = nodes, + totalCount = response.totalCount, + message = response.message.takeIf { status == LevelStatus.NEEDS_SETUP && it.isNotBlank() }, + ) + } + + /** + * How putting nodes on the playlist went: what to tell the user, if anything. When the nodes + * are gone, the level shown is asked for again. + */ + fun onAddResult(response: ResponseBrowseAdd): AddMessage? { + val ids = response.nodeIdsList.toList() + val index = adding.indexOfFirst { it.first == ids } + val action = if (index >= 0) adding.removeAt(index).second else BrowseAddAction.BROWSE_ADD_ACTION_APPEND + return when (response.result) { + BrowseAddResult.BROWSE_ADD_RESULT_ADDED -> when (action) { + BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT -> AddMessage.PLAYING_NEXT + // Playing it shows in the player. + BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NOW, BrowseAddAction.BROWSE_ADD_ACTION_REPLACE -> null + else -> AddMessage.ADDED + } + BrowseAddResult.BROWSE_ADD_RESULT_GONE -> { + refresh() + AddMessage.GONE + } + else -> AddMessage.NOT_PLAYABLE + } + } + + private fun ask(level: InternetLevel, offset: Int = 0) { + send(ClementineMessageFactory.buildBrowse(level.id, offset)) + } + + companion object { + /** + * What tapping [node] does: opens it if it has nodes below it; otherwise, if it can go on + * the playlist, plays it if Clementine isn't [playing], or adds it to the end if it is, + * as tapping a song in the library does. + */ + fun tapAction(node: InternetNode, playing: Boolean): TapAction = when { + node.hasChildren -> TapAction.Open + node.addable -> TapAction.Add( + if (playing) BrowseAddAction.BROWSE_ADD_ACTION_APPEND else BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NOW) + else -> TapAction.None + } + + /** + * The nodes of a level once a page of them comes: [page] replaces the nodes from [offset] + * on, and the list is cut to [total]. Null if the page starts past the nodes loaded, which + * would leave a gap. + */ + fun merge(loaded: List, offset: Int, page: List, total: Int): List? { + if (offset < 0 || offset > loaded.size) { + return null + } + val merged = loaded.take(offset) + page + loaded.drop(offset + page.size) + return merged.take(maxOf(total, 0)) + } + } +} diff --git a/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetScreen.kt b/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetScreen.kt new file mode 100644 index 00000000..d8eee5c7 --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetScreen.kt @@ -0,0 +1,320 @@ +package de.qspool.clementineremote.ui.internet + +import android.graphics.BitmapFactory +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.qspool.clementineremote.R +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction +import de.qspool.clementineremote.ui.browse.BrowseTile +import de.qspool.clementineremote.ui.browse.rememberLevelListState + +/** + * Internet: Clementine's internet services (SomaFM, Radio Browser, Subsonic, saved radio + * streams...) and what's below them, as its Internet sidebar shows them. Tapping a node opens it, + * or plays a track or stream (adds it, while Clementine is playing); a long press puts it on the + * playlist in other ways. Pulling down asks for the level again. + */ +@Composable +fun InternetScreen(viewModel: InternetViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + LaunchedEffect(viewModel) { + viewModel.added.collect { message -> + val text = when (message) { + AddMessage.ADDED -> R.string.internet_added + AddMessage.PLAYING_NEXT -> R.string.internet_playing_next + AddMessage.NOT_PLAYABLE -> R.string.internet_not_playable + AddMessage.GONE -> R.string.internet_gone + } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + } + InternetContent( + state, + onTap = viewModel::tap, + onAdd = viewModel::add, + onBack = { viewModel.back() }, + onRefresh = viewModel::refresh, + onLoadMore = viewModel::loadMore, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InternetContent( + state: InternetState, + onTap: (InternetNode) -> Unit, + onAdd: (InternetNode, BrowseAddAction) -> Unit, + onBack: () -> Unit, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + val shown = state.shown + // Held here, so a level keeps its place while it's loading or empty. + val listState = rememberLevelListState(state.levels.size) + + Column(modifier.fillMaxSize()) { + // Loading more of what's shown. + if (shown.status == LevelStatus.LOADING && shown.nodes.isNotEmpty()) { + LinearProgressIndicator(Modifier.fillMaxWidth().testTag("internetProgress")) + } + val opened = shown.opened + // How many items, once Clementine says. + val count = shown.totalCount.takeUnless { shown.status == LevelStatus.LOADING && shown.nodes.isEmpty() } + if (opened == null) Top() else Opened(opened, count, onBack, onAdd) + + PullToRefreshBox( + // Clementine's answer replaces what's shown; the pull only asks for it. + isRefreshing = false, + onRefresh = onRefresh, + modifier = Modifier.weight(1f).fillMaxWidth().testTag("internetRefresh"), + ) { + when { + shown.status == LevelStatus.NEEDS_SETUP -> Empty( + stringResource(R.string.internet_needs_setup), + shown.message, + "internetNeedsSetup", + ) + shown.nodes.isNotEmpty() -> LazyColumn(Modifier.fillMaxSize().testTag("internetNodes"), state = listState) { + itemsIndexed(shown.nodes) { index, node -> + NodeRow(node, onTap = { onTap(node) }, onAdd = { onAdd(node, it) }) + if (index == shown.nodes.lastIndex && shown.hasMore) { + LaunchedEffect(shown.nodes.size) { onLoadMore() } + } + } + } + shown.status == LevelStatus.LOADING -> Loading() + else -> Empty(stringResource(R.string.internet_empty), null, "internetEmpty") + } + } + } +} + +@Composable +private fun Top() { + Text( + stringResource(R.string.internet_title), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp) + .semantics { heading() }, + ) +} + +/** + * The header of an opened node: back, and its title. One that can go on the playlist, such as an + * album or a playlist, also says how many items it has ([count], once known), and plays or adds + * them all. + */ +@Composable +private fun Opened( + opened: InternetNode, + count: Int?, + onBack: () -> Unit, + onAdd: (InternetNode, BrowseAddAction) -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, end = 16.dp)) { + IconButton(onClick = onBack, modifier = Modifier.testTag("internetBack")) { + Icon(painterResource(R.drawable.ic_arrow_back), stringResource(R.string.library_back)) + } + Column(Modifier.weight(1f).padding(start = 4.dp, top = 8.dp, bottom = 8.dp)) { + Text( + opened.title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { heading() }.testTag("internetTitle"), + ) + if (opened.addable && count != null) { + Text( + pluralStringResource(R.plurals.number_items, count, count), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("internetCount"), + ) + } + } + } + if (opened.addable) { + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { onAdd(opened, BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NOW) }, + modifier = Modifier.testTag("internetPlayAll"), + ) { + Icon(painterResource(R.drawable.ic_player_play), contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.internet_play), modifier = Modifier.padding(start = 8.dp)) + } + FilledTonalButton( + onClick = { onAdd(opened, BrowseAddAction.BROWSE_ADD_ACTION_APPEND) }, + modifier = Modifier.testTag("internetAddAll"), + ) { + Icon(painterResource(R.drawable.ic_add), contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.library_add_to_playlist), modifier = Modifier.padding(start = 8.dp)) + } + } + } + } +} + +/** + * A node: tapping it opens it or puts it on the playlist; a long press on one that can go on + * the playlist offers the ways it can. One that does neither is shown, but does nothing. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun NodeRow(node: InternetNode, onTap: () -> Unit, onAdd: (BrowseAddAction) -> Unit) { + var menu by remember { mutableStateOf(false) } + val actions = stringResource(R.string.shell_more) + Box { + ListItem( + headlineContent = { Text(node.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = if (node.subtitle != null) { + { Text(node.subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } else { + null + }, + leadingContent = { NodeTile(node) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier + .then( + if (node.hasChildren || node.addable) { + Modifier.combinedClickable( + onClick = onTap, + onLongClickLabel = if (node.addable) actions else null, + onLongClick = if (node.addable) ({ menu = true }) else null, + ) + } else { + Modifier + }, + ) + .testTag("internetNode"), + ) + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + listOf( + BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NOW to R.string.internet_play_now, + BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT to R.string.internet_play_next, + BrowseAddAction.BROWSE_ADD_ACTION_APPEND to R.string.library_add_to_playlist, + BrowseAddAction.BROWSE_ADD_ACTION_REPLACE to R.string.internet_replace_playlist, + ).forEach { (action, label) -> + DropdownMenuItem( + text = { Text(stringResource(label)) }, + onClick = { + menu = false + onAdd(action) + }, + modifier = Modifier.testTag("internetMenu_${action.name}"), + ) + } + } + } +} + +/** A node's tile: a service's own icon (or a globe), or an icon for what kind of node it is. */ +@Composable +private fun NodeTile(node: InternetNode) { + val image = remember(node.icon) { + node.icon?.toByteArray()?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }?.asImageBitmap() + } + val icon = when (node.kind) { + NodeKind.SERVICE -> R.drawable.ic_public + NodeKind.FOLDER -> R.drawable.ic_folder + NodeKind.TRACK -> R.drawable.ic_music_note + NodeKind.STREAM -> R.drawable.ic_sensors + NodeKind.SMART_PLAYLIST -> R.drawable.ic_auto_awesome + } + BrowseTile(icon, playable = node.kind == NodeKind.TRACK || node.kind == NodeKind.STREAM, image = image) +} + +/** Nothing from Clementine yet. Scrollable, so pulling down still works. */ +@Composable +private fun Loading() { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp).testTag("internetLoading"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + stringResource(R.string.internet_loading), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** A level with nothing to show: [title], and [message] below it. Scrollable, so pulling down still works. */ +@Composable +private fun Empty(title: String, message: String?, tag: String) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp).testTag(tag), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + if (!message.isNullOrBlank()) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetViewModel.kt b/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetViewModel.kt new file mode 100644 index 00000000..8f1f112b --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/ui/internet/InternetViewModel.kt @@ -0,0 +1,102 @@ +package de.qspool.clementineremote.ui.internet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.qspool.clementineremote.backend.RemoteRepository +import de.qspool.clementineremote.backend.RemoteRepository.BrowseMessage +import de.qspool.clementineremote.backend.RemoteRepository.Browsing +import de.qspool.clementineremote.backend.pb.ClementineMessage +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** The Internet screen's state: the levels opened, from the services down. */ +data class InternetState( + val levels: List = listOf(InternetLevel()), +) { + val shown: InternetLevel get() = levels.last() +} + +/** + * Browses Clementine's Internet sidebar (its services, such as SomaFM or Subsonic, and what's + * below them) and puts what's picked on the playlist, following the connection: each new one + * starts again from the services. + */ +class InternetViewModel( + send: (ClementineMessage) -> Unit = RemoteRepository::send, + private val browsing: StateFlow = RemoteRepository.browsing, + private val messages: Flow = RemoteRepository.browseMessages, + /** Whether Clementine is playing, so a tap adds to the playlist rather than plays. */ + private val playing: () -> Boolean = { RemoteRepository.nowPlaying.value.isPlaying }, +) : ViewModel() { + + private val browser = InternetBrowser(send) + + private val _state = MutableStateFlow(InternetState()) + + val state: StateFlow = _state.asStateFlow() + + private val _added = Channel(Channel.BUFFERED) + + /** What to tell the user after putting something on the playlist. */ + val added: Flow = _added.receiveAsFlow() + + /** The connection browsed; answers from another are ignored. */ + private var connection = -1 + + init { + // Listening before asking for anything. + viewModelScope.launch { + messages.collect { message -> + if (message.connection != connection) { + return@collect + } + when (message) { + is BrowseMessage.Browse -> update { browser.onBrowse(message.response) } + is BrowseMessage.AddResult -> { + var result: AddMessage? = null + update { result = browser.onAddResult(message.response) } + result?.let { _added.trySend(it) } + } + } + } + } + viewModelScope.launch { + browsing.collect { + if (it.connection != connection) { + connection = it.connection + update { browser.reset(it.supported) } + } + } + } + } + + /** Asks for the level shown again: when the screen shows, and when pulled down. */ + fun refresh() = update { browser.refresh() } + + /** Taps a node: opens it, plays it or adds it to the playlist, or nothing. */ + fun tap(node: InternetNode) = update { browser.tap(node, playing()) } + + /** Puts a node on the playlist, as [action] says. */ + fun add(node: InternetNode, action: BrowseAddAction) = update { browser.add(node, action) } + + /** Goes up a level; false at the services. */ + fun back(): Boolean { + var went = false + update { went = browser.back() } + return went + } + + /** The last node loaded is shown: loads more, if there are more. */ + fun loadMore() = update { browser.loadMore() } + + private inline fun update(change: () -> Unit) { + change() + _state.value = InternetState(browser.levels) + } +} diff --git a/app/src/main/java/de/qspool/clementineremote/ui/shell/AppShell.kt b/app/src/main/java/de/qspool/clementineremote/ui/shell/AppShell.kt index 8458e6b1..5f93fb95 100644 --- a/app/src/main/java/de/qspool/clementineremote/ui/shell/AppShell.kt +++ b/app/src/main/java/de/qspool/clementineremote/ui/shell/AppShell.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffo import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -82,6 +83,8 @@ import de.qspool.clementineremote.ui.hints.Hint import de.qspool.clementineremote.ui.hints.HintBox import de.qspool.clementineremote.ui.downloads.DownloadsScreen import de.qspool.clementineremote.ui.downloads.DownloadsViewModel +import de.qspool.clementineremote.ui.internet.InternetScreen +import de.qspool.clementineremote.ui.internet.InternetViewModel import de.qspool.clementineremote.ui.library.LibraryScreen import de.qspool.clementineremote.ui.library.LibraryViewModel import de.qspool.clementineremote.ui.player.PlayerActions @@ -97,6 +100,8 @@ import de.qspool.clementineremote.ui.search.SearchViewModel enum class Destination(@StringRes val label: Int, @DrawableRes val icon: Int, val tag: String) { QUEUE(R.string.nav_queue, R.drawable.ic_queue_music, "navQueue"), LIBRARY(R.string.library_title, R.drawable.ic_library_music, "navLibrary"), + /** Only when Clementine can be browsed (see [RemoteRepository.browsing]). */ + INTERNET(R.string.internet_title, R.drawable.ic_public, "navInternet"), SEARCH(R.string.menu_search, R.drawable.ic_search, "navSearch"), DOWNLOADS(R.string.downloads_title, R.drawable.ic_download, "navDownloads"), } @@ -115,10 +120,11 @@ interface ShellActions : ConnectionActions { } /** - * The app once connected: the queue, library, search and downloads, switched between with the - * navigation bar (a rail on wide screens), the connection chip at the top, and the mini player - * at the bottom, which opens the player full screen. The chip opens the connection sheet; the - * player opens the song details sheet. + * The app once connected: the queue, library, internet services (when Clementine can browse + * them), search and downloads, switched between with the navigation bar (a rail on wide + * screens), the connection chip at the top, and the mini player at the bottom, which opens the + * player full screen. The chip opens the connection sheet; the player opens the song details + * sheet. */ @Composable fun AppShell(shell: ShellViewModel, actions: ShellActions) { @@ -130,14 +136,21 @@ fun AppShell(shell: ShellViewModel, actions: ShellActions) { var choosingDownload by rememberSaveable { mutableStateOf(false) } var outputsOpen by rememberSaveable { mutableStateOf(false) } val outputs by RemoteRepository.outputs.collectAsStateWithLifecycle() + val browsing by RemoteRepository.browsing.collectAsStateWithLifecycle() val context = LocalContext.current + // A Clementine that can't be browsed has no Internet screen: showing it, go to the queue. + LaunchedEffect(browsing.supported) { + if (!browsing.supported && shell.destination == Destination.INTERNET) { + shell.destination = Destination.QUEUE + } + } val layout = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()) Box(Modifier.fillMaxSize()) { NavigationSuiteScaffold( layoutType = layout, navigationSuiteItems = { - Destination.entries.forEach { destination -> + Destination.entries.filter { it != Destination.INTERNET || browsing.supported }.forEach { destination -> item( selected = shell.destination == destination, onClick = { shell.destination = destination }, @@ -253,7 +266,9 @@ private fun Destinations(shell: ShellViewModel, onConnection: () -> Unit, onSett val library: LibraryViewModel = viewModel() val search: SearchViewModel = viewModel() val downloads: DownloadsViewModel = viewModel() + val internet: InternetViewModel = viewModel() val libraryState by library.state.collectAsStateWithLifecycle() + val internetState by internet.state.collectAsStateWithLifecycle() // Clementine's computer, or its address if it doesn't say. val host = remember { App.Clementine.hostname?.takeIf { it.isNotBlank() } @@ -263,6 +278,7 @@ private fun Destinations(shell: ShellViewModel, onConnection: () -> Unit, onSett // Back leaves search results, then the screen, for the queue. BackHandler(shell.destination != Destination.QUEUE) { shell.destination = Destination.QUEUE } BackHandler(shell.destination == Destination.LIBRARY && libraryState.levels.size > 1) { library.back() } + BackHandler(shell.destination == Destination.INTERNET && internetState.levels.size > 1) { internet.back() } Column(Modifier.fillMaxSize()) { when (shell.destination) { @@ -326,6 +342,15 @@ private fun Destinations(shell: ShellViewModel, onConnection: () -> Unit, onSett FilterableTopBar(host, onConnection, onSettings, library::setFilter) LibraryScreen(library) } + Destination.INTERNET -> { + // Ask for the level shown again, so Clementine keeps it up to date. + LifecycleResumeEffect(internet) { + internet.refresh() + onPauseOrDispose {} + } + TopBar(host, onConnection, onSettings) + InternetScreen(internet) + } Destination.SEARCH -> { TopBar(host, onConnection, onSettings) SearchScreen(search) diff --git a/app/src/main/proto/remotecontrolmessages.proto b/app/src/main/proto/remotecontrolmessages.proto index fbcf624e..3073839d 100644 --- a/app/src/main/proto/remotecontrolmessages.proto +++ b/app/src/main/proto/remotecontrolmessages.proto @@ -108,6 +108,14 @@ enum MsgType { RENDER_SET_VOLUME = 326; // Server -> all clients OUTPUTS = 340; + + // Browsing the Internet sidebar. + // Client -> server + REQUEST_BROWSE = 360; + REQUEST_BROWSE_ADD = 361; + // Server -> client + BROWSE = 370; + BROWSE_ADD_RESULT = 371; } // Valid Engine states @@ -478,6 +486,8 @@ enum ServerFeature { SERVER_FEATURE_UNSPECIFIED = 0; // The server can send playback to renderers (streaming is enabled). SERVER_FEATURE_RENDERING = 1; + // The server can be browsed like its Internet sidebar (REQUEST_BROWSE). + SERVER_FEATURE_BROWSE = 2; } // Optional abilities of a renderer, beyond decoding its formats. @@ -661,6 +671,129 @@ message RendererError { optional RendererErrorScope scope = 3; } +// Browsing the Internet sidebar: services, and the nodes below them. + +// What kind of node a BrowseNode is, for its icon and what a tap does. +enum BrowseNodeKind { + // Treat as BROWSE_NODE_KIND_FOLDER. + BROWSE_NODE_KIND_UNSPECIFIED = 0; + // A service's root: SomaFM, Plex, Google Drive... + BROWSE_NODE_KIND_SERVICE = 1; + // Anything grouping others: an artist, album, playlist, genre, category. + BROWSE_NODE_KIND_FOLDER = 2; + // A song with a length. + BROWSE_NODE_KIND_TRACK = 3; + // A radio stream, or anything else that plays with no known length. + BROWSE_NODE_KIND_STREAM = 4; + // A smart playlist, generated when added. + BROWSE_NODE_KIND_SMART_PLAYLIST = 5; +} + +// Whether a node has children to browse into. +enum BrowseChildren { + // Treat as BROWSE_CHILDREN_NONE. + BROWSE_CHILDREN_UNSPECIFIED = 0; + BROWSE_CHILDREN_NONE = 1; + // It has children, or will load some when browsed. + BROWSE_CHILDREN_SOME = 2; +} + +// Whether a node can go on the playlist, as a drag from the sidebar can. +enum BrowsePlayability { + // Treat as BROWSE_PLAYABILITY_NONE. + BROWSE_PLAYABILITY_UNSPECIFIED = 0; + BROWSE_PLAYABILITY_NONE = 1; + // Adding it adds it, or everything under it (an album, a playlist). + BROWSE_PLAYABILITY_ADDABLE = 2; +} + +// One node of the tree the Internet sidebar shows. +message BrowseNode { + // Opaque, issued by the server, valid on this connection until the node + // goes away. Never a URL or a path. + optional string node_id = 1; + optional string title = 2; + // A second line where there is one, such as a track's artist. + optional string subtitle = 3; + optional BrowseNodeKind kind = 4; + optional BrowseChildren children = 5; + optional BrowsePlayability playability = 6; + // PNG, at most 48x48. Only on service nodes. + optional bytes icon_png = 7; + // For tracks the server has metadata for: artist, album, length. + optional SongMetadata song = 8; +} + +// Asks for a node's children, or for the services when node_id is unset. +message RequestBrowse { + optional string node_id = 1; + // A page of the children. Unset or 0 limit: the server's maximum (500). + optional int32 offset = 2; + optional int32 limit = 3; +} + +// Where a node's children stand. +enum BrowseState { + // Treat as BROWSE_STATE_READY. + BROWSE_STATE_UNSPECIFIED = 0; + BROWSE_STATE_READY = 1; + // Still loading. Another BROWSE for this node follows when they change. + BROWSE_STATE_LOADING = 2; + // The service has to be set up on the computer first; message says how. + BROWSE_STATE_NEEDS_SETUP = 3; + // The node no longer exists: the client goes back up. + BROWSE_STATE_GONE = 4; +} + +// A node's children, sent for a RequestBrowse and again when they change +// while the client is looking at that node. +message ResponseBrowse { + optional string node_id = 1; + optional BrowseState state = 2; + repeated BrowseNode nodes = 3; + // Where this page starts, and how many children there are in all. + optional int32 offset = 4; + optional int32 total_count = 5; + // Shown to the user with BROWSE_STATE_NEEDS_SETUP, translated. + optional string message = 6; +} + +// What adding nodes does to the playlist. +enum BrowseAddAction { + // Treat as BROWSE_ADD_ACTION_APPEND. + BROWSE_ADD_ACTION_UNSPECIFIED = 0; + // Add to the end of the current playlist. + BROWSE_ADD_ACTION_APPEND = 1; + // Add, and play the first one added. + BROWSE_ADD_ACTION_PLAY_NOW = 2; + // Add, and queue it to play straight after the current song. + BROWSE_ADD_ACTION_PLAY_NEXT = 3; + // Clear the current playlist, add, and play. + BROWSE_ADD_ACTION_REPLACE = 4; +} + +// Puts nodes on the playlist, as dragging them from the sidebar does. +message RequestBrowseAdd { + repeated string node_ids = 1; + optional BrowseAddAction action = 2; +} + +// How a RequestBrowseAdd went. +enum BrowseAddResult { + // Treat as BROWSE_ADD_RESULT_NOT_PLAYABLE. + BROWSE_ADD_RESULT_UNSPECIFIED = 0; + BROWSE_ADD_RESULT_ADDED = 1; + // None of the nodes can go on the playlist. + BROWSE_ADD_RESULT_NOT_PLAYABLE = 2; + // A node no longer exists. + BROWSE_ADD_RESULT_GONE = 3; +} + +message ResponseBrowseAdd { + repeated string node_ids = 1; + optional BrowseAddResult result = 2; +} + // The message itself message Message { // The app keeps 21, the version of the Clementine releases it supports: @@ -718,4 +851,10 @@ message Message { optional RendererStatus renderer_status = 67; optional RendererError renderer_error = 68; optional RendererTrackEnded renderer_track_ended = 69; + + // Browsing the Internet sidebar + optional RequestBrowse request_browse = 70; + optional ResponseBrowse response_browse = 71; + optional RequestBrowseAdd request_browse_add = 72; + optional ResponseBrowseAdd response_browse_add = 73; } diff --git a/app/src/main/res/drawable/ic_auto_awesome.xml b/app/src/main/res/drawable/ic_auto_awesome.xml new file mode 100644 index 00000000..0ca3d3dd --- /dev/null +++ b/app/src/main/res/drawable/ic_auto_awesome.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 00000000..161de59f --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_public.xml b/app/src/main/res/drawable/ic_public.xml new file mode 100644 index 00000000..13f02d64 --- /dev/null +++ b/app/src/main/res/drawable/ic_public.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_sensors.xml b/app/src/main/res/drawable/ic_sensors.xml new file mode 100644 index 00000000..84d8824e --- /dev/null +++ b/app/src/main/res/drawable/ic_sensors.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index edaecba0..54c646de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,20 @@ Add to playlist Back + + Internet + Loading… + Set up in Clementine + Nothing here + Play + Play now + Play next + Replace playlist + Added to the playlist + Playing next + Clementine can\'t add that to the playlist + That\'s no longer in Clementine + Enter search terms above to find music on your computer and on the internet Search for anything diff --git a/app/src/test/java/de/qspool/clementineremote/backend/RemoteRepositoryTest.kt b/app/src/test/java/de/qspool/clementineremote/backend/RemoteRepositoryTest.kt index 71a85e01..6c51de25 100644 --- a/app/src/test/java/de/qspool/clementineremote/backend/RemoteRepositoryTest.kt +++ b/app/src/test/java/de/qspool/clementineremote/backend/RemoteRepositoryTest.kt @@ -3,10 +3,13 @@ package de.qspool.clementineremote.backend import de.qspool.clementineremote.App import de.qspool.clementineremote.backend.pb.ClementineMessage import de.qspool.clementineremote.backend.pb.ClementinePbParser +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddResult import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Message import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Output import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.OutputState +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowse +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowseAdd import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseClementineInfo import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseOutputs import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ServerFeature @@ -18,6 +21,9 @@ import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Shuf import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.SongMetadata import de.qspool.clementineremote.backend.player.MySong import de.qspool.clementineremote.ui.player.PlayerViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -183,4 +189,34 @@ class RemoteRepositoryTest { receive(message(MsgType.INFO).setResponseClementineInfo(ResponseClementineInfo.newBuilder().setVersion("1.4"))) assertFalse(RemoteRepository.outputs.value.supported) } + + @Test + fun followsWhetherClementineCanBeBrowsed() { + val before = RemoteRepository.browsing.value.connection + val received = mutableListOf() + val collecting = CoroutineScope(Dispatchers.Unconfined).launch { + RemoteRepository.browseMessages.collect { received += it } + } + try { + receive(message(MsgType.INFO).setResponseClementineInfo( + ResponseClementineInfo.newBuilder().setVersion("1.5").addFeatures(ServerFeature.SERVER_FEATURE_BROWSE))) + assertTrue(RemoteRepository.browsing.value.supported) + assertEquals(before + 1, RemoteRepository.browsing.value.connection) + + // Clementine's answers are handed on, with the connection they came on. + receive(message(MsgType.BROWSE).setResponseBrowse(ResponseBrowse.newBuilder().setNodeId("n1"))) + receive(message(MsgType.BROWSE_ADD_RESULT).setResponseBrowseAdd(ResponseBrowseAdd.newBuilder() + .addNodeIds("n2").setResult(BrowseAddResult.BROWSE_ADD_RESULT_ADDED))) + assertEquals(listOf(before + 1, before + 1), received.map { it.connection }) + assertEquals("n1", (received[0] as RemoteRepository.BrowseMessage.Browse).response.nodeId) + assertEquals(listOf("n2"), (received[1] as RemoteRepository.BrowseMessage.AddResult).response.nodeIdsList) + + // Each connection is a new one; this Clementine can't be browsed. + receive(message(MsgType.INFO).setResponseClementineInfo(ResponseClementineInfo.newBuilder().setVersion("1.4"))) + assertFalse(RemoteRepository.browsing.value.supported) + assertEquals(before + 2, RemoteRepository.browsing.value.connection) + } finally { + collecting.cancel() + } + } } diff --git a/app/src/test/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactoryTest.java b/app/src/test/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactoryTest.java index eb607083..6ca2ccfd 100644 --- a/app/src/test/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactoryTest.java +++ b/app/src/test/java/de/qspool/clementineremote/backend/pb/ClementineMessageFactoryTest.java @@ -6,6 +6,7 @@ import java.util.LinkedList; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.AudioFormat; +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.DownloadItem; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Message; import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType; @@ -147,4 +148,28 @@ public void globalSearch() throws Exception { assertEquals(MsgType.GLOBAL_SEARCH, parsed.getType()); assertEquals("beatles", parsed.getRequestGlobalSearch().getQuery()); } + + @Test + public void browse() throws Exception { + // The services: no node, from the first, as many as Clementine sends. + Message parsed = roundTrip(ClementineMessageFactory.buildBrowse("", 0)); + assertEquals(MsgType.REQUEST_BROWSE, parsed.getType()); + assertFalse(parsed.getRequestBrowse().hasNodeId()); + assertFalse(parsed.getRequestBrowse().hasOffset()); + assertFalse(parsed.getRequestBrowse().hasLimit()); + + parsed = roundTrip(ClementineMessageFactory.buildBrowse("n7", 500)); + assertEquals("n7", parsed.getRequestBrowse().getNodeId()); + assertEquals(500, parsed.getRequestBrowse().getOffset()); + assertFalse(parsed.getRequestBrowse().hasLimit()); + } + + @Test + public void browseAdd() throws Exception { + Message parsed = roundTrip(ClementineMessageFactory.buildBrowseAdd( + Arrays.asList("n3"), BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT)); + assertEquals(MsgType.REQUEST_BROWSE_ADD, parsed.getType()); + assertEquals(Arrays.asList("n3"), parsed.getRequestBrowseAdd().getNodeIdsList()); + assertEquals(BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT, parsed.getRequestBrowseAdd().getAction()); + } } diff --git a/app/src/test/java/de/qspool/clementineremote/ui/internet/InternetBrowserTest.kt b/app/src/test/java/de/qspool/clementineremote/ui/internet/InternetBrowserTest.kt new file mode 100644 index 00000000..10239258 --- /dev/null +++ b/app/src/test/java/de/qspool/clementineremote/ui/internet/InternetBrowserTest.kt @@ -0,0 +1,376 @@ +package de.qspool.clementineremote.ui.internet + +import com.google.protobuf.ByteString +import de.qspool.clementineremote.backend.pb.ClementineMessage +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddResult +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseChildren +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseNode +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseNodeKind +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowsePlayability +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseState +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RequestBrowse +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowse +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowseAdd +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Browsing Clementine's Internet sidebar: which levels show, how pages of nodes come together, + * going back up when a node is gone, starting again on a new connection, and what a tap does. + */ +class InternetBrowserTest { + + private val sent = mutableListOf() + + private val browser = InternetBrowser { sent += it } + + /** The browse requests sent, as (node id, offset), and forgets them. */ + private fun asked(): List> { + val asked = sent.filter { it.messageType == MsgType.REQUEST_BROWSE } + .map { it.message.requestBrowse.let { r: RequestBrowse -> r.nodeId to r.offset } } + sent.clear() + return asked + } + + private fun node( + id: String, + kind: BrowseNodeKind = BrowseNodeKind.BROWSE_NODE_KIND_FOLDER, + children: Boolean = true, + addable: Boolean = false, + ): BrowseNode = BrowseNode.newBuilder() + .setNodeId(id) + .setTitle("Title $id") + .setKind(kind) + .setChildren(if (children) BrowseChildren.BROWSE_CHILDREN_SOME else BrowseChildren.BROWSE_CHILDREN_NONE) + .setPlayability(if (addable) BrowsePlayability.BROWSE_PLAYABILITY_ADDABLE else BrowsePlayability.BROWSE_PLAYABILITY_NONE) + .build() + + private fun nodes(from: Int, count: Int) = (from until from + count).map { node("n$it") } + + private fun answer( + nodeId: String, + nodes: List = emptyList(), + offset: Int = 0, + total: Int = offset + nodes.size, + state: BrowseState = BrowseState.BROWSE_STATE_READY, + message: String? = null, + ) { + browser.onBrowse(ResponseBrowse.newBuilder() + .setNodeId(nodeId) + .setState(state) + .addAllNodes(nodes) + .setOffset(offset) + .setTotalCount(total) + .apply { message?.let { setMessage(it) } } + .build()) + } + + private fun addResult(ids: List, result: BrowseAddResult) = + browser.onAddResult(ResponseBrowseAdd.newBuilder().addAllNodeIds(ids).setResult(result).build()) + + /** Connected, with the services shown: "soma" and "subsonic". */ + private fun showServices() { + browser.reset(supported = true) + answer("", listOf( + node("soma", BrowseNodeKind.BROWSE_NODE_KIND_SERVICE), + node("subsonic", BrowseNodeKind.BROWSE_NODE_KIND_SERVICE))) + sent.clear() + } + + @Test + fun asksForTheServicesOnlyWhenClementineCanBeBrowsed() { + browser.reset(supported = false) + assertTrue(sent.isEmpty()) + assertEquals(LevelStatus.LOADING, browser.shown.status) + + browser.reset(supported = true) + assertEquals(listOf("" to 0), asked()) + // Before the first answer, the level is loading, with nothing in it. + assertNull(browser.shown.opened) + assertEquals(LevelStatus.LOADING, browser.shown.status) + assertTrue(browser.shown.nodes.isEmpty()) + } + + @Test + fun showsTheServices() { + browser.reset(supported = true) + val icon = ByteString.copyFrom(byteArrayOf(1, 2, 3)) + answer("", listOf( + node("soma", BrowseNodeKind.BROWSE_NODE_KIND_SERVICE).toBuilder().setIconPng(icon).build(), + node("radio", BrowseNodeKind.BROWSE_NODE_KIND_SERVICE))) + + val shown = browser.shown + assertEquals(LevelStatus.READY, shown.status) + assertEquals(listOf("soma", "radio"), shown.nodes.map { it.id }) + assertEquals(icon, shown.nodes[0].icon) + assertNull(shown.nodes[1].icon) + assertEquals(NodeKind.SERVICE, shown.nodes[0].kind) + assertEquals("Title soma", shown.nodes[0].title) + assertNull(shown.nodes[0].subtitle) + } + + @Test + fun readsWhatANodeIs() { + val track = InternetNode.of(node("t", BrowseNodeKind.BROWSE_NODE_KIND_TRACK, children = false, addable = true) + .toBuilder().setSubtitle("Erik Satie").build()) + assertEquals(NodeKind.TRACK, track.kind) + assertEquals("Erik Satie", track.subtitle) + assertFalse(track.hasChildren) + assertTrue(track.addable) + + // Unspecified is a folder; unspecified children and playability are none. + val unspecified = InternetNode.of(BrowseNode.newBuilder().setNodeId("u").setTitle("U").build()) + assertEquals(NodeKind.FOLDER, unspecified.kind) + assertFalse(unspecified.hasChildren) + assertFalse(unspecified.addable) + + assertEquals(NodeKind.STREAM, InternetNode.of(node("s", BrowseNodeKind.BROWSE_NODE_KIND_STREAM)).kind) + assertEquals(NodeKind.SMART_PLAYLIST, InternetNode.of(node("p", BrowseNodeKind.BROWSE_NODE_KIND_SMART_PLAYLIST)).kind) + } + + @Test + fun opensANodeAndGoingBackAsksForTheLevelAgain() { + showServices() + val soma = browser.shown.nodes[0] + + browser.open(soma) + assertEquals(listOf("soma" to 0), asked()) + assertEquals(soma, browser.shown.opened) + assertEquals(LevelStatus.LOADING, browser.shown.status) + + answer("soma", listOf(node("groove", BrowseNodeKind.BROWSE_NODE_KIND_STREAM, children = false, addable = true))) + assertEquals(listOf("groove"), browser.shown.nodes.map { it.id }) + + // Back up: the services, still shown, and asked for again so Clementine watches them. + assertTrue(browser.back()) + assertEquals(listOf("" to 0), asked()) + assertEquals(listOf("soma", "subsonic"), browser.shown.nodes.map { it.id }) + assertFalse(browser.back()) + assertTrue(sent.isEmpty()) + } + + @Test + fun onlyNodesWithChildrenOpen() { + showServices() + browser.open(InternetNode("leaf", "Leaf", hasChildren = false, addable = true)) + assertEquals(1, browser.levels.size) + assertTrue(sent.isEmpty()) + } + + @Test + fun ignoresAnswersForAnotherLevel() { + showServices() + browser.open(browser.shown.nodes[0]) + + // The services changed, but Clementine now watches SomaFM: a late answer. + answer("", nodes(0, 3)) + assertTrue(browser.shown.nodes.isEmpty()) + assertEquals(LevelStatus.LOADING, browser.shown.status) + } + + @Test + fun statesOfALevel() { + showServices() + browser.open(browser.shown.nodes[1]) + + answer("subsonic", state = BrowseState.BROWSE_STATE_LOADING) + assertEquals(LevelStatus.LOADING, browser.shown.status) + + answer("subsonic", state = BrowseState.BROWSE_STATE_NEEDS_SETUP, + message = "Set up Subsonic in Clementine's settings on the computer") + assertEquals(LevelStatus.NEEDS_SETUP, browser.shown.status) + assertEquals("Set up Subsonic in Clementine's settings on the computer", browser.shown.message) + + answer("subsonic", nodes(0, 2), state = BrowseState.BROWSE_STATE_LOADING) + assertEquals(LevelStatus.LOADING, browser.shown.status) + assertEquals(2, browser.shown.nodes.size) + assertNull(browser.shown.message) + + answer("subsonic") + assertEquals(LevelStatus.READY, browser.shown.status) + assertTrue(browser.shown.nodes.isEmpty()) + } + + @Test + fun goneGoesBackUpAndAsksForTheLevelThere() { + showServices() + browser.open(browser.shown.nodes[0]) + answer("soma", nodes(0, 2)) + browser.open(browser.shown.nodes[1]) + sent.clear() + + answer("n1", state = BrowseState.BROWSE_STATE_GONE) + assertEquals("soma", browser.shown.id) + assertEquals(listOf("soma" to 0), asked()) + + // SomaFM went too: up again, to the services. + answer("soma", state = BrowseState.BROWSE_STATE_GONE) + assertNull(browser.shown.opened) + assertEquals(listOf("" to 0), asked()) + } + + @Test + fun loadsMorePagesAndKeepsThem() { + showServices() + browser.open(browser.shown.nodes[0]) + sent.clear() + + answer("soma", nodes(0, 500), total = 1200) + assertTrue(browser.shown.hasMore) + + // The last row shows: the next page, once. + browser.loadMore() + browser.loadMore() + assertEquals(listOf("soma" to 500), asked()) + + answer("soma", nodes(500, 500), offset = 500, total = 1200) + assertEquals(1000, browser.shown.nodes.size) + browser.loadMore() + assertEquals(listOf("soma" to 1000), asked()) + answer("soma", nodes(1000, 200), offset = 1000, total = 1200) + assertEquals((0 until 1200).map { "n$it" }, browser.shown.nodes.map { it.id }) + assertFalse(browser.shown.hasMore) + + // Everything's loaded: nothing more to ask for. + browser.loadMore() + assertTrue(sent.isEmpty()) + } + + @Test + fun aPageReplacesTheRowsFromItsOffsetAndTheListIsCutToTheTotal() { + showServices() + browser.open(browser.shown.nodes[0]) + answer("soma", nodes(0, 500), total = 1000) + answer("soma", nodes(500, 500), offset = 500, total = 1000) + + // The watched page changed: its rows are replaced, the others kept. + answer("soma", listOf(node("new500"), node("new501")) + nodes(502, 498), offset = 500, total = 1000) + assertEquals("n499", browser.shown.nodes[499].id) + assertEquals("new500", browser.shown.nodes[500].id) + assertEquals("n999", browser.shown.nodes[999].id) + + // Refreshed from the first page, with fewer in all: cut to the new total. + answer("soma", nodes(0, 500).map { it.toBuilder().setTitle("Renamed").build() }, total = 600) + assertEquals(600, browser.shown.nodes.size) + assertEquals(600, browser.shown.totalCount) + assertEquals("Renamed", browser.shown.nodes[0].title) + assertEquals("new500", browser.shown.nodes[500].id) + assertFalse(browser.shown.hasMore) + } + + @Test + fun mergeLeavesNoGaps() { + val loaded = listOf(InternetNode("a", "A"), InternetNode("b", "B")) + assertNull(InternetBrowser.merge(loaded, 3, listOf(InternetNode("d", "D")), 4)) + assertEquals(listOf("a", "b", "c"), + InternetBrowser.merge(loaded, 2, listOf(InternetNode("c", "C")), 3)!!.map { it.id }) + assertEquals(listOf("x"), InternetBrowser.merge(loaded, 0, listOf(InternetNode("x", "X")), 1)!!.map { it.id }) + assertEquals(emptyList(), InternetBrowser.merge(loaded, 0, emptyList(), 0)) + } + + @Test + fun aNewConnectionStartsAgainFromTheServices() { + showServices() + browser.open(browser.shown.nodes[0]) + answer("soma", nodes(0, 2)) + browser.add(browser.shown.nodes[0].copy(addable = true), BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT) + sent.clear() + + browser.reset(supported = true) + assertEquals(1, browser.levels.size) + assertNull(browser.shown.opened) + assertTrue(browser.shown.nodes.isEmpty()) + assertEquals(LevelStatus.LOADING, browser.shown.status) + assertEquals(listOf("" to 0), asked()) + // What was being added on the old connection is forgotten: an answer is taken as appended. + assertEquals(AddMessage.ADDED, addResult(listOf("n0"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + + // An older Clementine: nothing to ask for. + browser.reset(supported = false) + assertTrue(sent.isEmpty()) + browser.refresh() + assertTrue(sent.isEmpty()) + } + + @Test + fun tapOpensPlaysOrAdds() { + val folder = InternetNode("f", "Folder", hasChildren = true) + val album = InternetNode("a", "Album", hasChildren = true, addable = true) + val stream = InternetNode("s", "Stream", kind = NodeKind.STREAM, addable = true) + val nothing = InternetNode("n", "Nothing") + + assertEquals(TapAction.Open, InternetBrowser.tapAction(folder, playing = false)) + // Something that opens opens, even if it can go on the playlist. + assertEquals(TapAction.Open, InternetBrowser.tapAction(album, playing = true)) + // A track or stream plays, unless Clementine is playing: then it's added. + assertEquals(TapAction.Add(BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NOW), InternetBrowser.tapAction(stream, playing = false)) + assertEquals(TapAction.Add(BrowseAddAction.BROWSE_ADD_ACTION_APPEND), InternetBrowser.tapAction(stream, playing = true)) + assertEquals(TapAction.None, InternetBrowser.tapAction(nothing, playing = false)) + } + + @Test + fun tapSendsWhatItDoes() { + showServices() + browser.tap(InternetNode("s", "Stream", kind = NodeKind.STREAM, addable = true), playing = true) + val add = sent.single().message + assertEquals(MsgType.REQUEST_BROWSE_ADD, add.type) + assertEquals(listOf("s"), add.requestBrowseAdd.nodeIdsList) + assertEquals(BrowseAddAction.BROWSE_ADD_ACTION_APPEND, add.requestBrowseAdd.action) + sent.clear() + + browser.tap(InternetNode("n", "Nothing"), playing = false) + assertTrue(sent.isEmpty()) + + browser.tap(browser.shown.nodes[0], playing = false) + assertEquals(listOf("soma" to 0), asked()) + } + + @Test + fun saysHowAddingWent() { + showServices() + val stream = InternetNode("s", "Stream", kind = NodeKind.STREAM, addable = true) + val add = { action: BrowseAddAction -> browser.add(stream, action) } + + add(BrowseAddAction.BROWSE_ADD_ACTION_APPEND) + assertEquals(AddMessage.ADDED, addResult(listOf("s"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + add(BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT) + assertEquals(AddMessage.PLAYING_NEXT, addResult(listOf("s"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + // Playing shows in the player. + add(BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NOW) + assertNull(addResult(listOf("s"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + add(BrowseAddAction.BROWSE_ADD_ACTION_REPLACE) + assertNull(addResult(listOf("s"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + + add(BrowseAddAction.BROWSE_ADD_ACTION_APPEND) + assertEquals(AddMessage.NOT_PLAYABLE, addResult(listOf("s"), BrowseAddResult.BROWSE_ADD_RESULT_NOT_PLAYABLE)) + sent.clear() + + // Gone: said, and the level is asked for again. + add(BrowseAddAction.BROWSE_ADD_ACTION_APPEND) + sent.clear() + assertEquals(AddMessage.GONE, addResult(listOf("s"), BrowseAddResult.BROWSE_ADD_RESULT_GONE)) + assertEquals(listOf("" to 0), asked()) + } + + @Test + fun answersAreMatchedToWhatWasAdded() { + showServices() + browser.add(InternetNode("a", "A", addable = true), BrowseAddAction.BROWSE_ADD_ACTION_PLAY_NEXT) + browser.add(InternetNode("b", "B", addable = true), BrowseAddAction.BROWSE_ADD_ACTION_APPEND) + + assertEquals(AddMessage.ADDED, addResult(listOf("b"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + assertEquals(AddMessage.PLAYING_NEXT, addResult(listOf("a"), BrowseAddResult.BROWSE_ADD_RESULT_ADDED)) + } + + @Test + fun onlyWhatCanGoOnThePlaylistIsAdded() { + showServices() + browser.add(InternetNode("f", "Folder", hasChildren = true), BrowseAddAction.BROWSE_ADD_ACTION_APPEND) + assertTrue(sent.isEmpty()) + } +} diff --git a/app/src/test/java/de/qspool/clementineremote/ui/internet/InternetScreenTest.kt b/app/src/test/java/de/qspool/clementineremote/ui/internet/InternetScreenTest.kt new file mode 100644 index 00000000..06a514c8 --- /dev/null +++ b/app/src/test/java/de/qspool/clementineremote/ui/internet/InternetScreenTest.kt @@ -0,0 +1,230 @@ +package de.qspool.clementineremote.ui.internet + +import android.os.Looper +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import de.qspool.clementineremote.backend.RemoteRepository.BrowseMessage +import de.qspool.clementineremote.backend.RemoteRepository.Browsing +import de.qspool.clementineremote.backend.pb.ClementineMessage +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddAction +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseAddResult +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseChildren +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseNode +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.BrowseNodeKind +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowse +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseBrowseAdd +import de.qspool.clementineremote.ui.theme.ClementineTheme +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** The Internet screen follows the connection, and shows each level's state. */ +@RunWith(RobolectricTestRunner::class) +class InternetScreenTest { + + @get:Rule + val compose = createComposeRule() + + private fun idle() = shadowOf(Looper.getMainLooper()).idle() + + private val services = listOf( + InternetNode("soma", "SomaFM", kind = NodeKind.SERVICE, hasChildren = true), + InternetNode("subsonic", "Subsonic", kind = NodeKind.SERVICE, hasChildren = true), + ) + + @Test + fun followsTheConnection() { + val sent = mutableListOf() + val browsing = MutableStateFlow(Browsing(supported = true, connection = 1)) + val messages = MutableSharedFlow(extraBufferCapacity = 8) + val internet = InternetViewModel(send = { sent += it }, browsing = browsing, messages = messages, playing = { false }) + idle() + assertEquals(MsgType.REQUEST_BROWSE, sent.single().messageType) + + messages.tryEmit(BrowseMessage.Browse(1, ResponseBrowse.newBuilder().setNodeId("") + .addNodes(BrowseNode.newBuilder().setNodeId("n1").setTitle("SomaFM") + .setKind(BrowseNodeKind.BROWSE_NODE_KIND_SERVICE).setChildren(BrowseChildren.BROWSE_CHILDREN_SOME)) + .setTotalCount(1).build())) + idle() + assertEquals(listOf("SomaFM"), internet.state.value.shown.nodes.map { it.title }) + + internet.tap(internet.state.value.shown.nodes.single()) + assertEquals(2, internet.state.value.levels.size) + + // Reconnected: the ids are forgotten, and the services asked for again. + sent.clear() + browsing.value = Browsing(supported = true, connection = 2) + idle() + assertEquals(1, internet.state.value.levels.size) + assertTrue(internet.state.value.shown.nodes.isEmpty()) + assertEquals("", sent.single().message.requestBrowse.nodeId) + + // A late answer from the connection before is ignored. + messages.tryEmit(BrowseMessage.Browse(1, ResponseBrowse.newBuilder().setNodeId("") + .addNodes(BrowseNode.newBuilder().setNodeId("n1").setTitle("Old")).setTotalCount(1).build())) + idle() + assertTrue(internet.state.value.shown.nodes.isEmpty()) + } + + @Test + fun saysHowAddingWent() { + val sent = mutableListOf() + val messages = MutableSharedFlow(extraBufferCapacity = 8) + val internet = InternetViewModel( + send = { sent += it }, + browsing = MutableStateFlow(Browsing(supported = true, connection = 1)), + messages = messages, + playing = { true }, + ) + val told = mutableListOf() + compose.setContent { + LaunchedEffect(Unit) { internet.added.collect { told += it } } + } + idle() + + // Clementine is playing: a tapped stream is added to the end. + internet.tap(InternetNode("s", "Groove Salad", kind = NodeKind.STREAM, addable = true)) + assertEquals(BrowseAddAction.BROWSE_ADD_ACTION_APPEND, sent.last().message.requestBrowseAdd.action) + messages.tryEmit(BrowseMessage.AddResult(1, ResponseBrowseAdd.newBuilder().addNodeIds("s") + .setResult(BrowseAddResult.BROWSE_ADD_RESULT_ADDED).build())) + compose.waitForIdle() + assertEquals(listOf(AddMessage.ADDED), told) + } + + private fun show(state: InternetState, done: MutableList = mutableListOf()): MutableList { + compose.setContent { + ClementineTheme(dynamicColor = false) { + InternetContent( + state, + onTap = { done += "tap ${it.id}" }, + onAdd = { node, action -> done += "add ${node.id} ${action.name}" }, + onBack = { done += "back" }, + onRefresh = { done += "refresh" }, + onLoadMore = { done += "more" }, + ) + } + } + return done + } + + @Test + fun showsTheServices() { + val done = show(InternetState(listOf(InternetLevel(status = LevelStatus.READY, nodes = services, totalCount = 2)))) + + compose.onNodeWithText("Internet").assertIsDisplayed() + compose.onNodeWithText("SomaFM").performClick() + assertEquals(listOf("tap soma"), done) + } + + @Test + fun loadingBeforeTheFirstAnswer() { + show(InternetState()) + compose.onNodeWithTag("internetLoading").assertIsDisplayed() + compose.onNodeWithText("Loading…").assertIsDisplayed() + } + + @Test + fun loadingMoreShowsTheNodesWithProgress() { + show(InternetState(listOf(InternetLevel(status = LevelStatus.LOADING, nodes = services, totalCount = 2)))) + compose.onNodeWithTag("internetProgress").assertIsDisplayed() + compose.onNodeWithText("Subsonic").assertIsDisplayed() + } + + @Test + fun needsSetupAndNothingHere() { + val subsonic = services[1] + show(InternetState(listOf( + InternetLevel(status = LevelStatus.READY, nodes = services, totalCount = 2), + InternetLevel(opened = subsonic, status = LevelStatus.NEEDS_SETUP, + message = "Set up Subsonic in Clementine's settings on the computer"), + ))) + compose.onNodeWithText("Set up in Clementine").assertIsDisplayed() + compose.onNodeWithText("Set up Subsonic in Clementine's settings on the computer").assertIsDisplayed() + compose.onNodeWithTag("internetTitle").assertTextEquals("Subsonic") + } + + @Test + fun nothingHere() { + show(InternetState(listOf( + InternetLevel(status = LevelStatus.READY, nodes = services, totalCount = 2), + InternetLevel(opened = services[0], status = LevelStatus.READY), + ))) + compose.onNodeWithText("Nothing here").assertIsDisplayed() + // Not something that plays: no count, no buttons. + compose.onNodeWithTag("internetCount").assertDoesNotExist() + compose.onNodeWithTag("internetPlayAll").assertDoesNotExist() + } + + @Test + fun anOpenedAlbumPlaysOrAddsItAll() { + val album = InternetNode("album", "Gymnopédies", hasChildren = true, addable = true) + val tracks = listOf( + InternetNode("t1", "Gymnopédie No. 1", subtitle = "Erik Satie", kind = NodeKind.TRACK, addable = true), + InternetNode("t2", "Gymnopédie No. 2", subtitle = "Erik Satie", kind = NodeKind.TRACK, addable = true), + ) + val done = show(InternetState(listOf( + InternetLevel(status = LevelStatus.READY, nodes = services, totalCount = 2), + InternetLevel(opened = album, status = LevelStatus.READY, nodes = tracks, totalCount = 3), + ))) + + compose.onNodeWithTag("internetTitle").assertTextEquals("Gymnopédies") + compose.onNodeWithTag("internetCount").assertTextEquals("3 items") + compose.onNodeWithTag("internetPlayAll").performClick() + compose.onNodeWithTag("internetAddAll").performClick() + compose.onNodeWithTag("internetBack").performClick() + // The last row shows, and there's more: more is asked for. + assertEquals( + listOf("more", "add album BROWSE_ADD_ACTION_PLAY_NOW", "add album BROWSE_ADD_ACTION_APPEND", "back"), + done) + } + + @Test + fun anAlbumsCountShowsOnceClementineSays() { + val album = InternetNode("album", "Gymnopédies", hasChildren = true, addable = true) + show(InternetState(listOf( + InternetLevel(status = LevelStatus.READY, nodes = services, totalCount = 2), + InternetLevel(opened = album), + ))) + compose.onNodeWithTag("internetLoading").assertIsDisplayed() + compose.onNodeWithTag("internetCount").assertDoesNotExist() + compose.onNodeWithTag("internetPlayAll").assertIsDisplayed() + } + + @Test + fun longPressOffersTheWaysToAdd() { + val stream = InternetNode("s", "Groove Salad", kind = NodeKind.STREAM, addable = true) + val done = show(InternetState(listOf(InternetLevel(status = LevelStatus.READY, nodes = listOf(stream), totalCount = 1)))) + + compose.onNodeWithText("Groove Salad").performTouchInput { longClick() } + compose.onNodeWithText("Play now").assertIsDisplayed() + compose.onNodeWithText("Add to playlist").assertIsDisplayed() + compose.onNodeWithText("Replace playlist").assertIsDisplayed() + compose.onNodeWithText("Play next").performClick() + assertEquals(listOf("add s BROWSE_ADD_ACTION_PLAY_NEXT"), done) + } + + @Test + fun aNodeThatDoesNothingIsShownButDoesNothing() { + val label = InternetNode("x", "Not playable") + val done = show(InternetState(listOf(InternetLevel(status = LevelStatus.READY, nodes = listOf(label), totalCount = 1)))) + + compose.onNodeWithText("Not playable").assertIsDisplayed().performClick() + compose.onNodeWithText("Not playable").performTouchInput { longClick() } + assertFalse(done.any { it.startsWith("tap") || it.startsWith("add") }) + } +} diff --git a/app/src/test/java/de/qspool/clementineremote/ui/shell/AppShellTest.kt b/app/src/test/java/de/qspool/clementineremote/ui/shell/AppShellTest.kt index 66041606..88861217 100644 --- a/app/src/test/java/de/qspool/clementineremote/ui/shell/AppShellTest.kt +++ b/app/src/test/java/de/qspool/clementineremote/ui/shell/AppShellTest.kt @@ -13,6 +13,10 @@ import androidx.compose.ui.test.performScrollTo import de.qspool.clementineremote.App import de.qspool.clementineremote.backend.Clementine import de.qspool.clementineremote.backend.RemoteRepository +import de.qspool.clementineremote.backend.pb.ClementineMessage +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseClementineInfo +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ServerFeature import de.qspool.clementineremote.backend.player.MySong import de.qspool.clementineremote.ui.theme.ClementineTheme import org.junit.Assert.assertEquals @@ -89,6 +93,39 @@ class AppShellTest { assertEquals(Destination.QUEUE, shell.destination) } + /** Clementine's INFO, as on connecting: it can be browsed, or not. */ + private fun connect(browse: Boolean) { + val info = ResponseClementineInfo.newBuilder().setVersion("1.4") + if (browse) { + info.addFeatures(ServerFeature.SERVER_FEATURE_BROWSE) + } + compose.runOnUiThread { + RemoteRepository.onMessage(ClementineMessage( + ClementineMessage.getMessageBuilder(MsgType.INFO).setResponseClementineInfo(info))) + } + compose.waitForIdle() + } + + @Test + fun theInternetScreenIsThereOnlyWhenClementineCanBeBrowsed() { + try { + connect(browse = false) + compose.onNodeWithTag("navInternet").assertDoesNotExist() + + connect(browse = true) + compose.onNodeWithTag("navInternet").performClick() + compose.onNodeWithTag("internetRefresh").assertIsDisplayed() + assertEquals(Destination.INTERNET, shell.destination) + + // Connected to a Clementine that can't be browsed, while it shows: the queue shows. + connect(browse = false) + compose.onNodeWithTag("navInternet").assertDoesNotExist() + assertEquals(Destination.QUEUE, shell.destination) + } finally { + connect(browse = false) + } + } + @Test fun theMiniPlayerOpensThePlayer() { compose.onNodeWithTag("miniTitle", useUnmergedTree = true).assertTextEquals("Clair de lune") From d16b96084731871ec247043b32aa4333668c397b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:02:48 +0000 Subject: [PATCH 2/4] Screenshot a service opened in the Internet screen The CI Clementine gets three saved radio streams, so "Your radio streams" has something to show without depending on a service online. The screenshots open it after the Internet screen's top level, as 11_internet_radio, in light and dark. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- .../clementineremote/StoreScreenshots.java | 19 ++++++++++++++++++- clementine-it/README.md | 1 + clementine-it/entrypoint.sh | 10 ++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java index c96e4749..0eda2f49 100644 --- a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java +++ b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java @@ -58,6 +58,9 @@ public class StoreScreenshots { /** Syncing and indexing the library takes a while on an emulator. */ private static final long LIBRARY_TIMEOUT = 120_000; + /** Clementine's service for the radio streams saved in it. */ + private static final String RADIO_STREAMS = "Your radio streams"; + @Rule public final GrantPermissionRule mPermissions = GrantPermissionRule.grant( Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.READ_PHONE_STATE); @@ -273,11 +276,19 @@ public void takeScreenshots() { waitFor(By.textStartsWith("Nocturne in")); screenshot("2_library_album"); - // Clementine's internet services, at the top level. + // Clementine's internet services, at the top level, then the radio streams saved in it + // (clementine-it/entrypoint.sh). navigateTo("navInternet"); waitFor(tag("internetNode")); SystemClock.sleep(SETTLE_MILLIS); screenshot("10_internet"); + waitFor(By.text(RADIO_STREAMS)).click(); + waitFor(By.text("Groove Salad")); + SystemClock.sleep(SETTLE_MILLIS); + screenshot("11_internet_radio"); + // Back up to the services, where the dark screenshots start. + mDevice.pressBack(); + waitFor(By.text(RADIO_STREAMS)); navigateTo("navSearch"); search("Gymnopédie"); @@ -309,6 +320,12 @@ public void takeScreenshots() { waitFor(tag("internetNode")); SystemClock.sleep(SETTLE_MILLIS); screenshot("dark_10_internet"); + waitFor(By.text(RADIO_STREAMS)).click(); + waitFor(By.text("Groove Salad")); + SystemClock.sleep(SETTLE_MILLIS); + screenshot("dark_11_internet_radio"); + mDevice.pressBack(); + waitFor(By.text(RADIO_STREAMS)); navigateTo("navQueue"); waitFor(By.text("Clair de lune").hasAncestor(tag("queueSongs"))); diff --git a/clementine-it/README.md b/clementine-it/README.md index dcbdf557..748fc672 100644 --- a/clementine-it/README.md +++ b/clementine-it/README.md @@ -6,6 +6,7 @@ This directory builds a Docker image that runs Clementine headless. It installs - PulseAudio's null sink provides audio output, so playback runs in real time. - The network remote is enabled on port 5500. - The library holds ten generated test tracks (see `generate-music.sh`). They are also loaded as the active playlist. +- Three radio streams are saved in it (SomaFM's Groove Salad, Drone Zone and Secret Agent), so its Internet sidebar has *Your radio streams* to browse. The tests are in `app/src/test/java/de/qspool/clementineremote/integration/`. They drive the app's own connection, message factory and parser code against that Clementine. They cover: diff --git a/clementine-it/entrypoint.sh b/clementine-it/entrypoint.sh index 644595e4..080fc948 100755 --- a/clementine-it/entrypoint.sh +++ b/clementine-it/entrypoint.sh @@ -37,6 +37,16 @@ doubleclick_playlist_addmode=1 [General] startupbehaviour=1 + +# Radio streams of its own, for browsing its internet services ("Your radio streams"). +[SavedRadio] +streams\\1\\name=Groove Salad +streams\\1\\url=http://ice1.somafm.com/groovesalad-128-mp3 +streams\\2\\name=Drone Zone +streams\\2\\url=http://ice1.somafm.com/dronezone-128-mp3 +streams\\3\\name=Secret Agent +streams\\3\\url=http://ice1.somafm.com/secretagent-128-mp3 +streams\\size=3 CONF } From 5c65be14978e6787b5043c5f0373141c18ad4d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:05:02 +0000 Subject: [PATCH 3/4] Scroll to the radio streams for their screenshot "Your radio streams" is the last of Clementine's services, below the fold, and a lazy list only has the rows in sight. The dark screenshots also scroll the services back to the top first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- .../clementineremote/StoreScreenshots.java | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java index 0eda2f49..41e61736 100644 --- a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java +++ b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java @@ -11,6 +11,7 @@ import androidx.test.rule.GrantPermissionRule; import androidx.test.uiautomator.By; import androidx.test.uiautomator.BySelector; +import androidx.test.uiautomator.Direction; import androidx.test.uiautomator.UiDevice; import androidx.test.uiautomator.UiObject2; import androidx.test.uiautomator.Until; @@ -160,6 +161,25 @@ private UiObject2 waitFor(BySelector selector) { return waitFor(selector, TIMEOUT); } + /** Scrolls the list tagged [list] down until [selector] shows, and returns that. */ + private UiObject2 scrollTo(String list, BySelector selector) { + UiObject2 scrollable = waitFor(tag(list)); + for (int i = 0; i < 20 && !mDevice.hasObject(selector); i++) { + if (!scrollable.scroll(Direction.DOWN, 0.8f)) { + break; + } + } + return waitFor(selector); + } + + /** Scrolls the list tagged [list] back to its top. */ + private void scrollToTop(String list) { + UiObject2 scrollable = waitFor(tag(list)); + for (int i = 0; i < 20 && scrollable.scroll(Direction.UP, 1f); i++) { + // Until it can't scroll any further. + } + } + /** A Compose element, by its test tag: its resource name, without the package. */ private static BySelector tag(String name) { return By.res(name); @@ -282,13 +302,14 @@ public void takeScreenshots() { waitFor(tag("internetNode")); SystemClock.sleep(SETTLE_MILLIS); screenshot("10_internet"); - waitFor(By.text(RADIO_STREAMS)).click(); + // The last of the services. + scrollTo("internetNodes", By.text(RADIO_STREAMS)).click(); waitFor(By.text("Groove Salad")); SystemClock.sleep(SETTLE_MILLIS); screenshot("11_internet_radio"); // Back up to the services, where the dark screenshots start. mDevice.pressBack(); - waitFor(By.text(RADIO_STREAMS)); + waitFor(tag("internetNode")); navigateTo("navSearch"); search("Gymnopédie"); @@ -318,14 +339,15 @@ public void takeScreenshots() { navigateTo("navInternet"); waitFor(tag("internetNode")); + scrollToTop("internetNodes"); SystemClock.sleep(SETTLE_MILLIS); screenshot("dark_10_internet"); - waitFor(By.text(RADIO_STREAMS)).click(); + scrollTo("internetNodes", By.text(RADIO_STREAMS)).click(); waitFor(By.text("Groove Salad")); SystemClock.sleep(SETTLE_MILLIS); screenshot("dark_11_internet_radio"); mDevice.pressBack(); - waitFor(By.text(RADIO_STREAMS)); + waitFor(tag("internetNode")); navigateTo("navQueue"); waitFor(By.text("Clair de lune").hasAncestor(tag("queueSongs"))); From 0a5909bf1b5010f8a9c94222f85b6dc2f37b047b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:29:01 +0000 Subject: [PATCH 4/4] Scroll the services until the radio streams show A Compose list says it can't scroll further when it can, so the screenshots stopped after one scroll, short of "Your radio streams". They now scroll until it shows, at most 15 times. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- .../clementineremote/StoreScreenshots.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java index 41e61736..7c7373a9 100644 --- a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java +++ b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java @@ -161,22 +161,21 @@ private UiObject2 waitFor(BySelector selector) { return waitFor(selector, TIMEOUT); } - /** Scrolls the list tagged [list] down until [selector] shows, and returns that. */ + /** + * Scrolls the list tagged [list] down until [selector] shows, and returns that. Whether a + * Compose list can scroll further isn't reported reliably, so it just scrolls until then. + */ private UiObject2 scrollTo(String list, BySelector selector) { - UiObject2 scrollable = waitFor(tag(list)); - for (int i = 0; i < 20 && !mDevice.hasObject(selector); i++) { - if (!scrollable.scroll(Direction.DOWN, 0.8f)) { - break; - } + for (int i = 0; i < 15 && !mDevice.hasObject(selector); i++) { + waitFor(tag(list)).scroll(Direction.DOWN, 0.5f); } return waitFor(selector); } /** Scrolls the list tagged [list] back to its top. */ private void scrollToTop(String list) { - UiObject2 scrollable = waitFor(tag(list)); - for (int i = 0; i < 20 && scrollable.scroll(Direction.UP, 1f); i++) { - // Until it can't scroll any further. + for (int i = 0; i < 3; i++) { + waitFor(tag(list)).fling(Direction.UP); } }