Queue mgmt, Podcast support, Favorites section.

This commit is contained in:
2026-04-08 10:26:50 +02:00
parent f55b7e478b
commit d7e7bef83f
14 changed files with 1177 additions and 60 deletions
@@ -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;
@@ -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
}
}
}
+61 -4
View File
@@ -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()
+149 -53
View File
@@ -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)
}
}
}
}
}
}