diff --git a/QueueCube/API.swift b/QueueCube/API.swift index 449d95a..79cdcac 100644 --- a/QueueCube/API.swift +++ b/QueueCube/API.swift @@ -63,6 +63,12 @@ struct API .json() } + public func fetchFavorites() async throws -> [MediaItem] { + try await request() + .path("/favorites") + .json() + } + public func play() async throws { try await request() .path("/play") @@ -95,6 +101,13 @@ struct API .post() } + public func addFavorite(mediaURL: String) async throws { + try await request() + .path("/favorites") + .body([ "filename" : mediaURL ]) + .post() + } + public func delete(index: Int) async throws { try await request() .path("/playlist/\(index)") diff --git a/QueueCube/ContentView.swift b/QueueCube/ContentView.swift index f87686f..7cd7510 100644 --- a/QueueCube/ContentView.swift +++ b/QueueCube/ContentView.swift @@ -38,20 +38,32 @@ struct ContentView: View } let addMediaModel = model.addMediaViewModel - addMediaModel.onAdd = { mediaURL in + addMediaModel.onAdd = { [model] mediaURL in let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines) if !strippedURL.isEmpty { addMediaModel.fieldContents = "" - Task { try await api.add(mediaURL: strippedURL) } + Task { + switch model.selectedTab { + case .playlist: + try await api.add(mediaURL: strippedURL) + case .favorites: + try await api.addFavorite(mediaURL: strippedURL) + } + } } } + + let favoritesModel = model.favoritesModel + favoritesModel.onPlay = { item in + Task { try await api.add(mediaURL: item.filename) } + } } } var body: some View { MainView(model: model) .task { await watchWebsocket() } - .task { await refresh([.nowPlaying, .playlist]) } + .task { await refresh([.nowPlaying, .playlist, .favorites]) } } // MARK: - Types @@ -62,6 +74,7 @@ struct ContentView: View static let nowPlaying = RefreshType(rawValue: 1 << 0) static let playlist = RefreshType(rawValue: 1 << 1) + static let favorites = RefreshType(rawValue: 1 << 2) } } @@ -99,6 +112,17 @@ extension ContentView } } + if what.contains(.favorites) { + let favorites = try await api.fetchFavorites() + model.favoritesModel.items = favorites.map { mediaItem in + FavoriteItem( + id: String(mediaItem.id), + title: mediaItem.title ?? mediaItem.filename ?? "", + filename: mediaItem.filename ?? "" + ) + } + } + model.connectionError = nil } catch { print("Error refreshing content: \(error)") @@ -133,19 +157,19 @@ extension ContentView case .playlistUpdate: await refresh(.playlist) + case .favoritesUpdate: + await refresh(.favorites) + case .metadataUpdate: fallthrough case .mpdUpdate: - await refresh([.playlist, .nowPlaying]) + await refresh([.playlist, .nowPlaying, .favorites]) case .receivedWebsocketPong: // This means we're online. if model.connectionError != nil { model.connectionError = nil - await refresh([.playlist, .nowPlaying]) + await refresh([.playlist, .nowPlaying, .favorites]) } - - default: - break } } } @@ -156,12 +180,19 @@ class MainViewModel var api = API.fromSettings() var connectionError: Error? = nil + var selectedTab: MainTab = .playlist var playlistModel = PlaylistViewModel() + var favoritesModel = FavoritesViewModel() var nowPlayingViewModel = NowPlayingViewModel() var addMediaViewModel = AddMediaBarViewModel() } +enum MainTab: String, CaseIterable { + case playlist + case favorites +} + struct MainView: View { @State var model: MainViewModel @@ -206,8 +237,16 @@ struct MainView: View Text(.connectionError) } } else { - PlaylistView(model: model.playlistModel) - .frame(maxWidth: 640.0) + TabView(selection: $model.selectedTab) { + Tab(.playlist, systemImage: "list.bullet", value: .playlist) { + PlaylistView(model: model.playlistModel) + } + + Tab(.favorites, systemImage: "heart.fill", value: .favorites) { + FavoritesView(model: model.favoritesModel) + } + } + .frame(maxWidth: 640.0) } } .frame(minHeight: 0.0) diff --git a/QueueCube/Localizable/Localizable.xcstrings b/QueueCube/Localizable/Localizable.xcstrings index ff6423f..fb1bc16 100644 --- a/QueueCube/Localizable/Localizable.xcstrings +++ b/QueueCube/Localizable/Localizable.xcstrings @@ -51,6 +51,16 @@ } } }, + "FAVORITES" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorites" + } + } + } + }, "General" : { }, @@ -75,6 +85,16 @@ } } }, + "PLAYLIST" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlist" + } + } + } + }, "SERVER_IS_ONLINE" : { "localizations" : { "en" : { diff --git a/QueueCube/Localizable/Strings.swift b/QueueCube/Localizable/Strings.swift index a239696..1ccacae 100644 --- a/QueueCube/Localizable/Strings.swift +++ b/QueueCube/Localizable/Strings.swift @@ -22,4 +22,6 @@ extension LocalizedStringKey static let validating = LocalizedStringKey("VALIDATING") static let general = LocalizedStringKey("GENERAL") static let connectionError = LocalizedStringKey("CONNECTION_ERROR") + static let playlist = LocalizedStringKey("PLAYLIST") + static let favorites = LocalizedStringKey("FAVORITES") } diff --git a/QueueCube/PlaylistView.swift b/QueueCube/PlaylistView.swift index cc5ec35..51f757e 100644 --- a/QueueCube/PlaylistView.swift +++ b/QueueCube/PlaylistView.swift @@ -16,6 +16,13 @@ struct PlaylistItem: Identifiable let isCurrent: Bool } +struct FavoriteItem: Identifiable +{ + let id: String + let title: String + let filename: String +} + @Observable class PlaylistViewModel { @@ -26,6 +33,14 @@ class PlaylistViewModel var onDelete: (PlaylistItem) -> Void = { _ in } } +@Observable +class FavoritesViewModel +{ + var items: [FavoriteItem] = [] + + var onPlay: (FavoriteItem) -> Void = { _ in } +} + struct PlaylistView: View { var model: PlaylistViewModel @@ -44,6 +59,21 @@ struct PlaylistView: View } } +struct FavoritesView: View +{ + var model: FavoritesViewModel + + var body: some View { + List(model.items) { item in + FavoriteItemCell( + title: item.title, + subtitle: item.filename, + onPlayButtonClick: { model.onPlay(item) } + ) + } + } +} + struct PlaylistItemCell: View { let title: String @@ -97,3 +127,34 @@ struct PlaylistItemCell: View case paused } } + +struct FavoriteItemCell: View +{ + let title: String + let subtitle: String + let onPlayButtonClick: () -> Void + + var body: some View { + HStack { + Button(action: onPlayButtonClick) { + Image(systemName: "play.fill") + } + .buttonStyle(BorderlessButtonStyle()) + .tint(Color.primary) + .frame(width: 15.0) + + VStack(alignment: .leading) { + Text(title) + .font(.body.bold()) + .lineLimit(1) + + Text(subtitle) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + } + .padding([.top, .bottom], 8.0) + } +}