diff --git a/README.md b/README.md index 22a56d5..99cc7ab 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ Doing so isn't fatal (it's not a secret), but it is annoying for other contribut ## Release Notes +### Not yet released + +* Inspector window has been improved + * You can now switch between now playing and current selection with a tab view. + * Playlist information can be viewed and edited. + ### Version 3.1 for Workgroups * Artists, albums, and tracks can be favourited ("starred" in Subsonic parlance; we use a heart to avoid being confused with ratings). diff --git a/Submariner.xcodeproj/project.pbxproj b/Submariner.xcodeproj/project.pbxproj index 5440900..759655a 100644 --- a/Submariner.xcodeproj/project.pbxproj +++ b/Submariner.xcodeproj/project.pbxproj @@ -98,6 +98,9 @@ 3EC5A6452A019B5C00025812 /* SBRepeatModeButtonStateTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC5A6442A019B5C00025812 /* SBRepeatModeButtonStateTransformer.swift */; }; 3ECF63FA280362BA004F9176 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3ECF63F9280362BA004F9176 /* Assets.xcassets */; }; 3ED4C4D42AC0E25400649FB2 /* SBLibraryPurgeOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED4C4D32AC0E25400649FB2 /* SBLibraryPurgeOperation.swift */; }; + 3EF978022BC3C4E300C986E9 /* SBMessageTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF978012BC3C4E300C986E9 /* SBMessageTextView.swift */; }; + 3EF978042BC4659B00C986E9 /* Binding+Nil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF978032BC4659B00C986E9 /* Binding+Nil.swift */; }; + 3EF978062BC49A9100C986E9 /* SBPropertyFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF978052BC49A9100C986E9 /* SBPropertyFieldView.swift */; }; 4C4DE62413A200AD006A1EC1 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4DE62213A200AA006A1EC1 /* Carbon.framework */; }; 4C4DE62513A200AD006A1EC1 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4DE62313A200AC006A1EC1 /* Security.framework */; }; 4C56868514050B9A00BE3478 /* SBPodcastItemView.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C56868414050B9A00BE3478 /* SBPodcastItemView.m */; }; @@ -254,6 +257,9 @@ 3EC5A6442A019B5C00025812 /* SBRepeatModeButtonStateTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBRepeatModeButtonStateTransformer.swift; sourceTree = ""; }; 3ECF63F9280362BA004F9176 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3ED4C4D32AC0E25400649FB2 /* SBLibraryPurgeOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBLibraryPurgeOperation.swift; sourceTree = ""; }; + 3EF978012BC3C4E300C986E9 /* SBMessageTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBMessageTextView.swift; sourceTree = ""; }; + 3EF978032BC4659B00C986E9 /* Binding+Nil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Nil.swift"; sourceTree = ""; }; + 3EF978052BC49A9100C986E9 /* SBPropertyFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBPropertyFieldView.swift; sourceTree = ""; }; 4C4DE62213A200AA006A1EC1 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; 4C4DE62313A200AC006A1EC1 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; 4C56868314050B9A00BE3478 /* SBPodcastItemView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SBPodcastItemView.h; sourceTree = ""; }; @@ -517,6 +523,8 @@ 3E94E5F92915AB130080FDF6 /* SBRoutePickerView.swift */, 3EB2BCCE2992F3CA00DC5056 /* SBVolumeButton.swift */, 3EB2BCD02992FD5A00DC5056 /* SBTracklistButton.swift */, + 3EF978012BC3C4E300C986E9 /* SBMessageTextView.swift */, + 3EF978052BC49A9100C986E9 /* SBPropertyFieldView.swift */, ); name = Views; sourceTree = ""; @@ -569,6 +577,7 @@ 4C87EDAC139CD9E90064DE2E /* Additions */ = { isa = PBXGroup; children = ( + 3EF978032BC4659B00C986E9 /* Binding+Nil.swift */, 3E87E90F2B4364CF00E85000 /* Collection+IndexSet.swift */, 3E04F63A2B7CA48400E24E56 /* Data+Random.swift */, 3EB2BCC22992D94E00DC5056 /* Data+Type.swift */, @@ -834,6 +843,7 @@ 3E7491972B6A1AE00052CBCE /* SBTracklistController.swift in Sources */, 3E32BE522B8D9FDE00E77CF0 /* SBLibraryItemPasteboardWriter.swift in Sources */, 3E1B785E2ACE5039008927C6 /* SBInspectorController.swift in Sources */, + 3EF978022BC3C4E300C986E9 /* SBMessageTextView.swift in Sources */, 3E70B2DF2A2BDC55002C0B93 /* SBApplication.swift in Sources */, 3E702DE72A428A1B005F7184 /* Synchronized.swift in Sources */, 3EC03B4029F4F2E0001FDE50 /* SBDownloads.swift in Sources */, @@ -846,6 +856,7 @@ 3E94E5F62912EEC40080FDF6 /* SBNavigationItem.swift in Sources */, 3EB2BCC12992D28A00DC5056 /* String+Hex.swift in Sources */, 3E0DAD5C29CA2A5600D895E2 /* SBTrackListLengthTransformer.swift in Sources */, + 3EF978062BC49A9100C986E9 /* SBPropertyFieldView.swift in Sources */, 3E32BE552B8E500500E77CF0 /* NSPasteboard+Library.swift in Sources */, 3E45201829F5DBDC00604079 /* SBTrackArtistNameTransformer.swift in Sources */, 3E04F6112B783BEC00E24E56 /* SBPreferencesController.swift in Sources */, @@ -884,6 +895,7 @@ 3E32BE502B8D9A5C00E77CF0 /* SBTableView+DragImage.swift in Sources */, 4C7AA24D139D64930050BE95 /* SBServerLibraryController.m in Sources */, 4CFAFC2C139EF08800E82B57 /* SBServerHomeController.m in Sources */, + 3EF978042BC4659B00C986E9 /* Binding+Nil.swift in Sources */, 3E04F5F22B71E33000E24E56 /* SBServerDirectoryController.swift in Sources */, 4CFAFC37139EF20600E82B57 /* MGScopeBar.m in Sources */, 4CFAFC38139EF20600E82B57 /* MGRecessedPopUpButtonCell.m in Sources */, diff --git a/Submariner/Binding+Nil.swift b/Submariner/Binding+Nil.swift new file mode 100644 index 0000000..601857e --- /dev/null +++ b/Submariner/Binding+Nil.swift @@ -0,0 +1,27 @@ +// +// Binding+Nil.swift +// Submariner +// +// Created by Calvin Buckley on 2024-04-08. +// +// Copyright (c) 2024 Calvin Buckley +// SPDX-License-Identifier: BSD-3-Clause +// + +import SwiftUI + +// https://alanquatermain.me/programming/swiftui/2019-11-15-CoreData-and-bindings/ +extension Binding { + init(_ source: Binding, replacingNilWith nilValue: Value) where Value: Equatable { + self.init( + get: { source.wrappedValue ?? nilValue }, + set: { newValue in + if newValue == nilValue { + source.wrappedValue = nil + } + else { + source.wrappedValue = newValue + } + }) + } +} diff --git a/Submariner/SBDatabaseController.m b/Submariner/SBDatabaseController.m index 864d5f7..cf5b1ec 100644 --- a/Submariner/SBDatabaseController.m +++ b/Submariner/SBDatabaseController.m @@ -1844,6 +1844,12 @@ - (void)pageController:(NSPageController *)pageController didTransitionToObject: [playlist.server getPlaylistTracks:playlist]; } [self updateSourceListSelection: playlistNavItem.playlist]; + // Sidebar for playlist; update the current playlist in inspector sidebar. + [[NSNotificationCenter defaultCenter] postNotificationName: @"SBPlaylistSelectionChanged" + object: playlistNavItem.playlist]; + } else { + [[NSNotificationCenter defaultCenter] postNotificationName: @"SBPlaylistSelectionChanged" + object: nil]; } // Selected item // XXX: Kinda messed up by the fact the controllers and nav item don't have a common ancestor for music item diff --git a/Submariner/SBInspectorController.swift b/Submariner/SBInspectorController.swift index a675981..142f10d 100644 --- a/Submariner/SBInspectorController.swift +++ b/Submariner/SBInspectorController.swift @@ -13,6 +13,7 @@ import QuickLook extension NSNotification.Name { // Actually defined in ParsingOperation for now static let SBTrackSelectionChanged = NSNotification.Name("SBTrackSelectionChanged") + static let SBPlaylistSelectionChanged = NSNotification.Name("SBPlaylistSelectionChanged") } @objc class SBInspectorController: SBViewController, ObservableObject { @@ -29,10 +30,15 @@ extension NSNotification.Name { selector: #selector(SBInspectorController.trackSelectionChange(notification:)), name: .SBTrackSelectionChanged, object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(SBInspectorController.playlistSelectionChange(notification:)), + name: .SBPlaylistSelectionChanged, + object: nil) } deinit { NotificationCenter.default.removeObserver(self, name: .SBTrackSelectionChanged, object: nil) + NotificationCenter.default.removeObserver(self, name: .SBPlaylistSelectionChanged, object: nil) } @objc private func trackSelectionChange(notification: Notification) { @@ -41,98 +47,59 @@ extension NSNotification.Name { } } + @objc private func playlistSelectionChange(notification: Notification) { + self.selectedPlaylist = notification.object as? SBPlaylist + } + @Published var selectedTracks: [SBTrack] = [] + @Published var selectedPlaylist: SBPlaylist? - struct TrackInfoView: View { - static let multipleDiffer = "..." - static var byteFormatter = ByteCountFormatter() - - let tracks: [SBTrack] - let isFromSelection: Bool + struct AlbumArtView: View { // used for quick look preview @State var coverUrl: URL? - - func valueIfSame(property: KeyPath) -> T? { - // one or none - if tracks.count == 1 { - return tracks[0][keyPath: property] - } else if tracks.count == 0 { - return nil - } - // if multiple - let values = Set(tracks.map { $0[keyPath: property] }) - if values.count > 1 { - return nil // too many - } else { - return tracks[0][keyPath: property] - } - } - @ViewBuilder func field(label: String, string: String) -> some View { - if #available(macOS 13, *) { - LabeledContent { - Text(string) - .textSelection(.enabled) - } label: { - Text(label) - } - } else { - TextField(label, text: .constant(string)) - } - } + let album: SBAlbum? - @ViewBuilder func stringField(label: String, for property: KeyPath) -> some View { - if let stringMaybeSingular = valueIfSame(property: property) { - if let string = stringMaybeSingular { - field(label: label, string: string) - } - // no thing -> nothing + var body: some View { + if let path = album?.cover?.imagePath, let image = NSImage(contentsOfFile: path as String) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .aspectRatio(contentMode: .fit) + .onTapGesture { + coverUrl = URL(fileURLWithPath: path as String) + } + .clipShape( + RoundedRectangle(cornerRadius: 6) + ) + .padding(.top, 20) + .padding(.horizontal, 20) + .quickLookPreview($coverUrl) } else { - field(label: label, string: TrackInfoView.multipleDiffer) + Image(systemName: "questionmark.square.dashed") + .resizable() + .scaledToFit() + .aspectRatio(contentMode: .fit) + .padding() + .foregroundColor(.secondary) } } + } + + struct TrackInfoView: SBPropertyFieldView { + static var byteFormatter = ByteCountFormatter() - @ViewBuilder func numberField(label: String, for property: KeyPath, formatter: Formatter? = nil) -> some View { - if let numberMaybeSingular = valueIfSame(property: property) { - if let number = numberMaybeSingular, number != 0 { - if let formatter = formatter, let string = formatter.string(for: number) { - field(label: label, string: string) - } else { - field(label: label, string: number.stringValue) - } - } - // no thing -> nothing - } else { - field(label: label, string: TrackInfoView.multipleDiffer) - } + typealias MI = SBTrack + var items: [SBTrack] { + return tracks } + let tracks: [SBTrack] + let isFromSelection: Bool + var body: some View { VStack(spacing: 0) { - if let albumMaybeSingular = valueIfSame(property: \.album), - let album = albumMaybeSingular, let cover = album.cover, - let path = cover.imagePath, let image = NSImage(contentsOfFile: path as String) { - Image(nsImage: image) - .resizable() - .scaledToFit() - .aspectRatio(contentMode: .fit) - .onTapGesture { - coverUrl = URL(fileURLWithPath: path as String) - } - .clipShape( - RoundedRectangle(cornerRadius: 6) - ) - .padding(.top, 20) - .padding(.horizontal, 20) - .quickLookPreview($coverUrl) - } else { - Image(systemName: "questionmark.square.dashed") - .resizable() - .scaledToFit() - .aspectRatio(contentMode: .fit) - .padding() - .foregroundColor(.secondary) - } + AlbumArtView(album: valueIfSame(property: \.album)!) Form { // Try to generalize, if multiple are selected then show something that indicates they differ Section { @@ -175,21 +142,59 @@ extension NSNotification.Name { if #available(macOS 13, *) { $0.formStyle(.grouped) } else { - $0 + $0.frame(maxHeight: .infinity) } } - HStack { - if isFromSelection { - // XXX: ugly for localization - Text("\(tracks.count) selected track\(tracks.count == 1 ? "" : "s")") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - // keep same font/baseline as the nibs w/ System 13pt - .font(.system(size: 13)) - .padding(.bottom, 4) + } + } + } + + struct PlaylistInspectorView: View, SBPropertyFieldView { + @ObservedObject var playlist: SBPlaylist + + // We don't yet use the protocol methods for the read-only stuff, + // but likely will with i.e. author field + typealias MI = SBPlaylist + var items: [SBPlaylist] { + return [playlist] + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + TextField("Name", text: Binding($playlist.resourceName, replacingNilWith: "")) + .onSubmit { + if let server = playlist.server, let id = playlist.itemId { + server.updatePlaylist(ID: id, name: playlist.resourceName) + } + } + if playlist.server != nil { + Toggle(isOn: $playlist.isPublic) { + Text("Public?") + } + .onSubmit { + if let server = playlist.server, let id = playlist.itemId { + server.updatePlaylist(ID: id, isPublic: playlist.isPublic) + } + } + } + TextField("Comment", text: Binding($playlist.comment, replacingNilWith: "")) + .onSubmit { + if let server = playlist.server, let id = playlist.itemId { + server.updatePlaylist(ID: id, comment: playlist.comment) + } + } + } + // count and duration are already displayed in status bar + } + .modify { + if #available(macOS 13, *) { + $0.formStyle(.grouped) + } else { + $0.frame(maxHeight: .infinity) } } - .frame(height: 41) } } } @@ -198,15 +203,81 @@ extension NSNotification.Name { @ObservedObject var inspectorController: SBInspectorController @ObservedObject var player = SBPlayer.sharedInstance() + @State var selectedType: InspectorTab = .trackNowPlaying + + enum InspectorTab { + // TODO: selected artist or artist if those ever has interesting properties in the future + case selectedPlaylist + case selectedTracks + case trackNowPlaying + } + + func updateSelection() { + if selectedType == .selectedTracks, + inspectorController.selectedTracks.count == 0, + player.isPlaying { + selectedType = .trackNowPlaying + } else if (selectedType == .trackNowPlaying || selectedType == .selectedPlaylist), + inspectorController.selectedTracks.count > 0 { + selectedType = .selectedTracks + } else if inspectorController.selectedPlaylist != nil { + selectedType = .selectedPlaylist + } + } + var body: some View { - if inspectorController.selectedTracks.count > 0 { - TrackInfoView(tracks: inspectorController.selectedTracks, isFromSelection: true) - } else if let currentTrack = player.currentTrack { - TrackInfoView(tracks: [currentTrack], isFromSelection: false) - } else { - Text("There are no tracks playing or selected.") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) + VStack(spacing: 0) { + switch selectedType { + case .selectedPlaylist: + if let currentPlaylist = inspectorController.selectedPlaylist { + PlaylistInspectorView(playlist: currentPlaylist) + } else { + SBMessageTextView(message: "There is no selected playlist.") + } + case .trackNowPlaying: + if let currentTrack = player.currentTrack { + TrackInfoView(tracks: [currentTrack], isFromSelection: false) + } else { + SBMessageTextView(message: "There is no playing track.") + } + case .selectedTracks: + if inspectorController.selectedTracks.count > 0 { + TrackInfoView(tracks: inspectorController.selectedTracks, isFromSelection: true) + } else { + SBMessageTextView(message: "There are no selected tracks.") + } + } + HStack { + Picker("Selected Item Type", selection: $selectedType) { + // We can't disable picker items, so hide what we can't use. + if inspectorController.selectedPlaylist != nil { + Text("Playlist") + .tag(InspectorTab.selectedPlaylist) + } + if inspectorController.selectedTracks.count > 0 { + // We're using text here for now since we can't combine it with the selection count very well. + Text("\(inspectorController.selectedTracks.count) Selected") + .tag(InspectorTab.selectedTracks) + } + if player.isPlaying { + Text("Now Playing") + .tag(InspectorTab.trackNowPlaying) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + .frame(height: 41) + .padding([.leading, .trailing], 8) + } + .onChange(of: inspectorController.selectedTracks) { _ in + updateSelection() + } + .onChange(of: player.isPlaying) { _ in + updateSelection() + } + .onAppear { + updateSelection() } } } diff --git a/Submariner/SBMessageTextView.swift b/Submariner/SBMessageTextView.swift new file mode 100644 index 0000000..5229e13 --- /dev/null +++ b/Submariner/SBMessageTextView.swift @@ -0,0 +1,26 @@ +// +// SBMessageTextView.swift +// Submariner +// +// Created by Calvin Buckley on 2024-04-08. +// +// Copyright (c) 2024 Calvin Buckley +// SPDX-License-Identifier: BSD-3-Clause +// + +import SwiftUI + +struct SBMessageTextView: View { + let message: String + + var body: some View { + Text(message) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + SBMessageTextView(message: "Hello world") +} diff --git a/Submariner/SBPropertyFieldView.swift b/Submariner/SBPropertyFieldView.swift new file mode 100644 index 0000000..d45b380 --- /dev/null +++ b/Submariner/SBPropertyFieldView.swift @@ -0,0 +1,75 @@ +// +// SBPropertyFieldView.swift +// Submariner +// +// Created by Calvin Buckley on 2024-04-08. +// +// Copyright (c) 2024 Calvin Buckley +// SPDX-License-Identifier: BSD-3-Clause +// + +import SwiftUI + +/// Provides a way to easily make form entries for key paths on an item. +protocol SBPropertyFieldView: View { + associatedtype Item + + var items: [Item] { get } +} + +extension SBPropertyFieldView { + func valueIfSame(property: KeyPath) -> T? { + // one or none + if items.count == 1 { + return items[0][keyPath: property] + } else if items.count == 0 { + return nil + } + // if multiple + let values = Set(items.map { $0[keyPath: property] }) + if values.count > 1 { + return nil // too many + } else { + return items[0][keyPath: property] + } + } + + @ViewBuilder func field(label: String, string: String) -> some View { + if #available(macOS 13, *) { + LabeledContent { + Text(string) + .textSelection(.enabled) + } label: { + Text(label) + } + } else { + TextField(label, text: .constant(string)) + } + } + + @ViewBuilder func stringField(label: String, for property: KeyPath) -> some View { + if let stringMaybeSingular = valueIfSame(property: property) { + if let string = stringMaybeSingular { + field(label: label, string: string) + } + // no thing -> nothing + } else { + field(label: label, string: "...") + } + } + + @ViewBuilder func numberField(label: String, for property: KeyPath, formatter: Formatter? = nil) -> some View { + if let numberMaybeSingular = valueIfSame(property: property) { + if let number = numberMaybeSingular, number != 0 { + if let formatter = formatter, let string = formatter.string(for: number) { + field(label: label, string: string) + } else { + field(label: label, string: number.stringValue) + } + } + // no thing -> nothing + } else { + field(label: label, string: "...") + } + } +} diff --git a/Submariner/SBServer.swift b/Submariner/SBServer.swift index 80b7394..61fb01f 100644 --- a/Submariner/SBServer.swift +++ b/Submariner/SBServer.swift @@ -471,6 +471,16 @@ public class SBServer: SBResource { OperationQueue.sharedServerQueue.addOperation(request) } + func updatePlaylist(ID: String, + name: String? = nil, + comment: String? = nil, + isPublic: Bool?, + appending: [SBTrack]? = nil, + removing: [Int]? = nil) { + let request = SBSubsonicRequestOperation(server: self, request: .updatePlaylist(id: ID, name: name, comment: comment, isPublic: isPublic, appending: appending, removing: removing)) + OperationQueue.sharedServerQueue.addOperation(request) + } + @objc func deletePlaylist(ID: String) { let request = SBSubsonicRequestOperation(server: self, request: .deletePlaylist(id: ID)) OperationQueue.sharedServerQueue.addOperation(request) diff --git a/Submariner/SBServerDirectoryController.swift b/Submariner/SBServerDirectoryController.swift index 50d891d..f3aa4e7 100644 --- a/Submariner/SBServerDirectoryController.swift +++ b/Submariner/SBServerDirectoryController.swift @@ -378,9 +378,7 @@ fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, catego } } } else { - Text("There is no server selected.") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) + SBMessageTextView(message: "There is no server selected.") } } } diff --git a/Submariner/SBServerUserViewController.swift b/Submariner/SBServerUserViewController.swift index 2e8c9d4..8c56371 100644 --- a/Submariner/SBServerUserViewController.swift +++ b/Submariner/SBServerUserViewController.swift @@ -295,13 +295,9 @@ extension NSNotification.Name { .frame(height: 41) } } else if let server = serverUsersController.server { - Text("\(server.resourceName ?? "This server") doesn't support now playing.") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) + SBMessageTextView(message: "\(server.resourceName ?? "This server") doesn't support now playing.") } else { - Text("There is no server selected.") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) + SBMessageTextView(message: "There is no server selected.") } } }