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..7c7373a9 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; @@ -58,6 +59,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); @@ -157,6 +161,24 @@ private UiObject2 waitFor(BySelector selector) { return waitFor(selector, TIMEOUT); } + /** + * 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) { + 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) { + for (int i = 0; i < 3; i++) { + waitFor(tag(list)).fling(Direction.UP); + } + } + /** A Compose element, by its test tag: its resource name, without the package. */ private static BySelector tag(String name) { return By.res(name); @@ -273,6 +295,21 @@ public void takeScreenshots() { waitFor(By.textStartsWith("Nocturne in")); screenshot("2_library_album"); + // 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"); + // 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(tag("internetNode")); + navigateTo("navSearch"); search("Gymnopédie"); // Songs matched by title are listed straight away. @@ -299,6 +336,18 @@ public void takeScreenshots() { SystemClock.sleep(SETTLE_MILLIS); screenshot("dark_2_library"); + navigateTo("navInternet"); + waitFor(tag("internetNode")); + scrollToTop("internetNodes"); + SystemClock.sleep(SETTLE_MILLIS); + screenshot("dark_10_internet"); + scrollTo("internetNodes", By.text(RADIO_STREAMS)).click(); + waitFor(By.text("Groove Salad")); + SystemClock.sleep(SETTLE_MILLIS); + screenshot("dark_11_internet_radio"); + mDevice.pressBack(); + waitFor(tag("internetNode")); + 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") 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 }