diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj index d96dabd..ec18de1 100644 --- a/Mobile Music Assistant.xcodeproj/project.pbxproj +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -280,7 +280,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -316,7 +316,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Mobile Music Assistant/HelpersMANavigationDestination.swift b/Mobile Music Assistant/HelpersMANavigationDestination.swift index d3e4e43..adc94b4 100644 --- a/Mobile Music Assistant/HelpersMANavigationDestination.swift +++ b/Mobile Music Assistant/HelpersMANavigationDestination.swift @@ -12,6 +12,7 @@ enum MANavigationDestination: Hashable { case artist(MAArtist) case album(MAAlbum) case playlist(MAPlaylist) + case podcast(MAPodcast) } /// ViewModifier to apply all navigation destinations consistently @@ -27,6 +28,9 @@ struct MANavigationDestinations: ViewModifier { .navigationDestination(for: MAPlaylist.self) { playlist in PlaylistDetailView(playlist: playlist) } + .navigationDestination(for: MAPodcast.self) { podcast in + PodcastDetailView(podcast: podcast) + } .navigationDestination(for: MANavigationDestination.self) { destination in switch destination { case .artist(let artist): @@ -35,6 +39,8 @@ struct MANavigationDestinations: ViewModifier { AlbumDetailView(album: album) case .playlist(let playlist): PlaylistDetailView(playlist: playlist) + case .podcast(let podcast): + PodcastDetailView(podcast: podcast) } } } diff --git a/Mobile Music Assistant/ModelsMAModels.swift b/Mobile Music Assistant/ModelsMAModels.swift index cabf2d4..9952139 100644 --- a/Mobile Music Assistant/ModelsMAModels.swift +++ b/Mobile Music Assistant/ModelsMAModels.swift @@ -428,6 +428,55 @@ struct MAPlaylist: Codable, Identifiable, Hashable { } } +// MARK: - Podcast + +struct MAPodcast: Codable, Identifiable, Hashable { + let uri: String + let name: String + let publisher: String? + let totalEpisodes: Int? + let metadata: MediaItemMetadata? + let favorite: Bool + + var id: String { uri } + var imageUrl: String? { metadata?.thumbImage?.path } + var imageProvider: String? { metadata?.thumbImage?.provider } + + enum CodingKeys: String, CodingKey { + case uri, name, publisher, metadata, favorite + case totalEpisodes = "total_episodes" + } + + init(uri: String, name: String, publisher: String? = nil, totalEpisodes: Int? = nil, imageUrl: String? = nil, favorite: Bool = false) { + self.uri = uri + self.name = name + self.publisher = publisher + self.totalEpisodes = totalEpisodes + self.favorite = favorite + self.metadata = imageUrl.map { + MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) + } + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + uri = try c.decode(String.self, forKey: .uri) + name = try c.decode(String.self, forKey: .name) + publisher = try? c.decodeIfPresent(String.self, forKey: .publisher) + totalEpisodes = try? c.decodeIfPresent(Int.self, forKey: .totalEpisodes) + favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false + metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) + } +} + +// MARK: - Repeat Mode + +enum RepeatMode: String, Codable, CaseIterable { + case off + case one + case all +} + // MARK: - Player Queue State /// Represents the state of a player's queue, including the currently playing item. @@ -440,6 +489,8 @@ struct MAPlayerQueue: Codable { let elapsedTime: Double? /// Unix timestamp when `elapsedTime` was last set by the server. let elapsedTimeLastUpdated: Double? + let shuffleEnabled: Bool + let repeatMode: RepeatMode enum CodingKeys: String, CodingKey { case queueId = "queue_id" @@ -447,6 +498,8 @@ struct MAPlayerQueue: Codable { case currentIndex = "current_index" case elapsedTime = "elapsed_time" case elapsedTimeLastUpdated = "elapsed_time_last_updated" + case shuffleEnabled = "shuffle_enabled" + case repeatMode = "repeat_mode" } init(from decoder: Decoder) throws { @@ -456,6 +509,8 @@ struct MAPlayerQueue: Codable { currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex) elapsedTime = try? c.decodeIfPresent(Double.self, forKey: .elapsedTime) elapsedTimeLastUpdated = try? c.decodeIfPresent(Double.self, forKey: .elapsedTimeLastUpdated) + shuffleEnabled = (try? c.decode(Bool.self, forKey: .shuffleEnabled)) ?? false + repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .off } } diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift index 8c109c3..0e14878 100644 --- a/Mobile Music Assistant/ServicesMALibraryManager.swift +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -22,6 +22,7 @@ final class MALibraryManager { private(set) var albumArtists: [MAArtist] = [] private(set) var albums: [MAAlbum] = [] private(set) var playlists: [MAPlaylist] = [] + private(set) var podcasts: [MAPodcast] = [] // Pagination private var artistsOffset = 0 @@ -37,6 +38,7 @@ final class MALibraryManager { private(set) var isLoadingAlbumArtists = false private(set) var isLoadingAlbums = false private(set) var isLoadingPlaylists = false + private(set) var isLoadingPodcasts = false /// URIs currently marked as favorites — source of truth for UI. /// Populated from decoded model data, then mutated optimistically on toggle. @@ -47,6 +49,7 @@ final class MALibraryManager { private(set) var lastAlbumArtistsRefresh: Date? private(set) var lastAlbumsRefresh: Date? private(set) var lastPlaylistsRefresh: Date? + private(set) var lastPodcastsRefresh: Date? // MARK: - Disk Cache @@ -109,17 +112,23 @@ final class MALibraryManager { playlists = cached logger.info("Loaded \(cached.count) playlists from disk cache") } + if let cached: [MAPodcast] = load("podcasts.json") { + podcasts = cached + logger.info("Loaded \(cached.count) podcasts from disk cache") + } // Seed favorite URIs from cached data for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) } for artist in albumArtists where artist.favorite { favoriteURIs.insert(artist.uri) } for album in albums where album.favorite { favoriteURIs.insert(album.uri) } + for podcast in podcasts where podcast.favorite { favoriteURIs.insert(podcast.uri) } let ud = UserDefaults.standard lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date lastAlbumArtistsRefresh = ud.object(forKey: "lib.lastAlbumArtistsRefresh") as? Date lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") as? Date + lastPodcastsRefresh = ud.object(forKey: "lib.lastPodcastsRefresh") as? Date } private func save(_ value: T, _ filename: String) { @@ -338,6 +347,32 @@ final class MALibraryManager { logger.info("Loaded \(loaded.count) playlists") } + // MARK: - Podcasts + + func loadPodcasts(refresh: Bool = false) async throws { + guard !isLoadingPodcasts else { return } + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + + isLoadingPodcasts = true + defer { isLoadingPodcasts = false } + + logger.info("Loading podcasts") + + let loaded = try await service.getPodcasts() + podcasts = loaded + for podcast in loaded where podcast.favorite { favoriteURIs.insert(podcast.uri) } + save(podcasts, "podcasts.json") + lastPodcastsRefresh = markRefreshed("lib.lastPodcastsRefresh") + + logger.info("Loaded \(loaded.count) podcasts") + } + + func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + logger.info("Loading episodes for podcast \(podcastUri)") + return try await service.getPodcastEpisodes(podcastUri: podcastUri) + } + // MARK: - Artist Albums & Tracks (not cached — fetched on demand) func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] { diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift index 9d5d2ed..e8e58a6 100644 --- a/Mobile Music Assistant/ServicesMAPlayerManager.swift +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -273,4 +273,31 @@ final class MAPlayerManager { } try await service.playIndex(playerId: playerId, index: index) } + + func setShuffle(playerId: String, enabled: Bool) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.setQueueShuffle(playerId: playerId, enabled: enabled) + } + + func setRepeatMode(playerId: String, mode: RepeatMode) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.setQueueRepeatMode(playerId: playerId, mode: mode) + } + + func clearQueue(playerId: String) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.clearQueue(playerId: playerId) + } + + func moveQueueItem(playerId: String, queueItemId: String, posShift: Int) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.moveQueueItem(playerId: playerId, queueItemId: queueItemId, posShift: posShift) + // Optimistic local update — move the item in our cached list + if var items = queues[playerId], let idx = items.firstIndex(where: { $0.queueItemId == queueItemId }) { + let item = items.remove(at: idx) + let dest = max(0, min(items.count, idx + posShift)) + items.insert(item, at: dest) + queues[playerId] = items + } + } } diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index 2b69da9..8cc1330 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -231,17 +231,47 @@ final class MAService { } /// Move queue item - func moveQueueItem(playerId: String, fromIndex: Int, toIndex: Int) async throws { - logger.debug("Moving queue item from \(fromIndex) to \(toIndex)") + func moveQueueItem(playerId: String, queueItemId: String, posShift: Int) async throws { + logger.debug("Moving queue item \(queueItemId) by \(posShift) positions") _ = try await webSocketClient.sendCommand( "player_queues/move_item", args: [ "queue_id": playerId, - "queue_item_id": fromIndex, - "pos_shift": toIndex - fromIndex + "queue_item_id": queueItemId, + "pos_shift": posShift ] ) } + + func setQueueShuffle(playerId: String, enabled: Bool) async throws { + logger.debug("Setting shuffle \(enabled) on queue \(playerId)") + _ = try await webSocketClient.sendCommand( + "player_queues/shuffle", + args: [ + "queue_id": playerId, + "shuffle_enabled": enabled + ] + ) + } + + func setQueueRepeatMode(playerId: String, mode: RepeatMode) async throws { + logger.debug("Setting repeat mode \(mode.rawValue) on queue \(playerId)") + _ = try await webSocketClient.sendCommand( + "player_queues/repeat", + args: [ + "queue_id": playerId, + "repeat_mode": mode.rawValue + ] + ) + } + + func clearQueue(playerId: String) async throws { + logger.debug("Clearing queue \(playerId)") + _ = try await webSocketClient.sendCommand( + "player_queues/clear", + args: ["queue_id": playerId] + ) + } // MARK: - Library @@ -302,6 +332,31 @@ final class MAService { resultType: [MAPlaylist].self ) } + + /// Get all podcasts in the library + func getPodcasts() async throws -> [MAPodcast] { + logger.debug("Fetching podcasts") + return try await webSocketClient.sendCommand( + "music/podcasts/library_items", + resultType: [MAPodcast].self + ) + } + + /// Get all episodes for a podcast + func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] { + logger.debug("Fetching episodes for podcast \(podcastUri)") + guard let (provider, itemId) = parseMAUri(podcastUri) else { + throw MAWebSocketClient.ClientError.serverError("Invalid podcast URI: \(podcastUri)") + } + return try await webSocketClient.sendCommand( + "music/podcasts/podcast_episodes", + args: [ + "item_id": itemId, + "provider_instance_id_or_domain": provider + ], + resultType: [MAMediaItem].self + ) + } /// Get full artist details (includes biography in metadata.description). /// Results are cached in memory once biography data is available, so repeated @@ -460,6 +515,7 @@ final class MAService { let artists: [MAMediaItem]? let playlists: [MAMediaItem]? let radios: [MAMediaItem]? + let podcasts: [MAMediaItem]? } let searchResults = try result.decode(as: SearchResults.self) @@ -471,6 +527,7 @@ final class MAService { if let artists = searchResults.artists { allItems.append(contentsOf: artists) } if let playlists = searchResults.playlists { allItems.append(contentsOf: playlists) } if let radios = searchResults.radios { allItems.append(contentsOf: radios) } + if let podcasts = searchResults.podcasts { allItems.append(contentsOf: podcasts) } logger.info("✅ Decoded \(allItems.count) search results (albums: \(searchResults.albums?.count ?? 0), tracks: \(searchResults.tracks?.count ?? 0), artists: \(searchResults.artists?.count ?? 0), radios: \(searchResults.radios?.count ?? 0))") return allItems diff --git a/Mobile Music Assistant/ViewsFavoritesView.swift b/Mobile Music Assistant/ViewsFavoritesView.swift new file mode 100644 index 0000000..783c76b --- /dev/null +++ b/Mobile Music Assistant/ViewsFavoritesView.swift @@ -0,0 +1,324 @@ +// +// FavoritesView.swift +// Mobile Music Assistant +// +// Created by Sven Hanold on 08.04.26. +// + +import SwiftUI +import UIKit + +enum FavoritesTab: String, CaseIterable { + case artists = "Artists" + case albums = "Albums" + case radios = "Radios" + case podcasts = "Podcasts" +} + +struct FavoritesView: View { + @Environment(MAService.self) private var service + @State private var selectedTab: FavoritesTab = .artists + + init() { + UISegmentedControl.appearance().setTitleTextAttributes( + [.font: UIFont.systemFont(ofSize: 11, weight: .medium)], + for: .normal + ) + } + + var body: some View { + NavigationStack { + Group { + switch selectedTab { + case .artists: FavoriteArtistsSection() + case .albums: FavoriteAlbumsSection() + case .radios: FavoriteRadiosSection() + case .podcasts: FavoritePodcastsSection() + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Favorites", selection: $selectedTab) { + ForEach(FavoritesTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 360) + } + } + .withMANavigation() + } + } +} + +// MARK: - Favorite Artists + +private struct FavoriteArtistsSection: View { + @Environment(MAService.self) private var service + @State private var scrollPosition: String? + + private var favoriteArtists: [MAArtist] { + // Merge artists + albumArtists, deduplicate by URI, filter favorites + var seen = Set() + let all = service.libraryManager.artists + service.libraryManager.albumArtists + return all.filter { artist in + guard !seen.contains(artist.uri) else { return false } + seen.insert(artist.uri) + return service.libraryManager.isFavorite(uri: artist.uri) + }.sorted { $0.name.lowercased() < $1.name.lowercased() } + } + + private var artistsByLetter: [(String, [MAArtist])] { + let grouped = Dictionary(grouping: favoriteArtists) { artist -> String in + let first = artist.name.prefix(1).uppercased() + return first.first?.isLetter == true ? String(first) : "#" + } + return grouped.sorted { + if $0.key == "#" { return false } + if $1.key == "#" { return true } + return $0.key < $1.key + } + } + + private var availableLetters: [String] { + artistsByLetter.map { $0.0 } + } + + private let allLetters: [String] = (65...90).map { String(UnicodeScalar($0)!) } + ["#"] + + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ] + + var body: some View { + Group { + if favoriteArtists.isEmpty { + ContentUnavailableView( + "No Favorite Artists", + systemImage: "heart.slash", + description: Text("Tap the heart icon on any artist to add them here.") + ) + } else { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(artistsByLetter, id: \.0) { letter, letterArtists in + Text(letter) + .font(.headline) + .fontWeight(.bold) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + .id("section-\(letter)") + + LazyVGrid(columns: columns, spacing: 8) { + ForEach(letterArtists) { artist in + NavigationLink(value: artist) { + ArtistGridItem(artist: artist) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + } + .padding(.trailing, 28) + } + .scrollPosition(id: $scrollPosition) + .overlay(alignment: .trailing) { + AlphabetIndexView( + letters: allLetters, + itemHeight: 17, + onSelect: { letter in + let target = "section-\(letter)" + scrollPosition = target + proxy.scrollTo(target, anchor: .top) + } + ) + .padding(.vertical, 8) + .padding(.trailing, 2) + } + } + } + } + } +} + +// MARK: - Favorite Albums + +private struct FavoriteAlbumsSection: View { + @Environment(MAService.self) private var service + + private var favoriteAlbums: [MAAlbum] { + service.libraryManager.albums + .filter { service.libraryManager.isFavorite(uri: $0.uri) } + .sorted { $0.name.lowercased() < $1.name.lowercased() } + } + + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ] + + var body: some View { + Group { + if favoriteAlbums.isEmpty { + ContentUnavailableView( + "No Favorite Albums", + systemImage: "heart.slash", + description: Text("Tap the heart icon on any album to add it here.") + ) + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(favoriteAlbums) { album in + NavigationLink(value: album) { + AlbumGridItem(album: album) + } + .buttonStyle(.plain) + } + } + .padding() + } + } + } + } +} + +// MARK: - Favorite Radios + +private struct FavoriteRadiosSection: View { + @Environment(MAService.self) private var service + + @State private var allRadios: [MAMediaItem] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showError = false + @State private var selectedRadio: MAMediaItem? + + private var favoriteRadios: [MAMediaItem] { + allRadios.filter { service.libraryManager.isFavorite(uri: $0.uri) } + } + + private var players: [MAPlayer] { + Array(service.playerManager.players.values) + .filter { $0.available } + .sorted { $0.name < $1.name } + } + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if favoriteRadios.isEmpty { + ContentUnavailableView( + "No Favorite Radios", + systemImage: "heart.slash", + description: Text("Tap the heart icon on any radio station to add it here.") + ) + } else { + List(favoriteRadios) { radio in + Button { + handleRadioTap(radio) + } label: { + RadioRow(radio: radio) + } + .buttonStyle(.plain) + .listRowSeparator(.visible) + } + .listStyle(.plain) + } + } + .task { + await loadRadios() + } + .refreshable { + await loadRadios() + } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage { Text(errorMessage) } + } + .sheet(item: $selectedRadio) { radio in + EnhancedPlayerPickerView( + players: players, + onSelect: { player in + Task { await playRadio(radio, on: player) } + } + ) + } + } + + private func handleRadioTap(_ radio: MAMediaItem) { + if players.count == 1 { + Task { await playRadio(radio, on: players.first!) } + } else { + selectedRadio = radio + } + } + + private func loadRadios() async { + isLoading = true + errorMessage = nil + do { + allRadios = try await service.getRadios() + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoading = false + } + + private func playRadio(_ radio: MAMediaItem, on player: MAPlayer) async { + do { + try await service.playerManager.playMedia(playerId: player.playerId, uri: radio.uri) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} + +// MARK: - Favorite Podcasts + +private struct FavoritePodcastsSection: View { + @Environment(MAService.self) private var service + + private var favoritePodcasts: [MAPodcast] { + service.libraryManager.podcasts + .filter { service.libraryManager.isFavorite(uri: $0.uri) } + .sorted { $0.name.lowercased() < $1.name.lowercased() } + } + + var body: some View { + Group { + if favoritePodcasts.isEmpty { + ContentUnavailableView( + "No Favorite Podcasts", + systemImage: "heart.slash", + description: Text("Tap the heart icon on any podcast to add it here.") + ) + } else { + List(favoritePodcasts) { podcast in + NavigationLink(value: podcast) { + PodcastRow(podcast: podcast) + } + } + .listStyle(.plain) + } + } + } +} + +#Preview { + FavoritesView() + .environment(MAService()) +} diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift index 5bebbe0..7032629 100644 --- a/Mobile Music Assistant/ViewsLibraryLibraryView.swift +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -13,6 +13,7 @@ enum LibraryTab: String, CaseIterable { case artists = "Artists" case albums = "Albums" case playlists = "Playlists" + case podcasts = "Podcasts" case radio = "Radio" } @@ -35,6 +36,7 @@ struct LibraryView: View { case .artists: ArtistsView() case .albums: AlbumsView() case .playlists: PlaylistsView() + case .podcasts: PodcastsView() case .radio: RadiosView() } } diff --git a/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift b/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift new file mode 100644 index 0000000..485f76e --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift @@ -0,0 +1,354 @@ +// +// PodcastDetailView.swift +// Mobile Music Assistant +// +// Created by Sven Hanold on 08.04.26. +// + +import SwiftUI + +struct PodcastDetailView: View { + @Environment(MAService.self) private var service + let podcast: MAPodcast + + @State private var episodes: [MAMediaItem] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showError = false + @State private var showPlayerPicker = false + @State private var showEnqueuePicker = false + @State private var selectedEpisode: MAMediaItem? + @State private var kenBurnsScale: CGFloat = 1.0 + + private var players: [MAPlayer] { + Array(service.playerManager.players.values) + .filter { $0.available } + .sorted { $0.name < $1.name } + } + + private var nowPlayingURIs: Set { + Set(service.playerManager.playerQueues.values.compactMap { + $0.currentItem?.mediaItem?.uri + }) + } + + var body: some View { + ZStack { + backgroundArtwork + + ScrollView { + VStack(spacing: 24) { + podcastHeader + actionButtons + Divider() + .background(Color.white.opacity(0.3)) + + if isLoading { + ProgressView() + .padding() + .tint(.white) + } else if episodes.isEmpty { + Text("No episodes found") + .foregroundStyle(.white.opacity(0.7)) + .padding() + } else { + episodeList + } + } + } + } + .navigationTitle(podcast.name) + .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + FavoriteButton(uri: podcast.uri, size: 22, showInLight: true) + } + } + .task(id: podcast.uri) { + await loadEpisodes() + guard !Task.isCancelled else { return } + withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { + kenBurnsScale = 1.15 + } + } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage { + Text(errorMessage) + } + } + .sheet(isPresented: $showPlayerPicker) { + EnhancedPlayerPickerView( + players: players, + onSelect: { player in + Task { await playPodcast(on: player) } + } + ) + } + .sheet(isPresented: $showEnqueuePicker) { + EnhancedPlayerPickerView( + players: players, + title: "Add to Queue on...", + onSelect: { player in + Task { await enqueuePodcast(on: player) } + } + ) + } + .sheet(item: $selectedEpisode) { episode in + EnhancedPlayerPickerView( + players: players, + onSelect: { player in + Task { await playEpisode(episode, on: player) } + } + ) + } + } + + // MARK: - Background Artwork + + @ViewBuilder + private var backgroundArtwork: some View { + GeometryReader { geometry in + CachedAsyncImage(url: service.imageProxyURL(path: podcast.imageUrl, provider: podcast.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(kenBurnsScale) + .blur(radius: 50) + .overlay { + LinearGradient( + colors: [ + Color.black.opacity(0.7), + Color.black.opacity(0.5), + Color.black.opacity(0.7) + ], + startPoint: .top, + endPoint: .bottom + ) + } + .clipped() + } placeholder: { + Rectangle() + .fill( + LinearGradient( + colors: [Color(.systemGray6), Color(.systemGray5)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { Color.black.opacity(0.6) } + } + } + .ignoresSafeArea() + } + + // MARK: - Podcast Header + + @ViewBuilder + private var podcastHeader: some View { + VStack(spacing: 16) { + CachedAsyncImage(url: service.imageProxyURL(path: podcast.imageUrl, provider: podcast.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.1)) + .overlay { + Image(systemName: "mic.fill") + .font(.system(size: 60)) + .foregroundStyle(.white.opacity(0.5)) + } + } + .frame(width: 250, height: 250) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.5), radius: 20, y: 10) + + VStack(spacing: 8) { + if let publisher = podcast.publisher { + Text(publisher) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + } + + HStack(spacing: 6) { + ProviderBadge(uri: podcast.uri, metadata: podcast.metadata) + + if !episodes.isEmpty { + Text("\(episodes.count) episodes") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } else if let total = podcast.totalEpisodes { + Text("\(total) episodes") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } + } + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + } + .padding(.horizontal) + } + .padding(.top) + } + + // MARK: - Action Buttons + + @ViewBuilder + private var actionButtons: some View { + HStack(spacing: 12) { + Button { + if players.count == 1 { + Task { await playPodcast(on: players.first!) } + } else { + showPlayerPicker = true + } + } label: { + Label("Play", systemImage: "play.fill") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background( + LinearGradient( + colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)], + startPoint: .top, endPoint: .bottom + ) + ) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 10, y: 5) + } + + Button { + if players.count == 1 { + Task { await enqueuePodcast(on: players.first!) } + } else { + showEnqueuePicker = true + } + } label: { + Label("Add to Queue", systemImage: "text.badge.plus") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background( + LinearGradient( + colors: [Color.white.opacity(0.2), Color.white.opacity(0.1)], + startPoint: .top, endPoint: .bottom + ) + ) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 10, y: 5) + } + } + .padding(.horizontal) + .disabled(episodes.isEmpty || players.isEmpty) + .opacity((episodes.isEmpty || players.isEmpty) ? 0.5 : 1.0) + } + + // MARK: - Episode List + + @ViewBuilder + private var episodeList: some View { + LazyVStack(spacing: 0) { + ForEach(Array(episodes.enumerated()), id: \.element.id) { index, episode in + TrackRow(track: episode, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(episode.uri)) + .contentShape(Rectangle()) + .onTapGesture { + if players.count == 1 { + Task { await playEpisode(episode, on: players.first!) } + } else { + selectedEpisode = episode + } + } + + if index < episodes.count - 1 { + Divider() + .background(Color.white.opacity(0.2)) + .padding(.leading, 60) + } + } + } + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + .padding(.bottom, 24) + } + + // MARK: - Actions + + private func loadEpisodes() async { + isLoading = true + do { + episodes = try await service.libraryManager.getPodcastEpisodes(podcastUri: podcast.uri) + isLoading = false + } catch is CancellationError { + return + } catch { + errorMessage = error.localizedDescription + showError = true + isLoading = false + } + } + + private func playPodcast(on player: MAPlayer) async { + do { + try await service.playerManager.playMedia(playerId: player.playerId, uri: podcast.uri) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + private func enqueuePodcast(on player: MAPlayer) async { + do { + try await service.playerManager.enqueueMedia(playerId: player.playerId, uri: podcast.uri) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } + + private func playEpisode(_ episode: MAMediaItem, on player: MAPlayer) async { + do { + try await service.playerManager.playMedia(playerId: player.playerId, uri: episode.uri) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} + +#Preview { + NavigationStack { + PodcastDetailView( + podcast: MAPodcast( + uri: "library://podcast/1", + name: "Test Podcast", + publisher: "Test Publisher", + totalEpisodes: 42 + ) + ) + .environment(MAService()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryPodcastsView.swift b/Mobile Music Assistant/ViewsLibraryPodcastsView.swift new file mode 100644 index 0000000..2517905 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryPodcastsView.swift @@ -0,0 +1,131 @@ +// +// PodcastsView.swift +// Mobile Music Assistant +// +// Created by Sven Hanold on 08.04.26. +// + +import SwiftUI + +struct PodcastsView: View { + @Environment(MAService.self) private var service + @State private var errorMessage: String? + @State private var showError = false + + private var podcasts: [MAPodcast] { + service.libraryManager.podcasts + } + + private var isLoading: Bool { + service.libraryManager.isLoadingPodcasts + } + + var body: some View { + Group { + if isLoading && podcasts.isEmpty { + ProgressView() + } else if podcasts.isEmpty { + ContentUnavailableView( + "No Podcasts", + systemImage: "mic.fill", + description: Text("Your library doesn't contain any podcasts yet.") + ) + } else { + List { + ForEach(podcasts) { podcast in + NavigationLink(value: podcast) { + PodcastRow(podcast: podcast) + } + } + } + .listStyle(.plain) + } + } + .refreshable { + await loadPodcasts() + } + .task { + await loadPodcasts(refresh: !podcasts.isEmpty) + } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage { + Text(errorMessage) + } + } + } + + private func loadPodcasts(refresh: Bool = true) async { + do { + try await service.libraryManager.loadPodcasts(refresh: refresh) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} + +// MARK: - Podcast Row + +struct PodcastRow: View { + @Environment(MAService.self) private var service + let podcast: MAPodcast + + var body: some View { + HStack(spacing: 12) { + CachedAsyncImage(url: service.imageProxyURL(path: podcast.imageUrl, provider: podcast.imageProvider, size: 128)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.2)) + .overlay { + Image(systemName: "mic.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + } + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(alignment: .bottomTrailing) { + if service.libraryManager.isFavorite(uri: podcast.uri) { + Image(systemName: "heart.fill") + .font(.system(size: 10)) + .foregroundStyle(.red) + .padding(3) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(podcast.name) + .font(.headline) + .lineLimit(2) + + if let publisher = podcast.publisher { + Text(publisher) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let total = podcast.totalEpisodes { + Text("\(total) episodes") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + Spacer() + } + .padding(.vertical, 4) + } +} + +#Preview { + NavigationStack { + PodcastsView() + .environment(MAService()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryRadiosView.swift b/Mobile Music Assistant/ViewsLibraryRadiosView.swift index 393a8d2..5a26d1d 100644 --- a/Mobile Music Assistant/ViewsLibraryRadiosView.swift +++ b/Mobile Music Assistant/ViewsLibraryRadiosView.swift @@ -99,7 +99,7 @@ struct RadiosView: View { // MARK: - Radio Row -private struct RadioRow: View { +struct RadioRow: View { @Environment(MAService.self) private var service let radio: MAMediaItem diff --git a/Mobile Music Assistant/ViewsLibrarySearchView.swift b/Mobile Music Assistant/ViewsLibrarySearchView.swift index 082b8c2..75babbb 100644 --- a/Mobile Music Assistant/ViewsLibrarySearchView.swift +++ b/Mobile Music Assistant/ViewsLibrarySearchView.swift @@ -134,6 +134,21 @@ struct SearchView: View { } } + // Podcasts + if let podcasts = groupedResults[.podcast], !podcasts.isEmpty { + Section { + ForEach(podcasts) { item in + NavigationLink(value: convertToPodcast(item)) { + SearchResultRow(item: item) + } + } + } header: { + Label("Podcasts", systemImage: "mic.fill") + .font(.headline) + .foregroundStyle(.primary) + } + } + // Radios if let radios = groupedResults[.radio], !radios.isEmpty { Section { @@ -177,6 +192,15 @@ struct SearchView: View { ) } + private func convertToPodcast(_ item: MAMediaItem) -> MAPodcast { + return MAPodcast( + uri: item.uri, + name: item.name, + publisher: item.artists?.first?.name, + imageUrl: item.imageUrl + ) + } + private func convertToArtist(_ item: MAMediaItem) -> MAArtist { print("🔄 Converting to artist: \(item.name) (URI: \(item.uri))") @@ -326,6 +350,8 @@ struct SearchResultRow: View { case .artist: return "music.mic" case .playlist: return "music.note.list" case .radio: return "antenna.radiowaves.left.and.right" + case .podcast: return "mic.fill" + case .podcastEpisode: return "mic" default: return "questionmark" } } diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index ccdbca2..d6e7ffb 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -17,6 +17,10 @@ struct MainTabView: View { LibraryView() } + Tab("Favorites", systemImage: "heart.fill") { + FavoritesView() + } + Tab("Search", systemImage: "magnifyingglass") { NavigationStack { SearchView() diff --git a/ViewsPlayerQueueView.swift b/ViewsPlayerQueueView.swift index 1ac84cf..992ab74 100644 --- a/ViewsPlayerQueueView.swift +++ b/ViewsPlayerQueueView.swift @@ -12,6 +12,7 @@ struct PlayerQueueView: View { let playerId: String @State private var isLoading = false + @State private var showClearConfirm = false private var queueItems: [MAQueueItem] { service.playerManager.queues[playerId] ?? [] @@ -25,63 +26,36 @@ struct PlayerQueueView: View { service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId } + private var shuffleEnabled: Bool { + service.playerManager.playerQueues[playerId]?.shuffleEnabled ?? false + } + + private var repeatMode: RepeatMode { + service.playerManager.playerQueues[playerId]?.repeatMode ?? .off + } + var body: some View { - Group { + VStack(spacing: 0) { + // Control buttons + controlBar + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + // Queue list if isLoading && queueItems.isEmpty { - VStack { - Spacer() - ProgressView() - Spacer() - } + Spacer() + ProgressView() + Spacer() } else if queueItems.isEmpty { - VStack { - Spacer() - Text("Queue is empty") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } + Spacer() + Text("Queue is empty") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() } else { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in - let isCurrent = currentIndex == index - || item.queueItemId == currentItemId - - QueueItemRow(item: item, isCurrent: isCurrent) - .id(item.queueItemId) - .contentShape(Rectangle()) - .onTapGesture { - Task { - try? await service.playerManager.playIndex( - playerId: playerId, - index: index - ) - } - } - - if index < queueItems.count - 1 { - Divider() - .padding(.leading, 76) - } - } - } - .padding(.bottom, 8) - } - .onAppear { - if let id = currentItemId { - proxy.scrollTo(id, anchor: .center) - } - } - .onChange(of: currentItemId) { _, newId in - if let newId { - withAnimation { - proxy.scrollTo(newId, anchor: .center) - } - } - } - } + queueList } } .task { @@ -89,6 +63,128 @@ struct PlayerQueueView: View { try? await service.playerManager.loadQueue(playerId: playerId) isLoading = false } + .confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) { + Button("Clear Queue", role: .destructive) { + Task { try? await service.playerManager.clearQueue(playerId: playerId) } + } + Button("Cancel", role: .cancel) { } + } + } + + // MARK: - Control Bar + + @ViewBuilder + private var controlBar: some View { + HStack(spacing: 0) { + // Shuffle + Button { + Task { try? await service.playerManager.setShuffle(playerId: playerId, enabled: !shuffleEnabled) } + } label: { + VStack(spacing: 3) { + Image(systemName: "shuffle") + .font(.system(size: 20)) + .foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary) + Text("Shuffle") + .font(.caption2) + .foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + + // Repeat + Button { + let next: RepeatMode + switch repeatMode { + case .off: next = .all + case .all: next = .one + case .one: next = .off + } + Task { try? await service.playerManager.setRepeatMode(playerId: playerId, mode: next) } + } label: { + VStack(spacing: 3) { + Image(systemName: repeatMode == .one ? "repeat.1" : "repeat") + .font(.system(size: 20)) + .foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor) + Text(repeatMode == .off ? "Repeat" : (repeatMode == .one ? "Repeat 1" : "Repeat All")) + .font(.caption2) + .foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + + // Clear queue + Button { + showClearConfirm = true + } label: { + VStack(spacing: 3) { + Image(systemName: "xmark.bin") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + Text("Clear") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .disabled(queueItems.isEmpty) + .opacity(queueItems.isEmpty ? 0.4 : 1.0) + } + } + + // MARK: - Queue List + + @ViewBuilder + private var queueList: some View { + ScrollViewReader { proxy in + List { + ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in + let isCurrent = currentIndex == index || item.queueItemId == currentItemId + QueueItemRow(item: item, isCurrent: isCurrent) + .id(item.queueItemId) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .contentShape(Rectangle()) + .onTapGesture { + Task { + try? await service.playerManager.playIndex( + playerId: playerId, + index: index + ) + } + } + } + .onMove { source, destination in + guard let from = source.first else { return } + let posShift = destination > from ? destination - from - 1 : destination - from + guard posShift != 0 else { return } + let itemId = queueItems[from].queueItemId + Task { + try? await service.playerManager.moveQueueItem( + playerId: playerId, + queueItemId: itemId, + posShift: posShift + ) + } + } + } + .listStyle(.plain) + .environment(\.editMode, .constant(.active)) + .onAppear { + if let id = currentItemId { + proxy.scrollTo(id, anchor: .center) + } + } + .onChange(of: currentItemId) { _, newId in + if let newId { + withAnimation { + proxy.scrollTo(newId, anchor: .center) + } + } + } + } } }