Navigation angepasst, Absprung in angetippten song.
This commit is contained in:
+3
@@ -51,6 +51,9 @@
|
||||
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Mobile Music Assistant/DonationProducts.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import SwiftUI
|
||||
struct Mobile_Music_AssistantApp: App {
|
||||
@State private var service = MAService()
|
||||
@State private var themeManager = MAThemeManager()
|
||||
@State private var storeManager = MAStoreManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(service)
|
||||
.environment(themeManager)
|
||||
.environment(storeManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,13 @@ final class MAPlayerManager {
|
||||
try await service.playMedia(playerId: playerId, uri: uri)
|
||||
}
|
||||
|
||||
func playMedia(playerId: String, uris: [String]) async throws {
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
try await service.playMedia(playerId: playerId, uris: uris)
|
||||
}
|
||||
|
||||
func enqueueMedia(playerId: String, uri: String) async throws {
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
|
||||
@@ -193,6 +193,18 @@ final class MAService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Play a list of media items (replaces current queue)
|
||||
func playMedia(playerId: String, uris: [String]) async throws {
|
||||
logger.debug("Playing \(uris.count) tracks on player \(playerId)")
|
||||
_ = try await webSocketClient.sendCommand(
|
||||
"player_queues/play_media",
|
||||
args: [
|
||||
"queue_id": playerId,
|
||||
"media": uris
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)")
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user