Version 1.4 - Translations, Like toasts Queue redesign.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "DC06E9B7",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "19.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "6761880856",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Donate the value of an album",
|
||||
"displayName" : "Album",
|
||||
"locale" : "en_US"
|
||||
},
|
||||
{
|
||||
"description" : "Spende mir den Gegenwert eines Albums",
|
||||
"displayName" : "Album",
|
||||
"locale" : "de"
|
||||
}
|
||||
],
|
||||
"productID" : "donatealbum",
|
||||
"referenceName" : "Spende - Album",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "49.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "6761880886",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Donate the value of an anthology",
|
||||
"displayName" : "Anthology",
|
||||
"locale" : "en_US"
|
||||
},
|
||||
{
|
||||
"description" : "Spende mir den Gegenwert einer Anthologie",
|
||||
"displayName" : "Anthologie",
|
||||
"locale" : "de"
|
||||
}
|
||||
],
|
||||
"productID" : "donateanthology",
|
||||
"referenceName" : "Spende - Anthology",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "6761880625",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Spende mir den Gegenwert eines Songs",
|
||||
"displayName" : "Song",
|
||||
"locale" : "de"
|
||||
},
|
||||
{
|
||||
"description" : "Donate the value of a song",
|
||||
"displayName" : "Song",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "donatesong",
|
||||
"referenceName" : "Spende - Song",
|
||||
"type" : "Consumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6761258120",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "EKFHUHT63T",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 797413193.48934102,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
|
||||
],
|
||||
"version" : {
|
||||
"major" : 5,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,16 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2616AF4E2F876BEF00CB210E /* ServicesMAStoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */; };
|
||||
2616AF502F87782600CB210E /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 2616AF4F2F87782600CB210E /* Donations.storekit */; };
|
||||
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */; };
|
||||
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */; };
|
||||
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesMAStoreManager.swift; sourceTree = "<group>"; };
|
||||
2616AF4F2F87782600CB210E /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = "<group>"; };
|
||||
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerQueueView.swift; sourceTree = "<group>"; };
|
||||
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsComponentsProviderBadge.swift; sourceTree = "<group>"; };
|
||||
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = "<group>"; };
|
||||
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -41,11 +43,12 @@
|
||||
26ED92582F759EEA0025419D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2616AF4F2F87782600CB210E /* Donations.storekit */,
|
||||
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
|
||||
26ED92622F759EEA0025419D /* Products */,
|
||||
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
|
||||
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
|
||||
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */,
|
||||
2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -103,6 +106,9 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
);
|
||||
mainGroup = 26ED92582F759EEA0025419D;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
@@ -121,6 +127,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2616AF502F87782600CB210E /* Donations.storekit in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -131,8 +138,8 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2616AF4E2F876BEF00CB210E /* ServicesMAStoreManager.swift in Sources */,
|
||||
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */,
|
||||
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */,
|
||||
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Mobile Music Assistant/DonationProducts.storekit">
|
||||
identifier = "../../Donations.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "subsonic_94696.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" version="1.1" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="linearGradient4207" x1="2" x2="8" y1="1038.1" y2="1038.1" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-opacity="0"/>
|
||||
<stop offset="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(0 -1020.4)">
|
||||
<g transform="matrix(.66667 0 0 .8 -1.3333 215.87)">
|
||||
<rect fill="#41b941" width="3" height="10" x="14" y="1014.4" rx="1.406" ry="1.747"/>
|
||||
<path fill="#fff" opacity=".2" d="m15.406 10c-0.779 0-1.406 0.778-1.406 1.746v1c0-0.968 0.627-1.746 1.406-1.746h0.1875c0.779 0 1.406 0.778 1.406 1.746v-1c0-0.968-0.627-1.746-1.406-1.746h-0.1875z" transform="translate(0 1004.4)"/>
|
||||
</g>
|
||||
<path fill="#ffca1d" d="m14.6 1027.4c-0.7756 0-1.6 0.6175-1.6 1.3846v1.6168c-2.5977 0.8209-4.3 3.114-5 4.4986-0.7-1.3846-5-2-5-2v3.5h-1v4h1v3c-0.027344 0 4.3-0.6154 5-2 0.7 1.3846 4.5 4 10.1 4s11.9-3.0272 11.9-7.6154c-0.0061-3.2607-2.7438-6.0433-7-7.3859v-1.6141c0-0.7671-0.6244-1.3846-1.4-1.3846z"/>
|
||||
<circle opacity=".2" cx="18" cy="1038.4" r="2"/>
|
||||
<circle fill="#fff" cx="18" cy="1037.4" r="2"/>
|
||||
<circle opacity=".2" cx="23" cy="1038.4" r="2"/>
|
||||
<circle fill="#fff" cx="23" cy="1037.4" r="2"/>
|
||||
<circle opacity=".2" cx="13" cy="1038.4" r="2"/>
|
||||
<circle fill="#fff" cx="13" cy="1037.4" r="2"/>
|
||||
<path fill="#fff" opacity=".2" d="m14.6 7c-0.776 0-1.6 0.6177-1.6 1.3848v1c0-0.7671 0.824-1.3848 1.6-1.3848h7c0.775 0 1.4 0.6177 1.4 1.3848v-1c0-0.7671-0.625-1.3848-1.4-1.3848zm8.4 2.998v1c3.9894 1.2584 6.6445 3.7828 6.9668 6.7793 0.01-0.131 0.033-0.259 0.033-0.392-0.006-3.261-2.744-6.044-7-7.387zm-10 0.00391c-2.598 0.821-4.3 3.113-5 4.498-0.7-1.385-5-2-5-2v1s4.3 0.6154 5 2c0.7-1.3846 2.4023-3.6771 5-4.498z" transform="translate(0 1020.4)"/>
|
||||
<path opacity=".2" d="m29.967 1038.1c-0.35239 4.3638-6.4312 7.2207-11.867 7.2207-5.6 0-9.3996-2.6154-10.1-4-0.7 1.3846-5.0273 2-5 2v1c-0.027344 0 4.3-0.6154 5-2 0.7 1.3846 4.4996 4 10.1 4 5.6 0 11.9-3.027 11.9-7.6152-0.000381-0.2038-0.01178-0.4057-0.0332-0.6055z"/>
|
||||
<path fill="url(#linearGradient4207)" opacity=".1" d="m8 1034.9c-0.7-1.3846-5-2-5-2v3.5h-1v4h1v3c-0.027344 0 4.3-0.6154 5-2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,129 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,19 @@ struct Mobile_Music_AssistantApp: App {
|
||||
@State private var service = MAService()
|
||||
@State private var themeManager = MAThemeManager()
|
||||
@State private var storeManager = MAStoreManager()
|
||||
|
||||
@State private var localeManager = MALocaleManager()
|
||||
@State private var toastManager = MAToastManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(service)
|
||||
.environment(themeManager)
|
||||
.environment(\.themeManager, themeManager)
|
||||
.environment(storeManager)
|
||||
.environment(localeManager)
|
||||
.environment(\.localeManager, localeManager)
|
||||
.environment(toastManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// ServicesMALocaleManager.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 09.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Supported Languages
|
||||
|
||||
enum SupportedLanguage: String, CaseIterable, Identifiable {
|
||||
case en = "en"
|
||||
case de = "de"
|
||||
case fr = "fr"
|
||||
case es = "es"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Native display name (endonym) — always shown in the language's own script
|
||||
var endonym: String {
|
||||
switch self {
|
||||
case .en: return "English"
|
||||
case .de: return "Deutsch"
|
||||
case .fr: return "Français"
|
||||
case .es: return "Español"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Locale Manager
|
||||
|
||||
@Observable
|
||||
class MALocaleManager {
|
||||
/// nil means "System" (no override — iOS uses device language)
|
||||
var selectedLanguageCode: String? {
|
||||
didSet {
|
||||
if let code = selectedLanguageCode {
|
||||
UserDefaults.standard.set(code, forKey: "appLanguageOverride")
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: "appLanguageOverride")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
selectedLanguageCode = UserDefaults.standard.string(forKey: "appLanguageOverride")
|
||||
}
|
||||
|
||||
/// The locale to inject into the environment. nil = let iOS choose.
|
||||
var overrideLocale: Locale? {
|
||||
guard let code = selectedLanguageCode else { return nil }
|
||||
return Locale(identifier: code)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment Key
|
||||
|
||||
private struct LocaleManagerKey: EnvironmentKey {
|
||||
static let defaultValue = MALocaleManager()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var localeManager: MALocaleManager {
|
||||
get { self[LocaleManagerKey.self] }
|
||||
set { self[LocaleManagerKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Locale Applied Modifier
|
||||
|
||||
struct LocaleAppliedModifier: ViewModifier {
|
||||
@Environment(\.localeManager) var localeManager
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.environment(\.locale, localeManager.overrideLocale ?? .current)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func applyLocale() -> some View {
|
||||
modifier(LocaleAppliedModifier())
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,13 @@ final class MAPlayerManager {
|
||||
try await service.previousTrack(playerId: playerId)
|
||||
}
|
||||
|
||||
func seek(playerId: String, position: Double) async throws {
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
try await service.seek(playerId: playerId, position: position)
|
||||
}
|
||||
|
||||
func setVolume(playerId: String, level: Int) async throws {
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
|
||||
@@ -146,6 +146,20 @@ final class MAService {
|
||||
)
|
||||
}
|
||||
|
||||
/// Seek to a position in the current track (seconds)
|
||||
func seek(playerId: String, position: Double) async throws {
|
||||
let seconds = Int(max(0, position))
|
||||
logger.debug("Seeking to \(seconds)s on player \(playerId)")
|
||||
let response = try await webSocketClient.sendCommand(
|
||||
"players/cmd/seek",
|
||||
args: [
|
||||
"player_id": playerId,
|
||||
"position": seconds
|
||||
]
|
||||
)
|
||||
logger.debug("Seek response: errorCode=\(String(describing: response.errorCode)) details=\(String(describing: response.details))")
|
||||
}
|
||||
|
||||
/// Set volume (0-100)
|
||||
func setVolume(playerId: String, level: Int) async throws {
|
||||
let clampedLevel = max(0, min(100, level))
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,25 +39,19 @@ enum AppColorScheme: String, CaseIterable, Identifiable {
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .system:
|
||||
return "System"
|
||||
case .light:
|
||||
return "Light"
|
||||
case .dark:
|
||||
return "Dark"
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
|
||||
var description: LocalizedStringKey {
|
||||
switch self {
|
||||
case .system:
|
||||
return "Follows your device's appearance"
|
||||
case .light:
|
||||
return "Always use light mode"
|
||||
case .dark:
|
||||
return "Always use dark mode"
|
||||
case .system: return "Follows your device's appearance"
|
||||
case .light: return "Always use light mode"
|
||||
case .dark: return "Always use dark mode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +87,6 @@ struct ThemeAppliedModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.preferredColorScheme(themeManager.preferredColorScheme)
|
||||
.id(themeManager.colorScheme) // Force refresh when theme changes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,15 @@ struct EnhancedPlayerPickerView: View {
|
||||
|
||||
let players: [MAPlayer]
|
||||
let title: String
|
||||
let showNowPlayingOnSelect: Bool
|
||||
let onSelect: (MAPlayer) -> Void
|
||||
|
||||
init(players: [MAPlayer], title: String = "Play on...", onSelect: @escaping (MAPlayer) -> Void) {
|
||||
@State private var nowPlayingPlayer: MAPlayer?
|
||||
|
||||
init(players: [MAPlayer], title: String = "Play on...", showNowPlayingOnSelect: Bool = false, onSelect: @escaping (MAPlayer) -> Void) {
|
||||
self.players = players
|
||||
self.title = title
|
||||
self.showNowPlayingOnSelect = showNowPlayingOnSelect
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
@@ -44,7 +48,11 @@ struct EnhancedPlayerPickerView: View {
|
||||
.compactMap { service.playerManager.players[$0]?.name }
|
||||
PickerGroupCard(leader: leader, memberNames: memberNames) {
|
||||
onSelect(leader)
|
||||
dismiss()
|
||||
if showNowPlayingOnSelect {
|
||||
nowPlayingPlayer = leader
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +60,11 @@ struct EnhancedPlayerPickerView: View {
|
||||
ForEach(soloPlayers) { player in
|
||||
PickerPlayerCard(player: player) {
|
||||
onSelect(player)
|
||||
dismiss()
|
||||
if showNowPlayingOnSelect {
|
||||
nowPlayingPlayer = player
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +79,10 @@ struct EnhancedPlayerPickerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $nowPlayingPlayer, onDismiss: { dismiss() }) { player in
|
||||
PlayerNowPlayingView(playerId: player.playerId)
|
||||
.environment(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@ import SwiftUI
|
||||
/// Reusable heart button for toggling favorites on artists, albums, and tracks.
|
||||
struct FavoriteButton: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@Environment(MAToastManager.self) private var toastManager
|
||||
let uri: String
|
||||
var size: CGFloat = 22
|
||||
var showInLight: Bool = false
|
||||
/// Display name shown in the toast when the item is liked. Pass nil to suppress the toast.
|
||||
var itemName: String? = nil
|
||||
|
||||
private var isFavorite: Bool {
|
||||
service.libraryManager.isFavorite(uri: uri)
|
||||
@@ -20,11 +23,19 @@ struct FavoriteButton: View {
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
let wasAlreadyFavorite = isFavorite
|
||||
Task {
|
||||
await service.libraryManager.toggleFavorite(
|
||||
uri: uri,
|
||||
currentlyFavorite: isFavorite
|
||||
currentlyFavorite: wasAlreadyFavorite
|
||||
)
|
||||
if let name = itemName {
|
||||
if wasAlreadyFavorite {
|
||||
toastManager.show(name, icon: "heart.slash", iconColor: .secondary)
|
||||
} else {
|
||||
toastManager.show(name, icon: "heart.fill", iconColor: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// ViewsComponentsToastOverlay.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 09.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Toast Manager
|
||||
|
||||
@Observable
|
||||
class MAToastManager {
|
||||
private(set) var message: String = ""
|
||||
private(set) var icon: String = "heart.fill"
|
||||
private(set) var iconColor: Color = .red
|
||||
private(set) var isVisible: Bool = false
|
||||
private var hideTask: Task<Void, Never>?
|
||||
|
||||
func show(_ message: String, icon: String = "heart.fill", iconColor: Color = .red) {
|
||||
self.message = message
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
withAnimation(.spring(duration: 0.3)) { isVisible = true }
|
||||
hideTask?.cancel()
|
||||
hideTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.4)) { isVisible = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toast View Modifier
|
||||
|
||||
struct ToastOverlayModifier: ViewModifier {
|
||||
@Environment(MAToastManager.self) private var toastManager
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.overlay(alignment: .top) {
|
||||
if toastManager.isVisible {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: toastManager.icon)
|
||||
.foregroundStyle(toastManager.iconColor)
|
||||
Text(toastManager.message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 11)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
|
||||
.padding(.top, 12)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func withToast() -> some View {
|
||||
modifier(ToastOverlayModifier())
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,17 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum FavoritesTab: String, CaseIterable {
|
||||
case artists = "Artists"
|
||||
case albums = "Albums"
|
||||
case radios = "Radios"
|
||||
case podcasts = "Podcasts"
|
||||
enum FavoritesTab: CaseIterable {
|
||||
case artists, albums, radios, podcasts
|
||||
|
||||
var title: LocalizedStringKey {
|
||||
switch self {
|
||||
case .artists: return "Artists"
|
||||
case .albums: return "Albums"
|
||||
case .radios: return "Radios"
|
||||
case .podcasts: return "Podcasts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoritesView: View {
|
||||
@@ -41,7 +47,7 @@ struct FavoritesView: View {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Picker("Favorites", selection: $selectedTab) {
|
||||
ForEach(FavoritesTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
@@ -58,6 +64,8 @@ struct FavoritesView: View {
|
||||
private struct FavoriteArtistsSection: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var scrollPosition: String?
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
|
||||
private var favoriteArtists: [MAArtist] {
|
||||
// Merge artists + albumArtists, deduplicate by URI, filter favorites
|
||||
@@ -147,6 +155,24 @@ private struct FavoriteArtistsSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await reloadArtists()
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
if let errorMessage { Text(errorMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadArtists() async {
|
||||
do {
|
||||
try await service.libraryManager.loadArtists(refresh: true)
|
||||
try await service.libraryManager.loadAlbumArtists(refresh: true)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +180,8 @@ private struct FavoriteArtistsSection: View {
|
||||
|
||||
private struct FavoriteAlbumsSection: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
|
||||
private var favoriteAlbums: [MAAlbum] {
|
||||
service.libraryManager.albums
|
||||
@@ -189,6 +217,23 @@ private struct FavoriteAlbumsSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await reloadAlbums()
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
if let errorMessage { Text(errorMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadAlbums() async {
|
||||
do {
|
||||
try await service.libraryManager.loadAlbums(refresh: true)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +295,7 @@ private struct FavoriteRadiosSection: View {
|
||||
.sheet(item: $selectedRadio) { radio in
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
showNowPlayingOnSelect: true,
|
||||
onSelect: { player in
|
||||
Task { await playRadio(radio, on: player) }
|
||||
}
|
||||
@@ -291,6 +337,8 @@ private struct FavoriteRadiosSection: View {
|
||||
|
||||
private struct FavoritePodcastsSection: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
|
||||
private var favoritePodcasts: [MAPodcast] {
|
||||
service.libraryManager.podcasts
|
||||
@@ -315,6 +363,23 @@ private struct FavoritePodcastsSection: View {
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await reloadPodcasts()
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
if let errorMessage { Text(errorMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadPodcasts() async {
|
||||
do {
|
||||
try await service.libraryManager.loadPodcasts(refresh: true)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ struct AlbumDetailView: View {
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
|
||||
FavoriteButton(uri: album.uri, size: 22, showInLight: true, itemName: album.name)
|
||||
}
|
||||
}
|
||||
.task(id: "tracks-\(album.uri)") {
|
||||
@@ -125,6 +125,7 @@ struct AlbumDetailView: View {
|
||||
.sheet(isPresented: $showPlayerPicker) {
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
showNowPlayingOnSelect: true,
|
||||
onSelect: { player in
|
||||
if let index = selectedTrackIndex {
|
||||
Task { await playTrack(fromIndex: index, on: player) }
|
||||
@@ -222,8 +223,6 @@ struct AlbumDetailView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: album.uri, metadata: album.metadata)
|
||||
|
||||
if let year = album.year {
|
||||
Text(String(year))
|
||||
.font(.subheadline)
|
||||
@@ -507,7 +506,7 @@ struct TrackRow: View {
|
||||
Spacer()
|
||||
|
||||
// Favorite
|
||||
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme)
|
||||
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme, itemName: track.name)
|
||||
|
||||
// Duration
|
||||
if let duration = track.duration {
|
||||
|
||||
@@ -59,7 +59,7 @@ struct ArtistDetailView: View {
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
|
||||
FavoriteButton(uri: artist.uri, size: 22, showInLight: true, itemName: artist.name)
|
||||
}
|
||||
}
|
||||
.task(id: "albums-\(artist.uri)") {
|
||||
@@ -128,43 +128,6 @@ struct ArtistDetailView: View {
|
||||
|
||||
// MARK: - Artist Header
|
||||
|
||||
/// All distinct music providers for this artist.
|
||||
/// Uses the authoritative provider_mappings field from MA when available,
|
||||
/// and falls back to scanning loaded album metadata.
|
||||
private var artistProviders: [MusicProvider] {
|
||||
var seen = Set<MusicProvider>()
|
||||
var result = [MusicProvider]()
|
||||
|
||||
// Primary: provider_mappings from the artist object (available immediately)
|
||||
for mapping in artist.providerMappings {
|
||||
if let p = MusicProvider.from(providerKey: mapping.providerDomain), !seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan loaded albums when providerMappings is empty
|
||||
if result.isEmpty {
|
||||
for album in albums {
|
||||
if let scheme = album.uri.components(separatedBy: "://").first,
|
||||
let p = MusicProvider.from(scheme: scheme),
|
||||
p != .library,
|
||||
!seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
for key in album.metadata?.images?.compactMap({ $0.provider }) ?? [] {
|
||||
if let p = MusicProvider.from(providerKey: key), !seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var artistHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
@@ -185,20 +148,6 @@ struct ArtistDetailView: View {
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
|
||||
|
||||
// One badge per distinct source
|
||||
if !artistProviders.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(artistProviders, id: \.self) { provider in
|
||||
Image(systemName: provider.icon)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 20, height: 20)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !albums.isEmpty {
|
||||
Text("\(albums.count) albums")
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -8,13 +8,19 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum LibraryTab: String, CaseIterable {
|
||||
case albumArtists = "Album Artists"
|
||||
case artists = "Artists"
|
||||
case albums = "Albums"
|
||||
case playlists = "Playlists"
|
||||
case podcasts = "Podcasts"
|
||||
case radio = "Radio"
|
||||
enum LibraryTab: CaseIterable {
|
||||
case albumArtists, artists, albums, playlists, podcasts, radio
|
||||
|
||||
var title: LocalizedStringKey {
|
||||
switch self {
|
||||
case .albumArtists: return "Album Artists"
|
||||
case .artists: return "Artists"
|
||||
case .albums: return "Albums"
|
||||
case .playlists: return "Playlists"
|
||||
case .podcasts: return "Podcasts"
|
||||
case .radio: return "Radio"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryView: View {
|
||||
@@ -45,7 +51,7 @@ struct LibraryView: View {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Picker("Library", selection: $selectedTab) {
|
||||
ForEach(LibraryTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -68,7 +68,7 @@ struct PlaylistDetailView: View {
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: playlist.uri, size: 22, showInLight: true)
|
||||
FavoriteButton(uri: playlist.uri, size: 22, showInLight: true, itemName: playlist.name)
|
||||
}
|
||||
}
|
||||
.task(id: playlist.uri) {
|
||||
@@ -88,6 +88,7 @@ struct PlaylistDetailView: View {
|
||||
.sheet(isPresented: $showPlayerPicker) {
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
showNowPlayingOnSelect: true,
|
||||
onSelect: { player in
|
||||
Task { await playPlaylist(on: player) }
|
||||
}
|
||||
@@ -180,8 +181,6 @@ struct PlaylistDetailView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: playlist.uri, metadata: playlist.metadata)
|
||||
|
||||
if !tracks.isEmpty {
|
||||
Text("\(tracks.count) tracks")
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -62,7 +62,7 @@ struct PodcastDetailView: View {
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: podcast.uri, size: 22, showInLight: true)
|
||||
FavoriteButton(uri: podcast.uri, size: 22, showInLight: true, itemName: podcast.name)
|
||||
}
|
||||
}
|
||||
.task(id: podcast.uri) {
|
||||
@@ -82,6 +82,7 @@ struct PodcastDetailView: View {
|
||||
.sheet(isPresented: $showPlayerPicker) {
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
showNowPlayingOnSelect: true,
|
||||
onSelect: { player in
|
||||
Task { await playPodcast(on: player) }
|
||||
}
|
||||
@@ -99,6 +100,7 @@ struct PodcastDetailView: View {
|
||||
.sheet(item: $selectedEpisode) { episode in
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
showNowPlayingOnSelect: true,
|
||||
onSelect: { player in
|
||||
Task { await playEpisode(episode, on: player) }
|
||||
}
|
||||
@@ -178,8 +180,6 @@ struct PodcastDetailView: View {
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: podcast.uri, metadata: podcast.metadata)
|
||||
|
||||
if !episodes.isEmpty {
|
||||
Text("\(episodes.count) episodes")
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -58,6 +58,7 @@ struct RadiosView: View {
|
||||
.sheet(item: $selectedRadio) { radio in
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
showNowPlayingOnSelect: true,
|
||||
onSelect: { player in
|
||||
Task { await playRadio(radio, on: player) }
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ struct LoginView: View {
|
||||
} header: {
|
||||
Text("Server")
|
||||
} footer: {
|
||||
Text("Enter your Music Assistant server URL (e.g., https://musicassistant-app.hanold.online)")
|
||||
Text("Enter your Music Assistant server URL (e.g., https://music.example.com)")
|
||||
}
|
||||
|
||||
// Token Section
|
||||
|
||||
@@ -10,32 +10,34 @@ import StoreKit
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var selectedTab: String = "library"
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Library", systemImage: "music.note.list") {
|
||||
TabView(selection: $selectedTab) {
|
||||
Tab("Library", systemImage: "music.note.list", value: "library") {
|
||||
LibraryView()
|
||||
}
|
||||
|
||||
Tab("Favorites", systemImage: "heart.fill") {
|
||||
Tab("Favorites", systemImage: "heart.fill", value: "favorites") {
|
||||
FavoritesView()
|
||||
}
|
||||
|
||||
Tab("Search", systemImage: "magnifyingglass") {
|
||||
Tab("Search", systemImage: "magnifyingglass", value: "search") {
|
||||
NavigationStack {
|
||||
SearchView()
|
||||
.withMANavigation()
|
||||
}
|
||||
}
|
||||
|
||||
Tab("Players", systemImage: "speaker.wave.2.fill") {
|
||||
Tab("Players", systemImage: "speaker.wave.2.fill", value: "players") {
|
||||
PlayerListView()
|
||||
}
|
||||
|
||||
Tab("Settings", systemImage: "gear") {
|
||||
Tab("Settings", systemImage: "gear", value: "settings") {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.withToast()
|
||||
.task {
|
||||
// Start listening to player events and load players when main view appears
|
||||
service.playerManager.startListening()
|
||||
@@ -386,8 +388,9 @@ struct PlayerRow: View {
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@Environment(MAStoreManager.self) private var storeManager
|
||||
@Environment(\.themeManager) private var themeManager
|
||||
@Environment(\.localeManager) private var localeManager
|
||||
@Environment(MAStoreManager.self) private var storeManager
|
||||
@State private var showThankYou = false
|
||||
|
||||
var body: some View {
|
||||
@@ -435,6 +438,24 @@ struct SettingsView: View {
|
||||
Text("Choose how the app looks. System follows your device settings.")
|
||||
}
|
||||
|
||||
// Language Section
|
||||
Section {
|
||||
Picker("Language", selection: Binding(
|
||||
get: { localeManager.selectedLanguageCode ?? "system" },
|
||||
set: { localeManager.selectedLanguageCode = $0 == "system" ? nil : $0 }
|
||||
)) {
|
||||
Text("System").tag("system")
|
||||
ForEach(SupportedLanguage.allCases) { lang in
|
||||
Text(verbatim: lang.endonym).tag(lang.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} header: {
|
||||
Text("Language")
|
||||
} footer: {
|
||||
Text("Choose the app language. System uses your device language.")
|
||||
}
|
||||
|
||||
// Connection Section
|
||||
Section {
|
||||
if let serverURL = service.authManager.serverURL {
|
||||
@@ -462,74 +483,67 @@ struct SettingsView: View {
|
||||
Label("Disconnect", systemImage: "arrow.right.square")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Support Development Section
|
||||
Section {
|
||||
if storeManager.products.isEmpty {
|
||||
HStack {
|
||||
if let loadError = storeManager.loadError {
|
||||
Label(loadError, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(storeManager.products, id: \.id) { product in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: storeManager.iconName(for: product))
|
||||
.font(.title2)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(storeManager.tierName(for: product))
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(product.displayPrice)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ForEach(storeManager.products, id: \.id) { product in
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await storeManager.purchase(product)
|
||||
}
|
||||
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)
|
||||
Text(product.displayPrice)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.foregroundStyle(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(storeManager.isPurchasing)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
} header: {
|
||||
Text("Support Development")
|
||||
} footer: {
|
||||
Text("Donations help keep this app updated and ad-free. Thank you!")
|
||||
Text("Do you find this app useful? Support the development by buying the developer a virtual record.")
|
||||
}
|
||||
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.task {
|
||||
await storeManager.loadProducts()
|
||||
}
|
||||
.alert("Thank You!", isPresented: $showThankYou) {
|
||||
Button("OK", role: .cancel) { }
|
||||
Button("You're welcome!", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Your support means a lot and helps keep this app alive!")
|
||||
Text("Your support means a lot and helps keep Mobile Music Assistant alive.")
|
||||
}
|
||||
.onChange(of: storeManager.purchaseResult) {
|
||||
if case .success = storeManager.purchaseResult {
|
||||
.onChange(of: storeManager.purchaseResult) { _, result in
|
||||
if case .success = result {
|
||||
showThankYou = true
|
||||
storeManager.purchaseResult = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.applyTheme()
|
||||
.applyLocale()
|
||||
.task {
|
||||
await initializeConnection()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// ServicesMAStoreManager.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 09.04.26.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "StoreManager")
|
||||
|
||||
enum PurchaseResult: Equatable {
|
||||
case success(Product)
|
||||
case cancelled
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Observable @MainActor final class MAStoreManager {
|
||||
var products: [Product] = []
|
||||
var purchaseResult: PurchaseResult?
|
||||
var isPurchasing = false
|
||||
var loadError: String?
|
||||
|
||||
private static let productIDs: Set<String> = [
|
||||
"donatesong",
|
||||
"donatealbum",
|
||||
"donateanthology"
|
||||
]
|
||||
|
||||
private var updateListenerTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
updateListenerTask = listenForTransactions()
|
||||
}
|
||||
|
||||
func loadProducts() async {
|
||||
loadError = nil
|
||||
do {
|
||||
let fetched = try await Product.products(for: Self.productIDs)
|
||||
products = fetched.sorted { $0.price < $1.price }
|
||||
if fetched.isEmpty {
|
||||
loadError = "No products returned. Make sure the StoreKit configuration is active in the scheme (Edit Scheme → Run → Options → StoreKit Configuration)."
|
||||
logger.warning("Product.products(for:) returned 0 results for IDs: \(Self.productIDs)")
|
||||
}
|
||||
} catch {
|
||||
loadError = error.localizedDescription
|
||||
logger.error("Failed to load products: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(_ product: Product) async {
|
||||
isPurchasing = true
|
||||
defer { isPurchasing = false }
|
||||
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
await transaction.finish()
|
||||
purchaseResult = .success(product)
|
||||
case .userCancelled:
|
||||
purchaseResult = .cancelled
|
||||
case .pending:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
purchaseResult = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .unverified(_, let error):
|
||||
throw error
|
||||
case .verified(let value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Never> {
|
||||
Task(priority: .background) { [weak self] in
|
||||
for await result in Transaction.updates {
|
||||
do {
|
||||
let transaction = try self?.checkVerified(result)
|
||||
await transaction?.finish()
|
||||
} catch {
|
||||
logger.error("Transaction verification failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func iconName(for product: Product) -> String {
|
||||
switch product.id {
|
||||
case "donatesong": return "music.note"
|
||||
case "donatealbum": return "opticaldisc"
|
||||
case "donateanthology": return "music.note.list"
|
||||
default: return "heart.fill"
|
||||
}
|
||||
}
|
||||
|
||||
func tierName(for product: Product) -> LocalizedStringKey {
|
||||
switch product.id {
|
||||
case "donatesong": return "Song"
|
||||
case "donatealbum": return "Album"
|
||||
case "donateanthology": return "Anthology"
|
||||
default: return LocalizedStringKey(product.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
//
|
||||
// ProviderBadge.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 06.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Monochrome badges indicating which music provider(s) an item comes from.
|
||||
/// For provider-specific URIs (e.g. spotify://) a single badge is shown.
|
||||
/// For library:// items all distinct source providers found in the metadata
|
||||
/// images are shown side by side.
|
||||
struct ProviderBadge: View {
|
||||
let uri: String
|
||||
var metadata: MediaItemMetadata? = nil
|
||||
|
||||
private var providers: [MusicProvider] {
|
||||
// Use string-based parsing — URL(string:) returns nil for schemes with
|
||||
// underscores like "apple_music" which violate RFC 2396.
|
||||
let scheme = uri.components(separatedBy: "://").first?.lowercased()
|
||||
|
||||
// Non-library URI → show only that provider
|
||||
if let fromScheme = MusicProvider.from(scheme: scheme), fromScheme != .library {
|
||||
return [fromScheme]
|
||||
}
|
||||
|
||||
// library:// URI → collect all distinct providers from metadata images
|
||||
var seen = Set<MusicProvider>()
|
||||
var result = [MusicProvider]()
|
||||
|
||||
let keys = metadata?.images?.compactMap { $0.provider } ?? []
|
||||
for key in keys {
|
||||
if let p = MusicProvider.from(providerKey: key), !seen.contains(p) {
|
||||
seen.insert(p)
|
||||
result.append(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing found for a library item → fall back to the library badge
|
||||
if result.isEmpty && scheme == "library" {
|
||||
return [.library]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(providers, id: \.self) { provider in
|
||||
if let assetName = provider.logoAssetName {
|
||||
Image(assetName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: provider.icon)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider Mapping
|
||||
|
||||
enum MusicProvider: Hashable {
|
||||
case library
|
||||
case subsonic
|
||||
case spotify
|
||||
case tidal
|
||||
case qobuz
|
||||
case plex
|
||||
case ytmusic
|
||||
case appleMusic
|
||||
case deezer
|
||||
case soundcloud
|
||||
case tunein
|
||||
case filesystem
|
||||
case jellyfin
|
||||
case dlna
|
||||
case ardAudiothek
|
||||
|
||||
/// Asset catalog image name for providers that have a custom logo.
|
||||
/// Returns `nil` when the provider uses an SF Symbol instead.
|
||||
var logoAssetName: String? {
|
||||
switch self {
|
||||
case .subsonic: return "SubsonicLogo"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol name for this provider (used when `logoAssetName` is nil).
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .library: return "building.columns.fill"
|
||||
case .subsonic: return "ferry.fill"
|
||||
case .spotify: return "antenna.radiowaves.left.and.right.circle.fill"
|
||||
case .tidal: return "water.waves"
|
||||
case .qobuz: return "hifispeaker.fill"
|
||||
case .plex: return "play.square.stack.fill"
|
||||
case .ytmusic: return "play.rectangle.fill"
|
||||
case .appleMusic: return "applelogo"
|
||||
case .deezer: return "waveform"
|
||||
case .soundcloud: return "cloud.fill"
|
||||
case .tunein: return "radio.fill"
|
||||
case .filesystem: return "folder.fill"
|
||||
case .jellyfin: return "server.rack"
|
||||
case .dlna: return "wifi"
|
||||
case .ardAudiothek: return "antenna.radiowaves.left.and.right"
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a URI scheme to a known provider.
|
||||
static func from(scheme: String?) -> MusicProvider? {
|
||||
guard let scheme = scheme?.lowercased() else { return nil }
|
||||
|
||||
if scheme == "library" { return .library }
|
||||
if scheme.hasPrefix("subsonic") { return .subsonic }
|
||||
if scheme.hasPrefix("spotify") { return .spotify }
|
||||
if scheme.hasPrefix("tidal") { return .tidal }
|
||||
if scheme.hasPrefix("qobuz") { return .qobuz }
|
||||
if scheme.hasPrefix("plex") { return .plex }
|
||||
if scheme.hasPrefix("ytmusic") { return .ytmusic }
|
||||
if scheme.hasPrefix("apple") { return .appleMusic }
|
||||
if scheme.hasPrefix("deezer") { return .deezer }
|
||||
if scheme.hasPrefix("soundcloud") { return .soundcloud }
|
||||
if scheme.hasPrefix("tunein") { return .tunein }
|
||||
if scheme.hasPrefix("filesystem") { return .filesystem }
|
||||
if scheme.hasPrefix("jellyfin") { return .jellyfin }
|
||||
if scheme.hasPrefix("dlna") { return .dlna }
|
||||
if scheme.hasPrefix("ard_audiothek") { return .ardAudiothek }
|
||||
if scheme.hasPrefix("ard") { return .ardAudiothek }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Match a provider key from image metadata (e.g. "subsonic", "spotify", "filesystem_local").
|
||||
static func from(providerKey key: String) -> MusicProvider? {
|
||||
let k = key.lowercased()
|
||||
|
||||
if k.hasPrefix("subsonic") { return .subsonic }
|
||||
if k.hasPrefix("spotify") { return .spotify }
|
||||
if k.hasPrefix("tidal") { return .tidal }
|
||||
if k.hasPrefix("qobuz") { return .qobuz }
|
||||
if k.hasPrefix("plex") { return .plex }
|
||||
if k.hasPrefix("ytmusic") { return .ytmusic }
|
||||
if k.hasPrefix("apple") { return .appleMusic }
|
||||
if k.hasPrefix("deezer") { return .deezer }
|
||||
if k.hasPrefix("soundcloud") { return .soundcloud }
|
||||
if k.hasPrefix("tunein") { return .tunein }
|
||||
if k.hasPrefix("filesystem") { return .filesystem }
|
||||
if k.hasPrefix("jellyfin") { return .jellyfin }
|
||||
if k.hasPrefix("dlna") { return .dlna }
|
||||
if k.hasPrefix("ard_audiothek") { return .ardAudiothek }
|
||||
if k.hasPrefix("ard") { return .ardAudiothek }
|
||||
// Image-only metadata providers — not a music source
|
||||
if k == "lastfm" || k == "musicbrainz" || k == "fanarttv" { return nil }
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+282
-40
@@ -14,12 +14,21 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
@State private var localVolume: Double = 0
|
||||
@State private var isVolumeEditing = false
|
||||
@State private var volumeSettleTask: Task<Void, Never>?
|
||||
@State private var isMuted = false
|
||||
@State private var preMuteVolume: Double = 50
|
||||
@State private var showQueue = false
|
||||
@State private var displayedElapsed: Double = 0
|
||||
@State private var isProgressEditing = false
|
||||
@State private var progressSettleTask: Task<Void, Never>?
|
||||
@State private var progressTimer: Timer?
|
||||
|
||||
// Queue scroll trigger
|
||||
@State private var scrollToQueue = false
|
||||
|
||||
// Queue state
|
||||
@State private var isQueueLoading = false
|
||||
@State private var showClearConfirm = false
|
||||
|
||||
// Auto-tracks live updates via @Observable
|
||||
private var player: MAPlayer? {
|
||||
service.playerManager.players[playerId]
|
||||
@@ -41,28 +50,62 @@ struct PlayerNowPlayingView: View {
|
||||
Double(currentItem?.duration ?? 0)
|
||||
}
|
||||
|
||||
// Queue computed properties
|
||||
private var queueItems: [MAQueueItem] {
|
||||
service.playerManager.queues[playerId] ?? []
|
||||
}
|
||||
|
||||
private var currentQueueIndex: Int? {
|
||||
service.playerManager.playerQueues[playerId]?.currentIndex
|
||||
}
|
||||
|
||||
private var currentItemId: String? {
|
||||
service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId
|
||||
}
|
||||
|
||||
private var shuffleEnabled: Bool {
|
||||
service.playerManager.playerQueues[playerId]?.shuffleEnabled ?? false
|
||||
}
|
||||
|
||||
private var repeatMode: RepeatMode {
|
||||
service.playerManager.playerQueues[playerId]?.repeatMode ?? .off
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
// Pinned header — always visible
|
||||
headerView
|
||||
|
||||
// Conditional content area
|
||||
if showQueue {
|
||||
PlayerQueueView(playerId: playerId)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
} else {
|
||||
playerContent
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
// Scrollable content (album art + queue only)
|
||||
// GeometryReader gives the actual available height so playerContent
|
||||
// can fill exactly (viewport − "Up Next" header), making only the
|
||||
// caption peek at the bottom as a scroll hint.
|
||||
GeometryReader { geo in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
playerContent
|
||||
// Leave ~48 pt so only the "Up Next" header peeks
|
||||
.frame(minHeight: geo.size.height - 48)
|
||||
|
||||
// Queue section — below the fold
|
||||
queueSection
|
||||
.id("queueSection")
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollToQueue) { _, shouldScroll in
|
||||
if shouldScroll {
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
proxy.scrollTo("queueSection", anchor: .top)
|
||||
}
|
||||
scrollToQueue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Progress bar (only in player mode, not queue)
|
||||
if !showQueue {
|
||||
progressView
|
||||
}
|
||||
|
||||
// Transport + volume (always visible)
|
||||
// Pinned controls — always outside the scroll view, no gesture conflicts
|
||||
progressView
|
||||
controlsView
|
||||
}
|
||||
.background {
|
||||
@@ -103,10 +146,9 @@ struct PlayerNowPlayingView: View {
|
||||
progressTimer = nil
|
||||
}
|
||||
.onChange(of: playerQueue?.elapsedTime) {
|
||||
syncElapsedTime()
|
||||
if !isProgressEditing { syncElapsedTime() }
|
||||
}
|
||||
.onChange(of: player?.state) {
|
||||
// Restart timer when play state changes
|
||||
syncElapsedTime()
|
||||
if player?.state == .playing {
|
||||
startProgressTimer()
|
||||
@@ -115,6 +157,17 @@ struct PlayerNowPlayingView: View {
|
||||
progressTimer = nil
|
||||
}
|
||||
}
|
||||
.task {
|
||||
isQueueLoading = true
|
||||
try? await service.playerManager.loadQueue(playerId: playerId)
|
||||
isQueueLoading = false
|
||||
}
|
||||
.confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) {
|
||||
Button("Clear Queue", role: .destructive) {
|
||||
Task { try? await service.playerManager.clearQueue(playerId: playerId) }
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
}
|
||||
@@ -143,7 +196,7 @@ struct PlayerNowPlayingView: View {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text(showQueue ? "Up Next" : "Now Playing")
|
||||
Text("Now Playing")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(player?.name ?? "")
|
||||
@@ -155,21 +208,20 @@ struct PlayerNowPlayingView: View {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
// Queue icon — scrolls to the queue section below
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showQueue.toggle()
|
||||
}
|
||||
scrollToQueue = true
|
||||
} label: {
|
||||
Image(systemName: "list.bullet")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(showQueue ? Color.accentColor : .primary)
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
if !showQueue, let uri = mediaItem?.uri {
|
||||
FavoriteButton(uri: uri, size: 22)
|
||||
if let uri = mediaItem?.uri {
|
||||
FavoriteButton(uri: uri, size: 22, itemName: currentItem?.name)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +282,7 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Transport + Volume Controls
|
||||
@@ -291,17 +344,46 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Slider(value: $localVolume, in: 0...100, step: 1) { editing in
|
||||
isVolumeEditing = editing
|
||||
if !editing {
|
||||
Task {
|
||||
try? await service.playerManager.setVolume(
|
||||
playerId: playerId,
|
||||
level: Int(localVolume)
|
||||
)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
let thumbX = max(0, min(geo.size.width, geo.size.width * localVolume / 100))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.primary.opacity(0.15))
|
||||
.frame(height: 6)
|
||||
Capsule()
|
||||
.fill(.primary)
|
||||
.frame(width: thumbX, height: 6)
|
||||
Circle()
|
||||
.fill(.primary)
|
||||
.frame(width: 14, height: 14)
|
||||
.offset(x: thumbX - 7)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isVolumeEditing = true
|
||||
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
|
||||
}
|
||||
.onEnded { value in
|
||||
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
|
||||
Task {
|
||||
try? await service.playerManager.setVolume(
|
||||
playerId: playerId,
|
||||
level: Int(localVolume)
|
||||
)
|
||||
}
|
||||
volumeSettleTask?.cancel()
|
||||
volumeSettleTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
isVolumeEditing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 28)
|
||||
|
||||
Button { adjustVolume(by: 5) } label: {
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
@@ -325,17 +407,51 @@ struct PlayerNowPlayingView: View {
|
||||
VStack(spacing: 4) {
|
||||
// Progress bar
|
||||
GeometryReader { geo in
|
||||
let thumbX = max(0, min(geo.size.width, geo.size.width * progress))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.primary.opacity(0.15))
|
||||
.frame(height: 4)
|
||||
|
||||
Capsule()
|
||||
.fill(.primary)
|
||||
.frame(width: geo.size.width * progress, height: 4)
|
||||
.frame(width: thumbX, height: 4)
|
||||
Circle()
|
||||
.fill(.primary)
|
||||
.frame(width: 14, height: 14)
|
||||
.offset(x: thumbX - 7)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard duration > 0 else { return }
|
||||
isProgressEditing = true
|
||||
progressTimer?.invalidate()
|
||||
displayedElapsed = max(0, min(duration, value.location.x / geo.size.width * duration))
|
||||
}
|
||||
.onEnded { value in
|
||||
guard duration > 0 else { return }
|
||||
let seekTo = max(0, min(duration, value.location.x / geo.size.width * duration))
|
||||
displayedElapsed = seekTo
|
||||
Task {
|
||||
do {
|
||||
try await service.playerManager.seek(playerId: playerId, position: seekTo)
|
||||
} catch {
|
||||
print("❌ Seek failed: \(error)")
|
||||
}
|
||||
}
|
||||
progressSettleTask?.cancel()
|
||||
progressSettleTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
isProgressEditing = false
|
||||
startProgressTimer()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 4)
|
||||
.frame(height: 28)
|
||||
|
||||
// Time labels
|
||||
HStack {
|
||||
@@ -354,6 +470,133 @@ struct PlayerNowPlayingView: View {
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// MARK: - Queue Section
|
||||
|
||||
@ViewBuilder
|
||||
private var queueSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Section header
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Up Next")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
Spacer()
|
||||
if !queueItems.isEmpty {
|
||||
Text("\(queueItems.count) tracks")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Control bar (shuffle / repeat / clear)
|
||||
queueControlBar
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Divider()
|
||||
|
||||
// Queue items
|
||||
if isQueueLoading && queueItems.isEmpty {
|
||||
ProgressView()
|
||||
.frame(height: 100)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if queueItems.isEmpty {
|
||||
Text("Queue is empty")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(height: 100)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
|
||||
let isCurrent = currentQueueIndex == index || item.queueItemId == currentItemId
|
||||
QueueItemRow(item: item, isCurrent: isCurrent)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task {
|
||||
try? await service.playerManager.playIndex(
|
||||
playerId: playerId,
|
||||
index: index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue Control Bar
|
||||
|
||||
@ViewBuilder
|
||||
private var queueControlBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Shuffle
|
||||
Button {
|
||||
Task { try? await service.playerManager.setShuffle(playerId: playerId, enabled: !shuffleEnabled) }
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary)
|
||||
Text("Shuffle")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Repeat
|
||||
Button {
|
||||
let next: RepeatMode
|
||||
switch repeatMode {
|
||||
case .off: next = .all
|
||||
case .all: next = .one
|
||||
case .one: next = .off
|
||||
}
|
||||
Task { try? await service.playerManager.setRepeatMode(playerId: playerId, mode: next) }
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: repeatMode == .one ? "repeat.1" : "repeat")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor)
|
||||
Text(repeatMode == .off ? "Repeat" : (repeatMode == .one ? "Repeat 1" : "Repeat All"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Clear queue
|
||||
Button {
|
||||
showClearConfirm = true
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "xmark.bin")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Clear")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(queueItems.isEmpty)
|
||||
.opacity(queueItems.isEmpty ? 0.4 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Helpers
|
||||
|
||||
private func syncElapsedTime() {
|
||||
@@ -378,9 +621,8 @@ struct PlayerNowPlayingView: View {
|
||||
guard player?.state == .playing else { return }
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
Task { @MainActor in
|
||||
guard player?.state == .playing else { return }
|
||||
guard player?.state == .playing, !isProgressEditing else { return }
|
||||
displayedElapsed += 0.5
|
||||
// Clamp to duration
|
||||
if trackDuration > 0 {
|
||||
displayedElapsed = min(displayedElapsed, trackDuration)
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ struct PlayerQueueView: View {
|
||||
|
||||
// MARK: - Queue Item Row
|
||||
|
||||
private struct QueueItemRow: View {
|
||||
struct QueueItemRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let item: MAQueueItem
|
||||
let isCurrent: Bool
|
||||
|
||||
Reference in New Issue
Block a user