Navigation angepasst, Absprung in angetippten song.
This commit is contained in:
+3
@@ -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,8 +126,12 @@ struct AlbumDetailView: View {
|
|||||||
EnhancedPlayerPickerView(
|
EnhancedPlayerPickerView(
|
||||||
players: players,
|
players: players,
|
||||||
onSelect: { player in
|
onSelect: { player in
|
||||||
|
if let index = selectedTrackIndex {
|
||||||
|
Task { await playTrack(fromIndex: index, on: player) }
|
||||||
|
} else {
|
||||||
Task { await playAlbum(on: player) }
|
Task { await playAlbum(on: player) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showEnqueuePicker) {
|
.sheet(isPresented: $showEnqueuePicker) {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user