Queue mgmt, Podcast support, Favorites section.
This commit is contained in:
@@ -280,7 +280,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
|
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
|
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ enum MANavigationDestination: Hashable {
|
|||||||
case artist(MAArtist)
|
case artist(MAArtist)
|
||||||
case album(MAAlbum)
|
case album(MAAlbum)
|
||||||
case playlist(MAPlaylist)
|
case playlist(MAPlaylist)
|
||||||
|
case podcast(MAPodcast)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ViewModifier to apply all navigation destinations consistently
|
/// ViewModifier to apply all navigation destinations consistently
|
||||||
@@ -27,6 +28,9 @@ struct MANavigationDestinations: ViewModifier {
|
|||||||
.navigationDestination(for: MAPlaylist.self) { playlist in
|
.navigationDestination(for: MAPlaylist.self) { playlist in
|
||||||
PlaylistDetailView(playlist: playlist)
|
PlaylistDetailView(playlist: playlist)
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: MAPodcast.self) { podcast in
|
||||||
|
PodcastDetailView(podcast: podcast)
|
||||||
|
}
|
||||||
.navigationDestination(for: MANavigationDestination.self) { destination in
|
.navigationDestination(for: MANavigationDestination.self) { destination in
|
||||||
switch destination {
|
switch destination {
|
||||||
case .artist(let artist):
|
case .artist(let artist):
|
||||||
@@ -35,6 +39,8 @@ struct MANavigationDestinations: ViewModifier {
|
|||||||
AlbumDetailView(album: album)
|
AlbumDetailView(album: album)
|
||||||
case .playlist(let playlist):
|
case .playlist(let playlist):
|
||||||
PlaylistDetailView(playlist: 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
|
// MARK: - Player Queue State
|
||||||
|
|
||||||
/// Represents the state of a player's queue, including the currently playing item.
|
/// Represents the state of a player's queue, including the currently playing item.
|
||||||
@@ -440,6 +489,8 @@ struct MAPlayerQueue: Codable {
|
|||||||
let elapsedTime: Double?
|
let elapsedTime: Double?
|
||||||
/// Unix timestamp when `elapsedTime` was last set by the server.
|
/// Unix timestamp when `elapsedTime` was last set by the server.
|
||||||
let elapsedTimeLastUpdated: Double?
|
let elapsedTimeLastUpdated: Double?
|
||||||
|
let shuffleEnabled: Bool
|
||||||
|
let repeatMode: RepeatMode
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case queueId = "queue_id"
|
case queueId = "queue_id"
|
||||||
@@ -447,6 +498,8 @@ struct MAPlayerQueue: Codable {
|
|||||||
case currentIndex = "current_index"
|
case currentIndex = "current_index"
|
||||||
case elapsedTime = "elapsed_time"
|
case elapsedTime = "elapsed_time"
|
||||||
case elapsedTimeLastUpdated = "elapsed_time_last_updated"
|
case elapsedTimeLastUpdated = "elapsed_time_last_updated"
|
||||||
|
case shuffleEnabled = "shuffle_enabled"
|
||||||
|
case repeatMode = "repeat_mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -456,6 +509,8 @@ struct MAPlayerQueue: Codable {
|
|||||||
currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex)
|
currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex)
|
||||||
elapsedTime = try? c.decodeIfPresent(Double.self, forKey: .elapsedTime)
|
elapsedTime = try? c.decodeIfPresent(Double.self, forKey: .elapsedTime)
|
||||||
elapsedTimeLastUpdated = try? c.decodeIfPresent(Double.self, forKey: .elapsedTimeLastUpdated)
|
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 albumArtists: [MAArtist] = []
|
||||||
private(set) var albums: [MAAlbum] = []
|
private(set) var albums: [MAAlbum] = []
|
||||||
private(set) var playlists: [MAPlaylist] = []
|
private(set) var playlists: [MAPlaylist] = []
|
||||||
|
private(set) var podcasts: [MAPodcast] = []
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
private var artistsOffset = 0
|
private var artistsOffset = 0
|
||||||
@@ -37,6 +38,7 @@ final class MALibraryManager {
|
|||||||
private(set) var isLoadingAlbumArtists = false
|
private(set) var isLoadingAlbumArtists = false
|
||||||
private(set) var isLoadingAlbums = false
|
private(set) var isLoadingAlbums = false
|
||||||
private(set) var isLoadingPlaylists = false
|
private(set) var isLoadingPlaylists = false
|
||||||
|
private(set) var isLoadingPodcasts = false
|
||||||
|
|
||||||
/// URIs currently marked as favorites — source of truth for UI.
|
/// URIs currently marked as favorites — source of truth for UI.
|
||||||
/// Populated from decoded model data, then mutated optimistically on toggle.
|
/// 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 lastAlbumArtistsRefresh: Date?
|
||||||
private(set) var lastAlbumsRefresh: Date?
|
private(set) var lastAlbumsRefresh: Date?
|
||||||
private(set) var lastPlaylistsRefresh: Date?
|
private(set) var lastPlaylistsRefresh: Date?
|
||||||
|
private(set) var lastPodcastsRefresh: Date?
|
||||||
|
|
||||||
// MARK: - Disk Cache
|
// MARK: - Disk Cache
|
||||||
|
|
||||||
@@ -109,17 +112,23 @@ final class MALibraryManager {
|
|||||||
playlists = cached
|
playlists = cached
|
||||||
logger.info("Loaded \(cached.count) playlists from disk cache")
|
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
|
// Seed favorite URIs from cached data
|
||||||
for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) }
|
for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) }
|
||||||
for artist in albumArtists 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 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
|
let ud = UserDefaults.standard
|
||||||
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
||||||
lastAlbumArtistsRefresh = ud.object(forKey: "lib.lastAlbumArtistsRefresh") as? Date
|
lastAlbumArtistsRefresh = ud.object(forKey: "lib.lastAlbumArtistsRefresh") as? Date
|
||||||
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
||||||
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") 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) {
|
private func save<T: Encodable>(_ value: T, _ filename: String) {
|
||||||
@@ -338,6 +347,32 @@ final class MALibraryManager {
|
|||||||
logger.info("Loaded \(loaded.count) playlists")
|
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)
|
// MARK: - Artist Albums & Tracks (not cached — fetched on demand)
|
||||||
|
|
||||||
func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] {
|
func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] {
|
||||||
|
|||||||
@@ -273,4 +273,31 @@ final class MAPlayerManager {
|
|||||||
}
|
}
|
||||||
try await service.playIndex(playerId: playerId, index: index)
|
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,18 +231,48 @@ final class MAService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move queue item
|
/// Move queue item
|
||||||
func moveQueueItem(playerId: String, fromIndex: Int, toIndex: Int) async throws {
|
func moveQueueItem(playerId: String, queueItemId: String, posShift: Int) async throws {
|
||||||
logger.debug("Moving queue item from \(fromIndex) to \(toIndex)")
|
logger.debug("Moving queue item \(queueItemId) by \(posShift) positions")
|
||||||
_ = try await webSocketClient.sendCommand(
|
_ = try await webSocketClient.sendCommand(
|
||||||
"player_queues/move_item",
|
"player_queues/move_item",
|
||||||
args: [
|
args: [
|
||||||
"queue_id": playerId,
|
"queue_id": playerId,
|
||||||
"queue_item_id": fromIndex,
|
"queue_item_id": queueItemId,
|
||||||
"pos_shift": toIndex - fromIndex
|
"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
|
// MARK: - Library
|
||||||
|
|
||||||
/// Get artists (with pagination)
|
/// Get artists (with pagination)
|
||||||
@@ -303,6 +333,31 @@ final class MAService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// Get full artist details (includes biography in metadata.description).
|
||||||
/// Results are cached in memory once biography data is available, so repeated
|
/// Results are cached in memory once biography data is available, so repeated
|
||||||
/// navigation is instant. Skips the cache if the previous fetch had no biography,
|
/// navigation is instant. Skips the cache if the previous fetch had no biography,
|
||||||
@@ -460,6 +515,7 @@ final class MAService {
|
|||||||
let artists: [MAMediaItem]?
|
let artists: [MAMediaItem]?
|
||||||
let playlists: [MAMediaItem]?
|
let playlists: [MAMediaItem]?
|
||||||
let radios: [MAMediaItem]?
|
let radios: [MAMediaItem]?
|
||||||
|
let podcasts: [MAMediaItem]?
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchResults = try result.decode(as: SearchResults.self)
|
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 artists = searchResults.artists { allItems.append(contentsOf: artists) }
|
||||||
if let playlists = searchResults.playlists { allItems.append(contentsOf: playlists) }
|
if let playlists = searchResults.playlists { allItems.append(contentsOf: playlists) }
|
||||||
if let radios = searchResults.radios { allItems.append(contentsOf: radios) }
|
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))")
|
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
|
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 artists = "Artists"
|
||||||
case albums = "Albums"
|
case albums = "Albums"
|
||||||
case playlists = "Playlists"
|
case playlists = "Playlists"
|
||||||
|
case podcasts = "Podcasts"
|
||||||
case radio = "Radio"
|
case radio = "Radio"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ struct LibraryView: View {
|
|||||||
case .artists: ArtistsView()
|
case .artists: ArtistsView()
|
||||||
case .albums: AlbumsView()
|
case .albums: AlbumsView()
|
||||||
case .playlists: PlaylistsView()
|
case .playlists: PlaylistsView()
|
||||||
|
case .podcasts: PodcastsView()
|
||||||
case .radio: RadiosView()
|
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
|
// MARK: - Radio Row
|
||||||
|
|
||||||
private struct RadioRow: View {
|
struct RadioRow: View {
|
||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
let radio: MAMediaItem
|
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
|
// Radios
|
||||||
if let radios = groupedResults[.radio], !radios.isEmpty {
|
if let radios = groupedResults[.radio], !radios.isEmpty {
|
||||||
Section {
|
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 {
|
private func convertToArtist(_ item: MAMediaItem) -> MAArtist {
|
||||||
print("🔄 Converting to artist: \(item.name) (URI: \(item.uri))")
|
print("🔄 Converting to artist: \(item.name) (URI: \(item.uri))")
|
||||||
|
|
||||||
@@ -326,6 +350,8 @@ struct SearchResultRow: View {
|
|||||||
case .artist: return "music.mic"
|
case .artist: return "music.mic"
|
||||||
case .playlist: return "music.note.list"
|
case .playlist: return "music.note.list"
|
||||||
case .radio: return "antenna.radiowaves.left.and.right"
|
case .radio: return "antenna.radiowaves.left.and.right"
|
||||||
|
case .podcast: return "mic.fill"
|
||||||
|
case .podcastEpisode: return "mic"
|
||||||
default: return "questionmark"
|
default: return "questionmark"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ struct MainTabView: View {
|
|||||||
LibraryView()
|
LibraryView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Tab("Favorites", systemImage: "heart.fill") {
|
||||||
|
FavoritesView()
|
||||||
|
}
|
||||||
|
|
||||||
Tab("Search", systemImage: "magnifyingglass") {
|
Tab("Search", systemImage: "magnifyingglass") {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
SearchView()
|
SearchView()
|
||||||
|
|||||||
+120
-24
@@ -12,6 +12,7 @@ struct PlayerQueueView: View {
|
|||||||
let playerId: String
|
let playerId: String
|
||||||
|
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@State private var showClearConfirm = false
|
||||||
|
|
||||||
private var queueItems: [MAQueueItem] {
|
private var queueItems: [MAQueueItem] {
|
||||||
service.playerManager.queues[playerId] ?? []
|
service.playerManager.queues[playerId] ?? []
|
||||||
@@ -25,32 +26,126 @@ struct PlayerQueueView: View {
|
|||||||
service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId
|
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 {
|
var body: some View {
|
||||||
Group {
|
VStack(spacing: 0) {
|
||||||
|
// Control buttons
|
||||||
|
controlBar
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Queue list
|
||||||
if isLoading && queueItems.isEmpty {
|
if isLoading && queueItems.isEmpty {
|
||||||
VStack {
|
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
|
||||||
} else if queueItems.isEmpty {
|
} else if queueItems.isEmpty {
|
||||||
VStack {
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Queue is empty")
|
Text("Queue is empty")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ScrollViewReader { proxy in
|
queueList
|
||||||
ScrollView {
|
}
|
||||||
LazyVStack(spacing: 0) {
|
}
|
||||||
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
|
.task {
|
||||||
let isCurrent = currentIndex == index
|
isLoading = true
|
||||||
|| item.queueItemId == currentItemId
|
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)
|
QueueItemRow(item: item, isCurrent: isCurrent)
|
||||||
.id(item.queueItemId)
|
.id(item.queueItemId)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task {
|
Task {
|
||||||
@@ -60,15 +155,23 @@ struct PlayerQueueView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if index < queueItems.count - 1 {
|
.onMove { source, destination in
|
||||||
Divider()
|
guard let from = source.first else { return }
|
||||||
.padding(.leading, 76)
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 8)
|
.listStyle(.plain)
|
||||||
}
|
.environment(\.editMode, .constant(.active))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if let id = currentItemId {
|
if let id = currentItemId {
|
||||||
proxy.scrollTo(id, anchor: .center)
|
proxy.scrollTo(id, anchor: .center)
|
||||||
@@ -83,13 +186,6 @@ struct PlayerQueueView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.task {
|
|
||||||
isLoading = true
|
|
||||||
try? await service.playerManager.loadQueue(playerId: playerId)
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Queue Item Row
|
// MARK: - Queue Item Row
|
||||||
|
|||||||
Reference in New Issue
Block a user