Queue, Favorites, Providers, Now playing
This commit is contained in:
@@ -7,10 +7,14 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */; };
|
||||
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */; };
|
||||
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerQueueView.swift; sourceTree = "<group>"; };
|
||||
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsComponentsProviderBadge.swift; sourceTree = "<group>"; };
|
||||
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = "<group>"; };
|
||||
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -40,6 +44,8 @@
|
||||
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
|
||||
26ED92622F759EEA0025419D /* Products */,
|
||||
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
|
||||
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
|
||||
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -125,6 +131,8 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */,
|
||||
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */,
|
||||
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -272,7 +280,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -308,7 +316,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -184,20 +184,22 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
|
||||
let album: MAAlbum?
|
||||
let metadata: MediaItemMetadata?
|
||||
let duration: Int?
|
||||
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, duration, artists, album, metadata
|
||||
case uri, name, duration, artists, album, metadata, favorite
|
||||
case mediaType = "media_type"
|
||||
case image // Direct image field from search results
|
||||
}
|
||||
|
||||
init(uri: String, name: String, mediaType: MediaType? = nil, artists: [MAArtist]? = nil, album: MAAlbum? = nil, imageUrl: String? = nil, duration: Int? = nil) {
|
||||
init(uri: String, name: String, mediaType: MediaType? = nil, artists: [MAArtist]? = nil, album: MAAlbum? = nil, imageUrl: String? = nil, duration: Int? = nil, favorite: Bool = false) {
|
||||
self.uri = uri; self.name = name; self.mediaType = mediaType
|
||||
self.artists = artists; self.album = album; self.duration = duration
|
||||
self.favorite = favorite
|
||||
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) }
|
||||
}
|
||||
|
||||
@@ -205,6 +207,7 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
uri = try c.decode(String.self, forKey: .uri)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
|
||||
|
||||
// Media type is critical - decode it first
|
||||
let mediaTypeString = try? c.decodeIfPresent(String.self, forKey: .mediaType)
|
||||
@@ -248,6 +251,7 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
|
||||
try c.encodeIfPresent(album, forKey: .album)
|
||||
try c.encodeIfPresent(duration, forKey: .duration)
|
||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
||||
try c.encode(favorite, forKey: .favorite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,28 +273,31 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
let metadata: MediaItemMetadata?
|
||||
let sortName: String?
|
||||
let musicbrainzId: String?
|
||||
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, metadata
|
||||
case uri, name, metadata, favorite
|
||||
case sortName = "sort_name"
|
||||
case musicbrainzId = "musicbrainz_id"
|
||||
case image // Direct image field
|
||||
}
|
||||
|
||||
init(uri: String, name: String, imageUrl: String? = nil, imageProvider: String? = nil, sortName: String? = nil, musicbrainzId: String? = nil) {
|
||||
init(uri: String, name: String, imageUrl: String? = nil, imageProvider: String? = nil, sortName: String? = nil, musicbrainzId: String? = nil, favorite: Bool = false) {
|
||||
self.uri = uri; self.name = name
|
||||
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
|
||||
self.sortName = sortName; self.musicbrainzId = musicbrainzId
|
||||
self.favorite = favorite
|
||||
}
|
||||
|
||||
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)
|
||||
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
|
||||
sortName = try? c.decodeIfPresent(String.self, forKey: .sortName)
|
||||
musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId)
|
||||
|
||||
@@ -314,6 +321,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
|
||||
try c.encodeIfPresent(sortName, forKey: .sortName)
|
||||
try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId)
|
||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
||||
try c.encode(favorite, forKey: .favorite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,18 +331,20 @@ struct MAAlbum: Codable, Identifiable, Hashable {
|
||||
let artists: [MAArtist]?
|
||||
let metadata: MediaItemMetadata?
|
||||
let year: Int?
|
||||
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, artists, metadata, year
|
||||
case uri, name, artists, metadata, year, favorite
|
||||
case image // Direct image field
|
||||
}
|
||||
|
||||
init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil) {
|
||||
init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil, favorite: Bool = false) {
|
||||
self.uri = uri; self.name = name; self.artists = artists; self.year = year
|
||||
self.favorite = favorite
|
||||
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
|
||||
}
|
||||
|
||||
@@ -342,6 +352,7 @@ struct MAAlbum: Codable, Identifiable, Hashable {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
uri = try c.decode(String.self, forKey: .uri)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
|
||||
artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists)
|
||||
year = try? c.decodeIfPresent(Int.self, forKey: .year)
|
||||
|
||||
@@ -365,6 +376,7 @@ struct MAAlbum: Codable, Identifiable, Hashable {
|
||||
try c.encodeIfPresent(artists, forKey: .artists)
|
||||
try c.encodeIfPresent(year, forKey: .year)
|
||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
||||
try c.encode(favorite, forKey: .favorite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,11 +419,17 @@ struct MAPlayerQueue: Codable {
|
||||
let queueId: String
|
||||
let currentItem: MAQueueItem?
|
||||
let currentIndex: Int?
|
||||
/// Seconds elapsed in current track (at the time of last update).
|
||||
let elapsedTime: Double?
|
||||
/// Unix timestamp when `elapsedTime` was last set by the server.
|
||||
let elapsedTimeLastUpdated: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case queueId = "queue_id"
|
||||
case currentItem = "current_item"
|
||||
case currentIndex = "current_index"
|
||||
case elapsedTime = "elapsed_time"
|
||||
case elapsedTimeLastUpdated = "elapsed_time_last_updated"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -419,6 +437,8 @@ struct MAPlayerQueue: Codable {
|
||||
queueId = try c.decode(String.self, forKey: .queueId)
|
||||
currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem)
|
||||
currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex)
|
||||
elapsedTime = try? c.decodeIfPresent(Double.self, forKey: .elapsedTime)
|
||||
elapsedTimeLastUpdated = try? c.decodeIfPresent(Double.self, forKey: .elapsedTimeLastUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ final class MALibraryManager {
|
||||
private(set) var isLoadingAlbums = false
|
||||
private(set) var isLoadingPlaylists = false
|
||||
|
||||
/// URIs currently marked as favorites — source of truth for UI.
|
||||
/// Populated from decoded model data, then mutated optimistically on toggle.
|
||||
private(set) var favoriteURIs: Set<String> = []
|
||||
|
||||
// Last refresh timestamps (persisted in UserDefaults)
|
||||
private(set) var lastArtistsRefresh: Date?
|
||||
private(set) var lastAlbumsRefresh: Date?
|
||||
@@ -42,7 +46,7 @@ final class MALibraryManager {
|
||||
// MARK: - Disk Cache
|
||||
|
||||
/// Increment this whenever the model format changes to invalidate stale caches.
|
||||
private static let cacheVersion = 2
|
||||
private static let cacheVersion = 3
|
||||
|
||||
private let cacheDirectory: URL = {
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
@@ -96,6 +100,10 @@ final class MALibraryManager {
|
||||
logger.info("Loaded \(cached.count) playlists from disk cache")
|
||||
}
|
||||
|
||||
// Seed favorite URIs from cached data
|
||||
for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) }
|
||||
for album in albums where album.favorite { favoriteURIs.insert(album.uri) }
|
||||
|
||||
let ud = UserDefaults.standard
|
||||
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
||||
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
||||
@@ -152,10 +160,13 @@ final class MALibraryManager {
|
||||
if refresh {
|
||||
artists = newArtists
|
||||
artistsOffset = newArtists.count
|
||||
// Reset and repopulate artist favorites on refresh
|
||||
for a in artists where a.favorite { favoriteURIs.insert(a.uri) }
|
||||
} else {
|
||||
artists.append(contentsOf: newArtists)
|
||||
artistsOffset += newArtists.count
|
||||
}
|
||||
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
||||
hasMoreArtists = newArtists.count >= pageSize
|
||||
|
||||
if refresh || artistsOffset <= pageSize {
|
||||
@@ -199,10 +210,12 @@ final class MALibraryManager {
|
||||
if refresh {
|
||||
albums = newAlbums
|
||||
albumsOffset = newAlbums.count
|
||||
for a in albums where a.favorite { favoriteURIs.insert(a.uri) }
|
||||
} else {
|
||||
albums.append(contentsOf: newAlbums)
|
||||
albumsOffset += newAlbums.count
|
||||
}
|
||||
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
|
||||
hasMoreAlbums = newAlbums.count >= pageSize
|
||||
|
||||
if refresh || albumsOffset <= pageSize {
|
||||
@@ -255,6 +268,53 @@ final class MALibraryManager {
|
||||
return try await service.getAlbumTracks(albumUri: albumUri)
|
||||
}
|
||||
|
||||
func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] {
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
logger.info("Loading tracks for playlist \(playlistUri)")
|
||||
return try await service.getPlaylistTracks(playlistUri: playlistUri)
|
||||
}
|
||||
|
||||
// MARK: - Favorites
|
||||
|
||||
/// Returns whether the given URI is currently favorited.
|
||||
func isFavorite(uri: String) -> Bool {
|
||||
favoriteURIs.contains(uri)
|
||||
}
|
||||
|
||||
/// Toggle favorite for any item. Performs optimistic update, then calls server.
|
||||
/// Reverts on failure.
|
||||
func toggleFavorite(uri: String, currentlyFavorite: Bool) async {
|
||||
// Optimistic update
|
||||
if currentlyFavorite {
|
||||
favoriteURIs.remove(uri)
|
||||
} else {
|
||||
favoriteURIs.insert(uri)
|
||||
}
|
||||
|
||||
// Call server
|
||||
guard let service else {
|
||||
// Revert if no service
|
||||
if currentlyFavorite { favoriteURIs.insert(uri) } else { favoriteURIs.remove(uri) }
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if currentlyFavorite {
|
||||
try await service.removeFavorite(uri: uri)
|
||||
} else {
|
||||
try await service.addFavorite(uri: uri)
|
||||
}
|
||||
} catch {
|
||||
// Revert on failure
|
||||
if currentlyFavorite {
|
||||
favoriteURIs.insert(uri)
|
||||
} else {
|
||||
favoriteURIs.remove(uri)
|
||||
}
|
||||
logger.error("Failed to toggle favorite for \(uri): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||
|
||||
@@ -119,8 +119,25 @@ final class MAPlayerManager {
|
||||
}
|
||||
|
||||
private func handleQueueItemsUpdated(_ event: MAEvent) async {
|
||||
// Similar to queue_updated
|
||||
// Update queue state (current item, current index)
|
||||
await handleQueueUpdated(event)
|
||||
|
||||
// Reload the items list if we already have it cached (i.e., queue view was opened)
|
||||
guard let data = event.data,
|
||||
let dict = data.value as? [String: Any],
|
||||
let queueId = dict["queue_id"] as? String,
|
||||
queues[queueId] != nil,
|
||||
let service else { return }
|
||||
|
||||
do {
|
||||
let items = try await service.getQueue(playerId: queueId)
|
||||
await MainActor.run {
|
||||
queues[queueId] = items
|
||||
logger.debug("Reloaded queue items for player \(queueId): \(items.count) items")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to reload queue items: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
@@ -235,6 +252,13 @@ final class MAPlayerManager {
|
||||
}
|
||||
try await service.playMedia(playerId: playerId, uri: uri)
|
||||
}
|
||||
|
||||
func enqueueMedia(playerId: String, uri: String) async throws {
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
try await service.enqueueMedia(playerId: playerId, uri: uri)
|
||||
}
|
||||
|
||||
func playIndex(playerId: String, index: Int) async throws {
|
||||
guard let service else {
|
||||
|
||||
@@ -192,6 +192,19 @@ final class MAService {
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Add media item to the end of a player's queue
|
||||
func enqueueMedia(playerId: String, uri: String) async throws {
|
||||
logger.debug("Enqueuing media \(uri) on player \(playerId)")
|
||||
_ = try await webSocketClient.sendCommand(
|
||||
"player_queues/play_media",
|
||||
args: [
|
||||
"queue_id": playerId,
|
||||
"media": [uri],
|
||||
"option": "add"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Play from queue index
|
||||
func playIndex(playerId: String, index: Int) async throws {
|
||||
@@ -328,6 +341,22 @@ final class MAService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Get playlist tracks
|
||||
func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] {
|
||||
logger.debug("Fetching tracks for playlist \(playlistUri)")
|
||||
guard let (provider, itemId) = parseMAUri(playlistUri) else {
|
||||
throw MAWebSocketClient.ClientError.serverError("Invalid playlist URI: \(playlistUri)")
|
||||
}
|
||||
return try await webSocketClient.sendCommand(
|
||||
"music/playlists/playlist_tracks",
|
||||
args: [
|
||||
"item_id": itemId,
|
||||
"provider_instance_id_or_domain": provider
|
||||
],
|
||||
resultType: [MAMediaItem].self
|
||||
)
|
||||
}
|
||||
|
||||
/// Get album tracks
|
||||
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
||||
logger.debug("Fetching tracks for album \(albumUri)")
|
||||
@@ -344,6 +373,36 @@ final class MAService {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Favorites
|
||||
|
||||
/// Add an item to favorites by URI
|
||||
func addFavorite(uri: String) async throws {
|
||||
logger.debug("Adding favorite: \(uri)")
|
||||
_ = try await webSocketClient.sendCommand(
|
||||
"music/favorites/add_item",
|
||||
args: ["item": uri]
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove an item from favorites.
|
||||
/// The server expects media_type (e.g. "artist") and library_item_id (e.g. "123").
|
||||
/// These are extracted from the URI format: library://artist/123
|
||||
func removeFavorite(uri: String) async throws {
|
||||
logger.debug("Removing favorite: \(uri)")
|
||||
guard let url = URL(string: uri),
|
||||
let host = url.host else {
|
||||
throw MAWebSocketClient.ClientError.serverError("Invalid URI for favorite removal: \(uri)")
|
||||
}
|
||||
let itemId = url.path.isEmpty ? host : String(url.path.dropFirst())
|
||||
_ = try await webSocketClient.sendCommand(
|
||||
"music/favorites/remove_item",
|
||||
args: [
|
||||
"media_type": host,
|
||||
"library_item_id": itemId
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// Search library
|
||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||
logger.debug("🔍 Searching for '\(query)'")
|
||||
|
||||
@@ -12,8 +12,15 @@ struct EnhancedPlayerPickerView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
|
||||
let players: [MAPlayer]
|
||||
let title: String
|
||||
let onSelect: (MAPlayer) -> Void
|
||||
|
||||
init(players: [MAPlayer], title: String = "Play on...", onSelect: @escaping (MAPlayer) -> Void) {
|
||||
self.players = players
|
||||
self.title = title
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
/// IDs of all players that are sync members (not the leader)
|
||||
private var syncedMemberIds: Set<String> {
|
||||
Set(players.flatMap { $0.groupChilds })
|
||||
@@ -52,7 +59,7 @@ struct EnhancedPlayerPickerView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.navigationTitle("Play on...")
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -16,6 +16,7 @@ struct AlbumDetailView: View {
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
@State private var showPlayerPicker = false
|
||||
@State private var showEnqueuePicker = false
|
||||
@State private var selectedPlayer: MAPlayer?
|
||||
@State private var kenBurnsScale: CGFloat = 1.0
|
||||
@State private var completeAlbum: MAAlbum?
|
||||
@@ -28,6 +29,13 @@ struct AlbumDetailView: View {
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
/// URIs of tracks currently playing on any player.
|
||||
private var nowPlayingURIs: Set<String> {
|
||||
Set(service.playerManager.playerQueues.values.compactMap {
|
||||
$0.currentItem?.mediaItem?.uri
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Blurred Background with Ken Burns Effect
|
||||
@@ -39,8 +47,8 @@ struct AlbumDetailView: View {
|
||||
// Album Header
|
||||
albumHeader
|
||||
|
||||
// Play Button
|
||||
playButton
|
||||
// Action Buttons
|
||||
actionButtons
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.3))
|
||||
@@ -90,6 +98,11 @@ struct AlbumDetailView: View {
|
||||
.navigationTitle(album.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
async let tracksLoad: () = loadTracks()
|
||||
async let detailLoad: () = loadAlbumDetail()
|
||||
@@ -113,6 +126,15 @@ struct AlbumDetailView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showEnqueuePicker) {
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
title: "Add to Queue on...",
|
||||
onSelect: { player in
|
||||
Task { await enqueueAlbum(on: player) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Artwork
|
||||
@@ -191,7 +213,9 @@ struct AlbumDetailView: View {
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: album.uri, imageProvider: album.imageProvider)
|
||||
|
||||
if let year = album.year {
|
||||
Text(String(year))
|
||||
.font(.subheadline)
|
||||
@@ -213,38 +237,67 @@ struct AlbumDetailView: View {
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
// MARK: - Play Button
|
||||
// MARK: - Action Buttons
|
||||
|
||||
@ViewBuilder
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
if players.count == 1 {
|
||||
selectedPlayer = players.first
|
||||
Task {
|
||||
await playAlbum(on: players.first!)
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Play Album
|
||||
Button {
|
||||
if players.count == 1 {
|
||||
selectedPlayer = players.first
|
||||
Task { await playAlbum(on: players.first!) }
|
||||
} else {
|
||||
showPlayerPicker = true
|
||||
}
|
||||
} else {
|
||||
showPlayerPicker = true
|
||||
}
|
||||
} label: {
|
||||
Label("Play Album", 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
|
||||
} 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)
|
||||
.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)
|
||||
}
|
||||
|
||||
// Add to Queue
|
||||
Button {
|
||||
if players.count == 1 {
|
||||
Task { await enqueueAlbum(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(tracks.isEmpty || players.isEmpty)
|
||||
@@ -257,7 +310,7 @@ struct AlbumDetailView: View {
|
||||
private var trackList: some View {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
|
||||
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true)
|
||||
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if players.count == 1 {
|
||||
@@ -371,6 +424,18 @@ struct AlbumDetailView: View {
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueueAlbum(on player: MAPlayer) async {
|
||||
do {
|
||||
try await service.playerManager.enqueueMedia(
|
||||
playerId: player.playerId,
|
||||
uri: album.uri
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
|
||||
do {
|
||||
@@ -388,17 +453,28 @@ struct AlbumDetailView: View {
|
||||
// MARK: - Track Row
|
||||
|
||||
struct TrackRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let track: MAMediaItem
|
||||
let trackNumber: Int
|
||||
var useLightTheme: Bool = false
|
||||
var isPlaying: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Track Number
|
||||
Text("\(trackNumber)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
|
||||
.frame(width: 30, alignment: .trailing)
|
||||
// Track Number / Now Playing indicator
|
||||
Group {
|
||||
if isPlaying {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
} else {
|
||||
Text("\(trackNumber)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 30, alignment: .trailing)
|
||||
|
||||
// Track Info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -416,6 +492,9 @@ struct TrackRow: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Favorite
|
||||
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme)
|
||||
|
||||
// Duration
|
||||
if let duration = track.duration {
|
||||
|
||||
@@ -115,6 +115,15 @@ struct AlbumGridItem: View {
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if service.libraryManager.isFavorite(uri: album.uri) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Album Info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
||||
@@ -56,6 +56,11 @@ struct ArtistDetailView: View {
|
||||
.navigationTitle(artist.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
async let albumsLoad: () = loadAlbums()
|
||||
async let detailLoad: () = loadArtistDetail()
|
||||
@@ -138,12 +143,16 @@ struct ArtistDetailView: View {
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
|
||||
|
||||
if !albums.isEmpty {
|
||||
Text("\(albums.count) albums")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider)
|
||||
|
||||
if !albums.isEmpty {
|
||||
Text("\(albums.count) albums")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
@@ -213,6 +213,15 @@ struct ArtistGridItem: View {
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipShape(Circle())
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if service.libraryManager.isFavorite(uri: artist.uri) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Text(artist.name)
|
||||
.font(.caption)
|
||||
|
||||
@@ -25,6 +25,12 @@ struct PlaylistDetailView: View {
|
||||
.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 {
|
||||
// Blurred Background with Ken Burns Effect
|
||||
@@ -268,7 +274,7 @@ struct PlaylistDetailView: View {
|
||||
private var trackList: some View {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
|
||||
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true)
|
||||
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if players.count == 1 {
|
||||
|
||||
@@ -17,20 +17,30 @@ struct PlayerNowPlayingView: View {
|
||||
@State private var isMuted = false
|
||||
@State private var preMuteVolume: Double = 50
|
||||
@State private var showQueue = false
|
||||
@State private var displayedElapsed: Double = 0
|
||||
@State private var progressTimer: Timer?
|
||||
|
||||
// Auto-tracks live updates via @Observable
|
||||
private var player: MAPlayer? {
|
||||
service.playerManager.players[playerId]
|
||||
}
|
||||
|
||||
private var playerQueue: MAPlayerQueue? {
|
||||
service.playerManager.playerQueues[playerId]
|
||||
}
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[playerId]?.currentItem
|
||||
playerQueue?.currentItem
|
||||
}
|
||||
|
||||
private var mediaItem: MAMediaItem? {
|
||||
currentItem?.mediaItem
|
||||
}
|
||||
|
||||
private var trackDuration: Double {
|
||||
Double(currentItem?.duration ?? 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
@@ -47,6 +57,11 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Progress bar (only in player mode, not queue)
|
||||
if !showQueue {
|
||||
progressView
|
||||
}
|
||||
|
||||
// Transport + volume (always visible)
|
||||
controlsView
|
||||
}
|
||||
@@ -80,6 +95,25 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
.onAppear {
|
||||
localVolume = Double(player?.volume ?? 50)
|
||||
syncElapsedTime()
|
||||
startProgressTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = nil
|
||||
}
|
||||
.onChange(of: playerQueue?.elapsedTime) {
|
||||
syncElapsedTime()
|
||||
}
|
||||
.onChange(of: player?.state) {
|
||||
// Restart timer when play state changes
|
||||
syncElapsedTime()
|
||||
if player?.state == .playing {
|
||||
startProgressTimer()
|
||||
} else {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = nil
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
@@ -129,12 +163,12 @@ struct PlayerNowPlayingView: View {
|
||||
Image(systemName: "list.bullet")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(showQueue ? .accent : .primary)
|
||||
.foregroundStyle(showQueue ? Color.accentColor : .primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
if let uri = mediaItem?.uri {
|
||||
if !showQueue, let uri = mediaItem?.uri {
|
||||
FavoriteButton(uri: uri, size: 22)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
@@ -281,6 +315,86 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress View
|
||||
|
||||
@ViewBuilder
|
||||
private var progressView: some View {
|
||||
let duration = trackDuration
|
||||
let progress = duration > 0 ? min(displayedElapsed / duration, 1.0) : 0
|
||||
|
||||
VStack(spacing: 4) {
|
||||
// Progress bar
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.primary.opacity(0.15))
|
||||
.frame(height: 4)
|
||||
|
||||
Capsule()
|
||||
.fill(.primary)
|
||||
.frame(width: geo.size.width * progress, height: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
|
||||
// Time labels
|
||||
HStack {
|
||||
Text(formatTime(displayedElapsed))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Spacer()
|
||||
Text("-\(formatTime(max(0, duration - displayedElapsed)))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// MARK: - Progress Helpers
|
||||
|
||||
private func syncElapsedTime() {
|
||||
guard let queue = playerQueue,
|
||||
let elapsed = queue.elapsedTime else {
|
||||
displayedElapsed = 0
|
||||
return
|
||||
}
|
||||
|
||||
if player?.state == .playing,
|
||||
let lastUpdated = queue.elapsedTimeLastUpdated {
|
||||
let serverNow = Date().timeIntervalSince1970
|
||||
let delta = serverNow - lastUpdated
|
||||
displayedElapsed = elapsed + max(0, delta)
|
||||
} else {
|
||||
displayedElapsed = elapsed
|
||||
}
|
||||
}
|
||||
|
||||
private func startProgressTimer() {
|
||||
progressTimer?.invalidate()
|
||||
guard player?.state == .playing else { return }
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
Task { @MainActor in
|
||||
guard player?.state == .playing else { return }
|
||||
displayedElapsed += 0.5
|
||||
// Clamp to duration
|
||||
if trackDuration > 0 {
|
||||
displayedElapsed = min(displayedElapsed, trackDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
let totalSeconds = Int(max(0, seconds))
|
||||
let m = totalSeconds / 60
|
||||
let s = totalSeconds % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
}
|
||||
|
||||
// MARK: - Volume Helpers
|
||||
|
||||
private func adjustVolume(by delta: Int) {
|
||||
|
||||
Reference in New Issue
Block a user