diff --git a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme
index a6ae343..ee42518 100644
--- a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme
+++ b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme
@@ -51,6 +51,9 @@
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
+
+
= [
+ "donate.song",
+ "donate.album",
+ "donate.anthology"
+ ]
+
+ var products: [Product] = []
+ var isPurchasing = false
+ var purchaseResult: PurchaseResult?
+
+ private var transactionListener: Task?
+
+ 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 {
+ Task.detached {
+ for await verificationResult in Transaction.updates {
+ if case .verified(let transaction) = verificationResult {
+ await transaction.finish()
+ }
+ }
+ }
+ }
+
+ // MARK: - Helpers
+
+ private func checkVerified(_ result: VerificationResult) 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"
+ }
+ }
+}
diff --git a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift
index 4dafbc1..e22aed0 100644
--- a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift
+++ b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift
@@ -18,6 +18,7 @@ struct AlbumDetailView: View {
@State private var showPlayerPicker = false
@State private var showEnqueuePicker = false
@State private var selectedPlayer: MAPlayer?
+ @State private var selectedTrackIndex: Int? = nil
@State private var kenBurnsScale: CGFloat = 1.0
@State private var completeAlbum: MAAlbum?
@State private var albumDescription: String?
@@ -125,7 +126,11 @@ struct AlbumDetailView: View {
EnhancedPlayerPickerView(
players: players,
onSelect: { player in
- Task { await playAlbum(on: player) }
+ if let index = selectedTrackIndex {
+ Task { await playTrack(fromIndex: index, on: player) }
+ } else {
+ Task { await playAlbum(on: player) }
+ }
}
)
}
@@ -251,6 +256,7 @@ struct AlbumDetailView: View {
selectedPlayer = players.first
Task { await playAlbum(on: players.first!) }
} else {
+ selectedTrackIndex = nil
showPlayerPicker = true
}
} label: {
@@ -318,9 +324,10 @@ struct AlbumDetailView: View {
.onTapGesture {
if players.count == 1 {
Task {
- await playTrack(track, on: players.first!)
+ await playTrack(fromIndex: index, on: players.first!)
}
} else {
+ selectedTrackIndex = index
showPlayerPicker = true
}
}
@@ -442,11 +449,12 @@ struct AlbumDetailView: View {
}
}
- private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
+ private func playTrack(fromIndex index: Int, on player: MAPlayer) async {
+ let uris = tracks[index...].map { $0.uri }
do {
try await service.playerManager.playMedia(
playerId: player.playerId,
- uri: track.uri
+ uris: uris
)
} catch {
errorMessage = error.localizedDescription
diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift
index 19dfd4f..3e36e76 100644
--- a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift
+++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift
@@ -18,6 +18,7 @@ struct ArtistDetailView: View {
@State private var showError = false
@State private var kenBurnsScale: CGFloat = 1.0
@State private var isBiographyExpanded = false
+ @State private var scrollPositionAlbumID: String? = nil
var body: some View {
ZStack {
@@ -52,6 +53,7 @@ struct ArtistDetailView: View {
}
}
}
+ .scrollPosition(id: $scrollPositionAlbumID)
}
.navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline)
@@ -182,6 +184,7 @@ struct ArtistDetailView: View {
.buttonStyle(.plain)
}
}
+ .scrollTargetLayout()
.padding(.horizontal)
.padding(.bottom, 24)
}
diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift
index 599ddd2..ccdbca2 100644
--- a/Mobile Music Assistant/ViewsMainTabView.swift
+++ b/Mobile Music Assistant/ViewsMainTabView.swift
@@ -6,6 +6,7 @@
//
import SwiftUI
+import StoreKit
struct MainTabView: View {
@Environment(MAService.self) private var service
@@ -381,7 +382,9 @@ struct PlayerRow: View {
struct SettingsView: View {
@Environment(MAService.self) private var service
+ @Environment(MAStoreManager.self) private var storeManager
@Environment(\.themeManager) private var themeManager
+ @State private var showThankYou = false
var body: some View {
NavigationStack {
@@ -455,8 +458,76 @@ struct SettingsView: View {
Label("Disconnect", systemImage: "arrow.right.square")
}
}
+
+ // Support Development Section
+ Section {
+ if storeManager.products.isEmpty {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else {
+ ForEach(storeManager.products, id: \.id) { product in
+ Button {
+ 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)
+ }
+ .buttonStyle(.plain)
+ .disabled(storeManager.isPurchasing)
+ }
+ }
+ } header: {
+ Text("Support Development")
+ } footer: {
+ Text("Donations help keep this app updated and ad-free. Thank you!")
+ }
}
.navigationTitle("Settings")
+ .task {
+ await storeManager.loadProducts()
+ }
+ .alert("Thank You!", isPresented: $showThankYou) {
+ Button("OK", role: .cancel) { }
+ } message: {
+ Text("Your support means a lot and helps keep this app alive!")
+ }
+ .onChange(of: storeManager.purchaseResult) {
+ if case .success = storeManager.purchaseResult {
+ showThankYou = true
+ storeManager.purchaseResult = nil
+ }
+ }
}
}
}