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