Appearance tweaks
This commit is contained in:
BIN
QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 574 KiB |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct ContentPlaceholderView<Label, Actions>: View
|
||||
|
||||
func contentPlaceholderView<Actions>(
|
||||
title: LocalizedStringKey,
|
||||
subtitle: (any StringProtocol)? = nil,
|
||||
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
|
||||
-> ContentPlaceholderView<AnyView, Actions>
|
||||
{
|
||||
@@ -50,6 +51,11 @@ func contentPlaceholderView<Actions>(
|
||||
Text(title)
|
||||
.foregroundStyle(.tint)
|
||||
.bold()
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.tint.opacity(0.5))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(height: 14.0)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user