From 4b4c8a3b68b6a60849c53e1f680ddec96e1cbe57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:08:10 +0000 Subject: [PATCH 1/5] Browse Clementine's internet services A new Internet tab browses the tree Clementine's Internet sidebar shows (clementine-player/Clementine#7530), as the Android remote does: the services with their icons, and the nodes below them, level by level. Tapping a track or stream plays it, or adds it while Clementine is playing; touch and hold for Play now, Play next, Add to playlist and Replace playlist, and an opened album or playlist has Play and Add to playlist. Levels show Clementine's loading and "set up first" states, update as Clementine sends changes, and page through long lists. The tab is only there for Clementine that advertises SERVER_FEATURE_BROWSE. InternetBrowser in ClementineKit keeps the levels, merges pages and updates, and forgets node ids on each new connection, with tests. Clementine's latest release no longer knows --experimental-remote- streaming and exits on options it doesn't know, so the screenshots only pass it to releases that have it. They add the Internet tab. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- App/AppModel.swift | 30 +- App/Internet/InternetView.swift | 225 ++++++ App/MainView.swift | 12 + ClementineRemote.xcodeproj/project.pbxproj | 12 + .../Proto/remotecontrolmessages.proto | 139 ++++ .../Internet/InternetBrowser.swift | 189 +++++ .../ClementineKit/Protocol/Messages.swift | 21 + .../Protocol/remotecontrolmessages.pb.swift | 735 +++++++++++++++++- .../ClementineKit/Session/RemoteSession.swift | 7 + .../InternetBrowserTests.swift | 192 +++++ README.md | 6 +- UITests/Screenshots.swift | 7 + clementine-it/start-clementine.sh | 12 +- design/DESIGN.md | 65 +- 14 files changed, 1632 insertions(+), 20 deletions(-) create mode 100644 App/Internet/InternetView.swift create mode 100644 Packages/ClementineKit/Sources/ClementineKit/Internet/InternetBrowser.swift create mode 100644 Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift diff --git a/App/AppModel.swift b/App/AppModel.swift index 697b6fb..c41af36 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -9,7 +9,7 @@ final class AppModel { static weak var shared: AppModel? enum Tab: Hashable { - case queue, library, search, downloads + case queue, library, internet, search, downloads } let session: RemoteSession @@ -22,6 +22,8 @@ final class AppModel { @ObservationIgnored private(set) var downloads: DownloadsModel! /// This phone as somewhere Clementine can play (remote streaming). let renderer: Renderer + /// Clementine's internet services. + let internet: InternetBrowser @ObservationIgnored private var nowPlaying: NowPlaying? @ObservationIgnored private var scenePhase = ScenePhase.active @@ -47,14 +49,38 @@ final class AppModel { let session = RemoteSession() self.session = session renderer = Renderer(playback: AVPlayback()) { session.send($0) } + internet = InternetBrowser { session.send($0) } library = LibraryModel(model: self) search = SearchModel(model: self) downloads = DownloadsModel(model: self) let nowPlaying = NowPlaying(model: self) self.nowPlaying = nowPlaying renderer.onUpdate = { nowPlaying.update() } - session.addObserver { [renderer] message in + session.addObserver { [renderer, internet] message in renderer.handle(message) + internet.handle(message) + } + internet.onAdded = { [weak self] action, result in + self?.showAdded(action, result: result) + } + } + + /// Says how putting internet service nodes on the playlist went, where the player doesn't show it. + private func showAdded(_ action: BrowseAddAction, result: BrowseAddResult) { + switch result { + case .added: + switch action { + case .append, .unspecified: + toasts.show("Added to the playlist") + case .playNext: + toasts.show("Playing next") + case .playNow, .replace: + break + } + case .gone: + toasts.show("That's no longer in Clementine") + case .notPlayable, .unspecified: + toasts.show("Clementine can't add that to the playlist") } } diff --git a/App/Internet/InternetView.swift b/App/Internet/InternetView.swift new file mode 100644 index 0000000..3cfda0f --- /dev/null +++ b/App/Internet/InternetView.swift @@ -0,0 +1,225 @@ +import ClementineKit +import SwiftUI + +/// Clementine's internet services, browsed as its Internet sidebar shows them: the services, and +/// what's below them, level by level. Tracks and streams play or go on the playlist. +struct InternetView: View { + @Environment(AppModel.self) private var model + @State private var path: [BrowseNode] = [] + + var body: some View { + NavigationStack(path: $path) { + InternetLevelView(node: nil) + .navigationDestination(for: BrowseNode.self) { node in + InternetLevelView(node: node) { goUp(from: node) } + } + } + .onChange(of: model.internet.generation) { + // A new connection: the nodes shown are gone with the old one. + path = [] + } + } + + /// Leaves [node], and anything opened from it. + private func goUp(from node: BrowseNode) { + if let index = path.firstIndex(of: node) { + path.removeSubrange(index...) + } + } +} + +/// One level: the services, or what's below an opened node. Clementine keeps the level shown up +/// to date, so it's asked for whenever it's shown. +private struct InternetLevelView: View { + let node: BrowseNode? + var goUp: () -> Void = {} + + @Environment(AppModel.self) private var model + @Environment(RemoteSession.self) private var session + + private var browser: InternetBrowser { model.internet } + + var body: some View { + let listing = browser.listing(of: node) + List { + if let node, node.isAddable { + InternetHeader(node: node, count: listing.totalCount) { add([node], $0) } + } + ForEach(listing.nodes, id: \.nodeID) { child in + row(child) + .onAppear { + if child.nodeID == listing.nodes.last?.nodeID { + browser.loadMore(node) + } + } + } + } + .listStyle(.plain) + .surfaceBackground() + .overlay { placeholder(listing) } + .safeAreaInset(edge: .top, spacing: 0) { + if listing.state == .loading, !listing.nodes.isEmpty { + ProgressBanner(text: "Loading…", fraction: nil) + } + } + .navigationTitle(node?.title ?? String(localized: "Internet")) + .navigationBarTitleDisplayMode(node == nil ? .large : .inline) + .refreshable { browser.browse(node) } + .toolbar { + if node == nil { + ToolbarItem(placement: .topBarTrailing) { + ConnectionChip() + } + .sharedBackgroundVisibility(.visible) + } + } + .task(id: browser.generation) { + browser.browse(node) + } + .onChange(of: listing.state == .gone) { _, gone in + if gone { + goUp() + } + } + } + + /// Opens a node with children; plays or adds a track or stream, as tapping a song in the + /// library does. Touch and hold for what else can be done with it. + @ViewBuilder + private func row(_ child: BrowseNode) -> some View { + Group { + if child.canOpen { + NavigationLink(value: child) { + InternetRow(node: child) + } + } else if child.isAddable { + Button { + add([child], InternetBrowser.tapAction(isPlaying: session.playState == .playing)) + } label: { + InternetRow(node: child) + } + .accessibilityHint("Plays it, or adds it to the playlist if Clementine is playing") + } else { + InternetRow(node: child) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("internetNode") + .contextMenu { + if child.isAddable { + InternetAddActions { add([child], $0) } + } + } + .listRowBackground(Palette.surface) + .listRowSeparator(.hidden) + } + + private func add(_ nodes: [BrowseNode], _ action: BrowseAddAction) { + browser.add(nodes, action: action) + } + + @ViewBuilder + private func placeholder(_ listing: InternetBrowser.Listing) -> some View { + if listing.nodes.isEmpty { + switch listing.state { + case .loading: + ProgressView("Loading…") + case .needsSetup(let message): + ContentUnavailableView { + Label("Set up in Clementine", systemImage: "gearshape") + } description: { + Text(message) + } + case .ready: + ContentUnavailableView("Nothing here", systemImage: "globe") + case .gone: + EmptyView() + } + } + } +} + +/// What can be done with a node that can go on the playlist. +struct InternetAddActions: View { + let add: (BrowseAddAction) -> Void + + var body: some View { + Button("Play now", systemImage: "play.fill") { add(.playNow) } + Button("Play next", systemImage: "text.line.first.and.arrowtriangle.forward") { add(.playNext) } + Button("Add to playlist", systemImage: "plus") { add(.append) } + Button("Replace playlist", systemImage: "arrow.triangle.2.circlepath") { add(.replace) } + } +} + +/// A node: its title and subtitle, with its service's icon, or one for its kind. +struct InternetRow: View { + let node: BrowseNode + + var body: some View { + MediaRow(title: node.title, meta: node.subtitle) { + if let icon = node.icon { + Image(uiImage: icon) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .frame(width: 40, height: 40) + .background(Palette.secondaryContainer, in: .circle) + } else { + IconTile(systemImage: node.systemImage) + } + } + } +} + +/// The header of an opened node that can go on the playlist, such as an album: its name, how many +/// items, and playing or adding all of it. +private struct InternetHeader: View { + let node: BrowseNode + let count: Int + let add: (BrowseAddAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: Metrics.space3) { + VStack(alignment: .leading, spacing: 2) { + Text(node.title) + .textStyle(.headlineSmall) + .foregroundStyle(Palette.onSurface) + .lineLimit(2) + .accessibilityAddTraits(.isHeader) + Text("\(count) items") + .textStyle(.bodyMedium) + .foregroundStyle(Palette.onSurfaceVariant) + } + HStack(spacing: Metrics.space2) { + Button("Play", systemImage: "play.fill") { add(.playNow) } + .buttonStyle(.borderedProminent) + .filledButtonTint() + Button("Add to playlist", systemImage: "plus") { add(.append) } + .buttonStyle(.bordered) + } + .controlSize(.regular) + } + .padding(.vertical, Metrics.space2) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(Palette.surface) + .listRowSeparator(.hidden) + } +} + +extension BrowseNode { + /// A service's own icon. + var icon: UIImage? { + iconPng.isEmpty ? nil : UIImage(data: iconPng) + } + + /// For a node without its own icon, one for its kind. + var systemImage: String { + switch kind { + case .service: "globe" + case .track: "music.note" + case .stream: "dot.radiowaves.left.and.right" + case .smartPlaylist: "wand.and.stars" + case .folder, .unspecified: "folder" + } + } +} diff --git a/App/MainView.swift b/App/MainView.swift index ae48bd8..0769bda 100644 --- a/App/MainView.swift +++ b/App/MainView.swift @@ -4,6 +4,7 @@ import SwiftUI /// Clementine's tabs, with the mini player above them. struct MainView: View { @Environment(AppModel.self) private var model + @Environment(RemoteSession.self) private var session @Namespace private var playerTransition var body: some View { @@ -15,6 +16,12 @@ struct MainView: View { Tab("Library", systemImage: "square.stack", value: AppModel.Tab.library) { LibraryView() } + // Only Clementine that can be browsed has it. + if session.canBrowse { + Tab("Internet", systemImage: "globe", value: AppModel.Tab.internet) { + InternetView() + } + } Tab("Search", systemImage: "magnifyingglass", value: AppModel.Tab.search, role: .search) { SearchView() } @@ -28,6 +35,11 @@ struct MainView: View { } .matchedTransitionSource(id: "player", in: playerTransition) } + .onChange(of: session.canBrowse) { _, canBrowse in + if !canBrowse, model.selectedTab == .internet { + model.selectedTab = .queue + } + } .tabViewStyle(.sidebarAdaptable) .tabBarMinimizeBehavior(.onScrollDown) .fullScreenCover(isPresented: $model.isPlayerPresented) { diff --git a/ClementineRemote.xcodeproj/project.pbxproj b/ClementineRemote.xcodeproj/project.pbxproj index d49a7e3..ee41bff 100644 --- a/ClementineRemote.xcodeproj/project.pbxproj +++ b/ClementineRemote.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 015B3D7098D15696CE753DCA /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4806AC63E537A5050C1E6BD /* LibraryView.swift */; }; + 02B054CAB79B78B9185BBFC3 /* InternetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46866E6F5E51462F605F0CA /* InternetView.swift */; }; 198DA2CE0EAEC3C225F8AC81 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 761E362C08E663B70599A6DD /* SwiftUI.framework */; }; 1B87E42072E67CED101AEDC9 /* ClementineRemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9202E1E8FCA97103ED358B /* ClementineRemoteApp.swift */; }; 1DBC787744D19CBA495822CC /* CommandIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD37699892851B35F20411F /* CommandIntents.swift */; }; @@ -120,6 +121,7 @@ C636902427E849DF54E5B07F /* AVPlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayback.swift; sourceTree = ""; }; CD9202E1E8FCA97103ED358B /* ClementineRemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClementineRemoteApp.swift; sourceTree = ""; }; CFC7AB47D2CE0823058DA1A6 /* NowPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlaying.swift; sourceTree = ""; }; + D46866E6F5E51462F605F0CA /* InternetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetView.swift; sourceTree = ""; }; D4806AC63E537A5050C1E6BD /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; D8B11B3C0886EBF71B26160B /* LibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryModel.swift; sourceTree = ""; }; E08CA8859F2B5F40175519D8 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; @@ -151,6 +153,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 06B7950B1505D083C77CD065 /* Internet */ = { + isa = PBXGroup; + children = ( + D46866E6F5E51462F605F0CA /* InternetView.swift */, + ); + path = Internet; + sourceTree = ""; + }; 21619A064D78FFF7022E0CAD /* Queue */ = { isa = PBXGroup; children = ( @@ -282,6 +292,7 @@ C29638AE1C4D87CD5F503A3C /* Connection */, 34B9B7135F563F9C0F195796 /* Downloads */, 3BD8BD833ADD0CB0AEFC36B2 /* Intents */, + 06B7950B1505D083C77CD065 /* Internet */, 2AC6FAA5E7DBA023F4A9CDF9 /* Library */, 56F26C6A8DE02190C5639C9A /* Player */, 21619A064D78FFF7022E0CAD /* Queue */, @@ -555,6 +566,7 @@ 5519998FF94C9CDB9C36CA95 /* ConnectionSheet.swift in Sources */, 761C4231EA060F078910BF81 /* DownloadsModel.swift in Sources */, AF5B4FBB88A0EB985218B5A6 /* DownloadsView.swift in Sources */, + 02B054CAB79B78B9185BBFC3 /* InternetView.swift in Sources */, A83B1DAF857B6BC5EB001422 /* LibraryModel.swift in Sources */, 015B3D7098D15696CE753DCA /* LibraryView.swift in Sources */, 2E89727B527288C7A3667C8D /* MainView.swift in Sources */, diff --git a/Packages/ClementineKit/Proto/remotecontrolmessages.proto b/Packages/ClementineKit/Proto/remotecontrolmessages.proto index fbcf624..3073839 100644 --- a/Packages/ClementineKit/Proto/remotecontrolmessages.proto +++ b/Packages/ClementineKit/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/Packages/ClementineKit/Sources/ClementineKit/Internet/InternetBrowser.swift b/Packages/ClementineKit/Sources/ClementineKit/Internet/InternetBrowser.swift new file mode 100644 index 0000000..fed6bc3 --- /dev/null +++ b/Packages/ClementineKit/Sources/ClementineKit/Internet/InternetBrowser.swift @@ -0,0 +1,189 @@ +import Foundation +import Observation + +/// A node of the tree Clementine's Internet sidebar shows: a service, or something below one. +public typealias BrowseNode = Pb_Remote_BrowseNode + +/// What adding nodes does to the playlist. +public typealias BrowseAddAction = Pb_Remote_BrowseAddAction + +/// How adding nodes went. +public typealias BrowseAddResult = Pb_Remote_BrowseAddResult + +/// Clementine's internet services, browsed as its Internet sidebar shows them: the services, and +/// the nodes below them, which can go on the playlist as a drag from the sidebar does. +/// +/// Clementine names nodes by ids that last as long as the connection, and sends a node's children +/// again whenever they change, as long as it's the node last asked for. +@MainActor +@Observable +public final class InternetBrowser { + /// A node's children as far as they've arrived. + public struct Listing: Equatable, Sendable { + public enum State: Equatable, Sendable { + /// Asked for, or still loading in Clementine: more follows. + case loading + case ready + /// The service has to be set up on the computer first; the message says how. + case needsSetup(String) + /// The node no longer exists. + case gone + } + + public var state = State.loading + /// The children that have arrived, from the first. + public var nodes: [BrowseNode] = [] + /// How many children there are in all. + public var totalCount = 0 + + /// Whether there are children still to ask for. + public var hasMore: Bool { + nodes.count < totalCount + } + + public init(state: State = .loading, nodes: [BrowseNode] = [], totalCount: Int = 0) { + self.state = state + self.nodes = nodes + self.totalCount = totalCount + } + } + + /// Which connection the node ids belong to: it changes with every connection, when they're + /// all forgotten. + public private(set) var generation = 0 + + /// Called with the action and result of each add, for the app to say how it went. + @ObservationIgnored public var onAdded: (@MainActor (BrowseAddAction, BrowseAddResult) -> Void)? + + /// By node id; the services under "". + private var listings: [String: Listing] = [:] + /// Where a page has been asked for, by node id, so it's asked for once. + @ObservationIgnored private var requestedOffsets: [String: Int] = [:] + /// Adds sent, in order, for the action of their results. + @ObservationIgnored private var pendingAdds: [(ids: [String], action: BrowseAddAction)] = [] + /// The node last asked for, which Clementine keeps up to date; nil for the services. + @ObservationIgnored private var watched: BrowseNode? + @ObservationIgnored private let send: @MainActor (RemoteMessage) -> Void + + /// Asks Clementine with [send]. + public init(send: @escaping @MainActor (RemoteMessage) -> Void) { + self.send = send + } + + /// [node]'s children, or the services for nil. + public func listing(of node: BrowseNode?) -> Listing { + listings[node?.nodeID ?? ""] ?? Listing() + } + + /// Asks for [node]'s children, or the services for nil, from the first. Clementine then sends + /// them again when they change, until another node is asked for. + public func browse(_ node: BrowseNode?) { + let id = node?.nodeID ?? "" + if listings[id] == nil || listings[id]?.state == .gone { + listings[id] = Listing() + } + requestedOffsets[id] = 0 + watched = node + send(Messages.browse(node?.nodeID)) + } + + /// Asks for the next page of [node]'s children, if there is one not asked for yet. + public func loadMore(_ node: BrowseNode?) { + let id = node?.nodeID ?? "" + guard let listing = listings[id], listing.hasMore, requestedOffsets[id] != listing.nodes.count else { return } + requestedOffsets[id] = listing.nodes.count + send(Messages.browse(node?.nodeID, offset: listing.nodes.count)) + } + + /// Puts [nodes] on the current playlist, doing [action]. + public func add(_ nodes: [BrowseNode], action: BrowseAddAction) { + let ids = nodes.map(\.nodeID) + pendingAdds.append((ids, action)) + send(Messages.browseAdd(ids, action: action)) + } + + /// What tapping a track or stream does: plays it if Clementine isn't playing, as tapping a song + /// in the library does, or else adds it to the playlist. + public static func tapAction(isPlaying: Bool) -> BrowseAddAction { + isPlaying ? .append : .playNow + } + + /// Forgets every node, as their ids don't outlive the connection. + public func reset() { + listings = [:] + requestedOffsets = [:] + pendingAdds = [] + watched = nil + generation += 1 + } + + /// Handles a message from Clementine; returns whether it was one for the browser. + @discardableResult + public func handle(_ message: RemoteMessage) -> Bool { + switch message.type { + case .info: + // A new connection: the old ids mean nothing to it. + reset() + return false + case .browse: + apply(message.responseBrowse) + case .browseAddResult: + let response = message.responseBrowseAdd + let index = pendingAdds.firstIndex { $0.ids == response.nodeIds } ?? pendingAdds.indices.first + guard let index else { return true } + let action = pendingAdds.remove(at: index).action + onAdded?(action, response.result) + if response.result == .gone { + // What's shown is out of date. + browse(watched) + } + default: + return false + } + return true + } + + private func apply(_ response: Pb_Remote_ResponseBrowse) { + let id = response.nodeID + var listing = listings[id] ?? Listing() + switch response.state { + case .gone: + listings[id] = Listing(state: .gone) + requestedOffsets[id] = nil + return + case .loading: + listing.state = .loading + case .needsSetup: + listing.state = .needsSetup(response.message) + case .ready, .unspecified: + listing.state = .ready + } + + // The page replaces what was there, and the list is cut to the children there are now. + let offset = Int(response.offset) + let total = Int(response.totalCount) + if offset <= listing.nodes.count { + let end = min(listing.nodes.count, offset + response.nodes.count) + listing.nodes.replaceSubrange(offset.. listing.nodes.count { + // Asked for a page that the children have since shrunk from. + requestedOffsets[id] = 0 + } + listings[id] = listing + } +} + +extension BrowseNode { + /// Whether it has children to open. + public var canOpen: Bool { + children == Pb_Remote_BrowseChildren.some + } + + /// Whether it can go on the playlist. + public var isAddable: Bool { + playability == .addable + } +} diff --git a/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift b/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift index 63a97ce..3b95387 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift @@ -101,6 +101,27 @@ public enum Messages { RemoteMessage(.setOutput) { $0.requestSetOutput.outputID = id } } + /// Lists [nodeID]'s children in Clementine's internet services, or the services when it's nil, + /// from [offset]; Clementine then sends updates of that page while it's the last one asked for. + public static func browse(_ nodeID: String?, offset: Int = 0) -> RemoteMessage { + RemoteMessage(.requestBrowse) { + if let nodeID { + $0.requestBrowse.nodeID = nodeID + } + if offset > 0 { + $0.requestBrowse.offset = Int32(offset) + } + } + } + + /// Puts internet service nodes on the current playlist, as dragging them from the sidebar does. + public static func browseAdd(_ nodeIDs: [String], action: BrowseAddAction) -> RemoteMessage { + RemoteMessage(.requestBrowseAdd) { + $0.requestBrowseAdd.nodeIds = nodeIDs + $0.requestBrowseAdd.action = action + } + } + public static func volume(_ percent: Int) -> RemoteMessage { RemoteMessage(.setVolume) { $0.requestSetVolume.volume = Int32(percent) } } diff --git a/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift b/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift index 65f05fa..44b92af 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift @@ -136,6 +136,15 @@ public nonisolated enum Pb_Remote_MsgType: Int, SwiftProtobuf.Enum, Swift.CaseIt /// Server -> all clients case outputs = 340 + /// Browsing the Internet sidebar. + /// Client -> server + case requestBrowse = 360 + case requestBrowseAdd = 361 + + /// Server -> client + case browse = 370 + case browseAddResult = 371 + public init() { self = .unknown } @@ -226,6 +235,9 @@ public nonisolated enum Pb_Remote_ServerFeature: Int, SwiftProtobuf.Enum, Swift. /// The server can send playback to renderers (streaming is enabled). case rendering = 1 + /// The server can be browsed like its Internet sidebar (REQUEST_BROWSE). + case browse = 2 + public init() { self = .unspecified } @@ -357,6 +369,130 @@ public nonisolated enum Pb_Remote_RendererErrorScope: Int, SwiftProtobuf.Enum, S } +/// What kind of node a BrowseNode is, for its icon and what a tap does. +public nonisolated enum Pb_Remote_BrowseNodeKind: Int, SwiftProtobuf.Enum, Swift.CaseIterable { + + /// Treat as BROWSE_NODE_KIND_FOLDER. + case unspecified = 0 + + /// A service's root: SomaFM, Plex, Google Drive... + case service = 1 + + /// Anything grouping others: an artist, album, playlist, genre, category. + case folder = 2 + + /// A song with a length. + case track = 3 + + /// A radio stream, or anything else that plays with no known length. + case stream = 4 + + /// A smart playlist, generated when added. + case smartPlaylist = 5 + + public init() { + self = .unspecified + } + +} + +/// Whether a node has children to browse into. +public nonisolated enum Pb_Remote_BrowseChildren: Int, SwiftProtobuf.Enum, Swift.CaseIterable { + + /// Treat as BROWSE_CHILDREN_NONE. + case unspecified = 0 + case none = 1 + + /// It has children, or will load some when browsed. + case some = 2 + + public init() { + self = .unspecified + } + +} + +/// Whether a node can go on the playlist, as a drag from the sidebar can. +public nonisolated enum Pb_Remote_BrowsePlayability: Int, SwiftProtobuf.Enum, Swift.CaseIterable { + + /// Treat as BROWSE_PLAYABILITY_NONE. + case unspecified = 0 + case none = 1 + + /// Adding it adds it, or everything under it (an album, a playlist). + case addable = 2 + + public init() { + self = .unspecified + } + +} + +/// Where a node's children stand. +public nonisolated enum Pb_Remote_BrowseState: Int, SwiftProtobuf.Enum, Swift.CaseIterable { + + /// Treat as BROWSE_STATE_READY. + case unspecified = 0 + case ready = 1 + + /// Still loading. Another BROWSE for this node follows when they change. + case loading = 2 + + /// The service has to be set up on the computer first; message says how. + case needsSetup = 3 + + /// The node no longer exists: the client goes back up. + case gone = 4 + + public init() { + self = .unspecified + } + +} + +/// What adding nodes does to the playlist. +public nonisolated enum Pb_Remote_BrowseAddAction: Int, SwiftProtobuf.Enum, Swift.CaseIterable { + + /// Treat as BROWSE_ADD_ACTION_APPEND. + case unspecified = 0 + + /// Add to the end of the current playlist. + case append = 1 + + /// Add, and play the first one added. + case playNow = 2 + + /// Add, and queue it to play straight after the current song. + case playNext = 3 + + /// Clear the current playlist, add, and play. + case replace = 4 + + public init() { + self = .unspecified + } + +} + +/// How a RequestBrowseAdd went. +public nonisolated enum Pb_Remote_BrowseAddResult: Int, SwiftProtobuf.Enum, Swift.CaseIterable { + + /// Treat as BROWSE_ADD_RESULT_NOT_PLAYABLE. + case unspecified = 0 + case added = 1 + + /// None of the nodes can go on the playlist. + case notPlayable = 2 + + /// A node no longer exists. + case gone = 3 + + public init() { + self = .unspecified + } + +} + /// Song Metadata public nonisolated struct Pb_Remote_SongMetadata: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the @@ -2486,6 +2622,260 @@ public nonisolated struct Pb_Remote_RendererError: Sendable { fileprivate var _scope: Pb_Remote_RendererErrorScope? = nil } +/// One node of the tree the Internet sidebar shows. +public nonisolated struct Pb_Remote_BrowseNode: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Opaque, issued by the server, valid on this connection until the node + /// goes away. Never a URL or a path. + public var nodeID: String { + get {_nodeID ?? String()} + set {_nodeID = newValue} + } + /// Returns true if `nodeID` has been explicitly set. + public var hasNodeID: Bool {self._nodeID != nil} + /// Clears the value of `nodeID`. Subsequent reads from it will return its default value. + public mutating func clearNodeID() {self._nodeID = nil} + + public var title: String { + get {_title ?? String()} + set {_title = newValue} + } + /// Returns true if `title` has been explicitly set. + public var hasTitle: Bool {self._title != nil} + /// Clears the value of `title`. Subsequent reads from it will return its default value. + public mutating func clearTitle() {self._title = nil} + + /// A second line where there is one, such as a track's artist. + public var subtitle: String { + get {_subtitle ?? String()} + set {_subtitle = newValue} + } + /// Returns true if `subtitle` has been explicitly set. + public var hasSubtitle: Bool {self._subtitle != nil} + /// Clears the value of `subtitle`. Subsequent reads from it will return its default value. + public mutating func clearSubtitle() {self._subtitle = nil} + + public var kind: Pb_Remote_BrowseNodeKind { + get {_kind ?? .unspecified} + set {_kind = newValue} + } + /// Returns true if `kind` has been explicitly set. + public var hasKind: Bool {self._kind != nil} + /// Clears the value of `kind`. Subsequent reads from it will return its default value. + public mutating func clearKind() {self._kind = nil} + + public var children: Pb_Remote_BrowseChildren { + get {_children ?? .unspecified} + set {_children = newValue} + } + /// Returns true if `children` has been explicitly set. + public var hasChildren: Bool {self._children != nil} + /// Clears the value of `children`. Subsequent reads from it will return its default value. + public mutating func clearChildren() {self._children = nil} + + public var playability: Pb_Remote_BrowsePlayability { + get {_playability ?? .unspecified} + set {_playability = newValue} + } + /// Returns true if `playability` has been explicitly set. + public var hasPlayability: Bool {self._playability != nil} + /// Clears the value of `playability`. Subsequent reads from it will return its default value. + public mutating func clearPlayability() {self._playability = nil} + + /// PNG, at most 48x48. Only on service nodes. + public var iconPng: Data { + get {_iconPng ?? Data()} + set {_iconPng = newValue} + } + /// Returns true if `iconPng` has been explicitly set. + public var hasIconPng: Bool {self._iconPng != nil} + /// Clears the value of `iconPng`. Subsequent reads from it will return its default value. + public mutating func clearIconPng() {self._iconPng = nil} + + /// For tracks the server has metadata for: artist, album, length. + public var song: Pb_Remote_SongMetadata { + get {_song ?? Pb_Remote_SongMetadata()} + set {_song = newValue} + } + /// Returns true if `song` has been explicitly set. + public var hasSong: Bool {self._song != nil} + /// Clears the value of `song`. Subsequent reads from it will return its default value. + public mutating func clearSong() {self._song = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _nodeID: String? = nil + fileprivate var _title: String? = nil + fileprivate var _subtitle: String? = nil + fileprivate var _kind: Pb_Remote_BrowseNodeKind? = nil + fileprivate var _children: Pb_Remote_BrowseChildren? = nil + fileprivate var _playability: Pb_Remote_BrowsePlayability? = nil + fileprivate var _iconPng: Data? = nil + fileprivate var _song: Pb_Remote_SongMetadata? = nil +} + +/// Asks for a node's children, or for the services when node_id is unset. +public nonisolated struct Pb_Remote_RequestBrowse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var nodeID: String { + get {_nodeID ?? String()} + set {_nodeID = newValue} + } + /// Returns true if `nodeID` has been explicitly set. + public var hasNodeID: Bool {self._nodeID != nil} + /// Clears the value of `nodeID`. Subsequent reads from it will return its default value. + public mutating func clearNodeID() {self._nodeID = nil} + + /// A page of the children. Unset or 0 limit: the server's maximum (500). + public var offset: Int32 { + get {_offset ?? 0} + set {_offset = newValue} + } + /// Returns true if `offset` has been explicitly set. + public var hasOffset: Bool {self._offset != nil} + /// Clears the value of `offset`. Subsequent reads from it will return its default value. + public mutating func clearOffset() {self._offset = nil} + + public var limit: Int32 { + get {_limit ?? 0} + set {_limit = newValue} + } + /// Returns true if `limit` has been explicitly set. + public var hasLimit: Bool {self._limit != nil} + /// Clears the value of `limit`. Subsequent reads from it will return its default value. + public mutating func clearLimit() {self._limit = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _nodeID: String? = nil + fileprivate var _offset: Int32? = nil + fileprivate var _limit: Int32? = nil +} + +/// A node's children, sent for a RequestBrowse and again when they change +/// while the client is looking at that node. +public nonisolated struct Pb_Remote_ResponseBrowse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var nodeID: String { + get {_nodeID ?? String()} + set {_nodeID = newValue} + } + /// Returns true if `nodeID` has been explicitly set. + public var hasNodeID: Bool {self._nodeID != nil} + /// Clears the value of `nodeID`. Subsequent reads from it will return its default value. + public mutating func clearNodeID() {self._nodeID = nil} + + public var state: Pb_Remote_BrowseState { + get {_state ?? .unspecified} + set {_state = newValue} + } + /// Returns true if `state` has been explicitly set. + public var hasState: Bool {self._state != nil} + /// Clears the value of `state`. Subsequent reads from it will return its default value. + public mutating func clearState() {self._state = nil} + + public var nodes: [Pb_Remote_BrowseNode] = [] + + /// Where this page starts, and how many children there are in all. + public var offset: Int32 { + get {_offset ?? 0} + set {_offset = newValue} + } + /// Returns true if `offset` has been explicitly set. + public var hasOffset: Bool {self._offset != nil} + /// Clears the value of `offset`. Subsequent reads from it will return its default value. + public mutating func clearOffset() {self._offset = nil} + + public var totalCount: Int32 { + get {_totalCount ?? 0} + set {_totalCount = newValue} + } + /// Returns true if `totalCount` has been explicitly set. + public var hasTotalCount: Bool {self._totalCount != nil} + /// Clears the value of `totalCount`. Subsequent reads from it will return its default value. + public mutating func clearTotalCount() {self._totalCount = nil} + + /// Shown to the user with BROWSE_STATE_NEEDS_SETUP, translated. + public var message: String { + get {_message ?? String()} + set {_message = newValue} + } + /// Returns true if `message` has been explicitly set. + public var hasMessage: Bool {self._message != nil} + /// Clears the value of `message`. Subsequent reads from it will return its default value. + public mutating func clearMessage() {self._message = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _nodeID: String? = nil + fileprivate var _state: Pb_Remote_BrowseState? = nil + fileprivate var _offset: Int32? = nil + fileprivate var _totalCount: Int32? = nil + fileprivate var _message: String? = nil +} + +/// Puts nodes on the playlist, as dragging them from the sidebar does. +public nonisolated struct Pb_Remote_RequestBrowseAdd: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var nodeIds: [String] = [] + + public var action: Pb_Remote_BrowseAddAction { + get {_action ?? .unspecified} + set {_action = newValue} + } + /// Returns true if `action` has been explicitly set. + public var hasAction: Bool {self._action != nil} + /// Clears the value of `action`. Subsequent reads from it will return its default value. + public mutating func clearAction() {self._action = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _action: Pb_Remote_BrowseAddAction? = nil +} + +public nonisolated struct Pb_Remote_ResponseBrowseAdd: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var nodeIds: [String] = [] + + public var result: Pb_Remote_BrowseAddResult { + get {_result ?? .unspecified} + set {_result = newValue} + } + /// Returns true if `result` has been explicitly set. + public var hasResult: Bool {self._result != nil} + /// Clears the value of `result`. Subsequent reads from it will return its default value. + public mutating func clearResult() {self._result = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _result: Pb_Remote_BrowseAddResult? = nil +} + /// The message itself public nonisolated struct Pb_Remote_Message: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the @@ -2919,6 +3309,43 @@ public nonisolated struct Pb_Remote_Message: @unchecked Sendable { /// Clears the value of `rendererTrackEnded`. Subsequent reads from it will return its default value. public mutating func clearRendererTrackEnded() {_uniqueStorage()._rendererTrackEnded = nil} + /// Browsing the Internet sidebar + public var requestBrowse: Pb_Remote_RequestBrowse { + get {_storage._requestBrowse ?? Pb_Remote_RequestBrowse()} + set {_uniqueStorage()._requestBrowse = newValue} + } + /// Returns true if `requestBrowse` has been explicitly set. + public var hasRequestBrowse: Bool {_storage._requestBrowse != nil} + /// Clears the value of `requestBrowse`. Subsequent reads from it will return its default value. + public mutating func clearRequestBrowse() {_uniqueStorage()._requestBrowse = nil} + + public var responseBrowse: Pb_Remote_ResponseBrowse { + get {_storage._responseBrowse ?? Pb_Remote_ResponseBrowse()} + set {_uniqueStorage()._responseBrowse = newValue} + } + /// Returns true if `responseBrowse` has been explicitly set. + public var hasResponseBrowse: Bool {_storage._responseBrowse != nil} + /// Clears the value of `responseBrowse`. Subsequent reads from it will return its default value. + public mutating func clearResponseBrowse() {_uniqueStorage()._responseBrowse = nil} + + public var requestBrowseAdd: Pb_Remote_RequestBrowseAdd { + get {_storage._requestBrowseAdd ?? Pb_Remote_RequestBrowseAdd()} + set {_uniqueStorage()._requestBrowseAdd = newValue} + } + /// Returns true if `requestBrowseAdd` has been explicitly set. + public var hasRequestBrowseAdd: Bool {_storage._requestBrowseAdd != nil} + /// Clears the value of `requestBrowseAdd`. Subsequent reads from it will return its default value. + public mutating func clearRequestBrowseAdd() {_uniqueStorage()._requestBrowseAdd = nil} + + public var responseBrowseAdd: Pb_Remote_ResponseBrowseAdd { + get {_storage._responseBrowseAdd ?? Pb_Remote_ResponseBrowseAdd()} + set {_uniqueStorage()._responseBrowseAdd = newValue} + } + /// Returns true if `responseBrowseAdd` has been explicitly set. + public var hasResponseBrowseAdd: Bool {_storage._responseBrowseAdd != nil} + /// Clears the value of `responseBrowseAdd`. Subsequent reads from it will return its default value. + public mutating func clearResponseBrowseAdd() {_uniqueStorage()._responseBrowseAdd = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -2931,7 +3358,7 @@ public nonisolated struct Pb_Remote_Message: @unchecked Sendable { fileprivate nonisolated let _protobuf_package = "pb.remote" nonisolated extension Pb_Remote_MsgType: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}CONNECT\0\u{1}DISCONNECT\0\u{1}REQUEST_PLAYLISTS\0\u{1}REQUEST_PLAYLIST_SONGS\0\u{1}CHANGE_SONG\0\u{1}SET_VOLUME\0\u{1}SET_TRACK_POSITION\0\u{1}INSERT_URLS\0\u{1}REMOVE_SONGS\0\u{1}OPEN_PLAYLIST\0\u{1}CLOSE_PLAYLIST\0\u{1}LOVE\0\u{1}BAN\0\u{1}GET_LYRICS\0\u{1}DOWNLOAD_SONGS\0\u{1}SONG_OFFER_RESPONSE\0\u{1}STOP_AFTER\0\u{1}GET_LIBRARY\0\u{1}RATE_SONG\0\u{1}PLAY\0\u{1}PLAYPAUSE\0\u{1}PAUSE\0\u{1}STOP\0\u{1}NEXT\0\u{1}PREVIOUS\0\u{1}SHUFFLE_PLAYLIST\0\u{1}REPEAT\0\u{1}SHUFFLE\0\u{2}\u{c}INFO\0\u{1}CURRENT_METAINFO\0\u{1}PLAYLISTS\0\u{1}PLAYLIST_SONGS\0\u{1}ENGINE_STATE_CHANGED\0\u{1}KEEP_ALIVE\0\u{1}UPDATE_TRACK_POSITION\0\u{1}ACTIVE_PLAYLIST_CHANGED\0\u{1}FIRST_DATA_SENT_COMPLETE\0\u{1}LYRICS\0\u{1}SONG_FILE_CHUNK\0\u{1}DOWNLOAD_QUEUE_EMPTY\0\u{1}LIBRARY_CHUNK\0\u{1}DOWNLOAD_TOTAL_SIZE\0\u{1}GLOBAL_SEARCH_RESULT\0\u{1}TRANSCODING_FILES\0\u{1}GLOBAL_SEARCH_STATUS\0\u{2}\u{4}UPDATE_PLAYLIST\0\u{2}(GLOBAL_SEARCH\0\u{2}\u{a}REQUEST_SAVED_RADIOS\0\u{2}Z\u{1}REQUEST_FILES\0\u{1}APPEND_FILES\0\u{1}LIST_FILES\0\u{2}d\u{1}RENDERER_STATUS\0\u{1}RENDERER_TRACK_ENDED\0\u{1}RENDERER_ERROR\0\u{1}REQUEST_OUTPUTS\0\u{1}SET_OUTPUT\0\u{2}\u{e}RENDER_LOAD\0\u{1}RENDER_PRELOAD\0\u{1}RENDER_PLAY\0\u{1}RENDER_PAUSE\0\u{1}RENDER_STOP\0\u{1}RENDER_SEEK\0\u{1}RENDER_SET_VOLUME\0\u{2}\u{e}OUTPUTS\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}CONNECT\0\u{1}DISCONNECT\0\u{1}REQUEST_PLAYLISTS\0\u{1}REQUEST_PLAYLIST_SONGS\0\u{1}CHANGE_SONG\0\u{1}SET_VOLUME\0\u{1}SET_TRACK_POSITION\0\u{1}INSERT_URLS\0\u{1}REMOVE_SONGS\0\u{1}OPEN_PLAYLIST\0\u{1}CLOSE_PLAYLIST\0\u{1}LOVE\0\u{1}BAN\0\u{1}GET_LYRICS\0\u{1}DOWNLOAD_SONGS\0\u{1}SONG_OFFER_RESPONSE\0\u{1}STOP_AFTER\0\u{1}GET_LIBRARY\0\u{1}RATE_SONG\0\u{1}PLAY\0\u{1}PLAYPAUSE\0\u{1}PAUSE\0\u{1}STOP\0\u{1}NEXT\0\u{1}PREVIOUS\0\u{1}SHUFFLE_PLAYLIST\0\u{1}REPEAT\0\u{1}SHUFFLE\0\u{2}\u{c}INFO\0\u{1}CURRENT_METAINFO\0\u{1}PLAYLISTS\0\u{1}PLAYLIST_SONGS\0\u{1}ENGINE_STATE_CHANGED\0\u{1}KEEP_ALIVE\0\u{1}UPDATE_TRACK_POSITION\0\u{1}ACTIVE_PLAYLIST_CHANGED\0\u{1}FIRST_DATA_SENT_COMPLETE\0\u{1}LYRICS\0\u{1}SONG_FILE_CHUNK\0\u{1}DOWNLOAD_QUEUE_EMPTY\0\u{1}LIBRARY_CHUNK\0\u{1}DOWNLOAD_TOTAL_SIZE\0\u{1}GLOBAL_SEARCH_RESULT\0\u{1}TRANSCODING_FILES\0\u{1}GLOBAL_SEARCH_STATUS\0\u{2}\u{4}UPDATE_PLAYLIST\0\u{2}(GLOBAL_SEARCH\0\u{2}\u{a}REQUEST_SAVED_RADIOS\0\u{2}Z\u{1}REQUEST_FILES\0\u{1}APPEND_FILES\0\u{1}LIST_FILES\0\u{2}d\u{1}RENDERER_STATUS\0\u{1}RENDERER_TRACK_ENDED\0\u{1}RENDERER_ERROR\0\u{1}REQUEST_OUTPUTS\0\u{1}SET_OUTPUT\0\u{2}\u{e}RENDER_LOAD\0\u{1}RENDER_PRELOAD\0\u{1}RENDER_PLAY\0\u{1}RENDER_PAUSE\0\u{1}RENDER_STOP\0\u{1}RENDER_SEEK\0\u{1}RENDER_SET_VOLUME\0\u{2}\u{e}OUTPUTS\0\u{2}\u{14}REQUEST_BROWSE\0\u{1}REQUEST_BROWSE_ADD\0\u{2}\u{9}BROWSE\0\u{1}BROWSE_ADD_RESULT\0") } nonisolated extension Pb_Remote_EngineState: SwiftProtobuf._ProtoNameProviding { @@ -2959,7 +3386,7 @@ nonisolated extension Pb_Remote_GlobalSearchStatus: SwiftProtobuf._ProtoNameProv } nonisolated extension Pb_Remote_ServerFeature: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0SERVER_FEATURE_UNSPECIFIED\0\u{1}SERVER_FEATURE_RENDERING\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0SERVER_FEATURE_UNSPECIFIED\0\u{1}SERVER_FEATURE_RENDERING\0\u{1}SERVER_FEATURE_BROWSE\0") } nonisolated extension Pb_Remote_RendererFeature: SwiftProtobuf._ProtoNameProviding { @@ -2990,6 +3417,30 @@ nonisolated extension Pb_Remote_RendererErrorScope: SwiftProtobuf._ProtoNameProv public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0RENDERER_ERROR_SCOPE_UNSPECIFIED\0\u{1}RENDERER_ERROR_SCOPE_ITEM\0\u{1}RENDERER_ERROR_SCOPE_TRANSIENT\0\u{1}RENDERER_ERROR_SCOPE_RENDERER\0") } +nonisolated extension Pb_Remote_BrowseNodeKind: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BROWSE_NODE_KIND_UNSPECIFIED\0\u{1}BROWSE_NODE_KIND_SERVICE\0\u{1}BROWSE_NODE_KIND_FOLDER\0\u{1}BROWSE_NODE_KIND_TRACK\0\u{1}BROWSE_NODE_KIND_STREAM\0\u{1}BROWSE_NODE_KIND_SMART_PLAYLIST\0") +} + +nonisolated extension Pb_Remote_BrowseChildren: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BROWSE_CHILDREN_UNSPECIFIED\0\u{1}BROWSE_CHILDREN_NONE\0\u{1}BROWSE_CHILDREN_SOME\0") +} + +nonisolated extension Pb_Remote_BrowsePlayability: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BROWSE_PLAYABILITY_UNSPECIFIED\0\u{1}BROWSE_PLAYABILITY_NONE\0\u{1}BROWSE_PLAYABILITY_ADDABLE\0") +} + +nonisolated extension Pb_Remote_BrowseState: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BROWSE_STATE_UNSPECIFIED\0\u{1}BROWSE_STATE_READY\0\u{1}BROWSE_STATE_LOADING\0\u{1}BROWSE_STATE_NEEDS_SETUP\0\u{1}BROWSE_STATE_GONE\0") +} + +nonisolated extension Pb_Remote_BrowseAddAction: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BROWSE_ADD_ACTION_UNSPECIFIED\0\u{1}BROWSE_ADD_ACTION_APPEND\0\u{1}BROWSE_ADD_ACTION_PLAY_NOW\0\u{1}BROWSE_ADD_ACTION_PLAY_NEXT\0\u{1}BROWSE_ADD_ACTION_REPLACE\0") +} + +nonisolated extension Pb_Remote_BrowseAddResult: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BROWSE_ADD_RESULT_UNSPECIFIED\0\u{1}BROWSE_ADD_RESULT_ADDED\0\u{1}BROWSE_ADD_RESULT_NOT_PLAYABLE\0\u{1}BROWSE_ADD_RESULT_GONE\0") +} + nonisolated extension Pb_Remote_SongMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SongMetadata" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}index\0\u{1}title\0\u{1}album\0\u{1}artist\0\u{1}albumartist\0\u{1}track\0\u{1}disc\0\u{3}pretty_year\0\u{1}genre\0\u{1}playcount\0\u{3}pretty_length\0\u{1}art\0\u{1}length\0\u{3}is_local\0\u{1}filename\0\u{3}file_size\0\u{1}rating\0\u{1}url\0\u{3}art_automatic\0\u{3}art_manual\0\u{1}type\0") @@ -5425,9 +5876,259 @@ nonisolated extension Pb_Remote_RendererError: SwiftProtobuf.Message, SwiftProto } } +nonisolated extension Pb_Remote_BrowseNode: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".BrowseNode" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}node_id\0\u{1}title\0\u{1}subtitle\0\u{1}kind\0\u{1}children\0\u{1}playability\0\u{3}icon_png\0\u{1}song\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self._nodeID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self._title) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._subtitle) }() + case 4: try { try decoder.decodeSingularEnumField(value: &self._kind) }() + case 5: try { try decoder.decodeSingularEnumField(value: &self._children) }() + case 6: try { try decoder.decodeSingularEnumField(value: &self._playability) }() + case 7: try { try decoder.decodeSingularBytesField(value: &self._iconPng) }() + case 8: try { try decoder.decodeSingularMessageField(value: &self._song) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._nodeID { + try visitor.visitSingularStringField(value: v, fieldNumber: 1) + } }() + try { if let v = self._title { + try visitor.visitSingularStringField(value: v, fieldNumber: 2) + } }() + try { if let v = self._subtitle { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() + try { if let v = self._kind { + try visitor.visitSingularEnumField(value: v, fieldNumber: 4) + } }() + try { if let v = self._children { + try visitor.visitSingularEnumField(value: v, fieldNumber: 5) + } }() + try { if let v = self._playability { + try visitor.visitSingularEnumField(value: v, fieldNumber: 6) + } }() + try { if let v = self._iconPng { + try visitor.visitSingularBytesField(value: v, fieldNumber: 7) + } }() + try { if let v = self._song { + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Pb_Remote_BrowseNode, rhs: Pb_Remote_BrowseNode) -> Bool { + if lhs._nodeID != rhs._nodeID {return false} + if lhs._title != rhs._title {return false} + if lhs._subtitle != rhs._subtitle {return false} + if lhs._kind != rhs._kind {return false} + if lhs._children != rhs._children {return false} + if lhs._playability != rhs._playability {return false} + if lhs._iconPng != rhs._iconPng {return false} + if lhs._song != rhs._song {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Pb_Remote_RequestBrowse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".RequestBrowse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}node_id\0\u{1}offset\0\u{1}limit\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self._nodeID) }() + case 2: try { try decoder.decodeSingularInt32Field(value: &self._offset) }() + case 3: try { try decoder.decodeSingularInt32Field(value: &self._limit) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._nodeID { + try visitor.visitSingularStringField(value: v, fieldNumber: 1) + } }() + try { if let v = self._offset { + try visitor.visitSingularInt32Field(value: v, fieldNumber: 2) + } }() + try { if let v = self._limit { + try visitor.visitSingularInt32Field(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Pb_Remote_RequestBrowse, rhs: Pb_Remote_RequestBrowse) -> Bool { + if lhs._nodeID != rhs._nodeID {return false} + if lhs._offset != rhs._offset {return false} + if lhs._limit != rhs._limit {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Pb_Remote_ResponseBrowse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ResponseBrowse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}node_id\0\u{1}state\0\u{1}nodes\0\u{1}offset\0\u{3}total_count\0\u{1}message\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self._nodeID) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self._state) }() + case 3: try { try decoder.decodeRepeatedMessageField(value: &self.nodes) }() + case 4: try { try decoder.decodeSingularInt32Field(value: &self._offset) }() + case 5: try { try decoder.decodeSingularInt32Field(value: &self._totalCount) }() + case 6: try { try decoder.decodeSingularStringField(value: &self._message) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._nodeID { + try visitor.visitSingularStringField(value: v, fieldNumber: 1) + } }() + try { if let v = self._state { + try visitor.visitSingularEnumField(value: v, fieldNumber: 2) + } }() + if !self.nodes.isEmpty { + try visitor.visitRepeatedMessageField(value: self.nodes, fieldNumber: 3) + } + try { if let v = self._offset { + try visitor.visitSingularInt32Field(value: v, fieldNumber: 4) + } }() + try { if let v = self._totalCount { + try visitor.visitSingularInt32Field(value: v, fieldNumber: 5) + } }() + try { if let v = self._message { + try visitor.visitSingularStringField(value: v, fieldNumber: 6) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Pb_Remote_ResponseBrowse, rhs: Pb_Remote_ResponseBrowse) -> Bool { + if lhs._nodeID != rhs._nodeID {return false} + if lhs._state != rhs._state {return false} + if lhs.nodes != rhs.nodes {return false} + if lhs._offset != rhs._offset {return false} + if lhs._totalCount != rhs._totalCount {return false} + if lhs._message != rhs._message {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Pb_Remote_RequestBrowseAdd: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".RequestBrowseAdd" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}node_ids\0\u{1}action\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedStringField(value: &self.nodeIds) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self._action) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.nodeIds.isEmpty { + try visitor.visitRepeatedStringField(value: self.nodeIds, fieldNumber: 1) + } + try { if let v = self._action { + try visitor.visitSingularEnumField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Pb_Remote_RequestBrowseAdd, rhs: Pb_Remote_RequestBrowseAdd) -> Bool { + if lhs.nodeIds != rhs.nodeIds {return false} + if lhs._action != rhs._action {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Pb_Remote_ResponseBrowseAdd: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ResponseBrowseAdd" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}node_ids\0\u{1}result\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedStringField(value: &self.nodeIds) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self._result) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.nodeIds.isEmpty { + try visitor.visitRepeatedStringField(value: self.nodeIds, fieldNumber: 1) + } + try { if let v = self._result { + try visitor.visitSingularEnumField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Pb_Remote_ResponseBrowseAdd, rhs: Pb_Remote_ResponseBrowseAdd) -> Bool { + if lhs.nodeIds != rhs.nodeIds {return false} + if lhs._result != rhs._result {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + nonisolated extension Pb_Remote_Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Message" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{1}type\0\u{4}\u{8}request_playlist_songs\0\u{3}request_change_song\0\u{3}request_set_volume\0\u{1}repeat\0\u{1}shuffle\0\u{3}response_clementine_info\0\u{3}response_current_metadata\0\u{3}response_playlists\0\u{3}response_playlist_songs\0\u{3}response_engine_state_changed\0\u{3}response_update_track_position\0\u{3}request_connect\0\u{3}response_disconnect\0\u{3}request_set_track_position\0\u{3}response_active_changed\0\u{3}request_insert_urls\0\u{3}request_remove_songs\0\u{3}request_playlists\0\u{3}request_open_playlist\0\u{3}request_close_playlist\0\u{3}response_lyrics\0\u{3}request_download_songs\0\u{3}response_song_file_chunk\0\u{3}response_song_offer\0\u{3}response_library_chunk\0\u{3}request_rate_song\0\u{3}response_download_total_size\0\u{3}request_global_search\0\u{3}response_global_search\0\u{3}response_transcoder_status\0\u{3}response_global_search_status\0\u{4}\u{a}request_list_files\0\u{3}request_append_files\0\u{3}response_list_files\0\u{3}request_update_playlist\0\u{3}response_saved_radios\0\u{4}\u{7}response_outputs\0\u{3}request_set_output\0\u{3}request_render_load\0\u{3}request_render_preload\0\u{3}request_render_seek\0\u{3}request_render_volume\0\u{3}renderer_status\0\u{3}renderer_error\0\u{3}renderer_track_ended\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{1}type\0\u{4}\u{8}request_playlist_songs\0\u{3}request_change_song\0\u{3}request_set_volume\0\u{1}repeat\0\u{1}shuffle\0\u{3}response_clementine_info\0\u{3}response_current_metadata\0\u{3}response_playlists\0\u{3}response_playlist_songs\0\u{3}response_engine_state_changed\0\u{3}response_update_track_position\0\u{3}request_connect\0\u{3}response_disconnect\0\u{3}request_set_track_position\0\u{3}response_active_changed\0\u{3}request_insert_urls\0\u{3}request_remove_songs\0\u{3}request_playlists\0\u{3}request_open_playlist\0\u{3}request_close_playlist\0\u{3}response_lyrics\0\u{3}request_download_songs\0\u{3}response_song_file_chunk\0\u{3}response_song_offer\0\u{3}response_library_chunk\0\u{3}request_rate_song\0\u{3}response_download_total_size\0\u{3}request_global_search\0\u{3}response_global_search\0\u{3}response_transcoder_status\0\u{3}response_global_search_status\0\u{4}\u{a}request_list_files\0\u{3}request_append_files\0\u{3}response_list_files\0\u{3}request_update_playlist\0\u{3}response_saved_radios\0\u{4}\u{7}response_outputs\0\u{3}request_set_output\0\u{3}request_render_load\0\u{3}request_render_preload\0\u{3}request_render_seek\0\u{3}request_render_volume\0\u{3}renderer_status\0\u{3}renderer_error\0\u{3}renderer_track_ended\0\u{3}request_browse\0\u{3}response_browse\0\u{3}request_browse_add\0\u{3}response_browse_add\0") fileprivate class _StorageClass { var _version: Int32? = nil @@ -5477,6 +6178,10 @@ nonisolated extension Pb_Remote_Message: SwiftProtobuf.Message, SwiftProtobuf._M var _rendererStatus: Pb_Remote_RendererStatus? = nil var _rendererError: Pb_Remote_RendererError? = nil var _rendererTrackEnded: Pb_Remote_RendererTrackEnded? = nil + var _requestBrowse: Pb_Remote_RequestBrowse? = nil + var _responseBrowse: Pb_Remote_ResponseBrowse? = nil + var _requestBrowseAdd: Pb_Remote_RequestBrowseAdd? = nil + var _responseBrowseAdd: Pb_Remote_ResponseBrowseAdd? = nil // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -5534,6 +6239,10 @@ nonisolated extension Pb_Remote_Message: SwiftProtobuf.Message, SwiftProtobuf._M _rendererStatus = source._rendererStatus _rendererError = source._rendererError _rendererTrackEnded = source._rendererTrackEnded + _requestBrowse = source._requestBrowse + _responseBrowse = source._responseBrowse + _requestBrowseAdd = source._requestBrowseAdd + _responseBrowseAdd = source._responseBrowseAdd } } @@ -5599,6 +6308,10 @@ nonisolated extension Pb_Remote_Message: SwiftProtobuf.Message, SwiftProtobuf._M case 67: try { try decoder.decodeSingularMessageField(value: &_storage._rendererStatus) }() case 68: try { try decoder.decodeSingularMessageField(value: &_storage._rendererError) }() case 69: try { try decoder.decodeSingularMessageField(value: &_storage._rendererTrackEnded) }() + case 70: try { try decoder.decodeSingularMessageField(value: &_storage._requestBrowse) }() + case 71: try { try decoder.decodeSingularMessageField(value: &_storage._responseBrowse) }() + case 72: try { try decoder.decodeSingularMessageField(value: &_storage._requestBrowseAdd) }() + case 73: try { try decoder.decodeSingularMessageField(value: &_storage._responseBrowseAdd) }() default: break } } @@ -5752,6 +6465,18 @@ nonisolated extension Pb_Remote_Message: SwiftProtobuf.Message, SwiftProtobuf._M try { if let v = _storage._rendererTrackEnded { try visitor.visitSingularMessageField(value: v, fieldNumber: 69) } }() + try { if let v = _storage._requestBrowse { + try visitor.visitSingularMessageField(value: v, fieldNumber: 70) + } }() + try { if let v = _storage._responseBrowse { + try visitor.visitSingularMessageField(value: v, fieldNumber: 71) + } }() + try { if let v = _storage._requestBrowseAdd { + try visitor.visitSingularMessageField(value: v, fieldNumber: 72) + } }() + try { if let v = _storage._responseBrowseAdd { + try visitor.visitSingularMessageField(value: v, fieldNumber: 73) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -5808,6 +6533,10 @@ nonisolated extension Pb_Remote_Message: SwiftProtobuf.Message, SwiftProtobuf._M if _storage._rendererStatus != rhs_storage._rendererStatus {return false} if _storage._rendererError != rhs_storage._rendererError {return false} if _storage._rendererTrackEnded != rhs_storage._rendererTrackEnded {return false} + if _storage._requestBrowse != rhs_storage._requestBrowse {return false} + if _storage._responseBrowse != rhs_storage._responseBrowse {return false} + if _storage._requestBrowseAdd != rhs_storage._requestBrowseAdd {return false} + if _storage._responseBrowseAdd != rhs_storage._responseBrowseAdd {return false} return true } if !storagesAreEqual {return false} diff --git a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift index 2d0706c..a9cb355 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift @@ -67,6 +67,11 @@ public final class RemoteSession { return activeOutput.id == renderer.rendererID } + // MARK: Internet + + /// Whether this Clementine can be browsed like its Internet sidebar, for its internet services. + public private(set) var canBrowse = false + // MARK: Playing public private(set) var song: Song? @@ -214,6 +219,7 @@ public final class RemoteSession { refreshPlaylistsWhenReady = false canChooseOutput = false outputs = [] + canBrowse = false } private func handle(_ event: ClementineConnection.Event, from source: ClementineConnection) { @@ -253,6 +259,7 @@ public final class RemoteSession { status = .downloadingData } canChooseOutput = info.features.contains(.rendering) + canBrowse = info.features.contains(.browse) if canChooseOutput { send(RemoteMessage(.requestOutputs)) } diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift new file mode 100644 index 0000000..7e187fb --- /dev/null +++ b/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing +@testable import ClementineKit + +@MainActor +struct InternetBrowserTests { + let browser: InternetBrowser + let sent: Sent + + /// What the browser sent Clementine. + @MainActor + final class Sent { + var messages: [RemoteMessage] = [] + } + + init() { + let sent = Sent() + self.sent = sent + browser = InternetBrowser { sent.messages.append($0) } + } + + static func node(_ id: String, title: String? = nil, children: Bool = false, addable: Bool = true) -> BrowseNode { + var node = BrowseNode() + node.nodeID = id + node.title = title ?? id + node.kind = children ? .folder : .track + node.children = children ? .some : .none + node.playability = addable ? .addable : .none + return node + } + + static func response( + _ id: String = "", state: Pb_Remote_BrowseState = .ready, nodes: [BrowseNode], offset: Int = 0, + total: Int? = nil, message: String = "" + ) -> RemoteMessage { + RemoteMessage(.browse) { + $0.responseBrowse.nodeID = id + $0.responseBrowse.state = state + $0.responseBrowse.nodes = nodes + $0.responseBrowse.offset = Int32(offset) + $0.responseBrowse.totalCount = Int32(total ?? nodes.count) + $0.responseBrowse.message = message + } + } + + static func nodes(_ range: Range) -> [BrowseNode] { + range.map { node("n\($0)") } + } + + @Test func asksForTheServices() { + browser.browse(nil) + #expect(sent.messages.last?.type == .requestBrowse) + #expect(sent.messages.last?.requestBrowse.hasNodeID == false) + #expect(browser.listing(of: nil) == .init(state: .loading)) + + #expect(browser.handle(Self.response(nodes: [Self.node("n1", title: "SomaFM", children: true)]))) + let listing = browser.listing(of: nil) + #expect(listing.state == .ready) + #expect(listing.nodes.map(\.title) == ["SomaFM"]) + #expect(listing.nodes.first?.canOpen == true) + } + + @Test func keepsWhatItHasWhileAskingAgain() { + let service = Self.node("n1", children: true) + browser.browse(service) + #expect(sent.messages.last?.requestBrowse.nodeID == "n1") + browser.handle(Self.response("n1", nodes: Self.nodes(2..<4))) + browser.browse(service) + #expect(browser.listing(of: service).nodes.count == 2) + } + + @Test func showsLoadingThenTheUpdate() { + let plex = Self.node("n1", children: true) + browser.browse(plex) + browser.handle(Self.response("n1", state: .loading, nodes: [])) + #expect(browser.listing(of: plex).state == .loading) + browser.handle(Self.response("n1", nodes: Self.nodes(2..<5))) + #expect(browser.listing(of: plex).state == .ready) + #expect(browser.listing(of: plex).nodes.count == 3) + } + + @Test func saysWhatToSetUp() { + let subsonic = Self.node("n1", children: true) + browser.browse(subsonic) + browser.handle(Self.response("n1", state: .needsSetup, nodes: [], message: "Set up Subsonic")) + #expect(browser.listing(of: subsonic).state == .needsSetup("Set up Subsonic")) + } + + @Test func pagesThroughManyChildren() { + let genre = Self.node("n1", children: true) + browser.browse(genre) + browser.handle(Self.response("n1", nodes: Self.nodes(0..<500), total: 1200)) + #expect(browser.listing(of: genre).hasMore) + + browser.loadMore(genre) + #expect(sent.messages.last?.requestBrowse.offset == 500) + // Asked for once, however often the end is reached. + let count = sent.messages.count + browser.loadMore(genre) + #expect(sent.messages.count == count) + + browser.handle(Self.response("n1", nodes: Self.nodes(500..<1000), offset: 500, total: 1200)) + #expect(browser.listing(of: genre).nodes.count == 1000) + browser.loadMore(genre) + #expect(sent.messages.last?.requestBrowse.offset == 1000) + browser.handle(Self.response("n1", nodes: Self.nodes(1000..<1200), offset: 1000, total: 1200)) + #expect(browser.listing(of: genre).nodes.map(\.nodeID) == Self.nodes(0..<1200).map(\.nodeID)) + #expect(!browser.listing(of: genre).hasMore) + } + + @Test func updatesAPageInPlaceAndCutsToTheTotal() { + let genre = Self.node("n1", children: true) + browser.browse(genre) + browser.handle(Self.response("n1", nodes: Self.nodes(0..<500), total: 700)) + browser.handle(Self.response("n1", nodes: Self.nodes(500..<700), offset: 500, total: 700)) + // The second page changes, and loses some. + browser.handle(Self.response("n1", nodes: Self.nodes(900..<950), offset: 500, total: 550)) + let ids = browser.listing(of: genre).nodes.map(\.nodeID) + #expect(ids == (Self.nodes(0..<500) + Self.nodes(900..<950)).map(\.nodeID)) + } + + @Test func goesWhenTheNodeHasGone() { + let album = Self.node("n7", children: true) + browser.browse(album) + browser.handle(Self.response("n7", nodes: Self.nodes(0..<3))) + browser.handle(Self.response("n7", state: .gone, nodes: [])) + #expect(browser.listing(of: album).state == .gone) + // Asking again starts afresh. + browser.browse(album) + #expect(browser.listing(of: album).state == .loading) + } + + @Test func forgetsEverythingOnANewConnection() { + browser.browse(nil) + browser.handle(Self.response(nodes: Self.nodes(0..<3))) + let generation = browser.generation + #expect(!browser.handle(RemoteMessage(.info))) + #expect(browser.generation == generation + 1) + #expect(browser.listing(of: nil).nodes.isEmpty) + } + + @Test func addsAndSaysHowItWent() { + var results: [(BrowseAddAction, BrowseAddResult)] = [] + browser.onAdded = { results.append(($0, $1)) } + let station = Self.node("n4") + browser.add([station], action: .playNext) + #expect(sent.messages.last?.type == .requestBrowseAdd) + #expect(sent.messages.last?.requestBrowseAdd.nodeIds == ["n4"]) + #expect(sent.messages.last?.requestBrowseAdd.action == .playNext) + browser.add([Self.node("n5")], action: .append) + + browser.handle(RemoteMessage(.browseAddResult) { + $0.responseBrowseAdd.nodeIds = ["n4"] + $0.responseBrowseAdd.result = .added + }) + browser.handle(RemoteMessage(.browseAddResult) { + $0.responseBrowseAdd.nodeIds = ["n5"] + $0.responseBrowseAdd.result = .gone + }) + #expect(results.map(\.0) == [.playNext, .append]) + #expect(results.map(\.1) == [.added, .gone]) + } + + @Test func asksAgainWhenAddingSomethingGone() { + let album = Self.node("n2", children: true) + browser.browse(album) + browser.add([Self.node("n9")], action: .append) + browser.handle(RemoteMessage(.browseAddResult) { + $0.responseBrowseAdd.nodeIds = ["n9"] + $0.responseBrowseAdd.result = .gone + }) + #expect(sent.messages.last?.type == .requestBrowse) + #expect(sent.messages.last?.requestBrowse.nodeID == "n2") + } + + @Test func tappingPlaysUnlessPlaying() { + #expect(InternetBrowser.tapAction(isPlaying: false) == .playNow) + #expect(InternetBrowser.tapAction(isPlaying: true) == .append) + } + + @Test func ignoresOtherMessages() { + #expect(!browser.handle(RemoteMessage(.keepAlive))) + } + + @Test func sessionKnowsWhetherItCanBrowse() { + let session = RemoteSession() + session.apply(RemoteMessage(.info) { $0.responseClementineInfo.features = [.rendering, .browse] }) + #expect(session.canBrowse) + session.apply(RemoteMessage(.info) { $0.responseClementineInfo.features = [.rendering] }) + #expect(!session.canBrowse) + } +} diff --git a/README.md b/README.md index 2e67416..a629800 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Clementine Remote controls the [Clementine](https://www.clementine-player.org/) music player on your computer from your iPhone or iPad, over your local network. It does what the [Android remote](https://github.com/clementine-player/Android-Remote) does: play and pause, browse -and search your library, manage playlists, read lyrics, rate songs, download songs to your phone, -and play Clementine's music on your phone instead of your computer (remote streaming, with -Clementine started with `--experimental-remote-streaming`). +and search your library, browse Clementine's internet services (radio stations, Jamendo, Subsonic, +Plex and the rest), manage playlists, read lyrics, rate songs, download songs to your phone, and +play Clementine's music on your phone instead of your computer (remote streaming). You need Clementine 1.3 or later, with Tools → Preferences → Network Remote turned on. diff --git a/UITests/Screenshots.swift b/UITests/Screenshots.swift index 70404b6..7214a70 100644 --- a/UITests/Screenshots.swift +++ b/UITests/Screenshots.swift @@ -101,6 +101,13 @@ final class Screenshots: XCTestCase { pause(Self.settle) try screenshot("09_outputs") try waitFor(app.navigationBars["Play on"].buttons["Done"]).tap() + + // Clementine's internet services. + try showTab("Internet") + try waitFor(app.navigationBars["Internet"]) + try waitFor(app.descendants(matching: .any)["internetNode"]) + pause(Self.settle) + try screenshot("10_internet") } // MARK: - Screens diff --git a/clementine-it/start-clementine.sh b/clementine-it/start-clementine.sh index d3469de..9529236 100755 --- a/clementine-it/start-clementine.sh +++ b/clementine-it/start-clementine.sh @@ -3,8 +3,9 @@ # library and its playlist, for the screenshots (.github/workflows/screenshots.yml). Clementine # runs in the background, logging to ; this returns once it's ready. # -# Remote streaming is on (--experimental-remote-streaming and "Allow playing on remote devices"), -# so the app can offer to play on the phone, and the screenshots show where Clementine can play. +# Remote streaming is on ("Allow playing on remote devices", and --experimental-remote-streaming for +# releases before 1.4.1-238, which need it), so the app can offer to play on the phone, and the +# screenshots show where Clementine can play. # # start-clementine.sh # @@ -54,7 +55,12 @@ defaults write "$domain" NetworkRemote.allow_streaming -bool true # The remote's CHANGE_SONG plays the song rather than queueing it. defaults write "$domain" MainWindow.doubleclick_playlist_addmode -int 1 -nohup "$binary" --verbose --experimental-remote-streaming > "$log" 2>&1 & +# Later releases don't know the flag, and exit on options they don't know. +streaming=() +if grep -q -a experimental-remote-streaming "$binary"; then + streaming=(--experimental-remote-streaming) +fi +nohup "$binary" --verbose ${streaming[@]+"${streaming[@]}"} > "$log" 2>&1 & pid=$! echo "$pid" > "$log.pid" if ! wait_for "the network remote" "nc -z localhost 5500"; then diff --git a/design/DESIGN.md b/design/DESIGN.md index e76d335..d2a9fe9 100644 --- a/design/DESIGN.md +++ b/design/DESIGN.md @@ -32,7 +32,7 @@ iOS's own controls and patterns. ▼ ┌──────────────────────────────────────────────────────────────┐ │ TabView │ - │ Queue │ Library │ Search │ Downloads │ + │ Queue │ Library │ Internet │ Search │ Downloads │ │ │ │ every tab: toolbar ConnectionChip ──▶ Connection sheet │ │ ├─ Switch Clementine│ @@ -47,7 +47,7 @@ iOS's own controls and patterns. | Android (today) | Redesign | iOS | |---|---|---| -| Drawer: Search, Player, Playlists, Library, Downloads, Settings, Quit | Bottom navigation: Queue, Library, Search, Downloads | `TabView` with four `Tab`s | +| Drawer: Search, Player, Playlists, Library, Downloads, Settings, Quit | Bottom navigation: Queue, Library, Internet, Search, Downloads | `TabView` with five `Tab`s (Internet only when Clementine can be browsed) | | Player tab with pages Player, Song details, Clementine | Mini player above the navigation, full-screen player sheet | `.tabViewBottomAccessory` mini player; `.fullScreenCover` player with a zoom transition from it | | Song details page | Details and lyrics bottom sheet | `.sheet` with medium and large detents; Details / Lyrics segmented control | | Connection page, Settings and Quit in the drawer | Connection chip at the top right, opening the connection sheet | Toolbar button on every tab root; `.sheet` | @@ -176,7 +176,30 @@ From the Library and Album boards. Clementine's library, copied to the phone. indeterminate bar and "Preparing the library…". - The library is kept per Clementine: connecting to another Clementine deletes it. -### Search (tab 3) +### Internet (tab 3) + +Clementine's internet services, browsed as its Internet sidebar shows them. Only there when the +Clementine connected to can be browsed (1.4.1-242 or later); the Android remote has the same screens. + +- Large title "Internet": the services (SomaFM, Radio Browser, Jamendo, Subsonic, Plex, your radio + streams…), each with its own icon on a round `secondary-container` tile. Toolbar: ConnectionChip. +- Tapping a node with children pushes the level below, titled with it. Rows have the node's title, + and its subtitle (a track's artist) as the second line; nodes without their own icon have one for + their kind (see Icons). +- Tapping a track or stream plays it if Clementine isn't playing, or else adds it to the playlist, + as tapping a song in the library does. Touched and held, anything that can go on the playlist + (albums and playlists too) has **Play now**, **Play next**, **Add to playlist** and **Replace + playlist**. An opened node that can go on the playlist has a header: its title, "*n* items", and + **Play** (filled) and **Add to playlist** buttons. +- "Loading…" with a spinner until the first answer, or while a service is still loading with + nothing to show; an indeterminate bar at the top while it loads more. A service that has to be + set up first: "Set up in Clementine", with Clementine's message saying where. An empty node: + "Nothing here". Pull to refresh asks again. +- After adding: "Added to the playlist", or "Playing next"; nothing for Play now and Replace + playlist, which the player shows. "Clementine can't add that to the playlist" and "That's no + longer in Clementine" when it didn't work. + +### Search (tab 4) From the Search board. Searches everything Clementine can search (library and internet services). @@ -199,7 +222,7 @@ From the Search board. Searches everything Clementine can search (library and in songs, with Add to playlist and select mode as in the library. See All has select mode too. - "No results" and a first-run "Search your library and Clementine's internet services" message. -### Downloads (tab 4) +### Downloads (tab 5) From the Downloads board. @@ -313,7 +336,9 @@ SF Symbols, filled, in the colour of their control: | Play / pause | `play.fill` / `pause.fill` | | Previous / next | `backward.end.fill` / `forward.end.fill` | | Shuffle / repeat / repeat track | `shuffle` / `repeat` / `repeat.1` | -| Queue / Library / Search / Downloads | `list.bullet` / `square.stack` / `magnifyingglass` / `arrow.down.circle` | +| Queue / Library / Internet / Search / Downloads | `list.bullet` / `square.stack` / `globe` / `magnifyingglass` / `arrow.down.circle` | +| Internet folder / track / stream / smart playlist | `folder` / `music.note` / `dot.radiowaves.left.and.right` / `wand.and.stars` | +| Play next / Replace playlist | `text.line.first.and.arrowtriangle.forward` / `arrow.triangle.2.circlepath` | | Computer (host) | `desktopcomputer` | | Playing | `waveform` (animated only when Reduce Motion is off) | | Song / album / artist | `music.note` / `opticaldisc` / `person.fill` | @@ -380,8 +405,8 @@ hidden while the app is active. ### Remote streaming -Clementine 1.4 started with `--experimental-remote-streaming`, and *Allow playing on remote -devices* on, can play on its remotes instead of its computer, as the Android remote does. The +Clementine 1.4 with *Allow playing on remote devices* on (and, before 1.4.1-238, started with +`--experimental-remote-streaming`) can play on its remotes instead of its computer, as the Android remote does. The protocol is Clementine's: `RENDER_*` messages to a renderer, `RENDERER_*` back, `OUTPUTS` to every remote. @@ -435,6 +460,26 @@ remote. - Groupings: Artist; Artist / Album (default); Album artist / Album; Artist / Year; Album; Genre / Album; Genre / Artist / Album. Sorting: ascending (default) or descending. +### Internet + +The protocol is Clementine's (clementine-player/Clementine#7530), which the Android remote speaks too. + +- Clementine that can be browsed lists `SERVER_FEATURE_BROWSE` in its info; only then is the tab + shown. If it goes while shown, the app goes to the Queue. +- `REQUEST_BROWSE` with a node id lists that node's children, or the services without one, 500 at + most from an offset. Clementine answers `BROWSE`: the page, the number of children in all, and a + state: ready, loading (more follows), needs setup (with a message to show) or gone (the level + goes back up). It sends the node again whenever its children change, for as long as it's the + node last asked for, so a level is asked for again whenever it's shown (going back up, or coming + back to the tab). +- Reaching the last row asks for the next page, if there are more; a page replaces the rows from + its offset, and the list is cut to the number there are. +- Node ids last as long as the connection: a new connection's `INFO` forgets them all, and the tab + goes back to the services. +- `REQUEST_BROWSE_ADD` puts nodes on Clementine's current playlist, as a drag from its sidebar does: + append, play now, play next or replace. `BROWSE_ADD_RESULT` says whether it worked; if a node has + gone, the level is asked for again. + ### Search - `GLOBAL_SEARCH` with the query. `GLOBAL_SEARCH_STATUS` *started* gives the search's id; results @@ -479,7 +524,7 @@ ClementineRemote.xcodeproj (generated from project.yml by XcodeGen) ├─ App/ SwiftUI app │ ├─ ClementineRemoteApp.swift scene, dependencies │ ├─ Theme/ colours, type, shapes, reusable views -│ ├─ Connect/ Queue/ Player/ Library/ Search/ Downloads/ Connection/ Settings/ +│ ├─ Connect/ Queue/ Player/ Library/ Internet/ Search/ Downloads/ Connection/ Settings/ │ ├─ Streaming/ AVPlayback (AVQueuePlayer), NowPlaying (lock screen) │ └─ Resources/ Assets.xcassets, Localizable.xcstrings ├─ Widget/ widget extension @@ -491,6 +536,7 @@ ClementineRemote.xcodeproj (generated from project.yml by XcodeGen) ├─ Model/ Song, Playlist, modes, LyricsProvider ├─ Session/ RemoteSession (@MainActor @Observable): state and commands ├─ Browse/ SQLite wrapper, SongQuery, SongBrowser, LibraryStore, SearchStore + ├─ Internet/ InternetBrowser: Clementine's internet services, level by level ├─ Downloads/ DownloadManager, SongDownloader, DownloadStorage ├─ Settings/ Settings keys and defaults └─ Streaming/ Renderer: plays what Clementine sends, through a Playback @@ -503,7 +549,8 @@ ClementineRemote.xcodeproj (generated from project.yml by XcodeGen) background actor. - **Testing:** Swift Testing in the package: framing and parsing, message builders, the connection against an in-process fake Clementine, song offers and chunking, `SongQuery` on a sample library, - and the session's state changes, and the renderer against a fake player. UI tests cover + and the session's state changes, the renderer against a fake player, and the internet browser's + paging and updates. UI tests cover connecting and the tabs against the fake server. On pull requests, a UI test also screenshots every screen, light and dark, against a real Clementine, and posts them on the pull request (`.github/workflows/screenshots.yml`). From 8019f39b93724844c1adc3be003e6387ce857fda Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:36:36 +0000 Subject: [PATCH 2/5] Screenshot the Internet tab before searching Searching expands the search field over the tab bar, so the Internet tab couldn't be found after the search screenshot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- UITests/Screenshots.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/UITests/Screenshots.swift b/UITests/Screenshots.swift index 7214a70..a65efb5 100644 --- a/UITests/Screenshots.swift +++ b/UITests/Screenshots.swift @@ -88,6 +88,13 @@ final class Screenshots: XCTestCase { pause(Self.settle) try screenshot("07_library_album") + // Clementine's internet services, before searching, which hides the other tabs. + try showTab("Internet") + try waitFor(app.navigationBars["Internet"]) + try waitFor(app.descendants(matching: .any)["internetNode"]) + pause(Self.settle) + try screenshot("10_internet") + try showTab("Search") try search("Gymnopédie") // Songs matched by title are listed straight away. @@ -101,13 +108,6 @@ final class Screenshots: XCTestCase { pause(Self.settle) try screenshot("09_outputs") try waitFor(app.navigationBars["Play on"].buttons["Done"]).tap() - - // Clementine's internet services. - try showTab("Internet") - try waitFor(app.navigationBars["Internet"]) - try waitFor(app.descendants(matching: .any)["internetNode"]) - pause(Self.settle) - try screenshot("10_internet") } // MARK: - Screens From b7f78f3a1ba7f5677ee0af70c32342569cc53b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:46:03 +0000 Subject: [PATCH 3/5] Match the Android client's Internet screen An opened album or playlist doesn't say "0 items" before Clementine first answers, and the new strings take the Android client's translations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- App/Internet/InternetView.swift | 14 +++++++++----- scripts/import-android-translations.py | 11 +++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/App/Internet/InternetView.swift b/App/Internet/InternetView.swift index 3cfda0f..b87fefa 100644 --- a/App/Internet/InternetView.swift +++ b/App/Internet/InternetView.swift @@ -43,7 +43,9 @@ private struct InternetLevelView: View { let listing = browser.listing(of: node) List { if let node, node.isAddable { - InternetHeader(node: node, count: listing.totalCount) { add([node], $0) } + // How many isn't known until Clementine first answers. + let answered = listing.state != .loading || !listing.nodes.isEmpty + InternetHeader(node: node, count: answered ? listing.totalCount : nil) { add([node], $0) } } ForEach(listing.nodes, id: \.nodeID) { child in row(child) @@ -175,7 +177,7 @@ struct InternetRow: View { /// items, and playing or adding all of it. private struct InternetHeader: View { let node: BrowseNode - let count: Int + let count: Int? let add: (BrowseAddAction) -> Void var body: some View { @@ -186,9 +188,11 @@ private struct InternetHeader: View { .foregroundStyle(Palette.onSurface) .lineLimit(2) .accessibilityAddTraits(.isHeader) - Text("\(count) items") - .textStyle(.bodyMedium) - .foregroundStyle(Palette.onSurfaceVariant) + if let count { + Text("\(count) items") + .textStyle(.bodyMedium) + .foregroundStyle(Palette.onSurfaceVariant) + } } HStack(spacing: Metrics.space2) { Button("Play", systemImage: "play.fill") { add(.playNow) } diff --git a/scripts/import-android-translations.py b/scripts/import-android-translations.py index d8cb8bf..e99e00b 100755 --- a/scripts/import-android-translations.py +++ b/scripts/import-android-translations.py @@ -139,6 +139,17 @@ "Choose where to play": "output_choose", "%@ (this phone)": "output_this_phone", "Let Clementine play on this phone": "pref_renderer", + "Internet": "internet_title", + "Loading…": "internet_loading", + "Set up in Clementine": "internet_needs_setup", + "Nothing here": "internet_empty", + "Play now": "internet_play_now", + "Play next": "internet_play_next", + "Replace playlist": "internet_replace_playlist", + "Added to the playlist": "internet_added", + "Playing next": "internet_playing_next", + "Clementine can't add that to the playlist": "internet_not_playable", + "That's no longer in Clementine": "internet_gone", } # iOS plural string -> Android plurals name. From 578ec841d6514d840ae9f8ccf1196b4eb1f44952 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:03:09 +0000 Subject: [PATCH 4/5] Screenshot a service opened in the Internet tab The CI Clementine gets three saved radio streams, so "Your radio streams" has something to show without depending on a service online. The screenshots open it after the Internet tab's top level, as 11_internet_radio, as the Android client's do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- UITests/Screenshots.swift | 5 +++++ clementine-it/start-clementine.sh | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/UITests/Screenshots.swift b/UITests/Screenshots.swift index a65efb5..e27a838 100644 --- a/UITests/Screenshots.swift +++ b/UITests/Screenshots.swift @@ -94,6 +94,11 @@ final class Screenshots: XCTestCase { try waitFor(app.descendants(matching: .any)["internetNode"]) pause(Self.settle) try screenshot("10_internet") + // The radio streams saved in it (clementine-it/start-clementine.sh). + try waitFor(item(startingWith: "Your radio streams")).tap() + try waitFor(item(startingWith: "Groove Salad")) + pause(Self.settle) + try screenshot("11_internet_radio") try showTab("Search") try search("Gymnopédie") diff --git a/clementine-it/start-clementine.sh b/clementine-it/start-clementine.sh index 9529236..03fb93b 100755 --- a/clementine-it/start-clementine.sh +++ b/clementine-it/start-clementine.sh @@ -54,6 +54,14 @@ defaults write "$domain" NetworkRemote.convert_lossless -bool false defaults write "$domain" NetworkRemote.allow_streaming -bool true # The remote's CHANGE_SONG plays the song rather than queueing it. defaults write "$domain" MainWindow.doubleclick_playlist_addmode -int 1 +# Radio streams of its own, for browsing its internet services ("Your radio streams"). +defaults write "$domain" SavedRadio.streams.1.name -string "Groove Salad" +defaults write "$domain" SavedRadio.streams.1.url -string http://ice1.somafm.com/groovesalad-128-mp3 +defaults write "$domain" SavedRadio.streams.2.name -string "Drone Zone" +defaults write "$domain" SavedRadio.streams.2.url -string http://ice1.somafm.com/dronezone-128-mp3 +defaults write "$domain" SavedRadio.streams.3.name -string "Secret Agent" +defaults write "$domain" SavedRadio.streams.3.url -string http://ice1.somafm.com/secretagent-128-mp3 +defaults write "$domain" SavedRadio.streams.size -int 3 # Later releases don't know the flag, and exit on options they don't know. streaming=() From 9f9ba5bbf3a44dccefc0f233138b0219abb2e13c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:05:15 +0000 Subject: [PATCH 5/5] Scroll to the radio streams for their screenshot "Your radio streams" is the last of Clementine's services, below the fold, and a list only has the rows in sight. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- UITests/Screenshots.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/UITests/Screenshots.swift b/UITests/Screenshots.swift index e27a838..3420571 100644 --- a/UITests/Screenshots.swift +++ b/UITests/Screenshots.swift @@ -94,8 +94,8 @@ final class Screenshots: XCTestCase { try waitFor(app.descendants(matching: .any)["internetNode"]) pause(Self.settle) try screenshot("10_internet") - // The radio streams saved in it (clementine-it/start-clementine.sh). - try waitFor(item(startingWith: "Your radio streams")).tap() + // The radio streams saved in it (clementine-it/start-clementine.sh), the last service. + try scrollTo(item(startingWith: "Your radio streams")).tap() try waitFor(item(startingWith: "Groove Salad")) pause(Self.settle) try screenshot("11_internet_radio") @@ -156,6 +156,16 @@ final class Screenshots: XCTestCase { try waitFor(artist, timeout: Self.libraryTimeout) } + /// Scrolls the list shown down until [element] is on screen, and returns it: a list only has + /// the rows in sight. + private func scrollTo(_ element: XCUIElement) throws -> XCUIElement { + // Other tabs' lists stay loaded, so the swipe is on the screen, not a list found. + for _ in 0..<15 where !(element.exists && element.isHittable) { + app.swipeUp() + } + return try waitFor(element) + } + private func showTab(_ name: String) throws { try waitFor(app.tabBars.buttons[name]).tap() pause(Self.settle)