Appearance tweaks

This commit is contained in:
2025-06-20 18:22:31 -07:00
parent 0d2eb229cf
commit d87d6e038e
13 changed files with 273 additions and 58 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

View File

@@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "AppIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

View File

@@ -118,6 +118,13 @@ struct API
.post() .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 { public func addFavorite(mediaURL: String) async throws {
try await request() try await request()
.path("/favorites") .path("/favorites")

View File

@@ -83,6 +83,7 @@ struct RequestBuilder
public func websocket() -> URL { public func websocket() -> URL {
guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() } guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() }
components.scheme = components.scheme == "https" ? "wss" : "ws" components.scheme = components.scheme == "https" ? "wss" : "ws"
components.host = components.host!.replacing(/\%(.*)$/, with: "")
return components.url! return components.url!
} }

View File

@@ -43,6 +43,9 @@
} }
} }
} }
},
"ADD_TO_QUEUE" : {
}, },
"CANCEL" : { "CANCEL" : {
"localizations" : { "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" : { "DISCOVERED" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -94,6 +117,16 @@
} }
} }
}, },
"EDIT" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit…"
}
}
}
},
"ENTER_MANUALLY" : { "ENTER_MANUALLY" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -104,6 +137,16 @@
} }
} }
}, },
"FAVORITE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favorite"
}
}
}
},
"FAVORITES" : { "FAVORITES" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -175,6 +218,16 @@
} }
} }
}, },
"NOT_PLAYING" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Not Playing"
}
}
}
},
"Nothing here yet." : { "Nothing here yet." : {
}, },
@@ -269,6 +322,9 @@
} }
} }
} }
},
"TODO" : {
}, },
"UNABLE_TO_CONNECT" : { "UNABLE_TO_CONNECT" : {
"localizations" : { "localizations" : {

View File

@@ -24,6 +24,7 @@ extension LocalizedStringKey
static let connectionError = LocalizedStringKey("CONNECTION_ERROR") static let connectionError = LocalizedStringKey("CONNECTION_ERROR")
static let playlist = LocalizedStringKey("PLAYLIST") static let playlist = LocalizedStringKey("PLAYLIST")
static let favorites = LocalizedStringKey("FAVORITES") static let favorites = LocalizedStringKey("FAVORITES")
static let favorite = LocalizedStringKey("FAVORITE")
static let servers = LocalizedStringKey("SERVERS") static let servers = LocalizedStringKey("SERVERS")
static let addServer = LocalizedStringKey("ADD_SERVER") static let addServer = LocalizedStringKey("ADD_SERVER")
static let cancel = LocalizedStringKey("CANCEL") static let cancel = LocalizedStringKey("CANCEL")
@@ -37,4 +38,9 @@ extension LocalizedStringKey
static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA") static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA")
static let searching = LocalizedStringKey("SEARCHING_") static let searching = LocalizedStringKey("SEARCHING_")
static let noResultsFound = LocalizedStringKey("NO_RESULTS_FOUND") 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")
} }

View File

@@ -33,6 +33,7 @@ struct ContentPlaceholderView<Label, Actions>: View
func contentPlaceholderView<Actions>( func contentPlaceholderView<Actions>(
title: LocalizedStringKey, title: LocalizedStringKey,
subtitle: (any StringProtocol)? = nil,
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() }) systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
-> ContentPlaceholderView<AnyView, Actions> -> ContentPlaceholderView<AnyView, Actions>
{ {
@@ -51,6 +52,11 @@ func contentPlaceholderView<Actions>(
.foregroundStyle(.tint) .foregroundStyle(.tint)
.bold() .bold()
if let subtitle {
Text(subtitle)
.foregroundStyle(.tint.opacity(0.5))
}
Spacer() Spacer()
.frame(height: 14.0) .frame(height: 14.0)
}) })

View File

@@ -30,6 +30,9 @@ struct ContentView: View
selection: $model.addMediaViewModel.activeDetent selection: $model.addMediaViewModel.activeDetent
) )
} }
.sheet(isPresented: $model.isEditSheetPresented) {
Text("TODO")
}
} }
// MARK: - Types // MARK: - Types
@@ -50,13 +53,8 @@ extension ContentView
await model.withModificationsViaAPI { api in await model.withModificationsViaAPI { api in
if what.contains(.nowPlaying) { if what.contains(.nowPlaying) {
let nowPlaying = try await api.fetchNowPlayingInfo() let nowPlaying = try await api.fetchNowPlayingInfo()
if let nowPlayingItem = nowPlaying.playingItem, let title = nowPlayingItem.title { model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
model.nowPlayingViewModel.title = title model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
model.nowPlayingViewModel.subtitle = nowPlayingItem.filename ?? ""
} else {
model.nowPlayingViewModel.title = "(Not Playing)"
model.nowPlayingViewModel.subtitle = ""
}
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0

View File

@@ -17,6 +17,7 @@ class MainViewModel
var isNowPlayingSheetPresented: Bool = false var isNowPlayingSheetPresented: Bool = false
var isAddMediaSheetPresented: Bool = false var isAddMediaSheetPresented: Bool = false
var isEditSheetPresented: Bool = false
var playlistModel = MediaListViewModel(mode: .playlist) var playlistModel = MediaListViewModel(mode: .playlist)
var favoritesModel = MediaListViewModel(mode: .favorites) 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 // Favorites
favoritesModel.onPlay = apiCallback { item, api in 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) try await api.add(mediaURL: item.filename)
} }
@@ -230,6 +244,7 @@ struct MainView: View
SettingsView(onDone: {}) SettingsView(onDone: {})
} }
} }
.tabViewStyle(.sidebarAdaptable)
} }
} }
@@ -251,6 +266,7 @@ struct NowPlayingMiniPlayerModifier: ViewModifier
NowPlayingMiniView(model: $model, onTap: onTap) NowPlayingMiniView(model: $model, onTap: onTap)
.padding() .padding()
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 800.0)
.onGeometryChange(for: CGSize.self) { $0.size } .onGeometryChange(for: CGSize.self) { $0.size }
action: { nowPlayingHeight = $0.height } action: { nowPlayingHeight = $0.height }
} }
@@ -346,8 +362,11 @@ struct ErrorDisplayModifier: ViewModifier
Rectangle() Rectangle()
.fill(.background) .fill(.background)
contentPlaceholderView(title: "\(String(describing: error))", systemImage: "exclamationmark.triangle.fill") contentPlaceholderView(
.tint(.label) title: .connectionError,
subtitle: error?.localizedDescription,
systemImage: "exclamationmark.triangle.fill"
).tint(.label)
} }
} }
} }

View File

@@ -12,6 +12,7 @@ struct NowPlayingMiniView: View {
let onTap: () -> Void let onTap: () -> Void
@GestureState private var tapGestureState = false @GestureState private var tapGestureState = false
private var nothingQueued: Bool { model.title == nil && model.subtitle == nil }
var body: some View { var body: some View {
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
@@ -25,15 +26,25 @@ struct NowPlayingMiniView: View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(model.title) if let title = model.title {
.font(.caption) Text(title)
.lineLimit(1) .font(.caption)
.bold() .lineLimit(1)
.bold()
}
Text(model.subtitle) if let subtitle = model.subtitle {
.lineLimit(1) Text(subtitle)
.font(.caption) .lineLimit(1)
.foregroundStyle(.secondary) .font(.caption)
.foregroundStyle(.secondary)
}
if nothingQueued {
Text(.notPlaying)
.font(.caption)
.foregroundStyle(.secondary)
}
} }
Spacer() Spacer()
@@ -42,7 +53,7 @@ struct NowPlayingMiniView: View {
.imageScale(.large) .imageScale(.large)
.padding(12.0) .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( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(tapGestureState ? .ultraThinMaterial : .bar) .fill(tapGestureState ? .ultraThinMaterial : .bar)

View File

@@ -18,60 +18,101 @@ class NowPlayingViewModel
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in } var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
var isPlaying: Bool = false var isPlaying: Bool = false
var title: String = "" var title: String? = ""
var subtitle: String = "" var subtitle: String? = ""
var volume: Double = 0.5 var volume: Double = 0.5
fileprivate var isSettingVolume: Bool = false
fileprivate var settingVolume: Double = 0.0 {
didSet { volume = settingVolume }
}
} }
struct NowPlayingView: View struct NowPlayingView: View
{ {
@State var model: NowPlayingViewModel @State var model: NowPlayingViewModel
private var nothingQueued: Bool { model.title == nil && model.subtitle == nil }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack { VStack {
Text(model.title)
.font(.title2)
.lineLimit(1)
.bold()
Text(model.subtitle)
.font(.title3)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer() Spacer()
.frame(height: 1.0)
HStack { VStack {
ForEach(Buttons.allCases) { button in if let title = model.title {
Spacer() Text(title)
.font(.title2)
.lineLimit(1)
.bold()
}
Button(action: button.action(model: model)) { if let subtitle = model.subtitle {
Image(systemName: button.imageName(isPlaying: model.isPlaying)) Text(subtitle)
.resizable() .font(.title3)
.aspectRatio(1.0, contentMode: .fit) .foregroundStyle(.secondary)
} .lineLimit(1)
}
Spacer() if nothingQueued {
Text(.notPlaying)
.font(.title2)
.foregroundStyle(.secondary)
} }
} }
.imageScale(.large)
.frame(height: 34.0)
.tint(.label)
Spacer() Spacer(minLength: 24.0)
Slider( VStack {
value: $model.volume, HStack {
in: 0.0...1.0, ForEach(Buttons.allCases) { button in
onEditingChanged: { _ in model.onVolumeChange(model) } 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 { .toolbar {
ToolbarItemGroup(placement: .topBarTrailing) { ToolbarItemGroup(placement: .topBarTrailing) {
@@ -96,6 +137,24 @@ struct NowPlayingView: View
var id: Int { rawValue } 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 { func imageName(isPlaying: Bool) -> String {
switch self { switch self {
case .backward: "backward.fill" case .backward: "backward.fill"

View File

@@ -40,8 +40,11 @@ class MediaListViewModel
var isPlaying: Bool = false var isPlaying: Bool = false
var items: [MediaListItem] = [] var items: [MediaListItem] = []
var onSeek: (MediaListItem) -> Void = { _ in } var onSeek: (MediaListItem) -> Void = { _ in }
var onPlay: (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) { init(mode: MediaListMode) {
self.mode = mode self.mode = mode
@@ -81,6 +84,44 @@ struct MediaListView: View
) )
} }
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil) .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)
}
}
} }
} }
} }

View File

@@ -111,6 +111,7 @@ struct AddServerView: View
class ViewModel class ViewModel
{ {
var serverURL: String = "" var serverURL: String = ""
var validationURL: String = ""
var validationState: ValidationState = .empty var validationState: ValidationState = .empty
var discoveredServers: [DiscoveredEndpoint] = [] var discoveredServers: [DiscoveredEndpoint] = []
@@ -152,6 +153,7 @@ struct AddServerView: View
} }
private func setNeedsValidation() { private func setNeedsValidation() {
self.validationURL = self.serverURL
self.validationTimer?.invalidate() self.validationTimer?.invalidate()
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
self?.validateSettings() self?.validateSettings()
@@ -159,7 +161,7 @@ struct AddServerView: View
} }
private func validateSettings() { private func validateSettings() {
guard !serverURL.isEmpty else { guard !validationURL.isEmpty else {
validationState = .empty validationState = .empty
return return
} }
@@ -168,14 +170,22 @@ struct AddServerView: View
Task { Task {
do { do {
let url = try URL(string: serverURL).try_unwrap() let url = try URL(string: validationURL).try_unwrap()
let api = API(baseURL: url) let api = API(baseURL: url)
_ = try await api.fetchNowPlayingInfo() _ = try await api.fetchNowPlayingInfo()
self.validationState = .valid self.validationState = .valid
self.serverURL = self.validationURL
} catch { } catch {
print("Validation failed: \(error)") 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
}
} }
} }
} }