Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -89,6 +117,21 @@ object RemoteRepository {
@JvmStatic
val outputs: StateFlow<Outputs> = _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> = _browsing.asStateFlow()

private val _browseMessages = MutableSharedFlow<BrowseMessage>(
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

/** Clementine's answers to browsing its Internet sidebar, in the order they came. */
@JvmStatic
val browseMessages: SharedFlow<BrowseMessage> = _browseMessages.asSharedFlow()

/** Whether Clementine plays on this phone (remote streaming). */
@JvmStatic
fun isPlayingHere(): Boolean = _outputs.value.active?.isThisPhone == true
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> nodeIds, BrowseAddAction action) {
Message.Builder msg = ClementineMessage.getMessageBuilder(MsgType.REQUEST_BROWSE_ADD);
msg.setRequestBrowseAdd(RequestBrowseAdd.newBuilder().addAllNodeIds(nodeIds).setAction(action));
return new ClementineMessage(msg);
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Loading
Loading