Queue mgmt, Podcast support, Favorites section.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T: Encodable>(_ 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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>()
|
||||
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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ struct MainTabView: View {
|
||||
LibraryView()
|
||||
}
|
||||
|
||||
Tab("Favorites", systemImage: "heart.fill") {
|
||||
FavoritesView()
|
||||
}
|
||||
|
||||
Tab("Search", systemImage: "magnifyingglass") {
|
||||
NavigationStack {
|
||||
SearchView()
|
||||
|
||||
Reference in New Issue
Block a user