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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions App/Library/BrowseViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions App/Library/LibraryModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions App/Library/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions App/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,9 @@
}
}
}
},
"Add to queue" : {

},
"Added to the playlist" : {
"localizations" : {
Expand Down Expand Up @@ -10902,6 +10905,9 @@
},
"Open Settings" : {

},
"Open in new playlist" : {

},
"Open-source software" : {
"localizations" : {
Expand Down
9 changes: 4 additions & 5 deletions App/Search/SearchModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
22 changes: 13 additions & 9 deletions App/Search/SearchResultsViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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) }
}
}
}
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions Packages/ClementineKit/Proto/remotecontrolmessages.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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() {}
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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)
}

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading