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
30 changes: 28 additions & 2 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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")
}
}

Expand Down
229 changes: 229 additions & 0 deletions App/Internet/InternetView.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
12 changes: 12 additions & 0 deletions App/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions ClementineRemote.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -120,6 +121,7 @@
C636902427E849DF54E5B07F /* AVPlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayback.swift; sourceTree = "<group>"; };
CD9202E1E8FCA97103ED358B /* ClementineRemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClementineRemoteApp.swift; sourceTree = "<group>"; };
CFC7AB47D2CE0823058DA1A6 /* NowPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlaying.swift; sourceTree = "<group>"; };
D46866E6F5E51462F605F0CA /* InternetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetView.swift; sourceTree = "<group>"; };
D4806AC63E537A5050C1E6BD /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
D8B11B3C0886EBF71B26160B /* LibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryModel.swift; sourceTree = "<group>"; };
E08CA8859F2B5F40175519D8 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -151,6 +153,14 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
06B7950B1505D083C77CD065 /* Internet */ = {
isa = PBXGroup;
children = (
D46866E6F5E51462F605F0CA /* InternetView.swift */,
);
path = Internet;
sourceTree = "<group>";
};
21619A064D78FFF7022E0CAD /* Queue */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -282,6 +292,7 @@
C29638AE1C4D87CD5F503A3C /* Connection */,
34B9B7135F563F9C0F195796 /* Downloads */,
3BD8BD833ADD0CB0AEFC36B2 /* Intents */,
06B7950B1505D083C77CD065 /* Internet */,
2AC6FAA5E7DBA023F4A9CDF9 /* Library */,
56F26C6A8DE02190C5639C9A /* Player */,
21619A064D78FFF7022E0CAD /* Queue */,
Expand Down Expand Up @@ -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 */,
Expand Down
Loading
Loading