Queue, Favorites, Providers, Now playing
This commit is contained in:
@@ -7,10 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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>"; };
|
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; };
|
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -40,6 +44,8 @@
|
|||||||
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
|
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
|
||||||
26ED92622F759EEA0025419D /* Products */,
|
26ED92622F759EEA0025419D /* Products */,
|
||||||
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
|
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
|
||||||
|
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
|
||||||
|
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -125,6 +131,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */,
|
||||||
|
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */,
|
||||||
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
|
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -272,7 +280,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.2;
|
||||||
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;
|
||||||
@@ -308,7 +316,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.2;
|
||||||
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;
|
||||||
|
|||||||
@@ -184,20 +184,22 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
|
|||||||
let album: MAAlbum?
|
let album: MAAlbum?
|
||||||
let metadata: MediaItemMetadata?
|
let metadata: MediaItemMetadata?
|
||||||
let duration: Int?
|
let duration: Int?
|
||||||
|
let favorite: Bool
|
||||||
|
|
||||||
var id: String { uri }
|
var id: String { uri }
|
||||||
var imageUrl: String? { metadata?.thumbImage?.path }
|
var imageUrl: String? { metadata?.thumbImage?.path }
|
||||||
var imageProvider: String? { metadata?.thumbImage?.provider }
|
var imageProvider: String? { metadata?.thumbImage?.provider }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case uri, name, duration, artists, album, metadata
|
case uri, name, duration, artists, album, metadata, favorite
|
||||||
case mediaType = "media_type"
|
case mediaType = "media_type"
|
||||||
case image // Direct image field from search results
|
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.uri = uri; self.name = name; self.mediaType = mediaType
|
||||||
self.artists = artists; self.album = album; self.duration = duration
|
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) }
|
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)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
uri = try c.decode(String.self, forKey: .uri)
|
uri = try c.decode(String.self, forKey: .uri)
|
||||||
name = try c.decode(String.self, forKey: .name)
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
|
||||||
|
|
||||||
// Media type is critical - decode it first
|
// Media type is critical - decode it first
|
||||||
let mediaTypeString = try? c.decodeIfPresent(String.self, forKey: .mediaType)
|
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(album, forKey: .album)
|
||||||
try c.encodeIfPresent(duration, forKey: .duration)
|
try c.encodeIfPresent(duration, forKey: .duration)
|
||||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
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 metadata: MediaItemMetadata?
|
||||||
let sortName: String?
|
let sortName: String?
|
||||||
let musicbrainzId: String?
|
let musicbrainzId: String?
|
||||||
|
let favorite: Bool
|
||||||
|
|
||||||
var id: String { uri }
|
var id: String { uri }
|
||||||
var imageUrl: String? { metadata?.thumbImage?.path }
|
var imageUrl: String? { metadata?.thumbImage?.path }
|
||||||
var imageProvider: String? { metadata?.thumbImage?.provider }
|
var imageProvider: String? { metadata?.thumbImage?.provider }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case uri, name, metadata
|
case uri, name, metadata, favorite
|
||||||
case sortName = "sort_name"
|
case sortName = "sort_name"
|
||||||
case musicbrainzId = "musicbrainz_id"
|
case musicbrainzId = "musicbrainz_id"
|
||||||
case image // Direct image field
|
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.uri = uri; self.name = name
|
||||||
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
|
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
|
||||||
self.sortName = sortName; self.musicbrainzId = musicbrainzId
|
self.sortName = sortName; self.musicbrainzId = musicbrainzId
|
||||||
|
self.favorite = favorite
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
uri = try c.decode(String.self, forKey: .uri)
|
uri = try c.decode(String.self, forKey: .uri)
|
||||||
name = try c.decode(String.self, forKey: .name)
|
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)
|
sortName = try? c.decodeIfPresent(String.self, forKey: .sortName)
|
||||||
musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId)
|
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(sortName, forKey: .sortName)
|
||||||
try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId)
|
try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId)
|
||||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
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 artists: [MAArtist]?
|
||||||
let metadata: MediaItemMetadata?
|
let metadata: MediaItemMetadata?
|
||||||
let year: Int?
|
let year: Int?
|
||||||
|
let favorite: Bool
|
||||||
|
|
||||||
var id: String { uri }
|
var id: String { uri }
|
||||||
var imageUrl: String? { metadata?.thumbImage?.path }
|
var imageUrl: String? { metadata?.thumbImage?.path }
|
||||||
var imageProvider: String? { metadata?.thumbImage?.provider }
|
var imageProvider: String? { metadata?.thumbImage?.provider }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case uri, name, artists, metadata, year
|
case uri, name, artists, metadata, year, favorite
|
||||||
case image // Direct image field
|
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.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) }
|
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)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
uri = try c.decode(String.self, forKey: .uri)
|
uri = try c.decode(String.self, forKey: .uri)
|
||||||
name = try c.decode(String.self, forKey: .name)
|
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)
|
artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists)
|
||||||
year = try? c.decodeIfPresent(Int.self, forKey: .year)
|
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(artists, forKey: .artists)
|
||||||
try c.encodeIfPresent(year, forKey: .year)
|
try c.encodeIfPresent(year, forKey: .year)
|
||||||
try c.encodeIfPresent(metadata, forKey: .metadata)
|
try c.encodeIfPresent(metadata, forKey: .metadata)
|
||||||
|
try c.encode(favorite, forKey: .favorite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,11 +419,17 @@ struct MAPlayerQueue: Codable {
|
|||||||
let queueId: String
|
let queueId: String
|
||||||
let currentItem: MAQueueItem?
|
let currentItem: MAQueueItem?
|
||||||
let currentIndex: Int?
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case queueId = "queue_id"
|
case queueId = "queue_id"
|
||||||
case currentItem = "current_item"
|
case currentItem = "current_item"
|
||||||
case currentIndex = "current_index"
|
case currentIndex = "current_index"
|
||||||
|
case elapsedTime = "elapsed_time"
|
||||||
|
case elapsedTimeLastUpdated = "elapsed_time_last_updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -419,6 +437,8 @@ struct MAPlayerQueue: Codable {
|
|||||||
queueId = try c.decode(String.self, forKey: .queueId)
|
queueId = try c.decode(String.self, forKey: .queueId)
|
||||||
currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem)
|
currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem)
|
||||||
currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex)
|
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 isLoadingAlbums = false
|
||||||
private(set) var isLoadingPlaylists = 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)
|
// Last refresh timestamps (persisted in UserDefaults)
|
||||||
private(set) var lastArtistsRefresh: Date?
|
private(set) var lastArtistsRefresh: Date?
|
||||||
private(set) var lastAlbumsRefresh: Date?
|
private(set) var lastAlbumsRefresh: Date?
|
||||||
@@ -42,7 +46,7 @@ final class MALibraryManager {
|
|||||||
// MARK: - Disk Cache
|
// MARK: - Disk Cache
|
||||||
|
|
||||||
/// Increment this whenever the model format changes to invalidate stale caches.
|
/// 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 = {
|
private let cacheDirectory: URL = {
|
||||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
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")
|
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
|
let ud = UserDefaults.standard
|
||||||
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
||||||
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
||||||
@@ -152,10 +160,13 @@ final class MALibraryManager {
|
|||||||
if refresh {
|
if refresh {
|
||||||
artists = newArtists
|
artists = newArtists
|
||||||
artistsOffset = newArtists.count
|
artistsOffset = newArtists.count
|
||||||
|
// Reset and repopulate artist favorites on refresh
|
||||||
|
for a in artists where a.favorite { favoriteURIs.insert(a.uri) }
|
||||||
} else {
|
} else {
|
||||||
artists.append(contentsOf: newArtists)
|
artists.append(contentsOf: newArtists)
|
||||||
artistsOffset += newArtists.count
|
artistsOffset += newArtists.count
|
||||||
}
|
}
|
||||||
|
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
||||||
hasMoreArtists = newArtists.count >= pageSize
|
hasMoreArtists = newArtists.count >= pageSize
|
||||||
|
|
||||||
if refresh || artistsOffset <= pageSize {
|
if refresh || artistsOffset <= pageSize {
|
||||||
@@ -199,10 +210,12 @@ final class MALibraryManager {
|
|||||||
if refresh {
|
if refresh {
|
||||||
albums = newAlbums
|
albums = newAlbums
|
||||||
albumsOffset = newAlbums.count
|
albumsOffset = newAlbums.count
|
||||||
|
for a in albums where a.favorite { favoriteURIs.insert(a.uri) }
|
||||||
} else {
|
} else {
|
||||||
albums.append(contentsOf: newAlbums)
|
albums.append(contentsOf: newAlbums)
|
||||||
albumsOffset += newAlbums.count
|
albumsOffset += newAlbums.count
|
||||||
}
|
}
|
||||||
|
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
|
||||||
hasMoreAlbums = newAlbums.count >= pageSize
|
hasMoreAlbums = newAlbums.count >= pageSize
|
||||||
|
|
||||||
if refresh || albumsOffset <= pageSize {
|
if refresh || albumsOffset <= pageSize {
|
||||||
@@ -255,6 +268,53 @@ final class MALibraryManager {
|
|||||||
return try await service.getAlbumTracks(albumUri: albumUri)
|
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
|
// MARK: - Search
|
||||||
|
|
||||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||||
|
|||||||
@@ -119,8 +119,25 @@ final class MAPlayerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleQueueItemsUpdated(_ event: MAEvent) async {
|
private func handleQueueItemsUpdated(_ event: MAEvent) async {
|
||||||
// Similar to queue_updated
|
// Update queue state (current item, current index)
|
||||||
await handleQueueUpdated(event)
|
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
|
// MARK: - Data Loading
|
||||||
@@ -236,6 +253,13 @@ final class MAPlayerManager {
|
|||||||
try await service.playMedia(playerId: playerId, uri: uri)
|
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 {
|
func playIndex(playerId: String, index: Int) async throws {
|
||||||
guard let service else {
|
guard let service else {
|
||||||
throw MAWebSocketClient.ClientError.notConnected
|
throw MAWebSocketClient.ClientError.notConnected
|
||||||
|
|||||||
@@ -193,6 +193,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
|
/// Play from queue index
|
||||||
func playIndex(playerId: String, index: Int) async throws {
|
func playIndex(playerId: String, index: Int) async throws {
|
||||||
logger.debug("Playing index \(index) on player \(playerId)")
|
logger.debug("Playing index \(index) on player \(playerId)")
|
||||||
@@ -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
|
/// Get album tracks
|
||||||
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
||||||
logger.debug("Fetching tracks for album \(albumUri)")
|
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
|
/// Search library
|
||||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||||
logger.debug("🔍 Searching for '\(query)'")
|
logger.debug("🔍 Searching for '\(query)'")
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ struct EnhancedPlayerPickerView: View {
|
|||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
|
|
||||||
let players: [MAPlayer]
|
let players: [MAPlayer]
|
||||||
|
let title: String
|
||||||
let onSelect: (MAPlayer) -> Void
|
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)
|
/// IDs of all players that are sync members (not the leader)
|
||||||
private var syncedMemberIds: Set<String> {
|
private var syncedMemberIds: Set<String> {
|
||||||
Set(players.flatMap { $0.groupChilds })
|
Set(players.flatMap { $0.groupChilds })
|
||||||
@@ -52,7 +59,7 @@ struct EnhancedPlayerPickerView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
.navigationTitle("Play on...")
|
.navigationTitle(title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct AlbumDetailView: View {
|
|||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
@State private var showPlayerPicker = false
|
@State private var showPlayerPicker = false
|
||||||
|
@State private var showEnqueuePicker = false
|
||||||
@State private var selectedPlayer: MAPlayer?
|
@State private var selectedPlayer: MAPlayer?
|
||||||
@State private var kenBurnsScale: CGFloat = 1.0
|
@State private var kenBurnsScale: CGFloat = 1.0
|
||||||
@State private var completeAlbum: MAAlbum?
|
@State private var completeAlbum: MAAlbum?
|
||||||
@@ -28,6 +29,13 @@ struct AlbumDetailView: View {
|
|||||||
.sorted { $0.name < $1.name }
|
.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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Blurred Background with Ken Burns Effect
|
// Blurred Background with Ken Burns Effect
|
||||||
@@ -39,8 +47,8 @@ struct AlbumDetailView: View {
|
|||||||
// Album Header
|
// Album Header
|
||||||
albumHeader
|
albumHeader
|
||||||
|
|
||||||
// Play Button
|
// Action Buttons
|
||||||
playButton
|
actionButtons
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.background(Color.white.opacity(0.3))
|
.background(Color.white.opacity(0.3))
|
||||||
@@ -90,6 +98,11 @@ struct AlbumDetailView: View {
|
|||||||
.navigationTitle(album.name)
|
.navigationTitle(album.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
async let tracksLoad: () = loadTracks()
|
async let tracksLoad: () = loadTracks()
|
||||||
async let detailLoad: () = loadAlbumDetail()
|
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
|
// MARK: - Background Artwork
|
||||||
@@ -191,7 +213,9 @@ struct AlbumDetailView: View {
|
|||||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
.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 {
|
if let year = album.year {
|
||||||
Text(String(year))
|
Text(String(year))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -213,38 +237,67 @@ struct AlbumDetailView: View {
|
|||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Play Button
|
// MARK: - Action Buttons
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var playButton: some View {
|
private var actionButtons: some View {
|
||||||
Button {
|
HStack(spacing: 12) {
|
||||||
if players.count == 1 {
|
// Play Album
|
||||||
selectedPlayer = players.first
|
Button {
|
||||||
Task {
|
if players.count == 1 {
|
||||||
await playAlbum(on: players.first!)
|
selectedPlayer = players.first
|
||||||
|
Task { await playAlbum(on: players.first!) }
|
||||||
|
} else {
|
||||||
|
showPlayerPicker = true
|
||||||
}
|
}
|
||||||
} else {
|
} label: {
|
||||||
showPlayerPicker = true
|
Label("Play", systemImage: "play.fill")
|
||||||
}
|
.font(.headline)
|
||||||
} label: {
|
.frame(maxWidth: .infinity)
|
||||||
Label("Play Album", systemImage: "play.fill")
|
.padding()
|
||||||
.font(.headline)
|
.background(
|
||||||
.frame(maxWidth: .infinity)
|
LinearGradient(
|
||||||
.padding()
|
colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)],
|
||||||
.background(
|
startPoint: .top,
|
||||||
LinearGradient(
|
endPoint: .bottom
|
||||||
colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)],
|
)
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
)
|
||||||
)
|
.foregroundStyle(.white)
|
||||||
.foregroundStyle(.white)
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.overlay(
|
||||||
.overlay(
|
RoundedRectangle(cornerRadius: 12)
|
||||||
RoundedRectangle(cornerRadius: 12)
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
)
|
||||||
)
|
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
|
||||||
.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)
|
.padding(.horizontal)
|
||||||
.disabled(tracks.isEmpty || players.isEmpty)
|
.disabled(tracks.isEmpty || players.isEmpty)
|
||||||
@@ -257,7 +310,7 @@ struct AlbumDetailView: View {
|
|||||||
private var trackList: some View {
|
private var trackList: some View {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
|
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())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if players.count == 1 {
|
if players.count == 1 {
|
||||||
@@ -372,6 +425,18 @@ struct AlbumDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
|
||||||
do {
|
do {
|
||||||
try await service.playerManager.playMedia(
|
try await service.playerManager.playMedia(
|
||||||
@@ -388,17 +453,28 @@ struct AlbumDetailView: View {
|
|||||||
// MARK: - Track Row
|
// MARK: - Track Row
|
||||||
|
|
||||||
struct TrackRow: View {
|
struct TrackRow: View {
|
||||||
|
@Environment(MAService.self) private var service
|
||||||
let track: MAMediaItem
|
let track: MAMediaItem
|
||||||
let trackNumber: Int
|
let trackNumber: Int
|
||||||
var useLightTheme: Bool = false
|
var useLightTheme: Bool = false
|
||||||
|
var isPlaying: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// Track Number
|
// Track Number / Now Playing indicator
|
||||||
Text("\(trackNumber)")
|
Group {
|
||||||
.font(.subheadline)
|
if isPlaying {
|
||||||
.foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
|
Image(systemName: "waveform")
|
||||||
.frame(width: 30, alignment: .trailing)
|
.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
|
// Track Info
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -417,6 +493,9 @@ struct TrackRow: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Favorite
|
||||||
|
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme)
|
||||||
|
|
||||||
// Duration
|
// Duration
|
||||||
if let duration = track.duration {
|
if let duration = track.duration {
|
||||||
Text(formatDuration(duration))
|
Text(formatDuration(duration))
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ struct AlbumGridItem: View {
|
|||||||
}
|
}
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.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
|
// Album Info
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ struct ArtistDetailView: View {
|
|||||||
.navigationTitle(artist.name)
|
.navigationTitle(artist.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
async let albumsLoad: () = loadAlbums()
|
async let albumsLoad: () = loadAlbums()
|
||||||
async let detailLoad: () = loadArtistDetail()
|
async let detailLoad: () = loadArtistDetail()
|
||||||
@@ -138,12 +143,16 @@ struct ArtistDetailView: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
|
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
|
||||||
|
|
||||||
if !albums.isEmpty {
|
HStack(spacing: 6) {
|
||||||
Text("\(albums.count) albums")
|
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider)
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
if !albums.isEmpty {
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
Text("\(albums.count) albums")
|
||||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ struct ArtistGridItem: View {
|
|||||||
}
|
}
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.clipShape(Circle())
|
.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)
|
Text(artist.name)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ struct PlaylistDetailView: View {
|
|||||||
.sorted { $0.name < $1.name }
|
.sorted { $0.name < $1.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nowPlayingURIs: Set<String> {
|
||||||
|
Set(service.playerManager.playerQueues.values.compactMap {
|
||||||
|
$0.currentItem?.mediaItem?.uri
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Blurred Background with Ken Burns Effect
|
// Blurred Background with Ken Burns Effect
|
||||||
@@ -268,7 +274,7 @@ struct PlaylistDetailView: View {
|
|||||||
private var trackList: some View {
|
private var trackList: some View {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
|
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())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if players.count == 1 {
|
if players.count == 1 {
|
||||||
|
|||||||
@@ -17,20 +17,30 @@ struct PlayerNowPlayingView: View {
|
|||||||
@State private var isMuted = false
|
@State private var isMuted = false
|
||||||
@State private var preMuteVolume: Double = 50
|
@State private var preMuteVolume: Double = 50
|
||||||
@State private var showQueue = false
|
@State private var showQueue = false
|
||||||
|
@State private var displayedElapsed: Double = 0
|
||||||
|
@State private var progressTimer: Timer?
|
||||||
|
|
||||||
// Auto-tracks live updates via @Observable
|
// Auto-tracks live updates via @Observable
|
||||||
private var player: MAPlayer? {
|
private var player: MAPlayer? {
|
||||||
service.playerManager.players[playerId]
|
service.playerManager.players[playerId]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var playerQueue: MAPlayerQueue? {
|
||||||
|
service.playerManager.playerQueues[playerId]
|
||||||
|
}
|
||||||
|
|
||||||
private var currentItem: MAQueueItem? {
|
private var currentItem: MAQueueItem? {
|
||||||
service.playerManager.playerQueues[playerId]?.currentItem
|
playerQueue?.currentItem
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mediaItem: MAMediaItem? {
|
private var mediaItem: MAMediaItem? {
|
||||||
currentItem?.mediaItem
|
currentItem?.mediaItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var trackDuration: Double {
|
||||||
|
Double(currentItem?.duration ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
@@ -47,6 +57,11 @@ struct PlayerNowPlayingView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
// Progress bar (only in player mode, not queue)
|
||||||
|
if !showQueue {
|
||||||
|
progressView
|
||||||
|
}
|
||||||
|
|
||||||
// Transport + volume (always visible)
|
// Transport + volume (always visible)
|
||||||
controlsView
|
controlsView
|
||||||
}
|
}
|
||||||
@@ -80,6 +95,25 @@ struct PlayerNowPlayingView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
localVolume = Double(player?.volume ?? 50)
|
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])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
@@ -129,12 +163,12 @@ struct PlayerNowPlayingView: View {
|
|||||||
Image(systemName: "list.bullet")
|
Image(systemName: "list.bullet")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundStyle(showQueue ? .accent : .primary)
|
.foregroundStyle(showQueue ? Color.accentColor : .primary)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
||||||
if let uri = mediaItem?.uri {
|
if !showQueue, let uri = mediaItem?.uri {
|
||||||
FavoriteButton(uri: uri, size: 22)
|
FavoriteButton(uri: uri, size: 22)
|
||||||
.frame(width: 44, height: 44)
|
.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
|
// MARK: - Volume Helpers
|
||||||
|
|
||||||
private func adjustVolume(by delta: Int) {
|
private func adjustVolume(by delta: Int) {
|
||||||
|
|||||||
Reference in New Issue
Block a user