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" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

View File

@@ -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")

View File

@@ -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!
}

View File

@@ -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" : {

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)
}
}
}
}
}

View File

@@ -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
}
}
}
}