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 + } + } } } }