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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
@@ -50,6 +51,11 @@ func contentPlaceholderView<Actions>(
|
|||||||
Text(title)
|
Text(title)
|
||||||
.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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
Button(action: button.action(model: model)) {
|
.lineLimit(1)
|
||||||
Image(systemName: button.imageName(isPlaying: model.isPlaying))
|
.bold()
|
||||||
.resizable()
|
}
|
||||||
.aspectRatio(1.0, contentMode: .fit)
|
|
||||||
}
|
if let subtitle = model.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
Spacer()
|
.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(
|
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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user