Navigation angepasst, Absprung in angetippten song.

This commit is contained in:
2026-04-07 12:57:24 +02:00
parent 040917479e
commit fe3ed1e204
9 changed files with 361 additions and 4 deletions
@@ -51,6 +51,9 @@
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj"> ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Mobile Music Assistant/DonationProducts.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" 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 { struct Mobile_Music_AssistantApp: App {
@State private var service = MAService() @State private var service = MAService()
@State private var themeManager = MAThemeManager() @State private var themeManager = MAThemeManager()
@State private var storeManager = MAStoreManager()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(service) .environment(service)
.environment(themeManager) .environment(themeManager)
.environment(storeManager)
} }
} }
} }
@@ -253,6 +253,13 @@ final class MAPlayerManager {
try await service.playMedia(playerId: playerId, uri: uri) 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 { func enqueueMedia(playerId: String, uri: String) async throws {
guard let service else { guard let service else {
throw MAWebSocketClient.ClientError.notConnected 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 /// Add media item to the end of a player's queue
func enqueueMedia(playerId: String, uri: String) async throws { func enqueueMedia(playerId: String, uri: String) async throws {
logger.debug("Enqueuing media \(uri) on player \(playerId)") 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 showPlayerPicker = false
@State private var showEnqueuePicker = false @State private var showEnqueuePicker = false
@State private var selectedPlayer: MAPlayer? @State private var selectedPlayer: MAPlayer?
@State private var selectedTrackIndex: Int? = nil
@State private var kenBurnsScale: CGFloat = 1.0 @State private var kenBurnsScale: CGFloat = 1.0
@State private var completeAlbum: MAAlbum? @State private var completeAlbum: MAAlbum?
@State private var albumDescription: String? @State private var albumDescription: String?
@@ -125,7 +126,11 @@ struct AlbumDetailView: View {
EnhancedPlayerPickerView( EnhancedPlayerPickerView(
players: players, players: players,
onSelect: { player in 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 selectedPlayer = players.first
Task { await playAlbum(on: players.first!) } Task { await playAlbum(on: players.first!) }
} else { } else {
selectedTrackIndex = nil
showPlayerPicker = true showPlayerPicker = true
} }
} label: { } label: {
@@ -318,9 +324,10 @@ struct AlbumDetailView: View {
.onTapGesture { .onTapGesture {
if players.count == 1 { if players.count == 1 {
Task { Task {
await playTrack(track, on: players.first!) await playTrack(fromIndex: index, on: players.first!)
} }
} else { } else {
selectedTrackIndex = index
showPlayerPicker = true 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 { do {
try await service.playerManager.playMedia( try await service.playerManager.playMedia(
playerId: player.playerId, playerId: player.playerId,
uri: track.uri uris: uris
) )
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -18,6 +18,7 @@ struct ArtistDetailView: View {
@State private var showError = false @State private var showError = false
@State private var kenBurnsScale: CGFloat = 1.0 @State private var kenBurnsScale: CGFloat = 1.0
@State private var isBiographyExpanded = false @State private var isBiographyExpanded = false
@State private var scrollPositionAlbumID: String? = nil
var body: some View { var body: some View {
ZStack { ZStack {
@@ -52,6 +53,7 @@ struct ArtistDetailView: View {
} }
} }
} }
.scrollPosition(id: $scrollPositionAlbumID)
} }
.navigationTitle(artist.name) .navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -182,6 +184,7 @@ struct ArtistDetailView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.scrollTargetLayout()
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 24) .padding(.bottom, 24)
} }
@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import StoreKit
struct MainTabView: View { struct MainTabView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@@ -381,7 +382,9 @@ struct PlayerRow: View {
struct SettingsView: View { struct SettingsView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@Environment(MAStoreManager.self) private var storeManager
@Environment(\.themeManager) private var themeManager @Environment(\.themeManager) private var themeManager
@State private var showThankYou = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -455,8 +458,76 @@ struct SettingsView: View {
Label("Disconnect", systemImage: "arrow.right.square") 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") .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
}
}
} }
} }
} }