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