Add iPhone local favorites
This commit is contained in:
parent
cb556ccf0a
commit
3d7252ce6d
@ -14,26 +14,44 @@
|
|||||||
3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; };
|
3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; };
|
||||||
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
||||||
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
|
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
|
||||||
|
4F8B36EB80C008CDD9B2F6A6 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = C199F1B9F55E51FD373EA729 /* VelodyUtilities */; };
|
||||||
|
58E2E18AAC9318AB98F81004 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 14CD56063C10911F40C9CBA3 /* VelodyDomain */; };
|
||||||
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
|
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
|
||||||
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
|
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
|
||||||
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
||||||
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
|
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
|
||||||
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
|
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
|
||||||
|
A2DDB28F9916D0DFB1ADD4FF /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */; };
|
||||||
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
||||||
AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
|
AB6C7E42A3A850D395E4F5E7 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
|
||||||
|
AC8B414ECE5493BD52DEC44A /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = B99672FF5519DDF310A5EBD1 /* VelodyPersistence */; };
|
||||||
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
||||||
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
||||||
|
DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */; };
|
||||||
|
E4627FCDF7EB169D83EA50DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = A571882D6B46CA9F729D13CB /* VelodySync */; };
|
||||||
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; };
|
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; };
|
||||||
FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
|
FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
|
||||||
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; };
|
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
488EB06E63B557EFEA13BE16 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = D14070EEF27F35DD4F5F547B /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = BA256DA9698C16C35E28D0EF;
|
||||||
|
remoteInfo = VelodyiPhone;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
||||||
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; };
|
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; };
|
||||||
15A17C02F8CBB0A492A82C14 /* VelodyPlayback */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPlayback; path = ../../packages/apple/VelodyPlayback; sourceTree = SOURCE_ROOT; };
|
15A17C02F8CBB0A492A82C14 /* VelodyPlayback */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPlayback; path = ../../packages/apple/VelodyPlayback; sourceTree = SOURCE_ROOT; };
|
||||||
|
1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VelodyiPhoneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMusicScanner.swift; sourceTree = "<group>"; };
|
3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMusicScanner.swift; sourceTree = "<group>"; };
|
||||||
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = "<group>"; };
|
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = "<group>"; };
|
||||||
|
6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModelFavoritesTests.swift; sourceTree = "<group>"; };
|
||||||
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
|
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
|
||||||
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VelodyMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VelodyMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
89FE56E825FC42D026EAC784 /* VelodyUtilities */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyUtilities; path = ../../packages/apple/VelodyUtilities; sourceTree = SOURCE_ROOT; };
|
89FE56E825FC42D026EAC784 /* VelodyUtilities */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyUtilities; path = ../../packages/apple/VelodyUtilities; sourceTree = SOURCE_ROOT; };
|
||||||
@ -73,9 +91,30 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
CE2E7DEBA00CD6E8FB4A4FA0 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
58E2E18AAC9318AB98F81004 /* VelodyDomain in Frameworks */,
|
||||||
|
A2DDB28F9916D0DFB1ADD4FF /* VelodyNetworking in Frameworks */,
|
||||||
|
AC8B414ECE5493BD52DEC44A /* VelodyPersistence in Frameworks */,
|
||||||
|
E4627FCDF7EB169D83EA50DB /* VelodySync in Frameworks */,
|
||||||
|
4F8B36EB80C008CDD9B2F6A6 /* VelodyUtilities in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
57CBC02BBC37680DB422EDDE /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */,
|
||||||
|
);
|
||||||
|
name = Tests;
|
||||||
|
path = VelodyiPhone/Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
72E9140E6A9783B11030B506 /* Sources */ = {
|
72E9140E6A9783B11030B506 /* Sources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -106,6 +145,7 @@
|
|||||||
770229FFB4009FE9ED8F7FA1 /* Packages */,
|
770229FFB4009FE9ED8F7FA1 /* Packages */,
|
||||||
72E9140E6A9783B11030B506 /* Sources */,
|
72E9140E6A9783B11030B506 /* Sources */,
|
||||||
C055BC0585BDA296E6A6D9F8 /* Sources */,
|
C055BC0585BDA296E6A6D9F8 /* Sources */,
|
||||||
|
57CBC02BBC37680DB422EDDE /* Tests */,
|
||||||
E7EA6D658DCAE7B098AACC54 /* Products */,
|
E7EA6D658DCAE7B098AACC54 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -127,6 +167,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */,
|
BF223F7E40D4B594935F5BC2 /* VelodyiPhone.app */,
|
||||||
|
1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */,
|
||||||
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */,
|
7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@ -159,6 +200,30 @@
|
|||||||
productReference = 7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */;
|
productReference = 7A1BDB40E1A38A685237BCF3 /* VelodyMac.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
75B4E7EF9F920A90B66109DD /* VelodyiPhoneTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 47B0505DA66C0C0151085FB5 /* Build configuration list for PBXNativeTarget "VelodyiPhoneTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
3910E580B4AC84E7F67D2A67 /* Sources */,
|
||||||
|
CE2E7DEBA00CD6E8FB4A4FA0 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
6E19F3A327667FEB6B990077 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = VelodyiPhoneTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
14CD56063C10911F40C9CBA3 /* VelodyDomain */,
|
||||||
|
A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */,
|
||||||
|
B99672FF5519DDF310A5EBD1 /* VelodyPersistence */,
|
||||||
|
A571882D6B46CA9F729D13CB /* VelodySync */,
|
||||||
|
C199F1B9F55E51FD373EA729 /* VelodyUtilities */,
|
||||||
|
);
|
||||||
|
productName = VelodyiPhoneTests;
|
||||||
|
productReference = 1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
BA256DA9698C16C35E28D0EF /* VelodyiPhone */ = {
|
BA256DA9698C16C35E28D0EF /* VelodyiPhone */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */;
|
buildConfigurationList = 6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */;
|
||||||
@ -215,6 +280,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
35991DE50CE00DB09C257624 /* VelodyMac */,
|
35991DE50CE00DB09C257624 /* VelodyMac */,
|
||||||
BA256DA9698C16C35E28D0EF /* VelodyiPhone */,
|
BA256DA9698C16C35E28D0EF /* VelodyiPhone */,
|
||||||
|
75B4E7EF9F920A90B66109DD /* VelodyiPhoneTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -230,6 +296,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
3910E580B4AC84E7F67D2A67 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
82777CB01A8F49D7D29F03D0 /* Sources */ = {
|
82777CB01A8F49D7D29F03D0 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -244,6 +318,14 @@
|
|||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
6E19F3A327667FEB6B990077 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = BA256DA9698C16C35E28D0EF /* VelodyiPhone */;
|
||||||
|
targetProxy = 488EB06E63B557EFEA13BE16 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
384C8F5F47E4F21667BF35CD /* Debug */ = {
|
384C8F5F47E4F21667BF35CD /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@ -307,6 +389,23 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
3DCD500A04B056B2DDF1ED8A /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.iphone.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VelodyiPhone.app/VelodyiPhone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
69FECBCBCA0B51ADDBD79E78 /* Debug */ = {
|
69FECBCBCA0B51ADDBD79E78 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@ -396,6 +495,23 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
CA2342EB0A1A156E1CEDEFD1 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.diyaa.velody.iphone.tests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VelodyiPhone.app/VelodyiPhone";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
D92AAC4C7558EFC853CF352F /* Debug */ = {
|
D92AAC4C7558EFC853CF352F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@ -433,6 +549,15 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
47B0505DA66C0C0151085FB5 /* Build configuration list for PBXNativeTarget "VelodyiPhoneTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
3DCD500A04B056B2DDF1ED8A /* Debug */,
|
||||||
|
CA2342EB0A1A156E1CEDEFD1 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */ = {
|
6BBCD1EA2ADBFF580325615C /* Build configuration list for PBXNativeTarget "VelodyiPhone" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
@ -494,6 +619,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodyNetworking;
|
productName = VelodyNetworking;
|
||||||
};
|
};
|
||||||
|
14CD56063C10911F40C9CBA3 /* VelodyDomain */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyDomain;
|
||||||
|
};
|
||||||
2449C403E81DD84D7A8DD7E1 /* VelodySync */ = {
|
2449C403E81DD84D7A8DD7E1 /* VelodySync */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodySync;
|
productName = VelodySync;
|
||||||
@ -514,6 +643,14 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodyNetworking;
|
productName = VelodyNetworking;
|
||||||
};
|
};
|
||||||
|
A571882D6B46CA9F729D13CB /* VelodySync */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodySync;
|
||||||
|
};
|
||||||
|
A5B134A54FAD5F2EB5ECBE57 /* VelodyNetworking */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyNetworking;
|
||||||
|
};
|
||||||
AAD3F903A475AA0B0159C79E /* VelodyUtilities */ = {
|
AAD3F903A475AA0B0159C79E /* VelodyUtilities */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodyUtilities;
|
productName = VelodyUtilities;
|
||||||
@ -522,6 +659,14 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodyUtilities;
|
productName = VelodyUtilities;
|
||||||
};
|
};
|
||||||
|
B99672FF5519DDF310A5EBD1 /* VelodyPersistence */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyPersistence;
|
||||||
|
};
|
||||||
|
C199F1B9F55E51FD373EA729 /* VelodyUtilities */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = VelodyUtilities;
|
||||||
|
};
|
||||||
C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */ = {
|
C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = VelodyPersistence;
|
productName = VelodyPersistence;
|
||||||
|
|||||||
@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1430"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BA256DA9698C16C35E28D0EF"
|
||||||
|
BuildableName = "VelodyiPhone.app"
|
||||||
|
BlueprintName = "VelodyiPhone"
|
||||||
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BA256DA9698C16C35E28D0EF"
|
||||||
|
BuildableName = "VelodyiPhone.app"
|
||||||
|
BlueprintName = "VelodyiPhone"
|
||||||
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "75B4E7EF9F920A90B66109DD"
|
||||||
|
BuildableName = "VelodyiPhoneTests.xctest"
|
||||||
|
BlueprintName = "VelodyiPhoneTests"
|
||||||
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BA256DA9698C16C35E28D0EF"
|
||||||
|
BuildableName = "VelodyiPhone.app"
|
||||||
|
BlueprintName = "VelodyiPhone"
|
||||||
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BA256DA9698C16C35E28D0EF"
|
||||||
|
BuildableName = "VelodyiPhone.app"
|
||||||
|
BlueprintName = "VelodyiPhone"
|
||||||
|
ReferencedContainer = "container:Velody.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@ -28,10 +28,20 @@ struct iPhoneLibraryView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if let trackID = viewModel.nowPlaying.trackID {
|
if let trackID = viewModel.nowPlaying.trackID {
|
||||||
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
|
HStack(spacing: 12) {
|
||||||
viewModel.togglePlayback(trackID: trackID)
|
if viewModel.nowPlayingFavoriteTrackID == trackID {
|
||||||
|
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
|
||||||
|
Task {
|
||||||
|
await viewModel.toggleFavorite(trackID: trackID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
|
||||||
|
viewModel.togglePlayback(trackID: trackID)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,6 +71,12 @@ struct iPhoneLibraryView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
favoriteButton(isFavorite: track.isFavorite) {
|
||||||
|
Task {
|
||||||
|
await viewModel.toggleFavorite(trackID: track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text(track.statusText)
|
Text(track.statusText)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@ -139,6 +155,12 @@ struct iPhoneLibraryView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
favoriteButton(isFavorite: track.isFavorite) {
|
||||||
|
Task {
|
||||||
|
await viewModel.toggleFavorite(trackID: track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(track.playButtonTitle) {
|
Button(track.playButtonTitle) {
|
||||||
viewModel.togglePlayback(trackID: track.id)
|
viewModel.togglePlayback(trackID: track.id)
|
||||||
}
|
}
|
||||||
@ -255,6 +277,19 @@ struct iPhoneLibraryView: View {
|
|||||||
return .red
|
return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func favoriteButton(
|
||||||
|
isFavorite: Bool,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(isFavorite ? .red : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ArtworkThumbnailView: View {
|
private struct ArtworkThumbnailView: View {
|
||||||
|
|||||||
@ -194,16 +194,20 @@ final class iPhoneLibraryViewModel {
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
errorMessage: nil
|
errorMessage: nil
|
||||||
)
|
)
|
||||||
|
var nowPlayingFavoriteTrackID: String?
|
||||||
|
var isNowPlayingTrackFavorite = false
|
||||||
|
|
||||||
private let environment: ServerEnvironment
|
private let environment: ServerEnvironment
|
||||||
private let apiClient: any VelodyAPIClient
|
private let apiClient: any VelodyAPIClient
|
||||||
private let syncService: RemoteLibrarySyncService
|
private let syncService: RemoteLibrarySyncService
|
||||||
private let offlineLibraryService: OfflineLibraryService
|
private let offlineLibraryService: OfflineLibraryService
|
||||||
|
private let favoriteTrackStore: any FavoriteTrackStore
|
||||||
private let keychainService: any KeychainService
|
private let keychainService: any KeychainService
|
||||||
private let player: any iPhoneLocalAudioPlaying
|
private let player: any iPhoneLocalAudioPlaying
|
||||||
private var cachedRemoteTracksByID: [String: RemoteTrack] = [:]
|
private var cachedRemoteTracksByID: [String: RemoteTrack] = [:]
|
||||||
private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = []
|
private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = []
|
||||||
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
|
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
|
||||||
|
private var cachedFavoriteTrackRecordsByID: [String: FavoriteTrackRecord] = [:]
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
|
||||||
var hasActiveSearch: Bool {
|
var hasActiveSearch: Bool {
|
||||||
@ -214,7 +218,7 @@ final class iPhoneLibraryViewModel {
|
|||||||
!cachedRemoteLibraryTracks.isEmpty
|
!cachedRemoteLibraryTracks.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
convenience init(
|
||||||
player: (any iPhoneLocalAudioPlaying)? = nil,
|
player: (any iPhoneLocalAudioPlaying)? = nil,
|
||||||
keychainService: any KeychainService = SystemKeychainService(
|
keychainService: any KeychainService = SystemKeychainService(
|
||||||
service: "de.diyaa.velody.iphone"
|
service: "de.diyaa.velody.iphone"
|
||||||
@ -229,6 +233,7 @@ final class iPhoneLibraryViewModel {
|
|||||||
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||||
let audioFileStore = Self.makeOfflineAudioFileStore()
|
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||||
let artworkStore = Self.makeArtworkStore()
|
let artworkStore = Self.makeArtworkStore()
|
||||||
|
let favoriteTrackStore = Self.makeFavoriteTrackStore()
|
||||||
let repository = DefaultRemoteLibraryRepository(
|
let repository = DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: store
|
store: store
|
||||||
@ -240,16 +245,38 @@ final class iPhoneLibraryViewModel {
|
|||||||
artworkStore: artworkStore
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
|
|
||||||
self.environment = environment
|
let offlineLibraryService = OfflineLibraryService(
|
||||||
self.apiClient = apiClient
|
|
||||||
self.keychainService = keychainService
|
|
||||||
self.player = player ?? iPhoneLocalAudioPlayer()
|
|
||||||
self.syncService = syncService
|
|
||||||
self.offlineLibraryService = OfflineLibraryService(
|
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
audioFileStore: audioFileStore,
|
audioFileStore: audioFileStore,
|
||||||
artworkStore: artworkStore
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
|
self.init(
|
||||||
|
environment: environment,
|
||||||
|
apiClient: apiClient,
|
||||||
|
syncService: syncService,
|
||||||
|
offlineLibraryService: offlineLibraryService,
|
||||||
|
favoriteTrackStore: favoriteTrackStore,
|
||||||
|
player: player ?? iPhoneLocalAudioPlayer(),
|
||||||
|
keychainService: keychainService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
environment: ServerEnvironment,
|
||||||
|
apiClient: any VelodyAPIClient,
|
||||||
|
syncService: RemoteLibrarySyncService,
|
||||||
|
offlineLibraryService: OfflineLibraryService,
|
||||||
|
favoriteTrackStore: any FavoriteTrackStore,
|
||||||
|
player: any iPhoneLocalAudioPlaying,
|
||||||
|
keychainService: any KeychainService
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
self.apiClient = apiClient
|
||||||
|
self.syncService = syncService
|
||||||
|
self.offlineLibraryService = offlineLibraryService
|
||||||
|
self.favoriteTrackStore = favoriteTrackStore
|
||||||
|
self.keychainService = keychainService
|
||||||
|
self.player = player
|
||||||
self.player.onStateChange = { [weak self] state in
|
self.player.onStateChange = { [weak self] state in
|
||||||
self?.handleNowPlayingStateChange(state)
|
self?.handleNowPlayingStateChange(state)
|
||||||
}
|
}
|
||||||
@ -259,9 +286,20 @@ final class iPhoneLibraryViewModel {
|
|||||||
guard !hasLoaded else { return }
|
guard !hasLoaded else { return }
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
|
|
||||||
|
var favoriteRestoreError: Error?
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await reloadFavoriteTracks()
|
||||||
|
} catch {
|
||||||
|
favoriteRestoreError = error
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let snapshot = try await reloadOfflineLibrary()
|
let snapshot = try await reloadOfflineLibrary()
|
||||||
applyRestoredTracks(snapshot)
|
applyRestoredTracks(snapshot)
|
||||||
|
if let favoriteRestoreError {
|
||||||
|
syncStatus += " Favorites could not be restored: \(favoriteRestoreError.localizedDescription)"
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
state = .idle
|
state = .idle
|
||||||
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
|
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
|
||||||
@ -354,6 +392,32 @@ final class iPhoneLibraryViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(trackID: String) async {
|
||||||
|
guard hasTrackInLibrarySnapshot(trackID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousFavorites = cachedFavoriteTrackRecordsByID
|
||||||
|
|
||||||
|
if cachedFavoriteTrackRecordsByID[trackID] != nil {
|
||||||
|
cachedFavoriteTrackRecordsByID.removeValue(forKey: trackID)
|
||||||
|
} else {
|
||||||
|
cachedFavoriteTrackRecordsByID[trackID] = FavoriteTrackRecord(
|
||||||
|
remoteTrackId: trackID,
|
||||||
|
favoritedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rebuildRows()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values))
|
||||||
|
} catch {
|
||||||
|
cachedFavoriteTrackRecordsByID = previousFavorites
|
||||||
|
rebuildRows()
|
||||||
|
syncStatus = "Favorite update failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func currentOrRegisterDeviceID() async throws -> String {
|
private func currentOrRegisterDeviceID() async throws -> String {
|
||||||
if let existingDeviceID = try await keychainService.loadValue(
|
if let existingDeviceID = try await keychainService.loadValue(
|
||||||
forKey: Self.deviceIDKey
|
forKey: Self.deviceIDKey
|
||||||
@ -430,6 +494,25 @@ final class iPhoneLibraryViewModel {
|
|||||||
return InMemoryArtworkStore()
|
return InMemoryArtworkStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeFavoriteTrackStore() -> any FavoriteTrackStore {
|
||||||
|
if let store = try? FileFavoriteTrackStore() {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
return InMemoryFavoriteTrackStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadFavoriteTracks() async throws {
|
||||||
|
let favoriteTracks = try await favoriteTrackStore.loadFavoriteTracks()
|
||||||
|
var favoritesByTrackID: [String: FavoriteTrackRecord] = [:]
|
||||||
|
|
||||||
|
for favoriteTrack in favoriteTracks {
|
||||||
|
favoritesByTrackID[favoriteTrack.remoteTrackId] = favoriteTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedFavoriteTrackRecordsByID = favoritesByTrackID
|
||||||
|
}
|
||||||
|
|
||||||
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
|
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
|
||||||
let snapshot = try await offlineLibraryService.loadSnapshot()
|
let snapshot = try await offlineLibraryService.loadSnapshot()
|
||||||
|
|
||||||
@ -444,6 +527,7 @@ final class iPhoneLibraryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rebuildRows() {
|
private func rebuildRows() {
|
||||||
|
let favoriteTrackIDs = Set(cachedFavoriteTrackRecordsByID.keys)
|
||||||
let filteredSnapshot = OfflineLibrarySnapshot(
|
let filteredSnapshot = OfflineLibrarySnapshot(
|
||||||
remoteTracks: cachedRemoteLibraryTracks,
|
remoteTracks: cachedRemoteLibraryTracks,
|
||||||
availableTracks: cachedAvailableOfflineTracks
|
availableTracks: cachedAvailableOfflineTracks
|
||||||
@ -452,15 +536,24 @@ final class iPhoneLibraryViewModel {
|
|||||||
remoteTracks = filteredSnapshot.remoteTracks.map { track in
|
remoteTracks = filteredSnapshot.remoteTracks.map { track in
|
||||||
RemoteTrackRowViewData(
|
RemoteTrackRowViewData(
|
||||||
track: track,
|
track: track,
|
||||||
nowPlaying: nowPlaying
|
nowPlaying: nowPlaying,
|
||||||
|
isFavorite: favoriteTrackIDs.contains(track.remoteTrack.trackId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
availableOfflineTracks = filteredSnapshot.availableTracks.map { track in
|
availableOfflineTracks = filteredSnapshot.availableTracks.map { track in
|
||||||
AvailableOfflineTrackRowViewData(
|
AvailableOfflineTrackRowViewData(
|
||||||
track: track,
|
track: track,
|
||||||
nowPlaying: nowPlaying
|
nowPlaying: nowPlaying,
|
||||||
|
isFavorite: favoriteTrackIDs.contains(track.remoteTrackId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if let trackID = nowPlaying.trackID, hasTrackInLibrarySnapshot(trackID) {
|
||||||
|
nowPlayingFavoriteTrackID = trackID
|
||||||
|
isNowPlayingTrackFavorite = favoriteTrackIDs.contains(trackID)
|
||||||
|
} else {
|
||||||
|
nowPlayingFavoriteTrackID = nil
|
||||||
|
isNowPlayingTrackFavorite = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
|
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
|
||||||
@ -481,6 +574,10 @@ final class iPhoneLibraryViewModel {
|
|||||||
cachedRemoteLibraryTracks[index] = transform(cachedRemoteLibraryTracks[index])
|
cachedRemoteLibraryTracks[index] = transform(cachedRemoteLibraryTracks[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hasTrackInLibrarySnapshot(_ trackID: String) -> Bool {
|
||||||
|
cachedRemoteTracksByID[trackID] != nil
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshOfflineLibraryInBackground() {
|
private func refreshOfflineLibraryInBackground() {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@ -508,6 +605,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|||||||
let title: String
|
let title: String
|
||||||
let artist: String
|
let artist: String
|
||||||
let durationText: String
|
let durationText: String
|
||||||
|
let isFavorite: Bool
|
||||||
let remoteTrackID: String
|
let remoteTrackID: String
|
||||||
let assetID: String
|
let assetID: String
|
||||||
let status: OfflineLibraryRemoteTrackStatus
|
let status: OfflineLibraryRemoteTrackStatus
|
||||||
@ -521,12 +619,14 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
track: OfflineLibraryRemoteTrack,
|
track: OfflineLibraryRemoteTrack,
|
||||||
nowPlaying: iPhoneNowPlayingState
|
nowPlaying: iPhoneNowPlayingState,
|
||||||
|
isFavorite: Bool
|
||||||
) {
|
) {
|
||||||
id = track.remoteTrack.trackId
|
id = track.remoteTrack.trackId
|
||||||
title = track.remoteTrack.title
|
title = track.remoteTrack.title
|
||||||
artist = track.remoteTrack.artist
|
artist = track.remoteTrack.artist
|
||||||
durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
|
durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
|
||||||
|
self.isFavorite = isFavorite
|
||||||
remoteTrackID = track.remoteTrack.trackId
|
remoteTrackID = track.remoteTrack.trackId
|
||||||
assetID = track.remoteTrack.assetId
|
assetID = track.remoteTrack.assetId
|
||||||
status = track.status
|
status = track.status
|
||||||
@ -581,6 +681,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
|
|||||||
let title: String
|
let title: String
|
||||||
let artist: String
|
let artist: String
|
||||||
let durationText: String
|
let durationText: String
|
||||||
|
let isFavorite: Bool
|
||||||
let remoteTrackID: String
|
let remoteTrackID: String
|
||||||
let assetID: String
|
let assetID: String
|
||||||
let playButtonTitle: String
|
let playButtonTitle: String
|
||||||
@ -588,12 +689,14 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
track: OfflineLibraryTrack,
|
track: OfflineLibraryTrack,
|
||||||
nowPlaying: iPhoneNowPlayingState
|
nowPlaying: iPhoneNowPlayingState,
|
||||||
|
isFavorite: Bool
|
||||||
) {
|
) {
|
||||||
id = track.remoteTrackId
|
id = track.remoteTrackId
|
||||||
title = track.title
|
title = track.title
|
||||||
artist = track.artist
|
artist = track.artist
|
||||||
durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds)
|
durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds)
|
||||||
|
self.isFavorite = isFavorite
|
||||||
remoteTrackID = track.remoteTrackId
|
remoteTrackID = track.remoteTrackId
|
||||||
assetID = track.assetId
|
assetID = track.assetId
|
||||||
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
|
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
|
||||||
|
|||||||
@ -0,0 +1,451 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
import VelodyDomain
|
||||||
|
import VelodyNetworking
|
||||||
|
import VelodyPersistence
|
||||||
|
import VelodySync
|
||||||
|
import VelodyUtilities
|
||||||
|
@testable import VelodyiPhone
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
|
||||||
|
func testFavoritingTrackUpdatesRemoteOfflineAndNowPlayingState() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-light-trap",
|
||||||
|
assetId: "asset-light-trap",
|
||||||
|
title: "Light Trap"
|
||||||
|
)
|
||||||
|
let player = TestPlayer()
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])],
|
||||||
|
player: player
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
viewModel.togglePlayback(trackID: track.trackId)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId)
|
||||||
|
XCTAssertFalse(viewModel.isNowPlayingTrackFavorite)
|
||||||
|
|
||||||
|
await viewModel.toggleFavorite(trackID: track.trackId)
|
||||||
|
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId)
|
||||||
|
XCTAssertTrue(viewModel.isNowPlayingTrackFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnfavoritingTrackRemovesFavoriteState() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-harbor-lights",
|
||||||
|
assetId: "asset-harbor-lights",
|
||||||
|
title: "Harbor Lights"
|
||||||
|
)
|
||||||
|
let favoriteStore = InMemoryFavoriteTrackStore(tracks: [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: favoriteStore,
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x1])]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
|
||||||
|
await viewModel.toggleFavorite(trackID: track.trackId)
|
||||||
|
|
||||||
|
XCTAssertFalse(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
XCTAssertFalse(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
|
||||||
|
XCTAssertTrue(savedFavorites.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFavoritesPersistAcrossReload() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-night-drive",
|
||||||
|
assetId: "asset-night-drive",
|
||||||
|
title: "Night Drive"
|
||||||
|
)
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstViewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL),
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x2])]
|
||||||
|
)
|
||||||
|
await firstViewModel.loadIfNeeded()
|
||||||
|
await firstViewModel.toggleFavorite(trackID: track.trackId)
|
||||||
|
|
||||||
|
let relaunchedViewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL),
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x2])]
|
||||||
|
)
|
||||||
|
await relaunchedViewModel.loadIfNeeded()
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: relaunchedViewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
|
||||||
|
await relaunchedViewModel.toggleFavorite(trackID: track.trackId)
|
||||||
|
|
||||||
|
let secondRelaunchViewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL),
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x2])]
|
||||||
|
)
|
||||||
|
await secondRelaunchViewModel.loadIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertFalse(try XCTUnwrap(remoteRow(in: secondRelaunchViewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleFavoritesAreTrackedIndependently() async throws {
|
||||||
|
let firstTrack = makeRemoteTrack(
|
||||||
|
trackId: "remote-first",
|
||||||
|
assetId: "asset-first",
|
||||||
|
title: "First Favorite"
|
||||||
|
)
|
||||||
|
let secondTrack = makeRemoteTrack(
|
||||||
|
trackId: "remote-second",
|
||||||
|
assetId: "asset-second",
|
||||||
|
title: "Second Favorite"
|
||||||
|
)
|
||||||
|
let favoriteStore = InMemoryFavoriteTrackStore()
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [firstTrack, secondTrack],
|
||||||
|
downloadStates: [
|
||||||
|
makeDownloadedState(for: firstTrack),
|
||||||
|
makeDownloadedState(for: secondTrack),
|
||||||
|
],
|
||||||
|
favoriteTrackStore: favoriteStore,
|
||||||
|
audioFiles: [
|
||||||
|
localFilePath(for: firstTrack): Data([0x1]),
|
||||||
|
localFilePath(for: secondTrack): Data([0x2]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
await viewModel.toggleFavorite(trackID: firstTrack.trackId)
|
||||||
|
await viewModel.toggleFavorite(trackID: secondTrack.trackId)
|
||||||
|
|
||||||
|
let savedIDs = try await favoriteStore.loadFavoriteTracks()
|
||||||
|
.map(\.remoteTrackId)
|
||||||
|
.sorted()
|
||||||
|
|
||||||
|
XCTAssertEqual(savedIDs, [firstTrack.trackId, secondTrack.trackId].sorted())
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite)
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-repeat",
|
||||||
|
assetId: "asset-repeat",
|
||||||
|
title: "Repeat Toggle"
|
||||||
|
)
|
||||||
|
let favoriteStore = InMemoryFavoriteTrackStore()
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: favoriteStore,
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x3])]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
for _ in 0..<5 {
|
||||||
|
await viewModel.toggleFavorite(trackID: track.trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
|
||||||
|
XCTAssertEqual(savedFavorites.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFavoriteStateDerivesCorrectlyInRemoteLibrary() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-only",
|
||||||
|
assetId: "asset-only",
|
||||||
|
title: "Remote Only"
|
||||||
|
)
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 4_000)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFavoriteStateDerivesCorrectlyInOfflineLibrary() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "offline-favorite",
|
||||||
|
assetId: "asset-offline-favorite",
|
||||||
|
title: "Offline Favorite"
|
||||||
|
)
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 5_000)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x4])]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchDoesNotModifyFavoriteState() async throws {
|
||||||
|
let favoriteTrack = makeRemoteTrack(
|
||||||
|
trackId: "remote-search-favorite",
|
||||||
|
assetId: "asset-search-favorite",
|
||||||
|
title: "Northern Lights"
|
||||||
|
)
|
||||||
|
let otherTrack = makeRemoteTrack(
|
||||||
|
trackId: "remote-search-other",
|
||||||
|
assetId: "asset-search-other",
|
||||||
|
title: "Quiet Harbor"
|
||||||
|
)
|
||||||
|
let favoriteStore = InMemoryFavoriteTrackStore()
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [favoriteTrack, otherTrack],
|
||||||
|
downloadStates: [
|
||||||
|
makeDownloadedState(for: favoriteTrack),
|
||||||
|
makeDownloadedState(for: otherTrack),
|
||||||
|
],
|
||||||
|
favoriteTrackStore: favoriteStore,
|
||||||
|
audioFiles: [
|
||||||
|
localFilePath(for: favoriteTrack): Data([0x5]),
|
||||||
|
localFilePath(for: otherTrack): Data([0x6]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
await viewModel.toggleFavorite(trackID: favoriteTrack.trackId)
|
||||||
|
|
||||||
|
viewModel.searchText = otherTrack.title
|
||||||
|
XCTAssertNil(remoteRow(in: viewModel, trackID: favoriteTrack.trackId))
|
||||||
|
|
||||||
|
viewModel.searchText = favoriteTrack.title
|
||||||
|
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: favoriteTrack.trackId)).isFavorite)
|
||||||
|
let savedFavoriteIDs = try await favoriteStore.loadFavoriteTracks().map(\.remoteTrackId)
|
||||||
|
XCTAssertEqual(savedFavoriteIDs, [favoriteTrack.trackId])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFileStateDoesNotRemoveFavorite() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-missing",
|
||||||
|
assetId: "asset-missing",
|
||||||
|
title: "Missing Favorite"
|
||||||
|
)
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 6_000)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
|
||||||
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
||||||
|
|
||||||
|
XCTAssertEqual(remoteTrack.status, .missing)
|
||||||
|
XCTAssertTrue(remoteTrack.isFavorite)
|
||||||
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class TestPlayer: iPhoneLocalAudioPlaying {
|
||||||
|
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
|
||||||
|
private(set) var state = iPhoneNowPlayingState(
|
||||||
|
trackID: nil,
|
||||||
|
title: nil,
|
||||||
|
artist: nil,
|
||||||
|
isPlaying: false,
|
||||||
|
errorMessage: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
func play(
|
||||||
|
trackID: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
fileURL: URL
|
||||||
|
) throws {
|
||||||
|
_ = fileURL
|
||||||
|
state = iPhoneNowPlayingState(
|
||||||
|
trackID: trackID,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
isPlaying: true,
|
||||||
|
errorMessage: nil
|
||||||
|
)
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() throws {
|
||||||
|
state.isPlaying = true
|
||||||
|
state.errorMessage = nil
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
state.isPlaying = false
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {
|
||||||
|
private let tracks: [RemoteTrack]
|
||||||
|
|
||||||
|
init(tracks: [RemoteTrack]) {
|
||||||
|
self.tracks = tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
|
||||||
|
_ = deviceId
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
|
||||||
|
_ = assetId
|
||||||
|
_ = deviceId
|
||||||
|
throw TestRepositoryError.unexpectedDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
|
||||||
|
_ = artworkId
|
||||||
|
_ = deviceId
|
||||||
|
throw TestRepositoryError.unexpectedDownload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestRepositoryError: Error {
|
||||||
|
case unexpectedDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeViewModel(
|
||||||
|
remoteTracks: [RemoteTrack],
|
||||||
|
downloadStates: [RemoteTrackDownloadState] = [],
|
||||||
|
favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(),
|
||||||
|
audioFiles: [String: Data] = [:],
|
||||||
|
player: (any iPhoneLocalAudioPlaying)? = nil
|
||||||
|
) -> iPhoneLibraryViewModel {
|
||||||
|
let repository = TestRemoteLibraryRepository(tracks: remoteTracks)
|
||||||
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates)
|
||||||
|
let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles)
|
||||||
|
let artworkStore = InMemoryArtworkStore()
|
||||||
|
let syncService = RemoteLibrarySyncService(
|
||||||
|
repository: repository,
|
||||||
|
downloadStateStore: downloadStateStore,
|
||||||
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: artworkStore
|
||||||
|
)
|
||||||
|
let offlineLibraryService = OfflineLibraryService(
|
||||||
|
syncService: syncService,
|
||||||
|
audioFileStore: audioFileStore,
|
||||||
|
artworkStore: artworkStore
|
||||||
|
)
|
||||||
|
|
||||||
|
return iPhoneLibraryViewModel(
|
||||||
|
environment: ServerEnvironment(
|
||||||
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||||
|
appVersion: "Tests"
|
||||||
|
),
|
||||||
|
apiClient: URLSessionVelodyAPIClient(
|
||||||
|
environment: ServerEnvironment(
|
||||||
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||||
|
appVersion: "Tests"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
syncService: syncService,
|
||||||
|
offlineLibraryService: offlineLibraryService,
|
||||||
|
favoriteTrackStore: favoriteTrackStore,
|
||||||
|
player: player ?? TestPlayer(),
|
||||||
|
keychainService: MemoryKeychainService()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRemoteTrack(
|
||||||
|
trackId: String,
|
||||||
|
assetId: String,
|
||||||
|
title: String
|
||||||
|
) -> RemoteTrack {
|
||||||
|
RemoteTrack(
|
||||||
|
trackId: trackId,
|
||||||
|
title: title,
|
||||||
|
artist: "Velody Artist",
|
||||||
|
durationSeconds: 245,
|
||||||
|
sha256: String(repeating: "a", count: 64),
|
||||||
|
assetId: assetId,
|
||||||
|
createdAt: "2026-05-30T08:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-30T08:05:00.000Z"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState {
|
||||||
|
RemoteTrackDownloadState(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
assetId: track.assetId,
|
||||||
|
localFilePath: localFilePath(for: track),
|
||||||
|
downloadedAt: Date(timeIntervalSince1970: 1_000),
|
||||||
|
downloadStatus: .downloaded
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localFilePath(for track: RemoteTrack) -> String {
|
||||||
|
"/in-memory/\(track.assetId).mp3"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func remoteRow(
|
||||||
|
in viewModel: iPhoneLibraryViewModel,
|
||||||
|
trackID: String
|
||||||
|
) -> RemoteTrackRowViewData? {
|
||||||
|
viewModel.remoteTracks.first(where: { $0.id == trackID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func offlineRow(
|
||||||
|
in viewModel: iPhoneLibraryViewModel,
|
||||||
|
trackID: String
|
||||||
|
) -> AvailableOfflineTrackRowViewData? {
|
||||||
|
viewModel.availableOfflineTracks.first(where: { $0.id == trackID })
|
||||||
|
}
|
||||||
@ -66,3 +66,28 @@ targets:
|
|||||||
product: VelodySync
|
product: VelodySync
|
||||||
- package: VelodyUtilities
|
- package: VelodyUtilities
|
||||||
product: VelodyUtilities
|
product: VelodyUtilities
|
||||||
|
scheme:
|
||||||
|
testTargets:
|
||||||
|
- VelodyiPhoneTests
|
||||||
|
VelodyiPhoneTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
hostApplication: VelodyiPhone
|
||||||
|
sources:
|
||||||
|
- path: VelodyiPhone/Tests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: de.diyaa.velody.iphone.tests
|
||||||
|
dependencies:
|
||||||
|
- target: VelodyiPhone
|
||||||
|
- package: VelodyDomain
|
||||||
|
product: VelodyDomain
|
||||||
|
- package: VelodyNetworking
|
||||||
|
product: VelodyNetworking
|
||||||
|
- package: VelodyPersistence
|
||||||
|
product: VelodyPersistence
|
||||||
|
- package: VelodySync
|
||||||
|
product: VelodySync
|
||||||
|
- package: VelodyUtilities
|
||||||
|
product: VelodyUtilities
|
||||||
|
|||||||
@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct FavoriteTrackRecord: Codable, Hashable, Sendable {
|
||||||
|
public var remoteTrackId: String
|
||||||
|
public var favoritedAt: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
remoteTrackId: String,
|
||||||
|
favoritedAt: Date
|
||||||
|
) {
|
||||||
|
self.remoteTrackId = remoteTrackId
|
||||||
|
self.favoritedAt = favoritedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol FavoriteTrackStore: Actor {
|
||||||
|
func loadFavoriteTracks() async throws -> [FavoriteTrackRecord]
|
||||||
|
func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension FavoriteTrackStore {
|
||||||
|
func saveFavoriteTrack(_ track: FavoriteTrackRecord) async throws {
|
||||||
|
var tracks = try await loadFavoriteTracks()
|
||||||
|
|
||||||
|
if let existingIndex = tracks.firstIndex(where: { $0.remoteTrackId == track.remoteTrackId }) {
|
||||||
|
tracks[existingIndex] = track
|
||||||
|
} else {
|
||||||
|
tracks.append(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await saveFavoriteTracks(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFavoriteTrack(remoteTrackId: String) async throws {
|
||||||
|
let tracks = try await loadFavoriteTracks()
|
||||||
|
.filter { $0.remoteTrackId != remoteTrackId }
|
||||||
|
try await saveFavoriteTracks(tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor FileFavoriteTrackStore: FavoriteTrackStore {
|
||||||
|
private let fileURL: URL
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL? = nil,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
if let fileURL {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
} else {
|
||||||
|
self.fileURL = try Self.defaultFileURL(fileManager: fileManager)
|
||||||
|
}
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] {
|
||||||
|
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
return try decoder.decode([FavoriteTrackRecord].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let sortedTracks = tracks.sorted { lhs, rhs in
|
||||||
|
lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending
|
||||||
|
}
|
||||||
|
let data = try encoder.encode(sortedTracks)
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultFileURL(fileManager: FileManager) throws -> URL {
|
||||||
|
guard let applicationSupportURL = fileManager.urls(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask
|
||||||
|
).first else {
|
||||||
|
throw CocoaError(.fileNoSuchFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicationSupportURL
|
||||||
|
.appendingPathComponent("Velody", isDirectory: true)
|
||||||
|
.appendingPathComponent("favorite-tracks.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor InMemoryFavoriteTrackStore: FavoriteTrackStore {
|
||||||
|
private var tracks: [FavoriteTrackRecord]
|
||||||
|
|
||||||
|
public init(tracks: [FavoriteTrackRecord] = []) {
|
||||||
|
self.tracks = tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadFavoriteTracks() async throws -> [FavoriteTrackRecord] {
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveFavoriteTracks(_ tracks: [FavoriteTrackRecord]) async throws {
|
||||||
|
self.tracks = tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import VelodyPersistence
|
||||||
|
|
||||||
|
final class FavoriteTrackStoreTests: XCTestCase {
|
||||||
|
func testFavoriteTrackStoreUpsertsAndRemovesByRemoteTrackID() async throws {
|
||||||
|
let store = InMemoryFavoriteTrackStore()
|
||||||
|
let originalFavorite = FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-123",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||||
|
)
|
||||||
|
let updatedFavorite = FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-123",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
||||||
|
)
|
||||||
|
let secondFavorite = FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-456",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 3_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
try await store.saveFavoriteTrack(originalFavorite)
|
||||||
|
try await store.saveFavoriteTrack(updatedFavorite)
|
||||||
|
try await store.saveFavoriteTrack(secondFavorite)
|
||||||
|
|
||||||
|
let savedFavorites = try await store.loadFavoriteTracks()
|
||||||
|
|
||||||
|
XCTAssertEqual(savedFavorites.count, 2)
|
||||||
|
XCTAssertTrue(savedFavorites.contains(updatedFavorite))
|
||||||
|
XCTAssertTrue(savedFavorites.contains(secondFavorite))
|
||||||
|
|
||||||
|
try await store.removeFavoriteTrack(remoteTrackId: "track-123")
|
||||||
|
let remainingFavorites = try await store.loadFavoriteTracks()
|
||||||
|
|
||||||
|
XCTAssertEqual(remainingFavorites, [secondFavorite])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileFavoriteTrackStorePersistsAcrossInstances() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
||||||
|
let favorites = [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-123",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||||
|
),
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-456",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstStore = try FileFavoriteTrackStore(fileURL: fileURL)
|
||||||
|
try await firstStore.saveFavoriteTracks(favorites)
|
||||||
|
|
||||||
|
let secondStore = try FileFavoriteTrackStore(fileURL: fileURL)
|
||||||
|
let restoredFavorites = try await secondStore.loadFavoriteTracks()
|
||||||
|
|
||||||
|
XCTAssertEqual(restoredFavorites, favorites)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileFavoriteTrackStoreWritesAtomicallyAndOverwritesOldContents() async throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
|
UUID().uuidString,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
||||||
|
let firstFavorites = [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-b",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
||||||
|
),
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-a",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let secondFavorites = [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: "track-c",
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 3_000)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? fileManager.removeItem(at: tempDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = try FileFavoriteTrackStore(fileURL: fileURL)
|
||||||
|
try await store.saveFavoriteTracks(firstFavorites)
|
||||||
|
try await store.saveFavoriteTracks(secondFavorites)
|
||||||
|
|
||||||
|
let rawData = try Data(contentsOf: fileURL)
|
||||||
|
let restoredFavorites = try decoder.decode([FavoriteTrackRecord].self, from: rawData)
|
||||||
|
let directoryContents = try fileManager.contentsOfDirectory(atPath: tempDirectory.path)
|
||||||
|
let json = try XCTUnwrap(
|
||||||
|
JSONSerialization.jsonObject(with: rawData) as? [[String: Any]]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(restoredFavorites, secondFavorites)
|
||||||
|
XCTAssertEqual(directoryContents.sorted(), ["favorite-tracks.json"])
|
||||||
|
XCTAssertEqual(Set(json[0].keys), ["favoritedAt", "remoteTrackId"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user