diff --git a/Donations.storekit b/Donations.storekit new file mode 100644 index 0000000..893c69e --- /dev/null +++ b/Donations.storekit @@ -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 + } +} diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj index aaa5109..d7a7307 100644 --- a/Mobile Music Assistant.xcodeproj/project.pbxproj +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -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 = ""; }; + 2616AF4F2F87782600CB210E /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = ""; }; 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerQueueView.swift; sourceTree = ""; }; - 2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsComponentsProviderBadge.swift; sourceTree = ""; }; 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = ""; }; 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 = ""; }; @@ -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; diff --git a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme index ee42518..bbb4e3e 100644 --- a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme +++ b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme @@ -52,7 +52,7 @@ + identifier = "../../Donations.storekit"> - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Mobile Music Assistant/DonationProducts.storekit b/Mobile Music Assistant/DonationProducts.storekit deleted file mode 100644 index df433b3..0000000 --- a/Mobile Music Assistant/DonationProducts.storekit +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/Mobile Music Assistant/Localizable.xcstrings b/Mobile Music Assistant/Localizable.xcstrings new file mode 100644 index 0000000..8cd3e15 --- /dev/null +++ b/Mobile Music Assistant/Localizable.xcstrings @@ -0,0 +1,2579 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "-%@" : { + "comment" : "A countdown timer that shows how much time is left in a song. The argument is the string “%1$d:%2$02d”.", + "isCommentAutoGenerated" : true + }, + "%lld" : { + "comment" : "A number indicating the track number in a playlist. The argument is the track number.", + "isCommentAutoGenerated" : true + }, + "%lld albums" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Album" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Alben" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld álbum" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld álbumes" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld album" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld albums" + } + } + } + } + } + } + }, + "%lld episodes" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Episode" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Episoden" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld episodio" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld episodios" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld épisode" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld épisodes" + } + } + } + } + } + } + }, + "%lld tracks" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Titel" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Titel" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pista" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pistas" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld titre" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld titres" + } + } + } + } + } + } + }, + "•" : { + "comment" : "A separator between the year and the number of tracks in an album.", + "isCommentAutoGenerated" : true + }, + "1. Open Music Assistant in a browser" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1. Öffne Music Assistant in einem Browser" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1. Abre Music Assistant en un navegador" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1. Ouvrez Music Assistant dans un navigateur" + } + } + } + }, + "2. Go to Settings → Users" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "2. Gehe zu Einstellungen → Benutzer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "2. Ve a Ajustes → Usuarios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "2. Allez dans Réglages → Utilisateurs" + } + } + } + }, + "3. Create a new long-lived access token" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "3. Erstelle ein neues langlebiges Zugriffstoken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "3. Crea un nuevo token de acceso de larga duración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3. Créez un nouveau jeton d'accès longue durée" + } + } + } + }, + "4. Copy and paste the token here" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "4. Kopiere das Token und füge es hier ein" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "4. Copia y pega el token aquí" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "4. Copiez et collez le jeton ici" + } + } + } + }, + "About" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À propos" + } + } + } + }, + "Add to Queue" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zur Warteschlange" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir a la cola" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter à la file" + } + } + } + }, + "Add to Queue on..." : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zur Warteschlange auf..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir a la cola en..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter à la file sur..." + } + } + } + }, + "Album" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Album" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Álbum" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Album" + } + } + } + }, + "Album Artists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Albumkünstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artistas de álbum" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artistes d'album" + } + } + } + }, + "Albums" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Álbumes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Albums" + } + } + } + }, + "Always use dark mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immer dunklen Modus verwenden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar siempre el modo oscuro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours utiliser le mode sombre" + } + } + } + }, + "Always use light mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immer hellen Modus verwenden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar siempre el modo claro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toujours utiliser le mode clair" + } + } + } + }, + "Anthology" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthologie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antología" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anthologie" + } + } + } + }, + "Appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Darstellung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apariencia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apparence" + } + } + } + }, + "Artists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Künstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artistas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artistes" + } + } + } + }, + "Artists, albums, tracks..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Künstler, Alben, Titel..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artistas, álbumes, pistas..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Artistes, albums, titres..." + } + } + } + }, + "Authentication" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifizierung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autenticación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentification" + } + } + } + }, + "By %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Von %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par %@" + } + } + } + }, + "Cancel" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + } + } + }, + "Choose how the app looks. System follows your device settings." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle das Erscheinungsbild der App. System folgt den Geräteeinstellungen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige cómo se ve la app. Sistema sigue los ajustes de tu dispositivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez l'apparence de l'application. Système suit les réglages de votre appareil." + } + } + } + }, + "Choose the app language. System uses your device language." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprache der App auswählen. System verwendet die Gerätesprache." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige el idioma de la app. Sistema usa el idioma de tu dispositivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez la langue de l'application. Système utilise la langue de votre appareil." + } + } + } + }, + "Clear" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leeren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer" + } + } + } + }, + "Clear Queue" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warteschlange leeren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaciar cola" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vider la file" + } + } + } + }, + "Clear the entire queue?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die gesamte Warteschlange leeren?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Vaciar toda la cola?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vider toute la file ?" + } + } + } + }, + "Connect" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter" + } + } + } + }, + "Connected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecté" + } + } + } + }, + "Connecting..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinde..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectando..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion en cours..." + } + } + } + }, + "Connection" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conexión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion" + } + } + } + }, + "Connection Error" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindungsfehler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de conexión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur de connexion" + } + } + } + }, + "Dark" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dunkel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oscuro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sombre" + } + } + } + }, + "Disconnect" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmelden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déconnecter" + } + } + } + }, + "Disconnected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Getrennt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déconnecté" + } + } + } + }, + "Do you find this app useful? Support the development by buying the developer a virtual record." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Findest du diese App nützlich? Unterstütze die Entwicklung, indem du dem Entwickler eine virtuelle Schallplatte kaufst." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Encuentras útil esta app? Apoya el desarrollo comprándole al desarrollador un disco virtual." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous trouvez cette application utile ? Soutenez le développement en achetant un disque virtuel au développeur." + } + } + } + }, + "Editable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearbeitbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editable" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifiable" + } + } + } + }, + "Enter your Music Assistant server URL (e.g., https://music.example.com)" : { + "comment" : "A description of the server URL field.", + "isCommentAutoGenerated" : true + }, + "Enter your Music Assistant server URL (e.g., https://musicassistant-app.hanold.online)" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gib deine Music Assistant Server-URL ein (z.B. https://musicassistant-app.hanold.online)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduce la URL de tu servidor Music Assistant (p. ej., https://musicassistant-app.hanold.online)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrez l'URL de votre serveur Music Assistant (ex. : https://musicassistant-app.hanold.online)" + } + } + } + }, + "Error" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur" + } + } + } + }, + "Error Loading Players" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler beim Laden der Player" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error al cargar los reproductores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur de chargement des lecteurs" + } + } + } + }, + "Favorites" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoriten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoris" + } + } + } + }, + "Find artists, albums, tracks, and playlists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Künstler, Alben, Titel und Playlisten finden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encontrar artistas, álbumes, pistas y listas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trouvez des artistes, albums, titres et playlists" + } + } + } + }, + "Follows your device's appearance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folgt dem Erscheinungsbild des Geräts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sigue la apariencia de tu dispositivo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suit l'apparence de votre appareil" + } + } + } + }, + "Hello, world!" : { + "comment" : "A greeting displayed in the main view of the app.", + "isCommentAutoGenerated" : true + }, + "How to get a token:" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "So erhältst du ein Token:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cómo obtener un token:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment obtenir un jeton :" + } + } + } + }, + "Invalid server URL" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Server-URL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL del servidor no válida" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL du serveur invalide" + } + } + } + }, + "Language" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprache" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idioma" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Langue" + } + } + } + }, + "Library" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bibliothek" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Biblioteca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bibliothèque" + } + } + } + }, + "Light" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hell" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clair" + } + } + } + }, + "Long-Lived Access Token" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Langlebiges Zugriffstoken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Token de acceso de larga duración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jeton d'accès longue durée" + } + } + } + }, + "Make sure your Music Assistant server has configured players" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stelle sicher, dass dein Music Assistant-Server Player konfiguriert hat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asegúrate de que tu servidor Music Assistant tenga reproductores configurados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assurez-vous que votre serveur Music Assistant a des lecteurs configurés" + } + } + } + }, + "Music Assistant" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Music Assistant" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Music Assistant" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Music Assistant" + } + } + } + }, + "No Album Artists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Albumkünstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin artistas de álbum" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun artiste d'album" + } + } + } + }, + "No Albums" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Alben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin álbumes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun album" + } + } + } + }, + "No albums found" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Alben gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron álbumes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun album trouvé" + } + } + } + }, + "No Artists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Künstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin artistas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun artiste" + } + } + } + }, + "No episodes found" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Episoden gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron episodios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun épisode trouvé" + } + } + } + }, + "No Favorite Albums" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Lieblingsalben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin álbumes favoritos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun album favori" + } + } + } + }, + "No Favorite Artists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Lieblingskünstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin artistas favoritos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun artiste favori" + } + } + } + }, + "No Favorite Podcasts" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Lieblings-Podcasts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin podcasts favoritos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun podcast favori" + } + } + } + }, + "No Favorite Radios" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Lieblingsradios" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin radios favoritas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune radio favorite" + } + } + } + }, + "No Players Found" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Player gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron reproductores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun lecteur trouvé" + } + } + } + }, + "No Playlists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Wiedergabelisten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin listas de reproducción" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune playlist" + } + } + } + }, + "No Podcasts" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Podcasts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin podcasts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun podcast" + } + } + } + }, + "No Radio Stations" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Radiosender" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin emisoras de radio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune station radio" + } + } + } + }, + "No radio stations found in your library." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Radiosender in deiner Bibliothek gefunden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron emisoras de radio en tu biblioteca." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune station de radio trouvée dans votre bibliothèque." + } + } + } + }, + "No Results" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Ergebnisse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin resultados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun résultat" + } + } + } + }, + "No results found for '%@'" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Ergebnisse für '%@' gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron resultados para '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun résultat pour '%@'" + } + } + } + }, + "No Track Playing" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Titel läuft" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin pista reproduciéndose" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun titre en lecture" + } + } + } + }, + "No tracks found" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Titel gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontraron pistas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun titre trouvé" + } + } + } + }, + "Now Playing" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läuft gerade" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproduciendo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En cours" + } + } + } + }, + "OK" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "Play" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspielen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproducir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lire" + } + } + } + }, + "Play on..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspielen auf..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproducir en..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lire sur..." + } + } + } + }, + "Players" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproductores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecteurs" + } + } + } + }, + "Playlists" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiedergabelisten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listas de reproducción" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playlists" + } + } + } + }, + "Podcasts" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podcasts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podcasts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podcasts" + } + } + } + }, + "Powered Off" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeschaltet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Éteint" + } + } + } + }, + "Queue is empty" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warteschlange ist leer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cola está vacía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La file est vide" + } + } + } + }, + "Radio" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radio" + } + } + } + }, + "Radio Stations" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radiosender" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emisoras de radio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stations de radio" + } + } + } + }, + "Radios" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stations de radio" + } + } + } + }, + "Repeat" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiederholen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repetir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Répéter" + } + } + } + }, + "Repeat 1" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 wiederholen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repetir 1" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Répéter 1" + } + } + } + }, + "Repeat All" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle wiederholen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repetir todo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tout répéter" + } + } + } + }, + "Search" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recherche" + } + } + } + }, + "Search Library" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bibliothek durchsuchen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar en biblioteca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher dans la bibliothèque" + } + } + } + }, + "Server" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servidor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serveur" + } + } + } + }, + "Server URL" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server-URL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL del servidor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL du serveur" + } + } + } + }, + "Settings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" + } + } + } + }, + "Show complete album" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vollständiges Album anzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver álbum completo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir l'album complet" + } + } + } + }, + "Show less" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weniger anzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar menos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher moins" + } + } + } + }, + "Show more" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr anzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar más" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher plus" + } + } + } + }, + "Shuffle" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zufallswiedergabe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aleatoria" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aléatoire" + } + } + } + }, + "Song" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lied" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canción" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chanson" + } + } + } + }, + "Status" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut" + } + } + } + }, + "Support Development" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entwicklung unterstützen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apoyar el desarrollo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soutenir le développement" + } + } + } + }, + "System" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sistema" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Système" + } + } + } + }, + "Tap the heart icon on any album to add it here." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe auf das Herz-Symbol bei einem Album, um es hier hinzuzufügen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca el ícono de corazón en cualquier álbum para añadirlo aquí." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur l'icône cœur sur un album pour l'ajouter ici." + } + } + } + }, + "Tap the heart icon on any artist to add them here." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe auf das Herz-Symbol bei einem Künstler, um ihn hier hinzuzufügen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca el ícono de corazón en cualquier artista para añadirlo aquí." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur l'icône cœur sur un artiste pour l'ajouter ici." + } + } + } + }, + "Tap the heart icon on any podcast to add it here." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe auf das Herz-Symbol bei einem Podcast, um ihn hier hinzuzufügen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca el ícono de corazón en cualquier podcast para añadirlo aquí." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur l'icône cœur sur un podcast pour l'ajouter ici." + } + } + } + }, + "Tap the heart icon on any radio station to add it here." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe auf das Herz-Symbol bei einem Radiosender, um ihn hier hinzuzufügen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca el ícono de corazón en cualquier emisora de radio para añadirla aquí." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur l'icône cœur sur une station radio pour l'ajouter ici." + } + } + } + }, + "Thank You!" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vielen Dank!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Gracias!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merci !" + } + } + } + }, + "Tracks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pistas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titres" + } + } + } + }, + "Up Next" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als Nächstes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "A continuación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivant" + } + } + } + }, + "You're welcome!" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gern geschehen!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡De nada!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "De rien !" + } + } + } + }, + "Your library doesn't contain any album artists yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Bibliothek enthält noch keine Albumkünstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu biblioteca no contiene artistas de álbum todavía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre bibliothèque ne contient pas encore d'artistes d'album" + } + } + } + }, + "Your library doesn't contain any albums yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Bibliothek enthält noch keine Alben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu biblioteca no contiene álbumes todavía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre bibliothèque ne contient pas encore d'albums" + } + } + } + }, + "Your library doesn't contain any artists yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Bibliothek enthält noch keine Künstler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu biblioteca no contiene artistas todavía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre bibliothèque ne contient pas encore d'artistes" + } + } + } + }, + "Your library doesn't contain any playlists yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Bibliothek enthält noch keine Wiedergabelisten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu biblioteca no contiene listas de reproducción todavía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre bibliothèque ne contient pas encore de playlists" + } + } + } + }, + "Your library doesn't contain any podcasts yet." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Bibliothek enthält noch keine Podcasts." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu biblioteca no contiene podcasts todavía." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre bibliothèque ne contient pas encore de podcasts." + } + } + } + }, + "Your support means a lot and helps keep Mobile Music Assistant alive." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Unterstützung bedeutet viel und hilft Mobile Music Assistant am Leben zu erhalten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu apoyo significa mucho y ayuda a mantener Mobile Music Assistant activo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre soutien est précieux et aide à maintenir Mobile Music Assistant en vie." + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Mobile Music Assistant/Mobile_Music_AssistantApp.swift b/Mobile Music Assistant/Mobile_Music_AssistantApp.swift index a0a32b1..c7cd00d 100644 --- a/Mobile Music Assistant/Mobile_Music_AssistantApp.swift +++ b/Mobile Music Assistant/Mobile_Music_AssistantApp.swift @@ -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) } } } diff --git a/Mobile Music Assistant/ServicesMALocaleManager.swift b/Mobile Music Assistant/ServicesMALocaleManager.swift new file mode 100644 index 0000000..23f9e62 --- /dev/null +++ b/Mobile Music Assistant/ServicesMALocaleManager.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift index e8e58a6..583219d 100644 --- a/Mobile Music Assistant/ServicesMAPlayerManager.swift +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -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 diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index 8cc1330..a9318a3 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -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)) diff --git a/Mobile Music Assistant/ServicesMAStoreManager.swift b/Mobile Music Assistant/ServicesMAStoreManager.swift deleted file mode 100644 index aa66a7b..0000000 --- a/Mobile Music Assistant/ServicesMAStoreManager.swift +++ /dev/null @@ -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 = [ - "donate.song", - "donate.album", - "donate.anthology" - ] - - var products: [Product] = [] - var isPurchasing = false - var purchaseResult: PurchaseResult? - - private var transactionListener: Task? - - 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 { - Task.detached { - for await verificationResult in Transaction.updates { - if case .verified(let transaction) = verificationResult { - await transaction.finish() - } - } - } - } - - // MARK: - Helpers - - private func checkVerified(_ result: VerificationResult) 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" - } - } -} diff --git a/Mobile Music Assistant/ServicesMAThemeManager.swift b/Mobile Music Assistant/ServicesMAThemeManager.swift index 0a68e17..fdbe8de 100644 --- a/Mobile Music Assistant/ServicesMAThemeManager.swift +++ b/Mobile Music Assistant/ServicesMAThemeManager.swift @@ -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 } } diff --git a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift index 5eb25fc..2957b90 100644 --- a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift +++ b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift @@ -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) + } } } diff --git a/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift b/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift index 982dcc2..02bf531 100644 --- a/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift +++ b/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift @@ -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") diff --git a/Mobile Music Assistant/ViewsComponentsToastOverlay.swift b/Mobile Music Assistant/ViewsComponentsToastOverlay.swift new file mode 100644 index 0000000..89a8aef --- /dev/null +++ b/Mobile Music Assistant/ViewsComponentsToastOverlay.swift @@ -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? + + 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()) + } +} diff --git a/Mobile Music Assistant/ViewsFavoritesView.swift b/Mobile Music Assistant/ViewsFavoritesView.swift index 783c76b..320a243 100644 --- a/Mobile Music Assistant/ViewsFavoritesView.swift +++ b/Mobile Music Assistant/ViewsFavoritesView.swift @@ -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 + } } } diff --git a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift index 45aec4c..882f763 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift @@ -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 { diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift index 7b7324e..2269c33 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift @@ -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() - 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) diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift index 7032629..4f14277 100644 --- a/Mobile Music Assistant/ViewsLibraryLibraryView.swift +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -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) diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift index 0f12897..bbfc3bd 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -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) diff --git a/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift b/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift index 485f76e..552424e 100644 --- a/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift @@ -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) diff --git a/Mobile Music Assistant/ViewsLibraryRadiosView.swift b/Mobile Music Assistant/ViewsLibraryRadiosView.swift index 5a26d1d..925edcc 100644 --- a/Mobile Music Assistant/ViewsLibraryRadiosView.swift +++ b/Mobile Music Assistant/ViewsLibraryRadiosView.swift @@ -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) } } diff --git a/Mobile Music Assistant/ViewsLoginView.swift b/Mobile Music Assistant/ViewsLoginView.swift index 00125d1..a039d79 100644 --- a/Mobile Music Assistant/ViewsLoginView.swift +++ b/Mobile Music Assistant/ViewsLoginView.swift @@ -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 diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index d6e7ffb..e78f627 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -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 } } } diff --git a/Mobile Music Assistant/ViewsRootView.swift b/Mobile Music Assistant/ViewsRootView.swift index ea51540..7be4d12 100644 --- a/Mobile Music Assistant/ViewsRootView.swift +++ b/Mobile Music Assistant/ViewsRootView.swift @@ -30,6 +30,7 @@ struct RootView: View { } } .applyTheme() + .applyLocale() .task { await initializeConnection() } diff --git a/ServicesMAStoreManager.swift b/ServicesMAStoreManager.swift new file mode 100644 index 0000000..367b336 --- /dev/null +++ b/ServicesMAStoreManager.swift @@ -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 = [ + "donatesong", + "donatealbum", + "donateanthology" + ] + + private var updateListenerTask: Task? + + 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(_ result: VerificationResult) throws -> T { + switch result { + case .unverified(_, let error): + throw error + case .verified(let value): + return value + } + } + + private func listenForTransactions() -> Task { + 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) + } + } +} diff --git a/ViewsComponentsProviderBadge.swift b/ViewsComponentsProviderBadge.swift deleted file mode 100644 index 5b2f909..0000000 --- a/ViewsComponentsProviderBadge.swift +++ /dev/null @@ -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() - 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 - } -} diff --git a/ViewsPlayerNowPlayingView.swift b/ViewsPlayerNowPlayingView.swift index 70a172e..1159f18 100644 --- a/ViewsPlayerNowPlayingView.swift +++ b/ViewsPlayerNowPlayingView.swift @@ -14,12 +14,21 @@ struct PlayerNowPlayingView: View { @State private var localVolume: Double = 0 @State private var isVolumeEditing = false + @State private var volumeSettleTask: Task? @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? @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) } diff --git a/ViewsPlayerQueueView.swift b/ViewsPlayerQueueView.swift index 992ab74..10810a7 100644 --- a/ViewsPlayerQueueView.swift +++ b/ViewsPlayerQueueView.swift @@ -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