diff --git a/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..a0354a5 Binary files /dev/null and b/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..ce8e776 100644 --- a/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/QueueCube/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/QueueCube/Backend/API.swift b/QueueCube/Backend/API.swift index a7e717a..6dd5ebe 100644 --- a/QueueCube/Backend/API.swift +++ b/QueueCube/Backend/API.swift @@ -118,6 +118,13 @@ struct API .post() } + public func replace(mediaURL: String) async throws { + try await request() + .path("/playlist/replace") + .body([ "url" : mediaURL ]) + .post() + } + public func addFavorite(mediaURL: String) async throws { try await request() .path("/favorites") diff --git a/QueueCube/Backend/Utilities.swift b/QueueCube/Backend/Utilities.swift index 975147f..dcd2e9a 100644 --- a/QueueCube/Backend/Utilities.swift +++ b/QueueCube/Backend/Utilities.swift @@ -83,6 +83,7 @@ struct RequestBuilder public func websocket() -> URL { guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() } components.scheme = components.scheme == "https" ? "wss" : "ws" + components.host = components.host!.replacing(/\%(.*)$/, with: "") return components.url! } diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index 31ceec3..4982176 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -43,6 +43,9 @@ } } } + }, + "ADD_TO_QUEUE" : { + }, "CANCEL" : { "localizations" : { @@ -74,6 +77,26 @@ } } }, + "COPY_TITLE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Title" + } + } + } + }, + "COPY_URL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy URL" + } + } + } + }, "DISCOVERED" : { "localizations" : { "en" : { @@ -94,6 +117,16 @@ } } }, + "EDIT" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit…" + } + } + } + }, "ENTER_MANUALLY" : { "localizations" : { "en" : { @@ -104,6 +137,16 @@ } } }, + "FAVORITE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorite" + } + } + } + }, "FAVORITES" : { "localizations" : { "en" : { @@ -175,6 +218,16 @@ } } }, + "NOT_PLAYING" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Playing" + } + } + } + }, "Nothing here yet." : { }, @@ -269,6 +322,9 @@ } } } + }, + "TODO" : { + }, "UNABLE_TO_CONNECT" : { "localizations" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index 22709d7..df79547 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -24,6 +24,7 @@ extension LocalizedStringKey static let connectionError = LocalizedStringKey("CONNECTION_ERROR") static let playlist = LocalizedStringKey("PLAYLIST") static let favorites = LocalizedStringKey("FAVORITES") + static let favorite = LocalizedStringKey("FAVORITE") static let servers = LocalizedStringKey("SERVERS") static let addServer = LocalizedStringKey("ADD_SERVER") static let cancel = LocalizedStringKey("CANCEL") @@ -37,4 +38,9 @@ extension LocalizedStringKey static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA") static let searching = LocalizedStringKey("SEARCHING_") static let noResultsFound = LocalizedStringKey("NO_RESULTS_FOUND") + static let copyTitle = LocalizedStringKey("COPY_TITLE") + static let copyURL = LocalizedStringKey("COPY_URL") + static let edit = LocalizedStringKey("EDIT") + static let addToQueue = LocalizedStringKey("ADD_TO_QUEUE") + static let notPlaying = LocalizedStringKey("NOT_PLAYING") } diff --git a/QueueCube/Views/ContentPlaceholderView.swift b/QueueCube/Views/ContentPlaceholderView.swift index 4bc548b..df33000 100644 --- a/QueueCube/Views/ContentPlaceholderView.swift +++ b/QueueCube/Views/ContentPlaceholderView.swift @@ -33,6 +33,7 @@ struct ContentPlaceholderView: View func contentPlaceholderView( title: LocalizedStringKey, + subtitle: (any StringProtocol)? = nil, systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() }) -> ContentPlaceholderView { @@ -50,6 +51,11 @@ func contentPlaceholderView( Text(title) .foregroundStyle(.tint) .bold() + + if let subtitle { + Text(subtitle) + .foregroundStyle(.tint.opacity(0.5)) + } Spacer() .frame(height: 14.0) diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 30eb7ab..cef63bd 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -30,6 +30,9 @@ struct ContentView: View selection: $model.addMediaViewModel.activeDetent ) } + .sheet(isPresented: $model.isEditSheetPresented) { + Text("TODO") + } } // MARK: - Types @@ -50,13 +53,8 @@ extension ContentView await model.withModificationsViaAPI { api in if what.contains(.nowPlaying) { let nowPlaying = try await api.fetchNowPlayingInfo() - if let nowPlayingItem = nowPlaying.playingItem, let title = nowPlayingItem.title { - model.nowPlayingViewModel.title = title - model.nowPlayingViewModel.subtitle = nowPlayingItem.filename ?? "" - } else { - model.nowPlayingViewModel.title = "(Not Playing)" - model.nowPlayingViewModel.subtitle = "" - } + model.nowPlayingViewModel.title = nowPlaying.playingItem?.title + model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 diff --git a/QueueCube/Views/MainView.swift b/QueueCube/Views/MainView.swift index 5d8a5f1..f6936fe 100644 --- a/QueueCube/Views/MainView.swift +++ b/QueueCube/Views/MainView.swift @@ -17,6 +17,7 @@ class MainViewModel var isNowPlayingSheetPresented: Bool = false var isAddMediaSheetPresented: Bool = false + var isEditSheetPresented: Bool = false var playlistModel = MediaListViewModel(mode: .playlist) var favoritesModel = MediaListViewModel(mode: .favorites) @@ -90,8 +91,21 @@ class MainViewModel } } + playlistModel.onFavorite = apiCallback { item, api in + try await api.addFavorite(mediaURL: item.filename) + } + // Favorites favoritesModel.onPlay = apiCallback { item, api in + try await api.replace(mediaURL: item.filename) + try await api.play() + } + + favoritesModel.onEdit = { [weak self] item in + self?.isEditSheetPresented = true + } + + favoritesModel.onQueue = apiCallback { item, api in try await api.add(mediaURL: item.filename) } @@ -230,6 +244,7 @@ struct MainView: View SettingsView(onDone: {}) } } + .tabViewStyle(.sidebarAdaptable) } } @@ -251,6 +266,7 @@ struct NowPlayingMiniPlayerModifier: ViewModifier NowPlayingMiniView(model: $model, onTap: onTap) .padding() .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 800.0) .onGeometryChange(for: CGSize.self) { $0.size } action: { nowPlayingHeight = $0.height } } @@ -346,8 +362,11 @@ struct ErrorDisplayModifier: ViewModifier Rectangle() .fill(.background) - contentPlaceholderView(title: "\(String(describing: error))", systemImage: "exclamationmark.triangle.fill") - .tint(.label) + contentPlaceholderView( + title: .connectionError, + subtitle: error?.localizedDescription, + systemImage: "exclamationmark.triangle.fill" + ).tint(.label) } } } diff --git a/QueueCube/Views/NowPlayingMiniView.swift b/QueueCube/Views/NowPlayingMiniView.swift index 6bfed43..e2c0715 100644 --- a/QueueCube/Views/NowPlayingMiniView.swift +++ b/QueueCube/Views/NowPlayingMiniView.swift @@ -12,6 +12,7 @@ struct NowPlayingMiniView: View { let onTap: () -> Void @GestureState private var tapGestureState = false + private var nothingQueued: Bool { model.title == nil && model.subtitle == nil } var body: some View { let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" @@ -25,15 +26,25 @@ struct NowPlayingMiniView: View { HStack { VStack(alignment: .leading) { - Text(model.title) - .font(.caption) - .lineLimit(1) - .bold() + if let title = model.title { + Text(title) + .font(.caption) + .lineLimit(1) + .bold() + } - Text(model.subtitle) - .lineLimit(1) - .font(.caption) - .foregroundStyle(.secondary) + if let subtitle = model.subtitle { + Text(subtitle) + .lineLimit(1) + .font(.caption) + .foregroundStyle(.secondary) + } + + if nothingQueued { + Text(.notPlaying) + .font(.caption) + .foregroundStyle(.secondary) + } } Spacer() @@ -42,7 +53,7 @@ struct NowPlayingMiniView: View { .imageScale(.large) .padding(12.0) } - .padding(EdgeInsets(top: 4.0, leading: 10.0, bottom: 4.0, trailing: 10.0)) + .padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0)) .background( RoundedRectangle(cornerRadius: 12) .fill(tapGestureState ? .ultraThinMaterial : .bar) diff --git a/QueueCube/Views/NowPlayingView.swift b/QueueCube/Views/NowPlayingView.swift index 677dc3b..66e855e 100644 --- a/QueueCube/Views/NowPlayingView.swift +++ b/QueueCube/Views/NowPlayingView.swift @@ -18,60 +18,101 @@ class NowPlayingViewModel var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in } var isPlaying: Bool = false - var title: String = "" - var subtitle: String = "" + var title: String? = "" + var subtitle: String? = "" var volume: Double = 0.5 + + fileprivate var isSettingVolume: Bool = false + fileprivate var settingVolume: Double = 0.0 { + didSet { volume = settingVolume } + } } struct NowPlayingView: View { @State var model: NowPlayingViewModel + private var nothingQueued: Bool { model.title == nil && model.subtitle == nil } var body: some View { NavigationStack { VStack { - Text(model.title) - .font(.title2) - .lineLimit(1) - .bold() - - Text(model.subtitle) - .font(.title3) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer() + .frame(height: 1.0) - HStack { - ForEach(Buttons.allCases) { button in - Spacer() - - Button(action: button.action(model: model)) { - Image(systemName: button.imageName(isPlaying: model.isPlaying)) - .resizable() - .aspectRatio(1.0, contentMode: .fit) - } - - Spacer() + VStack { + if let title = model.title { + Text(title) + .font(.title2) + .lineLimit(1) + .bold() + } + + if let subtitle = model.subtitle { + Text(subtitle) + .font(.title3) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if nothingQueued { + Text(.notPlaying) + .font(.title2) + .foregroundStyle(.secondary) } } - .imageScale(.large) - .frame(height: 34.0) - .tint(.label) - Spacer() + Spacer(minLength: 24.0) - Slider( - value: $model.volume, - in: 0.0...1.0, - onEditingChanged: { _ in model.onVolumeChange(model) } + VStack { + HStack { + ForEach(Buttons.allCases) { button in + Spacer() + + Button(action: button.action(model: model)) { + Image(systemName: button.imageName(isPlaying: model.isPlaying)) + .resizable() + .aspectRatio(1.0, contentMode: .fit) + .scaleEffect(button.scale, anchor: .center) + .tint(button.tintColor) + } + .disabled(nothingQueued) + + Spacer() + } + } + .imageScale(.large) + .frame(height: 34.0) + .tint(.label) + + Spacer() + + Slider( + value: model.isSettingVolume ? $model.settingVolume : $model.volume, + in: 0.0...1.0, + onEditingChanged: { editing in + if model.isSettingVolume != editing { + model.settingVolume = model.volume + model.isSettingVolume = editing + } + + model.onVolumeChange(model) + } + ) + .padding(.horizontal, 18.0) + .padding(.bottom, -12.0) // intrinsic sizing bug workaround? + } + .padding(.vertical, 44.0) + .padding(.horizontal, 12.0) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 14.0) + .fill(.ultraThinMaterial) + .stroke(Color.label.opacity(0.08)) ) - .padding(.horizontal, 18.0) - - Spacer() } - .padding(24.0) + .padding(.horizontal, 15.0) + .padding(.bottom, 10.0) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { @@ -96,6 +137,24 @@ struct NowPlayingView: View var id: Int { rawValue } + var scale: Double { + switch self { + case .backward: 0.7 + case .forward: 0.7 + case .playPause: 1.0 + case .stop: 0.8 + } + } + + var tintColor: Color { + switch self { + case .backward: .label.mix(with: .gray, by: 0.5) + case .forward: .label.mix(with: .gray, by: 0.5) + case .playPause: .label + case .stop: .label + } + } + func imageName(isPlaying: Bool) -> String { switch self { case .backward: "backward.fill" diff --git a/QueueCube/Views/PlaylistView.swift b/QueueCube/Views/PlaylistView.swift index 5b9b3a4..9f312df 100644 --- a/QueueCube/Views/PlaylistView.swift +++ b/QueueCube/Views/PlaylistView.swift @@ -40,8 +40,11 @@ class MediaListViewModel var isPlaying: Bool = false var items: [MediaListItem] = [] - var onSeek: (MediaListItem) -> Void = { _ in } - var onPlay: (MediaListItem) -> Void = { _ in } + var onSeek: (MediaListItem) -> Void = { _ in } + var onPlay: (MediaListItem) -> Void = { _ in } + var onQueue: (MediaListItem) -> Void = { _ in } + var onEdit: (MediaListItem) -> Void = { _ in } + var onFavorite: (MediaListItem) -> Void = { _ in } init(mode: MediaListMode) { self.mode = mode @@ -81,6 +84,44 @@ struct MediaListView: View ) } .listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil) + .contextMenu { + Button(.copyTitle) { + UIPasteboard.general.string = item.title + } + + Button(.copyURL) { + if let url = URL(string: item.filename) { + UIPasteboard.general.url = url + } else { + UIPasteboard.general.string = item.filename + } + } + + if model.mode == .favorites { + Button(.edit) { + model.onEdit(item) + } + } + } + .swipeActions(edge: .leading) { + if model.mode == .favorites { + Button { + model.onQueue(item) + } label: { + Image(systemName: "plus.square.on.square") + Text(.addToQueue) + } + .tint(.blue) + } else if model.mode == .playlist { + Button { + model.onFavorite(item) + } label: { + Image(systemName: "star") + Text(.favorite) + } + .tint(.yellow) + } + } } } } diff --git a/QueueCube/Views/Settings View/AddServerView.swift b/QueueCube/Views/Settings View/AddServerView.swift index ba2422a..498b52d 100644 --- a/QueueCube/Views/Settings View/AddServerView.swift +++ b/QueueCube/Views/Settings View/AddServerView.swift @@ -111,6 +111,7 @@ struct AddServerView: View class ViewModel { var serverURL: String = "" + var validationURL: String = "" var validationState: ValidationState = .empty var discoveredServers: [DiscoveredEndpoint] = [] @@ -152,6 +153,7 @@ struct AddServerView: View } private func setNeedsValidation() { + self.validationURL = self.serverURL self.validationTimer?.invalidate() self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in self?.validateSettings() @@ -159,7 +161,7 @@ struct AddServerView: View } private func validateSettings() { - guard !serverURL.isEmpty else { + guard !validationURL.isEmpty else { validationState = .empty return } @@ -168,14 +170,22 @@ struct AddServerView: View Task { do { - let url = try URL(string: serverURL).try_unwrap() + let url = try URL(string: validationURL).try_unwrap() let api = API(baseURL: url) _ = try await api.fetchNowPlayingInfo() self.validationState = .valid + self.serverURL = self.validationURL } catch { print("Validation failed: \(error)") - self.validationState = .notValid + + if !validationURL.hasSuffix("/api") { + // Try adding /api and validating again. + self.validationURL = serverURL.appending("/api") + validateSettings() + } else { + self.validationState = .notValid + } } } }