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..b87fefa --- /dev/null +++ b/App/Internet/InternetView.swift @@ -0,0 +1,229 @@ +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 { + // 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) + .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) + if let count { + 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..3420571 100644 --- a/UITests/Screenshots.swift +++ b/UITests/Screenshots.swift @@ -88,6 +88,18 @@ 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") + // 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") + try showTab("Search") try search("Gymnopédie") // Songs matched by title are listed straight away. @@ -144,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) diff --git a/clementine-it/start-clementine.sh b/clementine-it/start-clementine.sh index d3469de..03fb93b 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 # @@ -53,8 +54,21 @@ 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 -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`). 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.