Version 1.4 - Translations, Like toasts Queue redesign.

This commit is contained in:
2026-04-09 16:54:41 +02:00
parent ec1ffcb0b1
commit 5f3902cb54
30 changed files with 3472 additions and 654 deletions
+102
View File
@@ -0,0 +1,102 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "DC06E9B7",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "19.99",
"familyShareable" : false,
"internalID" : "6761880856",
"localizations" : [
{
"description" : "Donate the value of an album",
"displayName" : "Album",
"locale" : "en_US"
},
{
"description" : "Spende mir den Gegenwert eines Albums",
"displayName" : "Album",
"locale" : "de"
}
],
"productID" : "donatealbum",
"referenceName" : "Spende - Album",
"type" : "Consumable"
},
{
"displayPrice" : "49.99",
"familyShareable" : false,
"internalID" : "6761880886",
"localizations" : [
{
"description" : "Donate the value of an anthology",
"displayName" : "Anthology",
"locale" : "en_US"
},
{
"description" : "Spende mir den Gegenwert einer Anthologie",
"displayName" : "Anthologie",
"locale" : "de"
}
],
"productID" : "donateanthology",
"referenceName" : "Spende - Anthology",
"type" : "Consumable"
},
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "6761880625",
"localizations" : [
{
"description" : "Spende mir den Gegenwert eines Songs",
"displayName" : "Song",
"locale" : "de"
},
{
"description" : "Donate the value of a song",
"displayName" : "Song",
"locale" : "en_US"
}
],
"productID" : "donatesong",
"referenceName" : "Spende - Song",
"type" : "Consumable"
}
],
"settings" : {
"_applicationInternalID" : "6761258120",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_developerTeamID" : "EKFHUHT63T",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 797413193.48934102,
"_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "USA",
"_storeKitErrors" : [
],
"_timeRate" : 0
},
"subscriptionGroups" : [
],
"version" : {
"major" : 5,
"minor" : 0
}
}
@@ -7,14 +7,16 @@
objects = {
/* Begin PBXBuildFile section */
2616AF4E2F876BEF00CB210E /* ServicesMAStoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */; };
2616AF502F87782600CB210E /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 2616AF4F2F87782600CB210E /* Donations.storekit */; };
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 */
2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesMAStoreManager.swift; sourceTree = "<group>"; };
2616AF4F2F87782600CB210E /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = "<group>"; };
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 */
@@ -41,11 +43,12 @@
26ED92582F759EEA0025419D = {
isa = PBXGroup;
children = (
2616AF4F2F87782600CB210E /* Donations.storekit */,
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
26ED92622F759EEA0025419D /* Products */,
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */,
2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */,
);
sourceTree = "<group>";
};
@@ -103,6 +106,9 @@
knownRegions = (
en,
Base,
de,
es,
fr,
);
mainGroup = 26ED92582F759EEA0025419D;
minimizedProjectReferenceProxies = 1;
@@ -121,6 +127,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2616AF502F87782600CB210E /* Donations.storekit in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -131,8 +138,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2616AF4E2F876BEF00CB210E /* ServicesMAStoreManager.swift in Sources */,
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */,
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */,
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -52,7 +52,7 @@
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Mobile Music Assistant/DonationProducts.storekit">
identifier = "../../Donations.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
@@ -1,15 +0,0 @@
{
"images" : [
{
"filename" : "subsonic_94696.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" version="1.1" viewBox="0 0 32 32">
<defs>
<linearGradient id="linearGradient4207" x1="2" x2="8" y1="1038.1" y2="1038.1" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-opacity="0"/>
<stop offset="1"/>
</linearGradient>
</defs>
<g transform="translate(0 -1020.4)">
<g transform="matrix(.66667 0 0 .8 -1.3333 215.87)">
<rect fill="#41b941" width="3" height="10" x="14" y="1014.4" rx="1.406" ry="1.747"/>
<path fill="#fff" opacity=".2" d="m15.406 10c-0.779 0-1.406 0.778-1.406 1.746v1c0-0.968 0.627-1.746 1.406-1.746h0.1875c0.779 0 1.406 0.778 1.406 1.746v-1c0-0.968-0.627-1.746-1.406-1.746h-0.1875z" transform="translate(0 1004.4)"/>
</g>
<path fill="#ffca1d" d="m14.6 1027.4c-0.7756 0-1.6 0.6175-1.6 1.3846v1.6168c-2.5977 0.8209-4.3 3.114-5 4.4986-0.7-1.3846-5-2-5-2v3.5h-1v4h1v3c-0.027344 0 4.3-0.6154 5-2 0.7 1.3846 4.5 4 10.1 4s11.9-3.0272 11.9-7.6154c-0.0061-3.2607-2.7438-6.0433-7-7.3859v-1.6141c0-0.7671-0.6244-1.3846-1.4-1.3846z"/>
<circle opacity=".2" cx="18" cy="1038.4" r="2"/>
<circle fill="#fff" cx="18" cy="1037.4" r="2"/>
<circle opacity=".2" cx="23" cy="1038.4" r="2"/>
<circle fill="#fff" cx="23" cy="1037.4" r="2"/>
<circle opacity=".2" cx="13" cy="1038.4" r="2"/>
<circle fill="#fff" cx="13" cy="1037.4" r="2"/>
<path fill="#fff" opacity=".2" d="m14.6 7c-0.776 0-1.6 0.6177-1.6 1.3848v1c0-0.7671 0.824-1.3848 1.6-1.3848h7c0.775 0 1.4 0.6177 1.4 1.3848v-1c0-0.7671-0.625-1.3848-1.4-1.3848zm8.4 2.998v1c3.9894 1.2584 6.6445 3.7828 6.9668 6.7793 0.01-0.131 0.033-0.259 0.033-0.392-0.006-3.261-2.744-6.044-7-7.387zm-10 0.00391c-2.598 0.821-4.3 3.113-5 4.498-0.7-1.385-5-2-5-2v1s4.3 0.6154 5 2c0.7-1.3846 2.4023-3.6771 5-4.498z" transform="translate(0 1020.4)"/>
<path opacity=".2" d="m29.967 1038.1c-0.35239 4.3638-6.4312 7.2207-11.867 7.2207-5.6 0-9.3996-2.6154-10.1-4-0.7 1.3846-5.0273 2-5 2v1c-0.027344 0 4.3-0.6154 5-2 0.7 1.3846 4.4996 4 10.1 4 5.6 0 11.9-3.027 11.9-7.6152-0.000381-0.2038-0.01178-0.4057-0.0332-0.6055z"/>
<path fill="url(#linearGradient4207)" opacity=".1" d="m8 1034.9c-0.7-1.3846-5-2-5-2v3.5h-1v4h1v3c-0.027344 0 4.3-0.6154 5-2z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,129 +0,0 @@
{
"identifier" : "8A3F4B2E-1234-5678-9ABC-DEF012345678",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "D1000001",
"localizations" : [
{
"description" : "Buy the developer a song",
"displayName" : "Song",
"locale" : "en_US"
},
{
"description" : "Spendiere dem Entwickler einen Song",
"displayName" : "Song",
"locale" : "de"
}
],
"productID" : "donate.song",
"referenceName" : "Donate Song",
"type" : "Consumable"
},
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "D1000002",
"localizations" : [
{
"description" : "Buy the developer an album",
"displayName" : "Album",
"locale" : "en_US"
},
{
"description" : "Spendiere dem Entwickler ein Album",
"displayName" : "Album",
"locale" : "de"
}
],
"productID" : "donate.album",
"referenceName" : "Donate Album",
"type" : "Consumable"
},
{
"displayPrice" : "19.99",
"familyShareable" : false,
"internalID" : "D1000003",
"localizations" : [
{
"description" : "Buy the developer an anthology",
"displayName" : "Anthology",
"locale" : "en_US"
},
{
"description" : "Spendiere dem Entwickler eine Anthology",
"displayName" : "Anthology",
"locale" : "de"
}
],
"productID" : "donate.anthology",
"referenceName" : "Donate Anthology",
"type" : "Consumable"
}
],
"settings" : {
"_applicationInternalID" : "APP001",
"_developerTeamID" : "TEAM_ID",
"_failTransactionsEnabled" : false,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
}
]
},
"subscriptionGroups" : [
],
"version" : {
"major" : 4,
"minor" : 0
}
}
File diff suppressed because it is too large Load Diff
@@ -12,13 +12,19 @@ struct Mobile_Music_AssistantApp: App {
@State private var service = MAService()
@State private var themeManager = MAThemeManager()
@State private var storeManager = MAStoreManager()
@State private var localeManager = MALocaleManager()
@State private var toastManager = MAToastManager()
var body: some Scene {
WindowGroup {
RootView()
.environment(service)
.environment(themeManager)
.environment(\.themeManager, themeManager)
.environment(storeManager)
.environment(localeManager)
.environment(\.localeManager, localeManager)
.environment(toastManager)
}
}
}
@@ -0,0 +1,84 @@
//
// ServicesMALocaleManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 09.04.26.
//
import SwiftUI
// MARK: - Supported Languages
enum SupportedLanguage: String, CaseIterable, Identifiable {
case en = "en"
case de = "de"
case fr = "fr"
case es = "es"
var id: String { rawValue }
/// Native display name (endonym) always shown in the language's own script
var endonym: String {
switch self {
case .en: return "English"
case .de: return "Deutsch"
case .fr: return "Français"
case .es: return "Español"
}
}
}
// MARK: - Locale Manager
@Observable
class MALocaleManager {
/// nil means "System" (no override iOS uses device language)
var selectedLanguageCode: String? {
didSet {
if let code = selectedLanguageCode {
UserDefaults.standard.set(code, forKey: "appLanguageOverride")
} else {
UserDefaults.standard.removeObject(forKey: "appLanguageOverride")
}
}
}
init() {
selectedLanguageCode = UserDefaults.standard.string(forKey: "appLanguageOverride")
}
/// The locale to inject into the environment. nil = let iOS choose.
var overrideLocale: Locale? {
guard let code = selectedLanguageCode else { return nil }
return Locale(identifier: code)
}
}
// MARK: - Environment Key
private struct LocaleManagerKey: EnvironmentKey {
static let defaultValue = MALocaleManager()
}
extension EnvironmentValues {
var localeManager: MALocaleManager {
get { self[LocaleManagerKey.self] }
set { self[LocaleManagerKey.self] = newValue }
}
}
// MARK: - Locale Applied Modifier
struct LocaleAppliedModifier: ViewModifier {
@Environment(\.localeManager) var localeManager
func body(content: Content) -> some View {
content.environment(\.locale, localeManager.overrideLocale ?? .current)
}
}
extension View {
func applyLocale() -> some View {
modifier(LocaleAppliedModifier())
}
}
@@ -229,6 +229,13 @@ final class MAPlayerManager {
try await service.previousTrack(playerId: playerId)
}
func seek(playerId: String, position: Double) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.seek(playerId: playerId, position: position)
}
func setVolume(playerId: String, level: Int) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
@@ -146,6 +146,20 @@ final class MAService {
)
}
/// Seek to a position in the current track (seconds)
func seek(playerId: String, position: Double) async throws {
let seconds = Int(max(0, position))
logger.debug("Seeking to \(seconds)s on player \(playerId)")
let response = try await webSocketClient.sendCommand(
"players/cmd/seek",
args: [
"player_id": playerId,
"position": seconds
]
)
logger.debug("Seek response: errorCode=\(String(describing: response.errorCode)) details=\(String(describing: response.details))")
}
/// Set volume (0-100)
func setVolume(playerId: String, level: Int) async throws {
let clampedLevel = max(0, min(100, level))
@@ -1,122 +0,0 @@
//
// ServicesMAStoreManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 06.04.26.
//
import StoreKit
@Observable
@MainActor
final class MAStoreManager {
enum PurchaseResult: Equatable {
case success
case cancelled
case failed(String)
}
static let productIDs: Set<String> = [
"donate.song",
"donate.album",
"donate.anthology"
]
var products: [Product] = []
var isPurchasing = false
var purchaseResult: PurchaseResult?
private var transactionListener: Task<Void, Never>?
init() {
transactionListener = listenForTransactions()
}
// MARK: - Load Products
func loadProducts() async {
guard products.isEmpty else { return }
do {
let storeProducts = try await Product.products(for: Self.productIDs)
products = storeProducts.sorted { $0.price < $1.price }
} catch {
print("Failed to load products: \(error)")
}
}
// MARK: - Purchase
func purchase(_ product: Product) async {
isPurchasing = true
purchaseResult = nil
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
isPurchasing = false
purchaseResult = .success
case .userCancelled:
isPurchasing = false
purchaseResult = .cancelled
case .pending:
isPurchasing = false
@unknown default:
isPurchasing = false
}
} catch {
isPurchasing = false
purchaseResult = .failed(error.localizedDescription)
}
}
// MARK: - Transaction Listener
private func listenForTransactions() -> Task<Void, Never> {
Task.detached {
for await verificationResult in Transaction.updates {
if case .verified(let transaction) = verificationResult {
await transaction.finish()
}
}
}
}
// MARK: - Helpers
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let safe):
return safe
}
}
/// Returns the SF Symbol icon name for a given product ID
func iconName(for productID: String) -> String {
switch productID {
case "donate.song": return "music.note"
case "donate.album": return "opticaldisc"
case "donate.anthology": return "music.note.list"
default: return "gift"
}
}
/// Returns a friendly tier name for a given product ID
func tierName(for productID: String) -> String {
switch productID {
case "donate.song": return "Song"
case "donate.album": return "Album"
case "donate.anthology": return "Anthology"
default: return "Donation"
}
}
}
@@ -39,25 +39,19 @@ enum AppColorScheme: String, CaseIterable, Identifiable {
var id: String { rawValue }
var displayName: String {
var displayName: LocalizedStringKey {
switch self {
case .system:
return "System"
case .light:
return "Light"
case .dark:
return "Dark"
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var description: String {
var description: LocalizedStringKey {
switch self {
case .system:
return "Follows your device's appearance"
case .light:
return "Always use light mode"
case .dark:
return "Always use dark mode"
case .system: return "Follows your device's appearance"
case .light: return "Always use light mode"
case .dark: return "Always use dark mode"
}
}
@@ -93,7 +87,6 @@ struct ThemeAppliedModifier: ViewModifier {
func body(content: Content) -> some View {
content
.preferredColorScheme(themeManager.preferredColorScheme)
.id(themeManager.colorScheme) // Force refresh when theme changes
}
}
@@ -13,11 +13,15 @@ struct EnhancedPlayerPickerView: View {
let players: [MAPlayer]
let title: String
let showNowPlayingOnSelect: Bool
let onSelect: (MAPlayer) -> Void
init(players: [MAPlayer], title: String = "Play on...", onSelect: @escaping (MAPlayer) -> Void) {
@State private var nowPlayingPlayer: MAPlayer?
init(players: [MAPlayer], title: String = "Play on...", showNowPlayingOnSelect: Bool = false, onSelect: @escaping (MAPlayer) -> Void) {
self.players = players
self.title = title
self.showNowPlayingOnSelect = showNowPlayingOnSelect
self.onSelect = onSelect
}
@@ -44,7 +48,11 @@ struct EnhancedPlayerPickerView: View {
.compactMap { service.playerManager.players[$0]?.name }
PickerGroupCard(leader: leader, memberNames: memberNames) {
onSelect(leader)
dismiss()
if showNowPlayingOnSelect {
nowPlayingPlayer = leader
} else {
dismiss()
}
}
}
@@ -52,7 +60,11 @@ struct EnhancedPlayerPickerView: View {
ForEach(soloPlayers) { player in
PickerPlayerCard(player: player) {
onSelect(player)
dismiss()
if showNowPlayingOnSelect {
nowPlayingPlayer = player
} else {
dismiss()
}
}
}
}
@@ -67,6 +79,10 @@ struct EnhancedPlayerPickerView: View {
}
}
}
.sheet(item: $nowPlayingPlayer, onDismiss: { dismiss() }) { player in
PlayerNowPlayingView(playerId: player.playerId)
.environment(service)
}
}
}
@@ -10,9 +10,12 @@ import SwiftUI
/// Reusable heart button for toggling favorites on artists, albums, and tracks.
struct FavoriteButton: View {
@Environment(MAService.self) private var service
@Environment(MAToastManager.self) private var toastManager
let uri: String
var size: CGFloat = 22
var showInLight: Bool = false
/// Display name shown in the toast when the item is liked. Pass nil to suppress the toast.
var itemName: String? = nil
private var isFavorite: Bool {
service.libraryManager.isFavorite(uri: uri)
@@ -20,11 +23,19 @@ struct FavoriteButton: View {
var body: some View {
Button {
let wasAlreadyFavorite = isFavorite
Task {
await service.libraryManager.toggleFavorite(
uri: uri,
currentlyFavorite: isFavorite
currentlyFavorite: wasAlreadyFavorite
)
if let name = itemName {
if wasAlreadyFavorite {
toastManager.show(name, icon: "heart.slash", iconColor: .secondary)
} else {
toastManager.show(name, icon: "heart.fill", iconColor: .red)
}
}
}
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
@@ -0,0 +1,64 @@
//
// ViewsComponentsToastOverlay.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 09.04.26.
//
import SwiftUI
// MARK: - Toast Manager
@Observable
class MAToastManager {
private(set) var message: String = ""
private(set) var icon: String = "heart.fill"
private(set) var iconColor: Color = .red
private(set) var isVisible: Bool = false
private var hideTask: Task<Void, Never>?
func show(_ message: String, icon: String = "heart.fill", iconColor: Color = .red) {
self.message = message
self.icon = icon
self.iconColor = iconColor
withAnimation(.spring(duration: 0.3)) { isVisible = true }
hideTask?.cancel()
hideTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.4)) { isVisible = false }
}
}
}
// MARK: - Toast View Modifier
struct ToastOverlayModifier: ViewModifier {
@Environment(MAToastManager.self) private var toastManager
func body(content: Content) -> some View {
content.overlay(alignment: .top) {
if toastManager.isVisible {
HStack(spacing: 8) {
Image(systemName: toastManager.icon)
.foregroundStyle(toastManager.iconColor)
Text(toastManager.message)
.font(.subheadline.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 18)
.padding(.vertical, 11)
.background(.ultraThinMaterial, in: Capsule())
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
.padding(.top, 12)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
}
extension View {
func withToast() -> some View {
modifier(ToastOverlayModifier())
}
}
@@ -8,11 +8,17 @@
import SwiftUI
import UIKit
enum FavoritesTab: String, CaseIterable {
case artists = "Artists"
case albums = "Albums"
case radios = "Radios"
case podcasts = "Podcasts"
enum FavoritesTab: CaseIterable {
case artists, albums, radios, podcasts
var title: LocalizedStringKey {
switch self {
case .artists: return "Artists"
case .albums: return "Albums"
case .radios: return "Radios"
case .podcasts: return "Podcasts"
}
}
}
struct FavoritesView: View {
@@ -41,7 +47,7 @@ struct FavoritesView: View {
ToolbarItem(placement: .principal) {
Picker("Favorites", selection: $selectedTab) {
ForEach(FavoritesTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
@@ -58,6 +64,8 @@ struct FavoritesView: View {
private struct FavoriteArtistsSection: View {
@Environment(MAService.self) private var service
@State private var scrollPosition: String?
@State private var errorMessage: String?
@State private var showError = false
private var favoriteArtists: [MAArtist] {
// Merge artists + albumArtists, deduplicate by URI, filter favorites
@@ -147,6 +155,24 @@ private struct FavoriteArtistsSection: View {
}
}
}
.refreshable {
await reloadArtists()
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func reloadArtists() async {
do {
try await service.libraryManager.loadArtists(refresh: true)
try await service.libraryManager.loadAlbumArtists(refresh: true)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
@@ -154,6 +180,8 @@ private struct FavoriteArtistsSection: View {
private struct FavoriteAlbumsSection: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
private var favoriteAlbums: [MAAlbum] {
service.libraryManager.albums
@@ -189,6 +217,23 @@ private struct FavoriteAlbumsSection: View {
}
}
}
.refreshable {
await reloadAlbums()
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func reloadAlbums() async {
do {
try await service.libraryManager.loadAlbums(refresh: true)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
@@ -250,6 +295,7 @@ private struct FavoriteRadiosSection: View {
.sheet(item: $selectedRadio) { radio in
EnhancedPlayerPickerView(
players: players,
showNowPlayingOnSelect: true,
onSelect: { player in
Task { await playRadio(radio, on: player) }
}
@@ -291,6 +337,8 @@ private struct FavoriteRadiosSection: View {
private struct FavoritePodcastsSection: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
private var favoritePodcasts: [MAPodcast] {
service.libraryManager.podcasts
@@ -315,6 +363,23 @@ private struct FavoritePodcastsSection: View {
.listStyle(.plain)
}
}
.refreshable {
await reloadPodcasts()
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func reloadPodcasts() async {
do {
try await service.libraryManager.loadPodcasts(refresh: true)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
@@ -101,7 +101,7 @@ struct AlbumDetailView: View {
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
FavoriteButton(uri: album.uri, size: 22, showInLight: true, itemName: album.name)
}
}
.task(id: "tracks-\(album.uri)") {
@@ -125,6 +125,7 @@ struct AlbumDetailView: View {
.sheet(isPresented: $showPlayerPicker) {
EnhancedPlayerPickerView(
players: players,
showNowPlayingOnSelect: true,
onSelect: { player in
if let index = selectedTrackIndex {
Task { await playTrack(fromIndex: index, on: player) }
@@ -222,8 +223,6 @@ struct AlbumDetailView: View {
}
HStack(spacing: 6) {
ProviderBadge(uri: album.uri, metadata: album.metadata)
if let year = album.year {
Text(String(year))
.font(.subheadline)
@@ -507,7 +506,7 @@ struct TrackRow: View {
Spacer()
// Favorite
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme)
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme, itemName: track.name)
// Duration
if let duration = track.duration {
@@ -59,7 +59,7 @@ struct ArtistDetailView: View {
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
FavoriteButton(uri: artist.uri, size: 22, showInLight: true, itemName: artist.name)
}
}
.task(id: "albums-\(artist.uri)") {
@@ -128,43 +128,6 @@ struct ArtistDetailView: View {
// MARK: - Artist Header
/// All distinct music providers for this artist.
/// Uses the authoritative provider_mappings field from MA when available,
/// and falls back to scanning loaded album metadata.
private var artistProviders: [MusicProvider] {
var seen = Set<MusicProvider>()
var result = [MusicProvider]()
// Primary: provider_mappings from the artist object (available immediately)
for mapping in artist.providerMappings {
if let p = MusicProvider.from(providerKey: mapping.providerDomain), !seen.contains(p) {
seen.insert(p)
result.append(p)
}
}
// Fallback: scan loaded albums when providerMappings is empty
if result.isEmpty {
for album in albums {
if let scheme = album.uri.components(separatedBy: "://").first,
let p = MusicProvider.from(scheme: scheme),
p != .library,
!seen.contains(p) {
seen.insert(p)
result.append(p)
}
for key in album.metadata?.images?.compactMap({ $0.provider }) ?? [] {
if let p = MusicProvider.from(providerKey: key), !seen.contains(p) {
seen.insert(p)
result.append(p)
}
}
}
}
return result
}
@ViewBuilder
private var artistHeader: some View {
VStack(spacing: 12) {
@@ -185,20 +148,6 @@ struct ArtistDetailView: View {
.clipShape(Circle())
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
// One badge per distinct source
if !artistProviders.isEmpty {
HStack(spacing: 4) {
ForEach(artistProviders, id: \.self) { provider in
Image(systemName: provider.icon)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background(.black.opacity(0.55))
.clipShape(Circle())
}
}
}
if !albums.isEmpty {
Text("\(albums.count) albums")
.font(.subheadline)
@@ -8,13 +8,19 @@
import SwiftUI
import UIKit
enum LibraryTab: String, CaseIterable {
case albumArtists = "Album Artists"
case artists = "Artists"
case albums = "Albums"
case playlists = "Playlists"
case podcasts = "Podcasts"
case radio = "Radio"
enum LibraryTab: CaseIterable {
case albumArtists, artists, albums, playlists, podcasts, radio
var title: LocalizedStringKey {
switch self {
case .albumArtists: return "Album Artists"
case .artists: return "Artists"
case .albums: return "Albums"
case .playlists: return "Playlists"
case .podcasts: return "Podcasts"
case .radio: return "Radio"
}
}
}
struct LibraryView: View {
@@ -45,7 +51,7 @@ struct LibraryView: View {
ToolbarItem(placement: .principal) {
Picker("Library", selection: $selectedTab) {
ForEach(LibraryTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
@@ -68,7 +68,7 @@ struct PlaylistDetailView: View {
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: playlist.uri, size: 22, showInLight: true)
FavoriteButton(uri: playlist.uri, size: 22, showInLight: true, itemName: playlist.name)
}
}
.task(id: playlist.uri) {
@@ -88,6 +88,7 @@ struct PlaylistDetailView: View {
.sheet(isPresented: $showPlayerPicker) {
EnhancedPlayerPickerView(
players: players,
showNowPlayingOnSelect: true,
onSelect: { player in
Task { await playPlaylist(on: player) }
}
@@ -180,8 +181,6 @@ struct PlaylistDetailView: View {
}
HStack(spacing: 6) {
ProviderBadge(uri: playlist.uri, metadata: playlist.metadata)
if !tracks.isEmpty {
Text("\(tracks.count) tracks")
.font(.subheadline)
@@ -62,7 +62,7 @@ struct PodcastDetailView: View {
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: podcast.uri, size: 22, showInLight: true)
FavoriteButton(uri: podcast.uri, size: 22, showInLight: true, itemName: podcast.name)
}
}
.task(id: podcast.uri) {
@@ -82,6 +82,7 @@ struct PodcastDetailView: View {
.sheet(isPresented: $showPlayerPicker) {
EnhancedPlayerPickerView(
players: players,
showNowPlayingOnSelect: true,
onSelect: { player in
Task { await playPodcast(on: player) }
}
@@ -99,6 +100,7 @@ struct PodcastDetailView: View {
.sheet(item: $selectedEpisode) { episode in
EnhancedPlayerPickerView(
players: players,
showNowPlayingOnSelect: true,
onSelect: { player in
Task { await playEpisode(episode, on: player) }
}
@@ -178,8 +180,6 @@ struct PodcastDetailView: View {
}
HStack(spacing: 6) {
ProviderBadge(uri: podcast.uri, metadata: podcast.metadata)
if !episodes.isEmpty {
Text("\(episodes.count) episodes")
.font(.subheadline)
@@ -58,6 +58,7 @@ struct RadiosView: View {
.sheet(item: $selectedRadio) { radio in
EnhancedPlayerPickerView(
players: players,
showNowPlayingOnSelect: true,
onSelect: { player in
Task { await playRadio(radio, on: player) }
}
+1 -1
View File
@@ -31,7 +31,7 @@ struct LoginView: View {
} header: {
Text("Server")
} footer: {
Text("Enter your Music Assistant server URL (e.g., https://musicassistant-app.hanold.online)")
Text("Enter your Music Assistant server URL (e.g., https://music.example.com)")
}
// Token Section
+66 -52
View File
@@ -10,32 +10,34 @@ import StoreKit
struct MainTabView: View {
@Environment(MAService.self) private var service
@State private var selectedTab: String = "library"
var body: some View {
TabView {
Tab("Library", systemImage: "music.note.list") {
TabView(selection: $selectedTab) {
Tab("Library", systemImage: "music.note.list", value: "library") {
LibraryView()
}
Tab("Favorites", systemImage: "heart.fill") {
Tab("Favorites", systemImage: "heart.fill", value: "favorites") {
FavoritesView()
}
Tab("Search", systemImage: "magnifyingglass") {
Tab("Search", systemImage: "magnifyingglass", value: "search") {
NavigationStack {
SearchView()
.withMANavigation()
}
}
Tab("Players", systemImage: "speaker.wave.2.fill") {
Tab("Players", systemImage: "speaker.wave.2.fill", value: "players") {
PlayerListView()
}
Tab("Settings", systemImage: "gear") {
Tab("Settings", systemImage: "gear", value: "settings") {
SettingsView()
}
}
.withToast()
.task {
// Start listening to player events and load players when main view appears
service.playerManager.startListening()
@@ -386,8 +388,9 @@ struct PlayerRow: View {
struct SettingsView: View {
@Environment(MAService.self) private var service
@Environment(MAStoreManager.self) private var storeManager
@Environment(\.themeManager) private var themeManager
@Environment(\.localeManager) private var localeManager
@Environment(MAStoreManager.self) private var storeManager
@State private var showThankYou = false
var body: some View {
@@ -435,6 +438,24 @@ struct SettingsView: View {
Text("Choose how the app looks. System follows your device settings.")
}
// Language Section
Section {
Picker("Language", selection: Binding(
get: { localeManager.selectedLanguageCode ?? "system" },
set: { localeManager.selectedLanguageCode = $0 == "system" ? nil : $0 }
)) {
Text("System").tag("system")
ForEach(SupportedLanguage.allCases) { lang in
Text(verbatim: lang.endonym).tag(lang.rawValue)
}
}
.pickerStyle(.menu)
} header: {
Text("Language")
} footer: {
Text("Choose the app language. System uses your device language.")
}
// Connection Section
Section {
if let serverURL = service.authManager.serverURL {
@@ -462,74 +483,67 @@ struct SettingsView: View {
Label("Disconnect", systemImage: "arrow.right.square")
}
}
// Support Development Section
Section {
if storeManager.products.isEmpty {
HStack {
if let loadError = storeManager.loadError {
Label(loadError, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(storeManager.products, id: \.id) { product in
HStack(spacing: 12) {
Image(systemName: storeManager.iconName(for: product))
.font(.title2)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(storeManager.tierName(for: product))
.font(.body)
.foregroundStyle(.primary)
Text(product.displayPrice)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
ProgressView()
Spacer()
}
} else {
ForEach(storeManager.products, id: \.id) { product in
Button {
Task {
await storeManager.purchase(product)
}
Task { await storeManager.purchase(product) }
} label: {
HStack(spacing: 12) {
Image(systemName: storeManager.iconName(for: product.id))
.font(.title2)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(storeManager.tierName(for: product.id))
.font(.body)
.foregroundStyle(.primary)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(product.displayPrice)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(.orange.opacity(0.15))
)
}
.padding(.vertical, 4)
Text(product.displayPrice)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.15))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(storeManager.isPurchasing)
}
.padding(.vertical, 4)
}
} header: {
Text("Support Development")
} footer: {
Text("Donations help keep this app updated and ad-free. Thank you!")
Text("Do you find this app useful? Support the development by buying the developer a virtual record.")
}
}
.navigationTitle("Settings")
.task {
await storeManager.loadProducts()
}
.alert("Thank You!", isPresented: $showThankYou) {
Button("OK", role: .cancel) { }
Button("You're welcome!", role: .cancel) { }
} message: {
Text("Your support means a lot and helps keep this app alive!")
Text("Your support means a lot and helps keep Mobile Music Assistant alive.")
}
.onChange(of: storeManager.purchaseResult) {
if case .success = storeManager.purchaseResult {
.onChange(of: storeManager.purchaseResult) { _, result in
if case .success = result {
showThankYou = true
storeManager.purchaseResult = nil
}
}
}
@@ -30,6 +30,7 @@ struct RootView: View {
}
}
.applyTheme()
.applyLocale()
.task {
await initializeConnection()
}
+117
View File
@@ -0,0 +1,117 @@
//
// ServicesMAStoreManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 09.04.26.
//
import StoreKit
import OSLog
import SwiftUI
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "StoreManager")
enum PurchaseResult: Equatable {
case success(Product)
case cancelled
case failed(String)
}
@Observable @MainActor final class MAStoreManager {
var products: [Product] = []
var purchaseResult: PurchaseResult?
var isPurchasing = false
var loadError: String?
private static let productIDs: Set<String> = [
"donatesong",
"donatealbum",
"donateanthology"
]
private var updateListenerTask: Task<Void, Never>?
init() {
updateListenerTask = listenForTransactions()
}
func loadProducts() async {
loadError = nil
do {
let fetched = try await Product.products(for: Self.productIDs)
products = fetched.sorted { $0.price < $1.price }
if fetched.isEmpty {
loadError = "No products returned. Make sure the StoreKit configuration is active in the scheme (Edit Scheme → Run → Options → StoreKit Configuration)."
logger.warning("Product.products(for:) returned 0 results for IDs: \(Self.productIDs)")
}
} catch {
loadError = error.localizedDescription
logger.error("Failed to load products: \(error.localizedDescription)")
}
}
func purchase(_ product: Product) async {
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
purchaseResult = .success(product)
case .userCancelled:
purchaseResult = .cancelled
case .pending:
break
@unknown default:
break
}
} catch {
purchaseResult = .failed(error.localizedDescription)
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let value):
return value
}
}
private func listenForTransactions() -> Task<Void, Never> {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
do {
let transaction = try self?.checkVerified(result)
await transaction?.finish()
} catch {
logger.error("Transaction verification failed: \(error.localizedDescription)")
}
}
}
}
// MARK: - Helpers
func iconName(for product: Product) -> String {
switch product.id {
case "donatesong": return "music.note"
case "donatealbum": return "opticaldisc"
case "donateanthology": return "music.note.list"
default: return "heart.fill"
}
}
func tierName(for product: Product) -> LocalizedStringKey {
switch product.id {
case "donatesong": return "Song"
case "donatealbum": return "Album"
case "donateanthology": return "Anthology"
default: return LocalizedStringKey(product.displayName)
}
}
}
-168
View File
@@ -1,168 +0,0 @@
//
// ProviderBadge.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 06.04.26.
//
import SwiftUI
/// Monochrome badges indicating which music provider(s) an item comes from.
/// For provider-specific URIs (e.g. spotify://) a single badge is shown.
/// For library:// items all distinct source providers found in the metadata
/// images are shown side by side.
struct ProviderBadge: View {
let uri: String
var metadata: MediaItemMetadata? = nil
private var providers: [MusicProvider] {
// Use string-based parsing URL(string:) returns nil for schemes with
// underscores like "apple_music" which violate RFC 2396.
let scheme = uri.components(separatedBy: "://").first?.lowercased()
// Non-library URI show only that provider
if let fromScheme = MusicProvider.from(scheme: scheme), fromScheme != .library {
return [fromScheme]
}
// library:// URI collect all distinct providers from metadata images
var seen = Set<MusicProvider>()
var result = [MusicProvider]()
let keys = metadata?.images?.compactMap { $0.provider } ?? []
for key in keys {
if let p = MusicProvider.from(providerKey: key), !seen.contains(p) {
seen.insert(p)
result.append(p)
}
}
// Nothing found for a library item fall back to the library badge
if result.isEmpty && scheme == "library" {
return [.library]
}
return result
}
var body: some View {
HStack(spacing: 4) {
ForEach(providers, id: \.self) { provider in
if let assetName = provider.logoAssetName {
Image(assetName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 30, height: 30)
.background(.black.opacity(0.55))
.clipShape(Circle())
} else {
Image(systemName: provider.icon)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(.black.opacity(0.55))
.clipShape(Circle())
}
}
}
}
}
// MARK: - Provider Mapping
enum MusicProvider: Hashable {
case library
case subsonic
case spotify
case tidal
case qobuz
case plex
case ytmusic
case appleMusic
case deezer
case soundcloud
case tunein
case filesystem
case jellyfin
case dlna
case ardAudiothek
/// Asset catalog image name for providers that have a custom logo.
/// Returns `nil` when the provider uses an SF Symbol instead.
var logoAssetName: String? {
switch self {
case .subsonic: return "SubsonicLogo"
default: return nil
}
}
/// SF Symbol name for this provider (used when `logoAssetName` is nil).
var icon: String {
switch self {
case .library: return "building.columns.fill"
case .subsonic: return "ferry.fill"
case .spotify: return "antenna.radiowaves.left.and.right.circle.fill"
case .tidal: return "water.waves"
case .qobuz: return "hifispeaker.fill"
case .plex: return "play.square.stack.fill"
case .ytmusic: return "play.rectangle.fill"
case .appleMusic: return "applelogo"
case .deezer: return "waveform"
case .soundcloud: return "cloud.fill"
case .tunein: return "radio.fill"
case .filesystem: return "folder.fill"
case .jellyfin: return "server.rack"
case .dlna: return "wifi"
case .ardAudiothek: return "antenna.radiowaves.left.and.right"
}
}
/// Match a URI scheme to a known provider.
static func from(scheme: String?) -> MusicProvider? {
guard let scheme = scheme?.lowercased() else { return nil }
if scheme == "library" { return .library }
if scheme.hasPrefix("subsonic") { return .subsonic }
if scheme.hasPrefix("spotify") { return .spotify }
if scheme.hasPrefix("tidal") { return .tidal }
if scheme.hasPrefix("qobuz") { return .qobuz }
if scheme.hasPrefix("plex") { return .plex }
if scheme.hasPrefix("ytmusic") { return .ytmusic }
if scheme.hasPrefix("apple") { return .appleMusic }
if scheme.hasPrefix("deezer") { return .deezer }
if scheme.hasPrefix("soundcloud") { return .soundcloud }
if scheme.hasPrefix("tunein") { return .tunein }
if scheme.hasPrefix("filesystem") { return .filesystem }
if scheme.hasPrefix("jellyfin") { return .jellyfin }
if scheme.hasPrefix("dlna") { return .dlna }
if scheme.hasPrefix("ard_audiothek") { return .ardAudiothek }
if scheme.hasPrefix("ard") { return .ardAudiothek }
return nil
}
/// Match a provider key from image metadata (e.g. "subsonic", "spotify", "filesystem_local").
static func from(providerKey key: String) -> MusicProvider? {
let k = key.lowercased()
if k.hasPrefix("subsonic") { return .subsonic }
if k.hasPrefix("spotify") { return .spotify }
if k.hasPrefix("tidal") { return .tidal }
if k.hasPrefix("qobuz") { return .qobuz }
if k.hasPrefix("plex") { return .plex }
if k.hasPrefix("ytmusic") { return .ytmusic }
if k.hasPrefix("apple") { return .appleMusic }
if k.hasPrefix("deezer") { return .deezer }
if k.hasPrefix("soundcloud") { return .soundcloud }
if k.hasPrefix("tunein") { return .tunein }
if k.hasPrefix("filesystem") { return .filesystem }
if k.hasPrefix("jellyfin") { return .jellyfin }
if k.hasPrefix("dlna") { return .dlna }
if k.hasPrefix("ard_audiothek") { return .ardAudiothek }
if k.hasPrefix("ard") { return .ardAudiothek }
// Image-only metadata providers not a music source
if k == "lastfm" || k == "musicbrainz" || k == "fanarttv" { return nil }
return nil
}
}
+282 -40
View File
@@ -14,12 +14,21 @@ struct PlayerNowPlayingView: View {
@State private var localVolume: Double = 0
@State private var isVolumeEditing = false
@State private var volumeSettleTask: Task<Void, Never>?
@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 isProgressEditing = false
@State private var progressSettleTask: Task<Void, Never>?
@State private var progressTimer: Timer?
// Queue scroll trigger
@State private var scrollToQueue = false
// Queue state
@State private var isQueueLoading = false
@State private var showClearConfirm = false
// Auto-tracks live updates via @Observable
private var player: MAPlayer? {
service.playerManager.players[playerId]
@@ -41,28 +50,62 @@ struct PlayerNowPlayingView: View {
Double(currentItem?.duration ?? 0)
}
// Queue computed properties
private var queueItems: [MAQueueItem] {
service.playerManager.queues[playerId] ?? []
}
private var currentQueueIndex: Int? {
service.playerManager.playerQueues[playerId]?.currentIndex
}
private var currentItemId: String? {
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 {
VStack(spacing: 0) {
// Header
// Pinned header always visible
headerView
// Conditional content area
if showQueue {
PlayerQueueView(playerId: playerId)
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
playerContent
.transition(.move(edge: .leading).combined(with: .opacity))
// Scrollable content (album art + queue only)
// GeometryReader gives the actual available height so playerContent
// can fill exactly (viewport "Up Next" header), making only the
// caption peek at the bottom as a scroll hint.
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
playerContent
// Leave ~48 pt so only the "Up Next" header peeks
.frame(minHeight: geo.size.height - 48)
// Queue section below the fold
queueSection
.id("queueSection")
}
}
.onChange(of: scrollToQueue) { _, shouldScroll in
if shouldScroll {
withAnimation(.easeInOut(duration: 0.4)) {
proxy.scrollTo("queueSection", anchor: .top)
}
scrollToQueue = false
}
}
}
}
Spacer(minLength: 0)
// Progress bar (only in player mode, not queue)
if !showQueue {
progressView
}
// Transport + volume (always visible)
// Pinned controls always outside the scroll view, no gesture conflicts
progressView
controlsView
}
.background {
@@ -103,10 +146,9 @@ struct PlayerNowPlayingView: View {
progressTimer = nil
}
.onChange(of: playerQueue?.elapsedTime) {
syncElapsedTime()
if !isProgressEditing { syncElapsedTime() }
}
.onChange(of: player?.state) {
// Restart timer when play state changes
syncElapsedTime()
if player?.state == .playing {
startProgressTimer()
@@ -115,6 +157,17 @@ struct PlayerNowPlayingView: View {
progressTimer = nil
}
}
.task {
isQueueLoading = true
try? await service.playerManager.loadQueue(playerId: playerId)
isQueueLoading = 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) { }
}
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
}
@@ -143,7 +196,7 @@ struct PlayerNowPlayingView: View {
Spacer()
VStack(spacing: 2) {
Text(showQueue ? "Up Next" : "Now Playing")
Text("Now Playing")
.font(.caption)
.foregroundStyle(.secondary)
Text(player?.name ?? "")
@@ -155,21 +208,20 @@ struct PlayerNowPlayingView: View {
Spacer()
HStack(spacing: 4) {
// Queue icon scrolls to the queue section below
Button {
withAnimation(.easeInOut(duration: 0.3)) {
showQueue.toggle()
}
scrollToQueue = true
} label: {
Image(systemName: "list.bullet")
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(showQueue ? Color.accentColor : .primary)
.foregroundStyle(.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
if !showQueue, let uri = mediaItem?.uri {
FavoriteButton(uri: uri, size: 22)
if let uri = mediaItem?.uri {
FavoriteButton(uri: uri, size: 22, itemName: currentItem?.name)
.frame(width: 44, height: 44)
}
}
@@ -230,6 +282,7 @@ struct PlayerNowPlayingView: View {
Spacer(minLength: 8)
}
.padding(.bottom, 8)
}
// MARK: - Transport + Volume Controls
@@ -291,17 +344,46 @@ struct PlayerNowPlayingView: View {
}
.buttonStyle(.plain)
Slider(value: $localVolume, in: 0...100, step: 1) { editing in
isVolumeEditing = editing
if !editing {
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(localVolume)
)
}
GeometryReader { geo in
let thumbX = max(0, min(geo.size.width, geo.size.width * localVolume / 100))
ZStack(alignment: .leading) {
Capsule()
.fill(.primary.opacity(0.15))
.frame(height: 6)
Capsule()
.fill(.primary)
.frame(width: thumbX, height: 6)
Circle()
.fill(.primary)
.frame(width: 14, height: 14)
.offset(x: thumbX - 7)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isVolumeEditing = true
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
}
.onEnded { value in
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(localVolume)
)
}
volumeSettleTask?.cancel()
volumeSettleTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
isVolumeEditing = false
}
}
)
}
.frame(height: 28)
Button { adjustVolume(by: 5) } label: {
Image(systemName: "speaker.wave.3.fill")
@@ -325,17 +407,51 @@ struct PlayerNowPlayingView: View {
VStack(spacing: 4) {
// Progress bar
GeometryReader { geo in
let thumbX = max(0, min(geo.size.width, geo.size.width * progress))
ZStack(alignment: .leading) {
Capsule()
.fill(.primary.opacity(0.15))
.frame(height: 4)
Capsule()
.fill(.primary)
.frame(width: geo.size.width * progress, height: 4)
.frame(width: thumbX, height: 4)
Circle()
.fill(.primary)
.frame(width: 14, height: 14)
.offset(x: thumbX - 7)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard duration > 0 else { return }
isProgressEditing = true
progressTimer?.invalidate()
displayedElapsed = max(0, min(duration, value.location.x / geo.size.width * duration))
}
.onEnded { value in
guard duration > 0 else { return }
let seekTo = max(0, min(duration, value.location.x / geo.size.width * duration))
displayedElapsed = seekTo
Task {
do {
try await service.playerManager.seek(playerId: playerId, position: seekTo)
} catch {
print("❌ Seek failed: \(error)")
}
}
progressSettleTask?.cancel()
progressSettleTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
isProgressEditing = false
startProgressTimer()
}
}
)
}
.frame(height: 4)
.frame(height: 28)
// Time labels
HStack {
@@ -354,6 +470,133 @@ struct PlayerNowPlayingView: View {
.padding(.bottom, 4)
}
// MARK: - Queue Section
@ViewBuilder
private var queueSection: some View {
VStack(spacing: 0) {
// Section header
HStack {
HStack(spacing: 6) {
Image(systemName: "chevron.up")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("Up Next")
.font(.headline)
.fontWeight(.bold)
}
Spacer()
if !queueItems.isEmpty {
Text("\(queueItems.count) tracks")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 8)
// Control bar (shuffle / repeat / clear)
queueControlBar
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
// Queue items
if isQueueLoading && queueItems.isEmpty {
ProgressView()
.frame(height: 100)
.frame(maxWidth: .infinity)
} else if queueItems.isEmpty {
Text("Queue is empty")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(height: 100)
.frame(maxWidth: .infinity)
} else {
LazyVStack(spacing: 0) {
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
let isCurrent = currentQueueIndex == index || item.queueItemId == currentItemId
QueueItemRow(item: item, isCurrent: isCurrent)
.contentShape(Rectangle())
.onTapGesture {
Task {
try? await service.playerManager.playIndex(
playerId: playerId,
index: index
)
}
}
}
}
}
}
}
// MARK: - Queue Control Bar
@ViewBuilder
private var queueControlBar: 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: - Progress Helpers
private func syncElapsedTime() {
@@ -378,9 +621,8 @@ struct PlayerNowPlayingView: View {
guard player?.state == .playing else { return }
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
Task { @MainActor in
guard player?.state == .playing else { return }
guard player?.state == .playing, !isProgressEditing else { return }
displayedElapsed += 0.5
// Clamp to duration
if trackDuration > 0 {
displayedElapsed = min(displayedElapsed, trackDuration)
}
+1 -1
View File
@@ -190,7 +190,7 @@ struct PlayerQueueView: View {
// MARK: - Queue Item Row
private struct QueueItemRow: View {
struct QueueItemRow: View {
@Environment(MAService.self) private var service
let item: MAQueueItem
let isCurrent: Bool