213 lines
6.8 KiB
Swift
213 lines
6.8 KiB
Swift
//
|
|
// PlaylistView.swift
|
|
// QueueCube
|
|
//
|
|
// Created by James Magahern on 3/3/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct MediaListItem: Identifiable
|
|
{
|
|
let _id: String
|
|
let title: String
|
|
let filename: String
|
|
let index: Int?
|
|
let isCurrent: Bool
|
|
let playbackError: String?
|
|
|
|
var id: String {
|
|
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
|
}
|
|
|
|
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false, playbackError: String? = nil) {
|
|
self._id = id
|
|
self.title = title
|
|
self.filename = filename
|
|
self.index = index
|
|
self.isCurrent = isCurrent
|
|
self.playbackError = playbackError
|
|
}
|
|
}
|
|
|
|
enum MediaListMode {
|
|
case playlist
|
|
case favorites
|
|
}
|
|
|
|
@Observable
|
|
class MediaListViewModel
|
|
{
|
|
let mode: MediaListMode
|
|
var isPlaying: Bool = false
|
|
var items: [MediaListItem] = []
|
|
|
|
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 }
|
|
var onDelete: (MediaListItem) -> Void = { _ in }
|
|
|
|
init(mode: MediaListMode) {
|
|
self.mode = mode
|
|
}
|
|
}
|
|
|
|
struct MediaListView: View
|
|
{
|
|
@Binding var model: MediaListViewModel
|
|
@State private var errorAlertItem: MediaListItem? = nil
|
|
@State private var isShowingErrorAlert: Bool = false
|
|
|
|
var body: some View {
|
|
VStack {
|
|
if model.items.isEmpty {
|
|
let title: LocalizedStringKey = switch model.mode {
|
|
case .playlist: .playlistEmpty
|
|
case .favorites: .favoritesEmpty
|
|
}
|
|
|
|
contentPlaceholderView(title: title, systemImage: "list.bullet")
|
|
} else {
|
|
List($model.items, editActions: .delete) { item in
|
|
let item = item.wrappedValue
|
|
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
|
|
|
Button {
|
|
if let _ = item.playbackError {
|
|
errorAlertItem = item
|
|
isShowingErrorAlert = true
|
|
} else {
|
|
switch model.mode {
|
|
case .playlist:
|
|
model.onSeek(item)
|
|
case .favorites:
|
|
model.onPlay(item)
|
|
}
|
|
}
|
|
} label: {
|
|
MediaItemCell(
|
|
title: item.title,
|
|
subtitle: item.filename,
|
|
state: state,
|
|
playbackError: item.playbackError
|
|
)
|
|
}
|
|
.listRowBackground(
|
|
item.playbackError != nil ? Color.red.opacity(0.15) :
|
|
(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)
|
|
}
|
|
}
|
|
}
|
|
.alert(.playbackError, isPresented: $isShowingErrorAlert, presenting: errorAlertItem) { item in
|
|
Button(.cancel, role: .cancel) {
|
|
errorAlertItem = nil
|
|
isShowingErrorAlert = false
|
|
}
|
|
Button(.delete, role: .destructive) {
|
|
model.items.removeAll { $0.id == item.id }
|
|
model.onDelete(item)
|
|
errorAlertItem = nil
|
|
isShowingErrorAlert = false
|
|
}
|
|
} message: { item in
|
|
Text(item.playbackError ?? "Unknown error")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
struct MediaItemCell: View
|
|
{
|
|
let title: String
|
|
let subtitle: String
|
|
let state: State
|
|
let playbackError: String?
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Image(systemName: iconName)
|
|
.tint(playbackError == nil ? Color.primary : Color.orange)
|
|
.frame(width: 15.0)
|
|
.padding(.trailing, 10.0)
|
|
|
|
VStack(alignment: .leading) {
|
|
Text(title)
|
|
.bold()
|
|
.font(.subheadline)
|
|
.tint(.primary)
|
|
.lineLimit(1)
|
|
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding([.top, .bottom], 4.0)
|
|
.frame(minHeight: 44.0)
|
|
}
|
|
|
|
private var iconName: String {
|
|
if playbackError != nil {
|
|
return "exclamationmark.triangle.fill"
|
|
}
|
|
|
|
switch state {
|
|
case .queued: return "play.fill"
|
|
case .playing: return "speaker.wave.3.fill"
|
|
case .paused: return "speaker.fill"
|
|
}
|
|
}
|
|
|
|
// MARK: - Types
|
|
|
|
enum State {
|
|
case queued
|
|
case playing
|
|
case paused
|
|
}
|
|
}
|