diff --git a/App/Library/BrowseViews.swift b/App/Library/BrowseViews.swift index a5747ee..002d34e 100644 --- a/App/Library/BrowseViews.swift +++ b/App/Library/BrowseViews.swift @@ -2,7 +2,8 @@ import ClementineKit import SwiftUI /// The rows of a level of the library or search results. Groups open the level below; songs are -/// added to the playlist. In edit mode, rows are selected instead. +/// added to the playlist. Touching and holding a row offers what else can be done with it. In edit +/// mode, rows are selected instead. struct BrowseRows: View { let level: BrowseLevel @Binding var selection: Set @@ -16,7 +17,8 @@ struct BrowseRows: View { /// Splits artists, albums and genres into sections by their first letter, with an index. var alphabetical = false var descending = false - let addSong: (BrowseItem) -> Void + /// Adds the songs of the items to a playlist, doing an action. + let add: ([BrowseItem], PlaylistTarget, AddAction) -> Void var body: some View { if alphabetical, level.kind.isAlphabetical { @@ -69,7 +71,7 @@ struct BrowseRows: View { } } else if item.kind == .song { Button { - addSong(item) + add([item], .selected, .playIfStopped) } label: { BrowseRow(item: item, icon: icon(item), meta: meta(item)) } @@ -85,6 +87,11 @@ struct BrowseRows: View { } } .buttonStyle(.plain) + .contextMenu { + if !isEditing { + BrowseAddActions(item: item) { add([item], $0, $1) } + } + } .listRowBackground(selection.contains(index) ? Palette.secondaryContainer : Palette.surface) .listRowSeparator(.hidden) .tag(index) @@ -120,6 +127,29 @@ struct BrowseRow: View { } } +/// What can be done with an item of the library or search results, as Clementine's own library +/// offers. +struct BrowseAddActions: View { + let item: BrowseItem + let add: (PlaylistTarget, AddAction) -> Void + + @Environment(RemoteSession.self) private var session + + var body: some View { + Button("Play now", systemImage: "play.fill") { add(.selected, .playNow) } + if session.canEnqueueNext { + Button("Play next", systemImage: "text.line.first.and.arrowtriangle.forward") { add(.selected, .playNext) } + } + Button("Add to queue", systemImage: "text.line.last.and.arrowtriangle.forward") { add(.selected, .queue) } + Divider() + Button("Add to playlist", systemImage: "plus") { add(.selected, .append) } + Button("Replace playlist", systemImage: "arrow.triangle.2.circlepath") { add(.selected, .replace) } + Button("Open in new playlist", systemImage: "rectangle.stack.badge.plus") { + add(.new(item.displayTitle), .append) + } + } +} + /// The header of an opened group: its name, how many items, and what to do with all of it. struct BrowseHeader: View { let item: BrowseItem diff --git a/App/Library/LibraryModel.swift b/App/Library/LibraryModel.swift index 2e0f782..b6820f3 100644 --- a/App/Library/LibraryModel.swift +++ b/App/Library/LibraryModel.swift @@ -101,14 +101,13 @@ final class LibraryModel { } } - /// Adds the songs of [items] to [target], by default the playlist selected in the queue. - /// With [playIfStopped], Clementine plays them unless it's playing already, as it does - /// when you double-click a song in it. - func add(_ items: [BrowseItem], to target: PlaylistTarget = .selected, playIfStopped: Bool = false) async { + /// Adds the songs of [items] to [target], by default the playlist selected in the queue, + /// doing [action]. + func add(_ items: [BrowseItem], to target: PlaylistTarget = .selected, action: AddAction = .append) async { let settings = model.settings guard let urls = try? await store.songURLs(of: items, grouping: settings.libraryGrouping, sorting: settings.librarySorting), !urls.isEmpty, let playlist = await model.playlist(for: target) else { return } - model.session.add(urls: urls, to: playlist.id, playIfStopped: playIfStopped) + model.session.add(urls: urls, to: playlist.id, action: action) model.showAdded(urls.count, to: playlist) } @@ -151,10 +150,10 @@ final class LibrarySearch: SearchResults { try? await store.searchLevel(below: opened, sorting: model.settings.librarySorting) } - func add(_ items: [BrowseItem], to target: PlaylistTarget, playIfStopped: Bool) async { + func add(_ items: [BrowseItem], to target: PlaylistTarget, action: AddAction) async { let urls = await songURLs(of: items) guard !urls.isEmpty, let playlist = await model.playlist(for: target) else { return } - model.session.add(urls: urls, to: playlist.id, playIfStopped: playIfStopped) + model.session.add(urls: urls, to: playlist.id, action: action) model.showAdded(urls.count, to: playlist) } diff --git a/App/Library/LibraryView.swift b/App/Library/LibraryView.swift index 493dfaf..4c93e48 100644 --- a/App/Library/LibraryView.swift +++ b/App/Library/LibraryView.swift @@ -90,8 +90,8 @@ private struct LibraryLevelView: View { BrowseRows( level: level, selection: $selection, isEditing: editMode.isEditing, alphabetical: true, descending: sorting == LibrarySorting.descending.rawValue - ) { song in - Task { await library.add([song], playIfStopped: true) } + ) { items, target, action in + Task { await library.add(items, to: target, action: action) } } } } diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 4897eef..a02a420 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -1409,6 +1409,9 @@ } } } + }, + "Add to queue" : { + }, "Added to the playlist" : { "localizations" : { @@ -10902,6 +10905,9 @@ }, "Open Settings" : { + }, + "Open in new playlist" : { + }, "Open-source software" : { "localizations" : { diff --git a/App/Search/SearchModel.swift b/App/Search/SearchModel.swift index 01f625e..80037a9 100644 --- a/App/Search/SearchModel.swift +++ b/App/Search/SearchModel.swift @@ -66,13 +66,12 @@ final class SearchModel: SearchResults { /// Results of the global search are added to playlists, not downloaded. var download: (([BrowseItem]) async -> Void)? { nil } - /// Adds the songs of [items] to [target], by default the playlist selected in the queue. - /// With [playIfStopped], Clementine plays them unless it's playing already, as it does - /// when you double-click a song in it. - func add(_ items: [BrowseItem], to target: PlaylistTarget = .selected, playIfStopped: Bool = false) async { + /// Adds the songs of [items] to [target], by default the playlist selected in the queue, + /// doing [action]. + func add(_ items: [BrowseItem], to target: PlaylistTarget = .selected, action: AddAction = .append) async { guard let songs = try? await store.songs(of: items, sorting: model.settings.librarySorting), !songs.isEmpty, let playlist = await model.playlist(for: target) else { return } - model.session.add(songs: songs, to: playlist.id, playIfStopped: playIfStopped) + model.session.add(songs: songs, to: playlist.id, action: action) model.showAdded(songs.count, to: playlist) } } diff --git a/App/Search/SearchResultsViews.swift b/App/Search/SearchResultsViews.swift index e530f3f..5ce0a17 100644 --- a/App/Search/SearchResultsViews.swift +++ b/App/Search/SearchResultsViews.swift @@ -12,9 +12,8 @@ protocol SearchResults: AnyObject, Observable { var revision: Int { get } /// The level below [opened], an artist or album of the results. func level(below opened: BrowseItem) async -> BrowseLevel? - /// Adds the songs of [items] to [target]. With [playIfStopped], Clementine plays them unless - /// it's playing already, as it does when you double-click a song in it. - func add(_ items: [BrowseItem], to target: PlaylistTarget, playIfStopped: Bool) async + /// Adds the songs of [items] to [target], doing [action]. + func add(_ items: [BrowseItem], to target: PlaylistTarget, action: AddAction) async /// Downloads the songs of [items] to the phone, when they can be. var download: (([BrowseItem]) async -> Void)? { get } } @@ -143,7 +142,7 @@ private struct SearchSectionHeader: View { } /// A result on the first page: a song or station adds itself to the playlist; an artist or album -/// opens. +/// opens. Touch and hold for what else can be done with it. private struct SearchRow: View { let item: BrowseItem let section: SearchSection @@ -155,7 +154,7 @@ private struct SearchRow: View { Group { if item.kind == .song { Button { - Task { await results.add([item], to: .selected, playIfStopped: true) } + Task { await results.add([item], to: .selected, action: .playIfStopped) } } label: { row } @@ -167,6 +166,11 @@ private struct SearchRow: View { } } .buttonStyle(.plain) + .contextMenu { + BrowseAddActions(item: item) { target, action in + Task { await results.add([item], to: target, action: action) } + } + } .listRowBackground(Palette.surface) .listRowSeparator(.hidden) } @@ -200,7 +204,7 @@ struct SearchListView: View { if case .opened(let item) = page, let level, !editMode.isEditing { BrowseHeader( item: item, count: level.items.count, - add: { target in Task { await results.add([item], to: target, playIfStopped: false) } }, + add: { target in Task { await results.add([item], to: target, action: .append) } }, download: results.download.map { download in { Task { await download([item]) } } }) } if let level { @@ -216,8 +220,8 @@ struct SearchListView: View { return nil }, opens: { .opened($0) } - ) { song in - Task { await results.add([song], to: .selected, playIfStopped: true) } + ) { items, target, action in + Task { await results.add(items, to: target, action: action) } } } } @@ -256,7 +260,7 @@ struct SearchListView: View { .textStyle(.bodyMedium) Spacer() AddToPlaylistMenu { target in - Task { await results.add(items, to: target, playIfStopped: false) } + Task { await results.add(items, to: target, action: .append) } endSelection() } .disabled(items.isEmpty) diff --git a/Packages/ClementineKit/Proto/remotecontrolmessages.proto b/Packages/ClementineKit/Proto/remotecontrolmessages.proto index 8b83711..59bedee 100644 --- a/Packages/ClementineKit/Proto/remotecontrolmessages.proto +++ b/Packages/ClementineKit/Proto/remotecontrolmessages.proto @@ -327,6 +327,10 @@ message RequestInsertUrls { repeated SongMetadata songs = 6; // if we wish to create a new playlist optional string new_playlist_name = 7; + // Queue them to play straight after the current song, in front of anything + // queued already. Servers with SERVER_FEATURE_ENQUEUE_NEXT honor it; older + // ones just insert them. + optional bool enqueue_next = 8 [default = false]; } // Client want to change track @@ -498,6 +502,8 @@ enum ServerFeature { SERVER_FEATURE_RENDERING = 1; // The server can be browsed like its Internet sidebar (REQUEST_BROWSE). SERVER_FEATURE_BROWSE = 2; + // The server honors RequestInsertUrls.enqueue_next. + SERVER_FEATURE_ENQUEUE_NEXT = 3; } // Optional abilities of a renderer, beyond decoding its formats. diff --git a/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift b/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift index 3b95387..f4b6e32 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Protocol/Messages.swift @@ -155,21 +155,31 @@ public enum Messages { RemoteMessage(.rateSong) { $0.requestRateSong.rating = rating } } - /// Adds [urls] to a playlist; with [playNow], Clementine plays the first of them. - public static func insertURLs(_ urls: [String], playlistID: Int32, playNow: Bool = false) -> RemoteMessage { + /// Adds [urls] to a playlist; with [playNow], Clementine plays the first of them. With + /// [enqueue], they're queued after anything queued already; with [enqueueNext], in front of it. + public static func insertURLs( + _ urls: [String], playlistID: Int32, playNow: Bool = false, enqueue: Bool = false, enqueueNext: Bool = false + ) -> RemoteMessage { RemoteMessage(.insertUrls) { $0.requestInsertUrls.playlistID = playlistID $0.requestInsertUrls.urls = urls $0.requestInsertUrls.playNow = playNow + $0.requestInsertUrls.enqueue = enqueue + $0.requestInsertUrls.enqueueNext = enqueueNext } } - /// Adds [songs] to a playlist; with [playNow], Clementine plays the first of them. - public static func insertSongs(_ songs: [SongMetadata], playlistID: Int32, playNow: Bool = false) -> RemoteMessage { + /// Adds [songs] to a playlist; with [playNow], Clementine plays the first of them. With + /// [enqueue], they're queued after anything queued already; with [enqueueNext], in front of it. + public static func insertSongs( + _ songs: [SongMetadata], playlistID: Int32, playNow: Bool = false, enqueue: Bool = false, enqueueNext: Bool = false + ) -> RemoteMessage { RemoteMessage(.insertUrls) { $0.requestInsertUrls.playlistID = playlistID $0.requestInsertUrls.songs = songs $0.requestInsertUrls.playNow = playNow + $0.requestInsertUrls.enqueue = enqueue + $0.requestInsertUrls.enqueueNext = enqueueNext } } diff --git a/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift b/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift index ba9e0b0..da2be34 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Protocol/remotecontrolmessages.pb.swift @@ -247,6 +247,9 @@ public nonisolated enum Pb_Remote_ServerFeature: Int, SwiftProtobuf.Enum, Swift. /// The server can be browsed like its Internet sidebar (REQUEST_BROWSE). case browse = 2 + /// The server honors RequestInsertUrls.enqueue_next. + case enqueueNext = 3 + public init() { self = .unspecified } @@ -1312,6 +1315,18 @@ public nonisolated struct Pb_Remote_RequestInsertUrls: Sendable { /// Clears the value of `newPlaylistName`. Subsequent reads from it will return its default value. public mutating func clearNewPlaylistName() {self._newPlaylistName = nil} + /// Queue them to play straight after the current song, in front of anything + /// queued already. Servers with SERVER_FEATURE_ENQUEUE_NEXT honor it; older + /// ones just insert them. + public var enqueueNext: Bool { + get {_enqueueNext ?? false} + set {_enqueueNext = newValue} + } + /// Returns true if `enqueueNext` has been explicitly set. + public var hasEnqueueNext: Bool {self._enqueueNext != nil} + /// Clears the value of `enqueueNext`. Subsequent reads from it will return its default value. + public mutating func clearEnqueueNext() {self._enqueueNext = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -1321,6 +1336,7 @@ public nonisolated struct Pb_Remote_RequestInsertUrls: Sendable { fileprivate var _playNow: Bool? = nil fileprivate var _enqueue: Bool? = nil fileprivate var _newPlaylistName: String? = nil + fileprivate var _enqueueNext: Bool? = nil } /// Client want to change track @@ -3417,7 +3433,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\u{1}SERVER_FEATURE_BROWSE\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\u{1}SERVER_FEATURE_ENQUEUE_NEXT\0") } nonisolated extension Pb_Remote_RendererFeature: SwiftProtobuf._ProtoNameProviding { @@ -4353,7 +4369,7 @@ nonisolated extension Pb_Remote_RequestSetTrackPosition: SwiftProtobuf.Message, nonisolated extension Pb_Remote_RequestInsertUrls: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".RequestInsertUrls" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}playlist_id\0\u{1}urls\0\u{1}position\0\u{3}play_now\0\u{1}enqueue\0\u{1}songs\0\u{3}new_playlist_name\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}playlist_id\0\u{1}urls\0\u{1}position\0\u{3}play_now\0\u{1}enqueue\0\u{1}songs\0\u{3}new_playlist_name\0\u{3}enqueue_next\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -4368,6 +4384,7 @@ nonisolated extension Pb_Remote_RequestInsertUrls: SwiftProtobuf.Message, SwiftP case 5: try { try decoder.decodeSingularBoolField(value: &self._enqueue) }() case 6: try { try decoder.decodeRepeatedMessageField(value: &self.songs) }() case 7: try { try decoder.decodeSingularStringField(value: &self._newPlaylistName) }() + case 8: try { try decoder.decodeSingularBoolField(value: &self._enqueueNext) }() default: break } } @@ -4399,6 +4416,9 @@ nonisolated extension Pb_Remote_RequestInsertUrls: SwiftProtobuf.Message, SwiftP try { if let v = self._newPlaylistName { try visitor.visitSingularStringField(value: v, fieldNumber: 7) } }() + try { if let v = self._enqueueNext { + try visitor.visitSingularBoolField(value: v, fieldNumber: 8) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -4410,6 +4430,7 @@ nonisolated extension Pb_Remote_RequestInsertUrls: SwiftProtobuf.Message, SwiftP if lhs._enqueue != rhs._enqueue {return false} if lhs.songs != rhs.songs {return false} if lhs._newPlaylistName != rhs._newPlaylistName {return false} + if lhs._enqueueNext != rhs._enqueueNext {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Packages/ClementineKit/Sources/ClementineKit/Session/AddAction.swift b/Packages/ClementineKit/Sources/ClementineKit/Session/AddAction.swift new file mode 100644 index 0000000..eaba247 --- /dev/null +++ b/Packages/ClementineKit/Sources/ClementineKit/Session/AddAction.swift @@ -0,0 +1,17 @@ +/// What's done with songs added to a playlist, besides putting them at its end. +public enum AddAction: Sendable, CaseIterable { + /// Nothing more. + case append + /// Clementine plays the first of them unless it's playing already, as it does when you + /// double-click a song in it. + case playIfStopped + /// Clementine plays the first of them. + case playNow + /// They're queued to play after anything queued already. + case queue + /// They're queued to play straight after the current song, in front of anything queued already. + /// Needs a Clementine that can ([RemoteSession.canEnqueueNext]); others just add them. + case playNext + /// The playlist is emptied first, then Clementine plays the first of them. + case replace +} diff --git a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift index c7e6e3f..c452748 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift @@ -80,6 +80,9 @@ public final class RemoteSession { /// Whether this Clementine can be browsed like its Internet sidebar, for its internet services. public private(set) var canBrowse = false + /// Whether this Clementine can queue added songs to play next ([AddAction.playNext]). + public private(set) var canEnqueueNext = false + // MARK: Playing public private(set) var song: Song? @@ -232,6 +235,7 @@ public final class RemoteSession { canChooseOutput = false outputs = [] canBrowse = false + canEnqueueNext = false } private func handle(_ event: ClementineConnection.Event, from source: ClementineConnection) { @@ -272,6 +276,7 @@ public final class RemoteSession { } canChooseOutput = info.features.contains(.rendering) canBrowse = info.features.contains(.browse) + canEnqueueNext = info.features.contains(.enqueueNext) if canChooseOutput { send(RemoteMessage(.requestOutputs)) } @@ -470,18 +475,38 @@ public final class RemoteSession { send(Messages.closePlaylist(playlistID)) } - /// Adds songs, by their URLs, to playlist [playlistID], or the active playlist when nil. - /// With [playIfStopped], plays the first of them unless something is playing already. - public func add(urls: [String], to playlistID: Int32? = nil, playIfStopped: Bool = false) { + /// Adds songs, by their URLs, to playlist [playlistID], or the active playlist when nil, doing + /// [action]. + public func add(urls: [String], to playlistID: Int32? = nil, action: AddAction = .append) { guard let playlistID = playlistID ?? activePlaylistID, !urls.isEmpty else { return } - send(Messages.insertURLs(urls, playlistID: playlistID, playNow: playIfStopped && playState != .playing)) + prepare(action, playlistID: playlistID) + send(Messages.insertURLs( + urls, playlistID: playlistID, playNow: playsNow(action), + enqueue: action == .queue, enqueueNext: action == .playNext)) } - /// Adds songs, described in full, to playlist [playlistID], or the active playlist when nil. - /// With [playIfStopped], plays the first of them unless something is playing already. - public func add(songs: [SongMetadata], to playlistID: Int32? = nil, playIfStopped: Bool = false) { + /// Adds songs, described in full, to playlist [playlistID], or the active playlist when nil, + /// doing [action]. + public func add(songs: [SongMetadata], to playlistID: Int32? = nil, action: AddAction = .append) { guard let playlistID = playlistID ?? activePlaylistID, !songs.isEmpty else { return } - send(Messages.insertSongs(songs, playlistID: playlistID, playNow: playIfStopped && playState != .playing)) + prepare(action, playlistID: playlistID) + send(Messages.insertSongs( + songs, playlistID: playlistID, playNow: playsNow(action), + enqueue: action == .queue, enqueueNext: action == .playNext)) + } + + private func prepare(_ action: AddAction, playlistID: Int32) { + if action == .replace { + clear(playlistID: playlistID) + } + } + + private func playsNow(_ action: AddAction) -> Bool { + switch action { + case .playNow, .replace: true + case .playIfStopped: playState != .playing + case .append, .queue, .playNext: false + } } /// Creates a playlist called [name], and returns it once Clementine has; nil if Clementine diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/ConnectionTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/ConnectionTests.swift index b3bf4c7..58c202d 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/ConnectionTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/ConnectionTests.swift @@ -349,17 +349,60 @@ struct ConnectionTests { session.connect(to: clementine.endpoint, authCode: 0) try await eventually { session.status == .connected && session.playState == .playing } - session.add(urls: ["file:///a.ogg"], playIfStopped: true) + session.add(urls: ["file:///a.ogg"], action: .playIfStopped) await clementine.broadcast(RemoteMessage(.pause)) try await eventually { session.playState == .paused } - session.add(urls: ["file:///b.ogg"], playIfStopped: true) - session.add(songs: [FakeClementine.song], playIfStopped: true) + session.add(urls: ["file:///b.ogg"], action: .playIfStopped) + session.add(songs: [FakeClementine.song], action: .playIfStopped) session.add(urls: ["file:///c.ogg"]) try await clementine.waitUntil { $0.received.filter { $0.type == .insertUrls }.count == 4 } let inserts = clementine.received.filter { $0.type == .insertUrls } #expect(inserts.map(\.requestInsertUrls.playNow) == [false, true, true, false]) } + @Test func addsSongsAsEachActionSays() async throws { + let clementine = try await FakeClementine() + defer { clementine.stop() } + clementine.respondLikeClementine() + + let session = RemoteSession() + session.connect(to: clementine.endpoint, authCode: 0) + try await eventually { session.status == .connected && session.playState == .playing } + + let actions: [AddAction] = [.append, .playIfStopped, .playNow, .queue, .playNext] + for action in actions { + session.add(urls: ["file:///a.ogg"], action: action) + } + try await clementine.waitUntil { $0.received.filter { $0.type == .insertUrls }.count == actions.count } + let inserts = clementine.received.filter { $0.type == .insertUrls }.map(\.requestInsertUrls) + #expect(inserts.map(\.playNow) == [false, false, true, false, false]) + #expect(inserts.map(\.enqueue) == [false, false, false, true, false]) + #expect(inserts.map(\.enqueueNext) == [false, false, false, false, true]) + } + + @Test func replacingEmptiesThePlaylistThenPlays() async throws { + let clementine = try await FakeClementine() + defer { clementine.stop() } + clementine.respondLikeClementine() + + let session = RemoteSession() + session.connect(to: clementine.endpoint, authCode: 0) + try await eventually { session.status == .connected } + await clementine.broadcast(RemoteMessage(.playlistSongs) { + $0.responsePlaylistSongs.requestedPlaylist.id = 1 + $0.responsePlaylistSongs.songs = [FakeClementine.song] + }) + try await eventually { session.playlistSongs[1]?.count == 1 } + + session.add(songs: [FakeClementine.song], to: 1, action: .replace) + try await clementine.waitUntil { $0.received.contains { $0.type == .insertUrls } } + let sent = clementine.received.filter { $0.type == .removeSongs || $0.type == .insertUrls } + #expect(sent.map(\.type) == [.removeSongs, .insertUrls]) + #expect(sent.first?.requestRemoveSongs.playlistID == 1) + #expect(sent.last?.requestInsertUrls.playNow == true) + #expect(session.playlistSongs[1] == []) + } + @Test func anOlderClementineDoesntCreatePlaylists() async throws { let clementine = try await FakeClementine() defer { clementine.stop() } diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift index 7e187fb..05584e6 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/InternetBrowserTests.swift @@ -189,4 +189,12 @@ struct InternetBrowserTests { session.apply(RemoteMessage(.info) { $0.responseClementineInfo.features = [.rendering] }) #expect(!session.canBrowse) } + + @Test func sessionKnowsWhetherItCanQueueSongsToPlayNext() { + let session = RemoteSession() + session.apply(RemoteMessage(.info) { $0.responseClementineInfo.features = [.browse, .enqueueNext] }) + #expect(session.canEnqueueNext) + session.apply(RemoteMessage(.info) { $0.responseClementineInfo.features = [.browse] }) + #expect(!session.canEnqueueNext) + } } diff --git a/scripts/import-android-translations.py b/scripts/import-android-translations.py index 4099e1b..ad251f7 100755 --- a/scripts/import-android-translations.py +++ b/scripts/import-android-translations.py @@ -75,6 +75,9 @@ "This playlist is empty": "playlist_empty", "Search this playlist": "playlist_search_hint", "Add to playlist": "library_add_to_playlist", + "Add to queue": "library_add_to_queue", + "Open in new playlist": "library_open_in_new_playlist", + "Select": "library_select", "Download library": "library_download_action", "Downloading the library…": "library_download", "Preparing the library…": "library_optimize",