Version 1.4 - Translations, Like toasts Queue redesign.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user