Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f3c7912e8 | |||
| 548d599628 | |||
| 17f532556a | |||
| 00bd6cd81b | |||
| ce2b88ac03 | |||
| 624e575980 | |||
| b89d3d953f | |||
| 4951b54ede | |||
| 2337cd3844 | |||
| f8f836125d | |||
| d24bca48a2 | |||
| 1916a0628a | |||
| 579fd26846 | |||
| ed8352ff68 | |||
| 933a22bb72 | |||
| 47d3e5bea5 | |||
| fde33feeef | |||
| 94bc48c7f1 | |||
| de2c04cdaf | |||
| 738397c637 | |||
| 1f70860473 | |||
| a8865bb4d8 | |||
| dd2e790ca6 | |||
| c5bdbdd692 | |||
| ffa5bf8f2c | |||
| 9d8973e971 | |||
| cb72197f8e |
@@ -17,7 +17,7 @@ jobs:
|
||||
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||
strategy:
|
||||
matrix:
|
||||
destination: ["OS=latest,name=iPhone 13 Pro"]
|
||||
destination: ["OS=latest,name=iPhone 15 Pro"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: iOS - ${{ matrix.destination }}
|
||||
|
||||
@@ -10,6 +10,7 @@ xcuserdata/
|
||||
*.xccheckout
|
||||
|
||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||
**/.DS_Store
|
||||
build/
|
||||
DerivedData/
|
||||
*.moved-aside
|
||||
@@ -88,3 +89,5 @@ fastlane/test_output
|
||||
# https://github.com/johnno1962/injectionforxcode
|
||||
|
||||
iOSInjectionProject/
|
||||
/.DS_Store
|
||||
/AudioExample/AudioExample/.DS_Store
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>classNames</key>
|
||||
<dict>
|
||||
<key>AtomicTests</key>
|
||||
<dict>
|
||||
<key>testProtectedValuesAreAccessedSafely()</key>
|
||||
<dict>
|
||||
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
|
||||
<dict>
|
||||
<key>baselineAverage</key>
|
||||
<real>0.029769</real>
|
||||
<key>baselineIntegrationDisplayName</key>
|
||||
<string>Local Baseline</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>runDestinationsByUUID</key>
|
||||
<dict>
|
||||
<key>E340D9FA-D19A-49BB-82AA-9D0E236D4288</key>
|
||||
<dict>
|
||||
<key>localComputer</key>
|
||||
<dict>
|
||||
<key>busSpeedInMHz</key>
|
||||
<integer>0</integer>
|
||||
<key>cpuCount</key>
|
||||
<integer>1</integer>
|
||||
<key>cpuKind</key>
|
||||
<string>Apple M1 Pro</string>
|
||||
<key>cpuSpeedInMHz</key>
|
||||
<integer>0</integer>
|
||||
<key>logicalCPUCoresPerPackage</key>
|
||||
<integer>10</integer>
|
||||
<key>modelCode</key>
|
||||
<string>MacBookPro18,1</string>
|
||||
<key>physicalCPUCoresPerPackage</key>
|
||||
<integer>10</integer>
|
||||
<key>platformIdentifier</key>
|
||||
<string>com.apple.platform.macosx</string>
|
||||
</dict>
|
||||
<key>targetArchitecture</key>
|
||||
<string>arm64</string>
|
||||
<key>targetDevice</key>
|
||||
<dict>
|
||||
<key>modelCode</key>
|
||||
<string>iPhone16,1</string>
|
||||
<key>platformIdentifier</key>
|
||||
<string>com.apple.platform.iphonesimulator</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,442 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 50;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
984808A028C0F549001160E6 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9848089F28C0F549001160E6 /* hipjazz.wav */; };
|
||||
98C82AE22B8CA16A00AED485 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */; };
|
||||
B5220836256051830086FB3A /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220835256051830086FB3A /* AudioPlayerService.swift */; };
|
||||
B5220948256074910086FB3A /* MulticastDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220947256074910086FB3A /* MulticastDelegate.swift */; };
|
||||
B52209502561883E0086FB3A /* EqualizerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B522094F2561883E0086FB3A /* EqualizerViewController.swift */; };
|
||||
B5220954256188590086FB3A /* EqualizerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5220953256188590086FB3A /* EqualizerViewModel.swift */; };
|
||||
B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */; };
|
||||
B524D5A12560302100F5A88F /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A02560302100F5A88F /* PlayerViewController.swift */; };
|
||||
B524D5A32560303000F5A88F /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A22560303000F5A88F /* PlayerViewModel.swift */; };
|
||||
B524D5A52560303D00F5A88F /* PlayerControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */; };
|
||||
B524D5A72560305800F5A88F /* PlayerControlsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */; };
|
||||
B524D5A9256031DE00F5A88F /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5A8256031DE00F5A88F /* AppCoordinator.swift */; };
|
||||
B524D5AD25604E4B00F5A88F /* PlaylistItemsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */; };
|
||||
B524D5AF25604ED900F5A88F /* AudioContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B524D5AE25604ED900F5A88F /* AudioContent.swift */; };
|
||||
B580CB0E2561B912006D7DD8 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B580CB0D2561B912006D7DD8 /* EqualizerService.swift */; };
|
||||
B5AEDBD52475274C007D8101 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AEDBD42475274C007D8101 /* AppDelegate.swift */; };
|
||||
B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5AEDBDD2475274D007D8101 /* Assets.xcassets */; };
|
||||
B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */; };
|
||||
B5F883C624780A3D00D277C1 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F883C524780A3C00D277C1 /* AudioStreaming.framework */; };
|
||||
B5F883C724780A3D00D277C1 /* AudioStreaming.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B5F883C524780A3C00D277C1 /* AudioStreaming.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
B5F883C824780A3D00D277C1 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
B5F883C724780A3D00D277C1 /* AudioStreaming.framework in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
9848089F28C0F549001160E6 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
|
||||
98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = "<group>"; };
|
||||
B5220835256051830086FB3A /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
B5220947256074910086FB3A /* MulticastDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MulticastDelegate.swift; sourceTree = "<group>"; };
|
||||
B522094F2561883E0086FB3A /* EqualizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewController.swift; sourceTree = "<group>"; };
|
||||
B5220953256188590086FB3A /* EqualizerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerViewModel.swift; sourceTree = "<group>"; };
|
||||
B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = "<group>"; };
|
||||
B524D5A02560302100F5A88F /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = "<group>"; };
|
||||
B524D5A22560303000F5A88F /* PlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModel.swift; sourceTree = "<group>"; };
|
||||
B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsViewController.swift; sourceTree = "<group>"; };
|
||||
B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsViewModel.swift; sourceTree = "<group>"; };
|
||||
B524D5A8256031DE00F5A88F /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = "<group>"; };
|
||||
B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistItemsService.swift; sourceTree = "<group>"; };
|
||||
B524D5AE25604ED900F5A88F /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = "<group>"; };
|
||||
B580CB0D2561B912006D7DD8 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = "<group>"; };
|
||||
B5AEDBD12475274C007D8101 /* AudioExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B5AEDBD42475274C007D8101 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
B5AEDBDD2475274D007D8101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B5AEDBE02475274D007D8101 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
B5AEDBE22475274D007D8101 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B5F883C524780A3C00D277C1 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
B5AEDBCE2475274C007D8101 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B5F883C624780A3D00D277C1 /* AudioStreaming.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
B524D59D2560177C00F5A88F /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98C82AE12B8CA0F000AED485 /* bensound-jazzyfrenchy.m4a */,
|
||||
9848089F28C0F549001160E6 /* hipjazz.wav */,
|
||||
B524D59B2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 */,
|
||||
B5AEDBDD2475274D007D8101 /* Assets.xcassets */,
|
||||
B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B524D5AA25604E2E00F5A88F /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B524D5AE25604ED900F5A88F /* AudioContent.swift */,
|
||||
B524D5AC25604E4B00F5A88F /* PlaylistItemsService.swift */,
|
||||
B5220835256051830086FB3A /* AudioPlayerService.swift */,
|
||||
B5220947256074910086FB3A /* MulticastDelegate.swift */,
|
||||
B580CB0D2561B912006D7DD8 /* EqualizerService.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B524D5AB25604E3500F5A88F /* Controllers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B524D5A02560302100F5A88F /* PlayerViewController.swift */,
|
||||
B524D5A22560303000F5A88F /* PlayerViewModel.swift */,
|
||||
B524D5A42560303D00F5A88F /* PlayerControlsViewController.swift */,
|
||||
B524D5A62560305800F5A88F /* PlayerControlsViewModel.swift */,
|
||||
B522094F2561883E0086FB3A /* EqualizerViewController.swift */,
|
||||
B5220953256188590086FB3A /* EqualizerViewModel.swift */,
|
||||
);
|
||||
path = Controllers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B5AEDBC82475274C007D8101 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B5AEDBD32475274C007D8101 /* AudioExample */,
|
||||
B5AEDBD22475274C007D8101 /* Products */,
|
||||
B5F883C424780A3C00D277C1 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
wrapsLines = 0;
|
||||
};
|
||||
B5AEDBD22475274C007D8101 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B5AEDBD12475274C007D8101 /* AudioExample.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B5AEDBD32475274C007D8101 /* AudioExample */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B5AEDBD42475274C007D8101 /* AppDelegate.swift */,
|
||||
B524D5A8256031DE00F5A88F /* AppCoordinator.swift */,
|
||||
B524D5AA25604E2E00F5A88F /* Services */,
|
||||
B524D5AB25604E3500F5A88F /* Controllers */,
|
||||
B524D59D2560177C00F5A88F /* Resources */,
|
||||
B5AEDBE22475274D007D8101 /* Info.plist */,
|
||||
);
|
||||
path = AudioExample;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B5F883C424780A3C00D277C1 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B5F883C524780A3C00D277C1 /* AudioStreaming.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
B5AEDBD02475274C007D8101 /* AudioExample */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B5AEDBE52475274D007D8101 /* Build configuration list for PBXNativeTarget "AudioExample" */;
|
||||
buildPhases = (
|
||||
B5AEDBCD2475274C007D8101 /* Sources */,
|
||||
B5AEDBCE2475274C007D8101 /* Frameworks */,
|
||||
B5AEDBCF2475274C007D8101 /* Resources */,
|
||||
B5F883C824780A3D00D277C1 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = AudioExample;
|
||||
productName = AudioExample;
|
||||
productReference = B5AEDBD12475274C007D8101 /* AudioExample.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
B5AEDBC92475274C007D8101 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1140;
|
||||
LastUpgradeCheck = 1200;
|
||||
ORGANIZATIONNAME = "Dimitrios Chatzieleftheriou";
|
||||
TargetAttributes = {
|
||||
B5AEDBD02475274C007D8101 = {
|
||||
CreatedOnToolsVersion = 11.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = B5AEDBCC2475274C007D8101 /* Build configuration list for PBXProject "AudioExample" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = B5AEDBC82475274C007D8101;
|
||||
productRefGroup = B5AEDBD22475274C007D8101 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
B5AEDBD02475274C007D8101 /* AudioExample */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
B5AEDBCF2475274C007D8101 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B524D59C2560176C00F5A88F /* bensound-jazzyfrenchy.mp3 in Resources */,
|
||||
B5AEDBE12475274D007D8101 /* LaunchScreen.storyboard in Resources */,
|
||||
B5AEDBDE2475274D007D8101 /* Assets.xcassets in Resources */,
|
||||
98C82AE22B8CA16A00AED485 /* bensound-jazzyfrenchy.m4a in Resources */,
|
||||
984808A028C0F549001160E6 /* hipjazz.wav in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
B5AEDBCD2475274C007D8101 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B524D5AF25604ED900F5A88F /* AudioContent.swift in Sources */,
|
||||
B524D5A9256031DE00F5A88F /* AppCoordinator.swift in Sources */,
|
||||
B524D5AD25604E4B00F5A88F /* PlaylistItemsService.swift in Sources */,
|
||||
B524D5A32560303000F5A88F /* PlayerViewModel.swift in Sources */,
|
||||
B5220836256051830086FB3A /* AudioPlayerService.swift in Sources */,
|
||||
B5AEDBD52475274C007D8101 /* AppDelegate.swift in Sources */,
|
||||
B524D5A12560302100F5A88F /* PlayerViewController.swift in Sources */,
|
||||
B580CB0E2561B912006D7DD8 /* EqualizerService.swift in Sources */,
|
||||
B5220954256188590086FB3A /* EqualizerViewModel.swift in Sources */,
|
||||
B5220948256074910086FB3A /* MulticastDelegate.swift in Sources */,
|
||||
B524D5A52560303D00F5A88F /* PlayerControlsViewController.swift in Sources */,
|
||||
B524D5A72560305800F5A88F /* PlayerControlsViewModel.swift in Sources */,
|
||||
B52209502561883E0086FB3A /* EqualizerViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
B5AEDBDF2475274D007D8101 /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
B5AEDBE02475274D007D8101 /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
B5AEDBE32475274D007D8101 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B5AEDBE42475274D007D8101 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B5AEDBE62475274D007D8101 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = AudioExample/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B5AEDBE72475274D007D8101 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = AudioExample/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioExample;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
B5AEDBCC2475274C007D8101 /* Build configuration list for PBXProject "AudioExample" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B5AEDBE32475274D007D8101 /* Debug */,
|
||||
B5AEDBE42475274D007D8101 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B5AEDBE52475274D007D8101 /* Build configuration list for PBXNativeTarget "AudioExample" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B5AEDBE62475274D007D8101 /* Debug */,
|
||||
B5AEDBE72475274D007D8101 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = B5AEDBC92475274C007D8101 /* Project object */;
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>AudioExample.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>B5AEDBD02475274C007D8101</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// AppCoordinator.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
final class AppCoordinator {
|
||||
enum Route {
|
||||
case equalizer
|
||||
}
|
||||
|
||||
private var navigationController: UINavigationController?
|
||||
|
||||
private let playerService: AudioPlayerService
|
||||
private let equaliserService: EqualizerService
|
||||
|
||||
init() {
|
||||
playerService = AudioPlayerService()
|
||||
equaliserService = EqualizerService(playerService: playerService)
|
||||
}
|
||||
|
||||
func start(window: UIWindow) {
|
||||
window.rootViewController = buildMain()
|
||||
window.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
private func buildMain() -> UINavigationController {
|
||||
let playlistItemsService = PlaylistItemsService(initialItemsProvider: provideInitialPlaylistItems)
|
||||
let viewModel = PlayerViewModel(playlistItemsService: playlistItemsService,
|
||||
playerService: playerService,
|
||||
routeTo: { [weak self] in self?.routeTo($0) })
|
||||
let viewController = PlayerViewController(viewModel: viewModel,
|
||||
controlsProvider: providePlayerControls)
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: viewController)
|
||||
self.navigationController = navigationController
|
||||
return navigationController
|
||||
}
|
||||
|
||||
private func routeTo(_ route: AppCoordinator.Route) {
|
||||
switch route {
|
||||
case .equalizer:
|
||||
showEqualizerControls()
|
||||
}
|
||||
}
|
||||
|
||||
private func providePlayerControls() -> UIViewController {
|
||||
let viewModel = PlayerControlsViewModel(playerService: playerService)
|
||||
return PlayerControlsViewController(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func showEqualizerControls() {
|
||||
let viewModel = EqualzerViewModel(equalizerService: equaliserService)
|
||||
let viewController = EqualizerViewController(viewModel: viewModel)
|
||||
let navigationController = UINavigationController(rootViewController: viewController)
|
||||
self.navigationController?.present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 20/05/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
var appCoordinator: AppCoordinator?
|
||||
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
let appCoordinator = AppCoordinator()
|
||||
appCoordinator.start(window: window)
|
||||
self.window = window
|
||||
self.appCoordinator = appCoordinator
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//
|
||||
// EqualizerViewController.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class EqualizerViewController: UIViewController {
|
||||
private lazy var enableTextLabel = UILabel()
|
||||
private lazy var enableButton = UISwitch()
|
||||
|
||||
private var eqSlider = [UISlider]()
|
||||
|
||||
private let viewModel: EqualzerViewModel
|
||||
|
||||
init(viewModel: EqualzerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "Equaliser"
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Reset", style: .plain, target: self, action: #selector(resetEq))
|
||||
|
||||
enableTextLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
enableTextLabel.text = "Enable"
|
||||
|
||||
enableButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
enableButton.isOn = viewModel.equaliserIsOn
|
||||
enableButton.onTintColor = .systemTeal
|
||||
enableButton.addTarget(self, action: #selector(enableEq), for: .valueChanged)
|
||||
|
||||
let enableStackView = UIStackView(arrangedSubviews: [enableTextLabel, enableButton])
|
||||
enableStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
enableStackView.axis = .horizontal
|
||||
enableStackView.alignment = .center
|
||||
enableStackView.spacing = 10
|
||||
enableStackView.isLayoutMarginsRelativeArrangement = true
|
||||
enableStackView.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
|
||||
|
||||
let equaliserControls = UIStackView(arrangedSubviews: buildSliders())
|
||||
equaliserControls.translatesAutoresizingMaskIntoConstraints = false
|
||||
equaliserControls.axis = .vertical
|
||||
equaliserControls.alignment = .fill
|
||||
equaliserControls.distribution = .fillEqually
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [enableStackView, equaliserControls])
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10)
|
||||
|
||||
view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate(
|
||||
[
|
||||
enableStackView.heightAnchor.constraint(equalToConstant: 60),
|
||||
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.8),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@objc func enableEq() {
|
||||
viewModel.enableEq(enableButton.isOn)
|
||||
}
|
||||
|
||||
@objc func resetEq() {
|
||||
viewModel.resetEq { value in
|
||||
eqSlider.forEach { $0.setValue(value, animated: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private func buildSliders() -> [UIView] {
|
||||
var sliders = [UIView]()
|
||||
for index in 0 ..< viewModel.numberOfBands() {
|
||||
guard let item = viewModel.band(at: index) else { continue }
|
||||
let slider = buildSlider(item: item, index: index)
|
||||
sliders.append(slider)
|
||||
}
|
||||
return sliders
|
||||
}
|
||||
|
||||
@objc private func valueChanged(_ slider: UISlider) {
|
||||
viewModel.update(gain: slider.value, for: slider.tag)
|
||||
}
|
||||
|
||||
private func buildSlider(item: EQBand, index: Int) -> UIView {
|
||||
let freqLabel = UILabel()
|
||||
freqLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
freqLabel.text = item.frequency
|
||||
freqLabel.textAlignment = .right
|
||||
freqLabel.widthAnchor.constraint(equalToConstant: 40).isActive = true
|
||||
|
||||
let slider = UISlider()
|
||||
slider.translatesAutoresizingMaskIntoConstraints = false
|
||||
slider.tag = index // cheating here
|
||||
slider.minimumValue = item.min
|
||||
slider.maximumValue = item.max
|
||||
slider.value = item.value
|
||||
slider.isContinuous = true
|
||||
slider.addTarget(self, action: #selector(valueChanged(_:)), for: .valueChanged)
|
||||
eqSlider.append(slider)
|
||||
|
||||
let minLabel = UILabel()
|
||||
minLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
minLabel.text = "\(item.min)db"
|
||||
|
||||
let centerLabel = UILabel()
|
||||
centerLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
centerLabel.text = "0db"
|
||||
centerLabel.textAlignment = .center
|
||||
|
||||
let maxLabel = UILabel()
|
||||
maxLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
maxLabel.text = "\(item.max)db"
|
||||
maxLabel.textAlignment = .right
|
||||
|
||||
let dbStackView = UIStackView(arrangedSubviews: [minLabel, centerLabel, maxLabel])
|
||||
dbStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
dbStackView.axis = .horizontal
|
||||
dbStackView.distribution = .fillEqually
|
||||
|
||||
let stackViewSlider = UIStackView(arrangedSubviews: [slider, dbStackView])
|
||||
stackViewSlider.spacing = 5
|
||||
stackViewSlider.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackViewSlider.axis = .vertical
|
||||
stackViewSlider.setContentHuggingPriority(.fittingSizeLevel, for: .horizontal)
|
||||
stackViewSlider.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal)
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [freqLabel, stackViewSlider])
|
||||
stackView.spacing = 10
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillProportionally
|
||||
stackView.alignment = .fill
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.directionalLayoutMargins = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
|
||||
|
||||
return stackView
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// EqualizerViewModel.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
struct EQBand {
|
||||
let frequency: String
|
||||
let min: Float
|
||||
let max: Float
|
||||
let value: Float
|
||||
}
|
||||
|
||||
final class EqualzerViewModel {
|
||||
private var bands: [EQBand] = []
|
||||
|
||||
private let equalizerService: EqualizerService
|
||||
|
||||
var equaliserIsOn: Bool {
|
||||
equalizerService.isActivated
|
||||
}
|
||||
|
||||
init(equalizerService: EqualizerService) {
|
||||
self.equalizerService = equalizerService
|
||||
|
||||
bands = equalizerService.bands.map { item in
|
||||
var measurement = item.frequency
|
||||
var frequency = String(Int(measurement))
|
||||
if item.frequency >= 1000 {
|
||||
measurement = item.frequency / 1000
|
||||
frequency = "\(String(Int(measurement)))K"
|
||||
}
|
||||
return EQBand(frequency: frequency, min: -12, max: 12, value: item.gain)
|
||||
}
|
||||
}
|
||||
|
||||
func enableEq(_ enable: Bool) {
|
||||
if enable {
|
||||
equalizerService.activate()
|
||||
} else {
|
||||
equalizerService.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
func resetEq(updateSliders: (_ value: Float) -> Void) {
|
||||
equalizerService.reset()
|
||||
updateSliders(0)
|
||||
}
|
||||
|
||||
func update(gain: Float, for index: Int) {
|
||||
equalizerService.update(gain: gain, for: index)
|
||||
}
|
||||
|
||||
func numberOfBands() -> Int {
|
||||
equalizerService.bands.count
|
||||
}
|
||||
|
||||
func band(at index: Int) -> EQBand? {
|
||||
guard index < numberOfBands() else { return nil }
|
||||
return bands[index]
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//
|
||||
// PlayerControlsViewController.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class PlayerControlsViewController: UIViewController {
|
||||
private lazy var resumeButton = UIButton()
|
||||
private lazy var stopButton = UIButton(type: .custom)
|
||||
private lazy var muteButton = UIButton()
|
||||
|
||||
private lazy var slider = UISlider()
|
||||
private lazy var elapsedPlayTimeLabel = UILabel()
|
||||
private lazy var remainingPlayTimeLabel = UILabel()
|
||||
|
||||
private lazy var rateSlider = UISlider()
|
||||
private lazy var rateSliderValueLabel = UILabel()
|
||||
|
||||
private lazy var playerStatus = UILabel()
|
||||
|
||||
private let viewModel: PlayerControlsViewModel
|
||||
|
||||
init(viewModel: PlayerControlsViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
setupUI()
|
||||
setupBinding()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
muteButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
muteButton.setTitle("Mute", for: .normal)
|
||||
muteButton.setTitleColor(.label, for: .normal)
|
||||
muteButton.setTitleColor(.secondaryLabel, for: .highlighted)
|
||||
muteButton.setTitleColor(.tertiaryLabel, for: .disabled)
|
||||
muteButton.accessibilityIdentifier = "muteButton"
|
||||
muteButton.addTarget(self, action: #selector(toggleMute), for: .touchUpInside)
|
||||
|
||||
resumeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
resumeButton.setTitle("Pause", for: .normal)
|
||||
resumeButton.accessibilityIdentifier = "resumeButton"
|
||||
resumeButton.setTitleColor(.label, for: .normal)
|
||||
resumeButton.setTitleColor(.secondaryLabel, for: .highlighted)
|
||||
resumeButton.setTitleColor(.tertiaryLabel, for: .disabled)
|
||||
resumeButton.addTarget(self, action: #selector(pauseResume), for: .touchUpInside)
|
||||
|
||||
stopButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
stopButton.setTitle("Stop", for: .normal)
|
||||
stopButton.setTitleColor(.label, for: .normal)
|
||||
stopButton.setTitleColor(.secondaryLabel, for: .highlighted)
|
||||
stopButton.setTitleColor(.tertiaryLabel, for: .disabled)
|
||||
stopButton.accessibilityIdentifier = "stopButton"
|
||||
stopButton.addTarget(self, action: #selector(stop), for: .touchUpInside)
|
||||
|
||||
let controlsStackView = UIStackView(arrangedSubviews: [resumeButton, stopButton, muteButton])
|
||||
controlsStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
controlsStackView.axis = .horizontal
|
||||
controlsStackView.distribution = .fillEqually
|
||||
controlsStackView.alignment = .center
|
||||
controlsStackView.accessibilityIdentifier = "controlsStackView"
|
||||
|
||||
slider.translatesAutoresizingMaskIntoConstraints = false
|
||||
slider.accessibilityIdentifier = "slider"
|
||||
slider.tintColor = .systemGray2
|
||||
slider.thumbTintColor = .systemGray
|
||||
slider.isContinuous = true
|
||||
slider.semanticContentAttribute = .playback
|
||||
slider.addTarget(self, action: #selector(sliderTouchedDown), for: .touchDown)
|
||||
slider.addTarget(self, action: #selector(sliderTouchedUp), for: [.touchUpInside, .touchUpOutside])
|
||||
slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
|
||||
|
||||
elapsedPlayTimeLabel.text = "--:--"
|
||||
elapsedPlayTimeLabel.accessibilityIdentifier = "elapsedPlayTimeLabel"
|
||||
elapsedPlayTimeLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
elapsedPlayTimeLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
|
||||
elapsedPlayTimeLabel.textAlignment = .left
|
||||
remainingPlayTimeLabel.text = "--:--"
|
||||
remainingPlayTimeLabel.accessibilityIdentifier = "remainingPlayTimeLabel"
|
||||
remainingPlayTimeLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
remainingPlayTimeLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
|
||||
remainingPlayTimeLabel.textAlignment = .right
|
||||
|
||||
let playbackTimeLabelsStack = UIStackView(arrangedSubviews: [elapsedPlayTimeLabel, remainingPlayTimeLabel])
|
||||
playbackTimeLabelsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
playbackTimeLabelsStack.axis = .horizontal
|
||||
playbackTimeLabelsStack.distribution = .fillEqually
|
||||
playbackTimeLabelsStack.accessibilityIdentifier = "playbackTimeLabelsStack"
|
||||
|
||||
playerStatus.text = ""
|
||||
playerStatus.translatesAutoresizingMaskIntoConstraints = false
|
||||
playerStatus.numberOfLines = 0
|
||||
playerStatus.accessibilityIdentifier = "playerStatus-label"
|
||||
|
||||
let sliderLabel = UILabel()
|
||||
sliderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
sliderLabel.text = "Rate: "
|
||||
|
||||
rateSliderValueLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
rateSliderValueLabel.text = viewModel.currentRateTitle
|
||||
|
||||
rateSlider.translatesAutoresizingMaskIntoConstraints = false
|
||||
rateSlider.minimumValue = viewModel.rateMinValue
|
||||
rateSlider.maximumValue = viewModel.rateMaxValue
|
||||
rateSlider.value = viewModel.rateMinValue
|
||||
rateSlider.addTarget(self, action: #selector(rateValueChanged), for: .valueChanged)
|
||||
|
||||
let sliderWarningLabel = UILabel()
|
||||
sliderWarningLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
sliderWarningLabel.text = "Adjusting rate in live broadcast is not recommended"
|
||||
sliderWarningLabel.numberOfLines = 2
|
||||
sliderWarningLabel.textColor = .systemRed
|
||||
|
||||
let rateSliderStackView = UIStackView(arrangedSubviews: [sliderLabel, rateSlider, rateSliderValueLabel])
|
||||
rateSliderStackView.spacing = 10
|
||||
rateSliderStackView.axis = .horizontal
|
||||
|
||||
let controlsAndSliderStack = UIStackView(arrangedSubviews: [controlsStackView,
|
||||
slider,
|
||||
playbackTimeLabelsStack,
|
||||
playerStatus,
|
||||
rateSliderStackView,
|
||||
sliderWarningLabel])
|
||||
controlsAndSliderStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
controlsAndSliderStack.spacing = 10
|
||||
controlsAndSliderStack.setCustomSpacing(15, after: playbackTimeLabelsStack)
|
||||
controlsAndSliderStack.axis = .vertical
|
||||
controlsAndSliderStack.distribution = .fill
|
||||
controlsAndSliderStack.alignment = .fill
|
||||
controlsAndSliderStack.isLayoutMarginsRelativeArrangement = true
|
||||
controlsAndSliderStack.layoutMargins = .init(top: 15, left: 10, bottom: 0, right: 10)
|
||||
controlsAndSliderStack.accessibilityIdentifier = "controlsAndSliderStack"
|
||||
|
||||
view.addSubview(controlsAndSliderStack)
|
||||
view.accessibilityIdentifier = "controller-view"
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
controlsAndSliderStack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
controlsAndSliderStack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
controlsAndSliderStack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
private func setupBinding() {
|
||||
viewModel.updateContent = { [unowned self] effect in
|
||||
switch effect {
|
||||
case let .updateMuteButton(title):
|
||||
self.muteButton.setTitle(title, for: .normal)
|
||||
case let .updatePauseResumeButton(title):
|
||||
self.resumeButton.setTitle(title, for: .normal)
|
||||
case let .updateSliderMinMaxValue(min, max):
|
||||
self.slider.minimumValue = min
|
||||
self.slider.maximumValue = max
|
||||
case let .updateSliderValue(value):
|
||||
self.slider.value = value
|
||||
case let .updateMetadata(title):
|
||||
self.playerStatus.text = title
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.updateProgressAndDurationTitles = { [elapsedPlayTimeLabel, remainingPlayTimeLabel] progress, duration in
|
||||
elapsedPlayTimeLabel.text = progress
|
||||
remainingPlayTimeLabel.text = duration
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func rateValueChanged() {
|
||||
viewModel.update(rate: rateSlider.value) { [rateSlider] value in
|
||||
rateSlider.value = value
|
||||
}
|
||||
rateSliderValueLabel.text = viewModel.currentRateTitle
|
||||
}
|
||||
|
||||
@objc private func toggleMute() {
|
||||
viewModel.toggleMute()
|
||||
}
|
||||
|
||||
@objc private func pauseResume() {
|
||||
viewModel.togglePauseResume()
|
||||
}
|
||||
|
||||
@objc private func stop() {
|
||||
viewModel.stop()
|
||||
}
|
||||
|
||||
@objc
|
||||
func sliderTouchedDown() {
|
||||
viewModel.seek(action: .started)
|
||||
}
|
||||
|
||||
@objc
|
||||
func sliderTouchedUp() {
|
||||
viewModel.seek(action: .ended)
|
||||
}
|
||||
|
||||
@objc
|
||||
func sliderValueChanged() {
|
||||
viewModel.seek(action: .updateSeek(time: slider.value))
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
//
|
||||
// PlayerControlsViewModel.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioStreaming
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum SeekAction: Equatable {
|
||||
case started
|
||||
case updateSeek(time: Float)
|
||||
case ended
|
||||
}
|
||||
|
||||
enum ControlsEffects {
|
||||
case updateMuteButton(String)
|
||||
case updatePauseResumeButton(String)
|
||||
case updateSliderMinMaxValue(min: Float, max: Float)
|
||||
case updateSliderValue(value: Float)
|
||||
case updateMetadata(String)
|
||||
}
|
||||
|
||||
final class PlayerControlsViewModel {
|
||||
var updateContent: ((ControlsEffects) -> Void)?
|
||||
var updateProgressAndDurationTitles: ((String, String) -> Void)?
|
||||
|
||||
private let playerService: AudioPlayerService
|
||||
|
||||
private var displayLink: CADisplayLink?
|
||||
|
||||
private var seekTime: Float = 0
|
||||
private var isScrubbing: Bool = false
|
||||
|
||||
let rateMinValue: Float = 1.0
|
||||
let rateMaxValue: Float = 3.0
|
||||
|
||||
var currentRateTitle: String {
|
||||
String(format: "%.1fx", playerService.rate)
|
||||
}
|
||||
|
||||
init(playerService: AudioPlayerService) {
|
||||
self.playerService = playerService
|
||||
self.playerService.delegate.add(delegate: self)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
playerService.stop()
|
||||
stopDisplayLink(resetLabels: true)
|
||||
updateContent?(.updatePauseResumeButton("Pause"))
|
||||
}
|
||||
|
||||
func togglePauseResume() {
|
||||
playerService.toggle()
|
||||
let isPaused = playerService.state == .paused
|
||||
updateContent?(.updatePauseResumeButton(isPaused ? "Resume" : "Pause"))
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
playerService.toggleMute()
|
||||
let isMuted = playerService.isMuted
|
||||
updateContent?(.updateMuteButton(isMuted ? "Unmute" : "Mute"))
|
||||
}
|
||||
|
||||
func seek(action: SeekAction) {
|
||||
switch action {
|
||||
case .started:
|
||||
isScrubbing = true
|
||||
seekTime = 0
|
||||
case let .updateSeek(time):
|
||||
seekTime = time
|
||||
case .ended:
|
||||
isScrubbing = false
|
||||
if playerService.duration > 0 {
|
||||
playerService.seek(at: seekTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(rate: Float, updater: (Float) -> Void) {
|
||||
let rate = round(rate / 0.5) * 0.5
|
||||
playerService.update(rate: rate)
|
||||
updater(rate)
|
||||
}
|
||||
|
||||
private func startDisplayLink() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
displayLink = UIScreen.main.displayLink(withTarget: self, selector: #selector(tick))
|
||||
displayLink?.preferredFramesPerSecond = 6
|
||||
displayLink?.add(to: .current, forMode: .common)
|
||||
}
|
||||
|
||||
private func stopDisplayLink(resetLabels: Bool) {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
if resetLabels {
|
||||
resetLabelsAndSlider()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tick() {
|
||||
let duration = playerService.duration
|
||||
let progress = playerService.progress
|
||||
if duration > 0 {
|
||||
let elapsed = Int(progress)
|
||||
let remaining = Int(duration - progress)
|
||||
|
||||
updateContent?(.updateSliderMinMaxValue(min: 0.0, max: Float(duration)))
|
||||
if !isScrubbing {
|
||||
updateContent?(.updateSliderValue(value: Float(progress)))
|
||||
}
|
||||
|
||||
updateProgressAndDurationTitles?(timeFrom(seconds: elapsed), timeFrom(seconds: remaining))
|
||||
} else {
|
||||
let elapsed = Int(progress)
|
||||
updateProgressAndDurationTitles?("Live broadcast", timeFrom(seconds: elapsed))
|
||||
}
|
||||
}
|
||||
|
||||
private func resetLabelsAndSlider() {
|
||||
updateProgressAndDurationTitles?("--:--", "--:--")
|
||||
updateContent?(.updateSliderMinMaxValue(min: 0, max: 0))
|
||||
updateContent?(.updateSliderValue(value: 0))
|
||||
}
|
||||
|
||||
private func timeFrom(seconds: Int) -> String {
|
||||
let correctSeconds = seconds % 60
|
||||
let minutes = (seconds / 60) % 60
|
||||
let hours = seconds / 3600
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
|
||||
}
|
||||
return String(format: "%02d:%02d", minutes, correctSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerControlsViewModel: AudioPlayerServiceDelegate {
|
||||
func didStopPlaying() {
|
||||
stopDisplayLink(resetLabels: true)
|
||||
updateContent?(.updateMetadata(""))
|
||||
}
|
||||
|
||||
func statusChanged(status _: AudioPlayerState) {}
|
||||
|
||||
func didStartPlaying() {
|
||||
startDisplayLink()
|
||||
resetLabelsAndSlider()
|
||||
updateContent?(.updateMetadata(""))
|
||||
}
|
||||
|
||||
func errorOccurred(error _: AudioPlayerError) {}
|
||||
|
||||
func metadataReceived(metadata: [String: String]) {
|
||||
guard !metadata.isEmpty else { return }
|
||||
if let title = metadata["StreamTitle"] {
|
||||
updateContent?(.updateMetadata("Now Playing: \(title)"))
|
||||
} else {
|
||||
updateContent?(.updateMetadata(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//
|
||||
// PlayerViewController.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class PlayerViewController: UIViewController {
|
||||
private lazy var tableView = UITableView()
|
||||
|
||||
private let viewModel: PlayerViewModel
|
||||
private var controlsProvider: () -> UIViewController
|
||||
private var playerControlsController: UIViewController?
|
||||
|
||||
init(viewModel: PlayerViewModel, controlsProvider: @escaping () -> UIViewController) {
|
||||
self.viewModel = viewModel
|
||||
self.controlsProvider = controlsProvider
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
|
||||
viewModel.reloadContent = { [weak self] action in
|
||||
switch action {
|
||||
case .all:
|
||||
self?.tableView.reloadData()
|
||||
case let .item(indexPath):
|
||||
self?.tableView.reloadRows(at: [indexPath], with: .automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
title = "Player"
|
||||
view.backgroundColor = .systemBackground
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add,
|
||||
target: self,
|
||||
action: #selector(addNowPlaylistItem))
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(showEqualizer))
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(PlaylistTableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
|
||||
|
||||
let controlsController = controlsProvider()
|
||||
playerControlsController = controlsController
|
||||
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .fill
|
||||
stackView.distribution = .fillProportionally
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
stackView.addArrangedSubview(tableView)
|
||||
|
||||
addChild(controlsController)
|
||||
controlsController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(controlsController.view)
|
||||
controlsController.didMove(toParent: self)
|
||||
|
||||
view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate(
|
||||
[
|
||||
controlsController.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func showEqualizer() {
|
||||
viewModel.showEqualizer()
|
||||
}
|
||||
|
||||
@objc private func addNowPlaylistItem() {
|
||||
let controller = UIAlertController(title: "Add new item", message: "", preferredStyle: .alert)
|
||||
controller.addTextField { textField in
|
||||
textField.placeholder = "Insert url here"
|
||||
}
|
||||
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] _ in
|
||||
if let textfield = controller.textFields?.first,
|
||||
let text = textfield.text
|
||||
{
|
||||
viewModel.add(urlString: text)
|
||||
}
|
||||
}
|
||||
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
|
||||
|
||||
controller.addAction(saveAction)
|
||||
controller.addAction(cancelAction)
|
||||
present(controller, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerViewController: UITableViewDataSource {
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
viewModel.itemsCount
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "PlaylistCell", for: indexPath)
|
||||
guard let item = viewModel.item(at: indexPath) else {
|
||||
return cell
|
||||
}
|
||||
cell.textLabel?.text = item.name
|
||||
let queuedItem = item.queues ? "Queue item" : nil
|
||||
cell.detailTextLabel?.text = queuedItem ?? item.subtitle
|
||||
update(status: item.status, of: cell)
|
||||
return cell
|
||||
}
|
||||
|
||||
private func update(status: PlaylistItem.Status, of cell: UITableViewCell) {
|
||||
switch status {
|
||||
case .buffering:
|
||||
let activity = UIActivityIndicatorView(style: .medium)
|
||||
activity.startAnimating()
|
||||
cell.accessoryView = activity
|
||||
case .playing:
|
||||
cell.accessoryView = UIImageView(image: UIImage(systemName: "play.fill"))
|
||||
case .paused:
|
||||
cell.accessoryView = UIImageView(image: UIImage(systemName: "pause.fill"))
|
||||
case .stopped:
|
||||
cell.accessoryView = nil
|
||||
case .error:
|
||||
cell.accessoryView = UIImageView(image: UIImage(systemName: "exclamationmark.octagon"))
|
||||
cell.accessoryView?.tintColor = .red
|
||||
}
|
||||
guard status != .error else { return }
|
||||
cell.accessoryView?.tintColor = .systemTeal
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
viewModel.playItem(at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
final class PlaylistTableViewCell: UITableViewCell {
|
||||
override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
//
|
||||
// PlayerViewModel.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioStreaming
|
||||
import Foundation
|
||||
|
||||
enum ReloadAction {
|
||||
case all
|
||||
case item(IndexPath)
|
||||
}
|
||||
|
||||
final class PlayerViewModel {
|
||||
private let playerService: AudioPlayerService
|
||||
private let playlistItemsService: PlaylistItemsService
|
||||
|
||||
private let routeTo: (AppCoordinator.Route) -> Void
|
||||
private var currentPlayingItemIndex: Int?
|
||||
|
||||
var reloadContent: ((ReloadAction) -> Void)?
|
||||
|
||||
init(playlistItemsService: PlaylistItemsService,
|
||||
playerService: AudioPlayerService,
|
||||
routeTo: @escaping (AppCoordinator.Route) -> Void)
|
||||
{
|
||||
self.playlistItemsService = playlistItemsService
|
||||
self.playerService = playerService
|
||||
self.routeTo = routeTo
|
||||
self.playerService.delegate.add(delegate: self)
|
||||
}
|
||||
|
||||
func showEqualizer() {
|
||||
routeTo(.equalizer)
|
||||
}
|
||||
|
||||
var itemsCount: Int {
|
||||
playlistItemsService.itemsCount
|
||||
}
|
||||
|
||||
func add(urlString: String) {
|
||||
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let result = detector.firstMatch(in: urlString, options: [], range: NSRange(location: 0, length: urlString.utf16.count))
|
||||
guard let url = URL(string: urlString), result != nil else {
|
||||
print("malformed url error")
|
||||
return
|
||||
}
|
||||
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, subtitle: nil, status: .stopped, queues: false))
|
||||
reloadContent?(.all)
|
||||
}
|
||||
|
||||
func item(at indexPath: IndexPath) -> PlaylistItem? {
|
||||
playlistItemsService.item(at: indexPath.row)
|
||||
}
|
||||
|
||||
func playItem(at indexPath: IndexPath) {
|
||||
guard let item = item(at: indexPath) else { return }
|
||||
if item.queues {
|
||||
playerService.queue(url: item.url)
|
||||
if currentPlayingItemIndex == nil {
|
||||
currentPlayingItemIndex = indexPath.row
|
||||
}
|
||||
} else {
|
||||
if let index = currentPlayingItemIndex {
|
||||
playlistItemsService.setStatus(for: index, status: .stopped)
|
||||
reloadContent?(.item(IndexPath(row: index, section: 0)))
|
||||
currentPlayingItemIndex = nil
|
||||
}
|
||||
playerService.play(url: item.url)
|
||||
currentPlayingItemIndex = indexPath.row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerViewModel: AudioPlayerServiceDelegate {
|
||||
func statusChanged(status: AudioPlayerState) {
|
||||
guard let item = currentPlayingItemIndex else { return }
|
||||
|
||||
switch status {
|
||||
case .bufferring:
|
||||
playlistItemsService.setStatus(for: item, status: .buffering)
|
||||
reloadContent?(.item(IndexPath(item: item, section: 0)))
|
||||
case .playing:
|
||||
playlistItemsService.setStatus(for: item, status: .playing)
|
||||
reloadContent?(.item(IndexPath(item: item, section: 0)))
|
||||
case .paused:
|
||||
playlistItemsService.setStatus(for: item, status: .paused)
|
||||
reloadContent?(.item(IndexPath(item: item, section: 0)))
|
||||
case .stopped:
|
||||
playlistItemsService.setStatus(for: item, status: .stopped)
|
||||
reloadContent?(.item(IndexPath(item: item, section: 0)))
|
||||
case .error:
|
||||
playlistItemsService.setStatus(for: item, status: .error)
|
||||
reloadContent?(.item(IndexPath(item: item, section: 0)))
|
||||
default:
|
||||
playlistItemsService.setStatus(for: item, status: .stopped)
|
||||
reloadContent?(.all)
|
||||
}
|
||||
}
|
||||
|
||||
func errorOccurred(error _: AudioPlayerError) {
|
||||
currentPlayingItemIndex = nil
|
||||
}
|
||||
|
||||
func metadataReceived(metadata _: [String: String]) {}
|
||||
func didStopPlaying() {}
|
||||
|
||||
func didStartPlaying() {}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Binary file not shown.
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// MulticastDelegate.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class MulticastDelegate<Delegate> {
|
||||
private let delegates = NSHashTable<AnyObject>.weakObjects()
|
||||
|
||||
func add(delegate: Delegate) {
|
||||
delegates.add(delegate as AnyObject)
|
||||
}
|
||||
|
||||
func remove(delegate: Delegate) {
|
||||
for oneDelegate in delegates.allObjects.reversed() {
|
||||
if oneDelegate === delegate as AnyObject {
|
||||
delegates.remove(oneDelegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func invoke(invocation: (Delegate) -> Void) {
|
||||
for delegate in delegates.allObjects.reversed() {
|
||||
invocation(delegate as! Delegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// NowPlayingCenter.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import MediaPlayer
|
||||
|
||||
final class NowPlayingCenter {
|
||||
private let infoCenter: MPNowPlayingInfoCenter
|
||||
|
||||
init(infoCenter: MPNowPlayingInfoCenter = .default()) {
|
||||
self.infoCenter = infoCenter
|
||||
}
|
||||
|
||||
func change(item: PlaylistItem, isLiveStream: Bool) {
|
||||
var nowPlayingInfo = infoCenter.nowPlayingInfo ?? [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.audio.rawValue
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = isLiveStream
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = item.name
|
||||
|
||||
infoCenter.nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
func update(with metadata: [String: String], with item: PlaylistItem) {
|
||||
var nowPlayingInfo = infoCenter.nowPlayingInfo ?? [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata["StreamTitle"]
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = item.name
|
||||
|
||||
infoCenter.nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// PlaylistItemsService.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PlaylistItem: Equatable {
|
||||
enum Status: Equatable {
|
||||
case playing
|
||||
case paused
|
||||
case buffering
|
||||
case stopped
|
||||
case error
|
||||
}
|
||||
|
||||
let url: URL
|
||||
let name: String
|
||||
let subtitle: String?
|
||||
let status: Status
|
||||
let queues: Bool
|
||||
|
||||
init(content: AudioContent, queues: Bool) {
|
||||
name = content.title
|
||||
subtitle = content.subtitle
|
||||
url = content.streamUrl
|
||||
status = .stopped
|
||||
self.queues = queues
|
||||
}
|
||||
|
||||
init(url: URL, name: String, subtitle: String?, status: Status, queues: Bool) {
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.queues = queues
|
||||
}
|
||||
}
|
||||
|
||||
final class PlaylistItemsService {
|
||||
private var items: [PlaylistItem] = []
|
||||
|
||||
var itemsCount: Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
let protectedItemCount: Int
|
||||
|
||||
init(initialItemsProvider: () -> [PlaylistItem]) {
|
||||
items = initialItemsProvider()
|
||||
protectedItemCount = items.count
|
||||
}
|
||||
|
||||
func item(at index: Int) -> PlaylistItem? {
|
||||
guard index < items.count else { return nil }
|
||||
return items[index]
|
||||
}
|
||||
|
||||
func index(for item: PlaylistItem) -> Int? {
|
||||
items.firstIndex(of: item)
|
||||
}
|
||||
|
||||
func add(item: PlaylistItem) {
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
func remove(item: PlaylistItem) {
|
||||
if let index = items.firstIndex(of: item) {
|
||||
items.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func setStatus(for index: Int, status: PlaylistItem.Status) {
|
||||
guard let item = item(at: index) else {
|
||||
return
|
||||
}
|
||||
items[index] = PlaylistItem(
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
subtitle: item.subtitle,
|
||||
status: status,
|
||||
queues: item.queues
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func provideInitialPlaylistItems() -> [PlaylistItem] {
|
||||
let allCases = AudioContent.allCases
|
||||
let casesForQueueing: [AudioContent] = [.piano, .local, .khruangbin]
|
||||
let allItems = allCases.map { PlaylistItem(content: $0, queues: false) }
|
||||
let casesForQueuingItems = casesForQueueing.map { PlaylistItem(content: $0, queues: true) }
|
||||
return allItems + casesForQueuingItems
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */; };
|
||||
9806E8182BC5D12500757370 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8172BC5D12500757370 /* App.swift */; };
|
||||
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8192BC5D12500757370 /* ContentView.swift */; };
|
||||
9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81B2BC5D12700757370 /* Assets.xcassets */; };
|
||||
9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9806E81E2BC5D12700757370 /* Preview Assets.xcassets */; };
|
||||
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8252BC5D2A900757370 /* Sidebar.swift */; };
|
||||
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8292BC68F8700757370 /* AudioPlayerView.swift */; };
|
||||
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9806E8302BC6927D00757370 /* AudioPlayerModel.swift */; };
|
||||
9816A8A52BC7D8A200AD1299 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */; };
|
||||
9816A8A62BC7D8A200AD1299 /* AudioStreaming.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */; };
|
||||
9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8AB2BC820DF00AD1299 /* AudioContent.swift */; };
|
||||
9816A8B12BC8330C00AD1299 /* bensound-jazzyfrenchy.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */; };
|
||||
9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */; };
|
||||
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */; };
|
||||
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */; };
|
||||
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9542BDAE59C004B427A /* Notifier.swift */; };
|
||||
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */; };
|
||||
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */; };
|
||||
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */; };
|
||||
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */; };
|
||||
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */; };
|
||||
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */; };
|
||||
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E6119B2BC72C0E0036BC47 /* DetailView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9816A8A72BC7D8A200AD1299 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
9816A8A62BC7D8A200AD1299 /* AudioStreaming.framework in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomStreamSource.swift; sourceTree = "<group>"; };
|
||||
9806E8142BC5D12500757370 /* AudioPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9806E8172BC5D12500757370 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
||||
9806E8192BC5D12500757370 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
9806E81B2BC5D12700757370 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
9806E81E2BC5D12700757370 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
9806E8252BC5D2A900757370 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
|
||||
9806E8292BC68F8700757370 /* AudioPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerView.swift; sourceTree = "<group>"; };
|
||||
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerModel.swift; sourceTree = "<group>"; };
|
||||
9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AudioStreaming.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTrack.swift; sourceTree = "<group>"; };
|
||||
9816A8AB2BC820DF00AD1299 /* AudioContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContent.swift; sourceTree = "<group>"; };
|
||||
9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "bensound-jazzyfrenchy.mp3"; sourceTree = "<group>"; };
|
||||
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = "<group>"; };
|
||||
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
|
||||
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
984DE9542BDAE59C004B427A /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
|
||||
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerControlsView.swift; sourceTree = "<group>"; };
|
||||
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefersTabNavigationEnvironmentKey.swift; sourceTree = "<group>"; };
|
||||
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = "<group>"; };
|
||||
98BFB41B2BCAAD8A00E812C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerView.swift; sourceTree = "<group>"; };
|
||||
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerService.swift; sourceTree = "<group>"; };
|
||||
98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddNewAudioURLView.swift; sourceTree = "<group>"; };
|
||||
98E6119B2BC72C0E0036BC47 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
9806E8112BC5D12500757370 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9816A8A52BC7D8A200AD1299 /* AudioStreaming.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
9806E80B2BC5D12500757370 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9806E8162BC5D12500757370 /* AudioPlayer */,
|
||||
9806E8152BC5D12500757370 /* Products */,
|
||||
9816A8A32BC7D8A200AD1299 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9806E8152BC5D12500757370 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9806E8142BC5D12500757370 /* AudioPlayer.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9806E8162BC5D12500757370 /* AudioPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98BFB41B2BCAAD8A00E812C0 /* Info.plist */,
|
||||
984DE9532BDAE57F004B427A /* Dependencies */,
|
||||
984DE9522BDAE571004B427A /* Helpers */,
|
||||
9816A8A82BC7F4DE00AD1299 /* Common */,
|
||||
9806E8282BC68F7300757370 /* Content */,
|
||||
9806E8272BC68F6600757370 /* Navigation */,
|
||||
9806E8172BC5D12500757370 /* App.swift */,
|
||||
9806E81B2BC5D12700757370 /* Assets.xcassets */,
|
||||
9816A8B02BC832E100AD1299 /* Resources */,
|
||||
9806E81D2BC5D12700757370 /* Preview Content */,
|
||||
);
|
||||
path = AudioPlayer;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9806E81D2BC5D12700757370 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9806E81E2BC5D12700757370 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9806E8272BC68F6600757370 /* Navigation */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9806E8192BC5D12500757370 /* ContentView.swift */,
|
||||
98E6119B2BC72C0E0036BC47 /* DetailView.swift */,
|
||||
9806E8252BC5D2A900757370 /* Sidebar.swift */,
|
||||
);
|
||||
path = Navigation;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9806E8282BC68F7300757370 /* Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98E3921C2BD845E100B586E9 /* AudioPlayer */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9816A8A32BC7D8A200AD1299 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9816A8A42BC7D8A200AD1299 /* AudioStreaming.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9816A8A82BC7F4DE00AD1299 /* Common */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98BFB4222BCE78AB00E812C0 /* AddNewAudioURLView.swift */,
|
||||
9816A8A92BC7F4F000AD1299 /* AudioTrack.swift */,
|
||||
9816A8AB2BC820DF00AD1299 /* AudioContent.swift */,
|
||||
);
|
||||
path = Common;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9816A8B02BC832E100AD1299 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */,
|
||||
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */,
|
||||
9816A8AD2BC832DB00AD1299 /* bensound-jazzyfrenchy.mp3 */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
984DE9522BDAE571004B427A /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
|
||||
984DE9542BDAE59C004B427A /* Notifier.swift */,
|
||||
989E08E62BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
984DE9532BDAE57F004B427A /* Dependencies */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
|
||||
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
|
||||
);
|
||||
path = Dependencies;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
98E3921C2BD845E100B586E9 /* AudioPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
42BE42F42C9322AA00C0E448 /* CustomStreamSource.swift */,
|
||||
9806E8302BC6927D00757370 /* AudioPlayerModel.swift */,
|
||||
9806E8292BC68F8700757370 /* AudioPlayerView.swift */,
|
||||
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */,
|
||||
984DE9562BDAFC7E004B427A /* AudioPlayerControlsView.swift */,
|
||||
);
|
||||
path = AudioPlayer;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
9806E8132BC5D12500757370 /* AudioPlayer */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */;
|
||||
buildPhases = (
|
||||
9806E8102BC5D12500757370 /* Sources */,
|
||||
9806E8112BC5D12500757370 /* Frameworks */,
|
||||
9806E8122BC5D12500757370 /* Resources */,
|
||||
9816A8A72BC7D8A200AD1299 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = AudioPlayer;
|
||||
productName = AudioPlayer;
|
||||
productReference = 9806E8142BC5D12500757370 /* AudioPlayer.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
9806E80C2BC5D12500757370 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1530;
|
||||
LastUpgradeCheck = 1530;
|
||||
TargetAttributes = {
|
||||
9806E8132BC5D12500757370 = {
|
||||
CreatedOnToolsVersion = 15.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 9806E80F2BC5D12500757370 /* Build configuration list for PBXProject "AudioPlayer" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 9806E80B2BC5D12500757370;
|
||||
productRefGroup = 9806E8152BC5D12500757370 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
9806E8132BC5D12500757370 /* AudioPlayer */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
9806E8122BC5D12500757370 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9806E81F2BC5D12700757370 /* Preview Assets.xcassets in Resources */,
|
||||
9806E81C2BC5D12700757370 /* Assets.xcassets in Resources */,
|
||||
9816A8B12BC8330C00AD1299 /* bensound-jazzyfrenchy.mp3 in Resources */,
|
||||
9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */,
|
||||
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
9806E8102BC5D12500757370 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
98BFB4232BCE78AB00E812C0 /* AddNewAudioURLView.swift in Sources */,
|
||||
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */,
|
||||
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */,
|
||||
9806E81A2BC5D12500757370 /* ContentView.swift in Sources */,
|
||||
98E6119C2BC72C0E0036BC47 /* DetailView.swift in Sources */,
|
||||
9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */,
|
||||
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */,
|
||||
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */,
|
||||
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */,
|
||||
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */,
|
||||
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */,
|
||||
9816A8AA2BC7F4F000AD1299 /* AudioTrack.swift in Sources */,
|
||||
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */,
|
||||
984DE9572BDAFC7E004B427A /* AudioPlayerControlsView.swift in Sources */,
|
||||
9806E8182BC5D12500757370 /* App.swift in Sources */,
|
||||
42BE42F52C9322AA00C0E448 /* CustomStreamSource.swift in Sources */,
|
||||
989E08E72BF7A4E300599F17 /* PrefersTabNavigationEnvironmentKey.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
9806E8202BC5D12700757370 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
9806E8212BC5D12700757370 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
9806E8232BC5D12700757370 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = TJ7GUC6B8Y;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = AudioPlayer/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
9806E8242BC5D12700757370 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"AudioPlayer/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = TJ7GUC6B8Y;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = AudioPlayer/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioPlayer;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
9806E80F2BC5D12500757370 /* Build configuration list for PBXProject "AudioPlayer" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
9806E8202BC5D12700757370 /* Debug */,
|
||||
9806E8212BC5D12700757370 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
9806E8222BC5D12700757370 /* Build configuration list for PBXNativeTarget "AudioPlayer" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
9806E8232BC5D12700757370 /* Debug */,
|
||||
9806E8242BC5D12700757370 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 9806E80C2BC5D12500757370 /* Project object */;
|
||||
}
|
||||
+17
-51
@@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
LastUpgradeVersion = "1530"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -14,10 +15,10 @@
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
|
||||
BuildableName = "AudioExample.app"
|
||||
BlueprintName = "AudioExample"
|
||||
ReferencedContainer = "container:AudioExample.xcodeproj">
|
||||
BlueprintIdentifier = "9806E8132BC5D12500757370"
|
||||
BuildableName = "AudioPlayer.app"
|
||||
BlueprintName = "AudioPlayer"
|
||||
ReferencedContainer = "container:AudioPlayer.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
@@ -27,64 +28,29 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
enableThreadSanitizer = "YES"
|
||||
codeCoverageEnabled = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:../AudioStreamingTests/AudioExample.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B5AEDBB624744153007D8101"
|
||||
BuildableName = "AudioStreamingTests.xctest"
|
||||
BlueprintName = "AudioStreamingTests"
|
||||
ReferencedContainer = "container:../AudioStreaming.xcodeproj">
|
||||
</BuildableReference>
|
||||
<SkippedTests>
|
||||
<Test
|
||||
Identifier = "ProtectedTests">
|
||||
</Test>
|
||||
</SkippedTests>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
enableASanStackUseAfterReturn = "YES"
|
||||
disableMainThreadChecker = "YES"
|
||||
enableThreadSanitizer = "YES"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
|
||||
BuildableName = "AudioExample.app"
|
||||
BlueprintName = "AudioExample"
|
||||
ReferencedContainer = "container:AudioExample.xcodeproj">
|
||||
BlueprintIdentifier = "9806E8132BC5D12500757370"
|
||||
BuildableName = "AudioPlayer.app"
|
||||
BlueprintName = "AudioPlayer"
|
||||
ReferencedContainer = "container:AudioPlayer.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "OS_ACTIVITY_MODE"
|
||||
value = "disable"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
@@ -96,10 +62,10 @@
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B5AEDBD02475274C007D8101"
|
||||
BuildableName = "AudioExample.app"
|
||||
BlueprintName = "AudioExample"
|
||||
ReferencedContainer = "container:AudioExample.xcodeproj">
|
||||
BlueprintIdentifier = "9806E8132BC5D12500757370"
|
||||
BuildableName = "AudioPlayer.app"
|
||||
BlueprintName = "AudioPlayer"
|
||||
ReferencedContainer = "container:AudioPlayer.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioStreaming
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct AudioPlayerApp: App {
|
||||
|
||||
@State var model = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class AppModel {
|
||||
@ObservationIgnored
|
||||
let audioPlayerService: AudioPlayerService
|
||||
@ObservationIgnored
|
||||
let equalizerService: EqualizerService
|
||||
|
||||
init(
|
||||
audioPlayerService: AudioPlayerService = provideAudioPlayerService(),
|
||||
equalizerService: (AudioPlayerService) -> EqualizerService = provideEqualizerService
|
||||
) {
|
||||
self.audioPlayerService = audioPlayerService
|
||||
self.equalizerService = equalizerService(audioPlayerService)
|
||||
}
|
||||
}
|
||||
|
||||
func provideEqualizerService(playerService: AudioPlayerService) -> EqualizerService {
|
||||
EqualizerService(playerService: playerService)
|
||||
}
|
||||
|
||||
func provideAudioPlayerService() -> AudioPlayerService {
|
||||
AudioPlayerService(
|
||||
audioPlayerProvider: provideDefaultAudioPlayer
|
||||
)
|
||||
}
|
||||
|
||||
func provideDefaultAudioPlayer() -> AudioPlayer {
|
||||
AudioPlayer(
|
||||
configuration: .init(
|
||||
flushQueueOnSeek: false,
|
||||
enableLogs: true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddNewAudioURLView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let urlStyle = URL.FormatStyle(path: .omitWhen(.path, matches: ["/"]), query: .omitWhen(.query, matches: [""]))
|
||||
|
||||
@State private var audioUrl: URL?
|
||||
|
||||
var onAddNewUrl: (URL) -> Void
|
||||
|
||||
init(onAddNewUrl: @escaping (URL) -> Void) {
|
||||
self.onAddNewUrl = onAddNewUrl
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
VStack(alignment: .leading) {
|
||||
TextField(value: $audioUrl, format: urlStyle, prompt: nil, label: {
|
||||
Text("Insert URL")
|
||||
})
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onSubmit {
|
||||
if let url = audioUrl {
|
||||
onAddNewUrl(url)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
Button {
|
||||
if let url = audioUrl {
|
||||
onAddNewUrl(url)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus")
|
||||
Text("Add")
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioUrl == nil)
|
||||
.opacity(audioUrl == nil ? 0.5 : 1.0)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.mint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.navigationTitle("Add Audio URL")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done", action: dismiss.callAsFunction)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddNewAudioURLView(onAddNewUrl: { _ in })
|
||||
}
|
||||
+47
-29
@@ -1,18 +1,16 @@
|
||||
//
|
||||
// AudioContent.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum AudioContent: Int, CaseIterable {
|
||||
enum AudioContent {
|
||||
case offradio
|
||||
case enlefko
|
||||
case pepper966
|
||||
case kosmos
|
||||
case kosmosJazz
|
||||
case radiox
|
||||
case khruangbin
|
||||
case piano
|
||||
@@ -21,62 +19,76 @@ enum AudioContent: Int, CaseIterable {
|
||||
case remoteWave
|
||||
case local
|
||||
case localWave
|
||||
case loopBeatFlac
|
||||
case custom(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .offradio:
|
||||
return "Offradio (stream)"
|
||||
return "Offradio"
|
||||
case .enlefko:
|
||||
return "Enlefko (stream)"
|
||||
return "Enlefko"
|
||||
case .pepper966:
|
||||
return "Pepper 96.6 (stream)"
|
||||
return "Pepper 96.6"
|
||||
case .kosmos:
|
||||
return "Kosmos 93.6 (stream)"
|
||||
return "Kosmos 93.6"
|
||||
case .kosmosJazz:
|
||||
return "Kosmos Jazz"
|
||||
case .radiox:
|
||||
return "Radio X (stream)"
|
||||
return "Radio X"
|
||||
case .khruangbin:
|
||||
return "Khruangbin (mp3 preview)"
|
||||
return "Khruangbin"
|
||||
case .piano:
|
||||
return "Piano (mp3)"
|
||||
return "Piano"
|
||||
case .remoteWave:
|
||||
return "Sample remote (wave)"
|
||||
return "Sample remote"
|
||||
case .local:
|
||||
return "Jazzy Frenchy (local mp3)"
|
||||
return "Jazzy Frenchy"
|
||||
case .localWave:
|
||||
return "Local file (local wave)"
|
||||
return "Local file"
|
||||
case .optimized:
|
||||
return "Jazze French (m4a - optimized)"
|
||||
return "Jazzy Frenchy"
|
||||
case .nonOptimized:
|
||||
return "Jazze French (m4a - non-optimized)"
|
||||
return "Jazzy Frenchy"
|
||||
case .loopBeatFlac:
|
||||
return "Beat loop"
|
||||
case .custom(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String? {
|
||||
switch self {
|
||||
case .offradio:
|
||||
return nil
|
||||
return "Stream • offradio.gr"
|
||||
case .enlefko:
|
||||
return nil
|
||||
return "Stream • enlefko.fm"
|
||||
case .pepper966:
|
||||
return nil
|
||||
return "Stream • pepper966.gr"
|
||||
case .kosmos:
|
||||
return nil
|
||||
return "Stream • ertecho.gr"
|
||||
case .kosmosJazz:
|
||||
return "Stream • ertecho.gr"
|
||||
case .radiox:
|
||||
return nil
|
||||
return "Stream • globalplayer.com"
|
||||
case .khruangbin:
|
||||
return nil
|
||||
return "Remote mp3"
|
||||
case .piano:
|
||||
return nil
|
||||
return "Remote mp3"
|
||||
case .remoteWave:
|
||||
return nil
|
||||
return "wave"
|
||||
case .local:
|
||||
return "Music by: bensound.com"
|
||||
case .localWave:
|
||||
return "Music by: bensound.com"
|
||||
case .optimized:
|
||||
return "Music by: bensound.com"
|
||||
return "Music by: bensound.com - m4a optimized"
|
||||
case .nonOptimized:
|
||||
return "Music by: bensound.com"
|
||||
return "Music by: bensound.com - m4a non-optimized"
|
||||
case .loopBeatFlac:
|
||||
return "Remote flac"
|
||||
case .custom:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +102,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return URL(string: "https://n04.radiojar.com/pepper.m4a?1662039818=&rj-tok=AAABgvlUaioALhdOXDt0mgajoA&rj-ttl=5")!
|
||||
case .kosmos:
|
||||
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
|
||||
case .kosmosJazz:
|
||||
return URL(string: "https://radiostreaming.ert.gr/ert-webjazz")!
|
||||
case .radiox:
|
||||
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
|
||||
case .khruangbin:
|
||||
@@ -107,7 +121,11 @@ enum AudioContent: Int, CaseIterable {
|
||||
let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")!
|
||||
return URL(fileURLWithPath: path)
|
||||
case .remoteWave:
|
||||
return URL(string: "https://file-examples.com/wp-content/storage/2017/11/file_example_WAV_5MG.wav")!
|
||||
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/5-MB-WAV.wav")!
|
||||
case .loopBeatFlac:
|
||||
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/drumbeat-loop.flac")!
|
||||
case .custom(let url):
|
||||
return URL(string: url)!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum AudioTrackStatus {
|
||||
case playing
|
||||
case paused
|
||||
case buffering
|
||||
case error
|
||||
case idle
|
||||
|
||||
var isPlaying: Bool {
|
||||
self == .playing || self == .paused
|
||||
}
|
||||
|
||||
var isError: Bool {
|
||||
self == .error
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
public class AudioTrack: Identifiable, Equatable {
|
||||
public static func == (lhs: AudioTrack, rhs: AudioTrack) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
url.absoluteString
|
||||
}
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let url: URL
|
||||
|
||||
var status: AudioTrackStatus
|
||||
|
||||
private let content: AudioContent
|
||||
|
||||
init(from content: AudioContent, status: AudioTrackStatus = .idle) {
|
||||
self.title = content.title
|
||||
self.subtitle = content.subtitle
|
||||
self.status = status
|
||||
self.url = content.streamUrl
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct AudioTrackView: View {
|
||||
@Bindable var track: AudioTrack
|
||||
|
||||
private let action: () -> Void
|
||||
|
||||
init(track: AudioTrack, action: @escaping () -> Void = {}) {
|
||||
self.track = track
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action, label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(track.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.black)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, track.subtitle == nil ? 8 : 0)
|
||||
if let subtitle = track.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder var status: some View {
|
||||
if track.status == .error {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
if track.status.isPlaying {
|
||||
Image(systemName: track.status == .playing ? "play.fill" : "pause.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.mint)
|
||||
} else if track.status == .buffering {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(alignment: .center)
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
AudioTrackView(
|
||||
track: AudioTrack(from: .enlefko)
|
||||
)
|
||||
AudioTrackView(
|
||||
track: AudioTrack(from: .enlefko, status: .playing)
|
||||
)
|
||||
AudioTrackView(
|
||||
track: AudioTrack(from: .enlefko, status: .paused)
|
||||
)
|
||||
AudioTrackView(
|
||||
track: AudioTrack(from: .enlefko, status: .error)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//
|
||||
// Created by Dimitris Chatzieleftheriou on 26/04/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import AudioStreaming
|
||||
|
||||
struct AudioPlayerControls: View {
|
||||
@State var model: Model
|
||||
@Binding var currentTrack: AudioTrack?
|
||||
|
||||
init(appModel: AppModel, currentTrack: Binding<AudioTrack?>) {
|
||||
self._model = State(wrappedValue: Model(audioPlayerService: appModel.audioPlayerService))
|
||||
self._currentTrack = currentTrack
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button(action: { model.playPause() }) {
|
||||
Image(systemName: model.isPlaying ? "pause" : "play")
|
||||
.symbolVariant(.fill)
|
||||
.font(.title)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Button(action: {
|
||||
model.stop()
|
||||
currentTrack = nil
|
||||
}) {
|
||||
Image(systemName: "stop")
|
||||
.symbolVariant(.fill)
|
||||
.font(.title)
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
Spacer()
|
||||
HStack {
|
||||
Slider(value: $model.volume)
|
||||
.frame(width: 80)
|
||||
.onChange(of: model.volume) { _, newValue in
|
||||
model.update(volume: newValue)
|
||||
}
|
||||
Button(action: { model.mute() }) {
|
||||
Image(systemName: model.iconForVolume)
|
||||
.symbolVariant(model.isMuted || model.volume == 0 ? .slash.fill : .fill)
|
||||
.foregroundStyle(.teal, .gray)
|
||||
.font(.title.monospaced())
|
||||
.imageScale(.small)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
.tint(.mint)
|
||||
.padding(16)
|
||||
if let audioMetadata = model.liveAudioMetadata, model.isLiveAudioStreaming {
|
||||
Text("Now Playing: \(audioMetadata)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
Divider()
|
||||
VStack {
|
||||
Slider(
|
||||
value: $model.currentTime,
|
||||
in: 0...(model.totalTime ?? 1.0),
|
||||
onEditingChanged: { scrubStarted in
|
||||
if scrubStarted {
|
||||
model.scrubState = .started
|
||||
} else {
|
||||
model.scrubState = .ended(model.currentTime)
|
||||
}
|
||||
}
|
||||
)
|
||||
.disabled(model.totalTime == nil)
|
||||
HStack {
|
||||
Text(model.formattedCurrentTime ?? "--:--")
|
||||
Spacer()
|
||||
Text(model.formattedTotalTime ?? "")
|
||||
}
|
||||
.foregroundStyle(.black)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 16)
|
||||
Divider()
|
||||
VStack(alignment: .leading) {
|
||||
Text("Playback Rate: \(String(format: "%0.1f", model.playbackRate))")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.black)
|
||||
Slider(value: $model.playbackRate, in: 1.0...4.0, step: 0.2)
|
||||
.onChange(of: model.playbackRate) { _, new in
|
||||
model.update(rate: Float(new))
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.onChange(of: currentTrack) { oldValue, newValue in
|
||||
if let track = newValue {
|
||||
model.play(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ScrubState: Equatable {
|
||||
case idle
|
||||
case started
|
||||
case ended(Double)
|
||||
}
|
||||
|
||||
extension AudioPlayerControls {
|
||||
@Observable
|
||||
final class Model {
|
||||
@ObservationIgnored
|
||||
private(set) var audioPlayerService: AudioPlayerService
|
||||
@ObservationIgnored
|
||||
private var displayLink: DisplayLink?
|
||||
|
||||
var isLiveAudioStreaming: Bool {
|
||||
totalTime == 0
|
||||
}
|
||||
|
||||
var liveAudioMetadata: String?
|
||||
|
||||
var isPlaying: Bool = false
|
||||
var isMuted: Bool = false
|
||||
|
||||
var volume: Float = 0.5
|
||||
|
||||
var playbackRate: Double = 0.0
|
||||
|
||||
var currentTime: Double = 0
|
||||
var totalTime: Double?
|
||||
|
||||
var scrubState: ScrubState = .idle
|
||||
|
||||
var formattedCurrentTime: String?
|
||||
var formattedTotalTime: String?
|
||||
|
||||
var currentTrack: AudioTrack?
|
||||
|
||||
var iconForVolume: String {
|
||||
if isMuted || volume == 0 {
|
||||
return "speaker"
|
||||
}
|
||||
if volume < 0.4 {
|
||||
return "speaker.wave.1"
|
||||
} else if volume < 0.8 {
|
||||
return "speaker.wave.2"
|
||||
} else {
|
||||
return "speaker.wave.3"
|
||||
}
|
||||
}
|
||||
|
||||
init(audioPlayerService: AudioPlayerService) {
|
||||
self.audioPlayerService = audioPlayerService
|
||||
|
||||
registerObservations()
|
||||
}
|
||||
|
||||
deinit {
|
||||
displayLink?.deactivate()
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
func registerObservations() {
|
||||
Task { @MainActor in
|
||||
for await status in await audioPlayerService.statusChangedNotifier.values() {
|
||||
isPlaying = status == .playing
|
||||
displayLink?.isPaused = !isPlaying
|
||||
switch status {
|
||||
case .bufferring:
|
||||
currentTrack?.status = .buffering
|
||||
case .error:
|
||||
currentTrack?.status = .error
|
||||
currentTrack = nil
|
||||
case .playing:
|
||||
currentTrack?.status = .playing
|
||||
case .paused:
|
||||
currentTrack?.status = .paused
|
||||
case .stopped:
|
||||
currentTrack?.status = .idle
|
||||
default:
|
||||
currentTrack?.status = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
for await metadata in await audioPlayerService.metadataReceivedNotifier.values() {
|
||||
guard !metadata.isEmpty else { break }
|
||||
if let title = metadata["StreamTitle"] {
|
||||
liveAudioMetadata = title.isEmpty ? "-" : title
|
||||
} else {
|
||||
liveAudioMetadata = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
for await startStopped in await audioPlayerService.playingStartedStopped.values() {
|
||||
if startStopped.started {
|
||||
self.didStartPlaying()
|
||||
} else {
|
||||
self.didStopPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mute() {
|
||||
isMuted.toggle()
|
||||
audioPlayerService.toggleMute()
|
||||
}
|
||||
|
||||
func playPause() {
|
||||
if audioPlayerService.state == .playing {
|
||||
audioPlayerService.pause()
|
||||
} else {
|
||||
audioPlayerService.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func update(rate: Float) {
|
||||
let rate = round(rate / 0.2) * 0.2
|
||||
audioPlayerService.update(rate: rate)
|
||||
}
|
||||
|
||||
func update(volume: Float) {
|
||||
audioPlayerService.update(volume: volume)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isPlaying = false
|
||||
audioPlayerService.stop()
|
||||
currentTrack?.status = .idle
|
||||
currentTrack = nil
|
||||
}
|
||||
|
||||
func play(_ track: AudioTrack) {
|
||||
if track != currentTrack {
|
||||
currentTrack?.status = .idle
|
||||
if track.url.scheme == "custom" {
|
||||
let source = createStreamSource()
|
||||
let audioFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false
|
||||
)!
|
||||
audioPlayerService.play(source: source, entryId: track.url.absoluteString, format: audioFormat)
|
||||
currentTrack = track
|
||||
} else {
|
||||
audioPlayerService.play(url: track.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createStreamSource() -> CoreAudioStreamSource {
|
||||
return CustomStreamAudioSource(underlyingQueue: audioPlayerService.player.sourceQueue)
|
||||
}
|
||||
|
||||
func onTick() {
|
||||
let duration = audioPlayerService.duration
|
||||
let progress = audioPlayerService.progress
|
||||
if duration > 0 {
|
||||
let elapsed = Int(progress)
|
||||
let remaining = Int(duration - progress)
|
||||
totalTime = duration
|
||||
switch scrubState {
|
||||
case .idle:
|
||||
currentTime = progress
|
||||
case .started:
|
||||
break
|
||||
case .ended(let seekTime):
|
||||
currentTime = seekTime
|
||||
if audioPlayerService.duration > 0 {
|
||||
audioPlayerService.seek(at: seekTime)
|
||||
}
|
||||
scrubState = .idle
|
||||
}
|
||||
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
|
||||
formattedTotalTime = timeFrom(seconds: remaining)
|
||||
} else {
|
||||
let elapsed = Int(progress)
|
||||
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
|
||||
if formattedTotalTime != nil {
|
||||
formattedTotalTime = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetLabels() {
|
||||
currentTime = 0
|
||||
totalTime = 0
|
||||
formattedCurrentTime = nil
|
||||
formattedTotalTime = nil
|
||||
}
|
||||
|
||||
private func timeFrom(seconds: Int) -> String {
|
||||
let correctSeconds = seconds % 60
|
||||
let minutes = (seconds / 60) % 60
|
||||
let hours = seconds / 3600
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
|
||||
}
|
||||
return String(format: "%02d:%02d", minutes, correctSeconds)
|
||||
}
|
||||
|
||||
private func didStartPlaying() {
|
||||
self.displayLink = DisplayLink(onTick: { [weak self] _ in
|
||||
self?.onTick()
|
||||
})
|
||||
displayLink?.activate()
|
||||
}
|
||||
|
||||
private func didStopPlaying() {
|
||||
resetLabels()
|
||||
liveAudioMetadata = nil
|
||||
playbackRate = 1.0
|
||||
displayLink?.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
import Foundation
|
||||
import AudioStreaming
|
||||
|
||||
struct AudioPlaylist: Equatable, Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
var tracks: [AudioTrack]
|
||||
}
|
||||
|
||||
@Observable
|
||||
public class AudioPlayerModel {
|
||||
@ObservationIgnored
|
||||
private(set) var audioPlayerService: AudioPlayerService
|
||||
|
||||
var audioTracks: [AudioPlaylist] = []
|
||||
|
||||
var currentTrack: AudioTrack?
|
||||
|
||||
init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: AudioPlayerService) {
|
||||
self.audioPlayerService = audioPlayerService
|
||||
self.audioTracks = audioTracksProvider()
|
||||
}
|
||||
|
||||
deinit {
|
||||
audioPlayerService.stop()
|
||||
}
|
||||
|
||||
func addNewAudioTrack(url: URL) {
|
||||
let customIndex = audioTracks.firstIndex(where: { $0.id == "Custom" })
|
||||
let audioTrack = AudioTrack(from: .custom(url.absoluteString), status: .idle)
|
||||
let playlist = AudioPlaylist(title: "Custom", tracks: [audioTrack])
|
||||
if let customIndex {
|
||||
let tracks = audioTracks[customIndex].tracks
|
||||
if !tracks.contains(audioTrack) {
|
||||
audioTracks[customIndex].tracks.append(audioTrack)
|
||||
}
|
||||
} else {
|
||||
audioTracks.append(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
func play(_ track: AudioTrack) {
|
||||
if track != currentTrack {
|
||||
currentTrack = track
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let radioTracks: [AudioContent] = [.offradio, .enlefko, .pepper966, .kosmos, .kosmosJazz, .radiox]
|
||||
private let audioTracks: [AudioContent] = [.khruangbin, .piano, .optimized, .nonOptimized, .remoteWave, .local, .localWave, .loopBeatFlac]
|
||||
private let customStreams: [AudioContent] = [.custom("custom://sinwave")]
|
||||
|
||||
func audioTracksProvider() -> [AudioPlaylist] {
|
||||
[
|
||||
AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }),
|
||||
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) }),
|
||||
AudioPlaylist(title: "Generated", tracks: customStreams.map { AudioTrack.init(from:$0) })
|
||||
]
|
||||
}
|
||||
|
||||
func audioQueueTrackProvider() -> [AudioPlaylist] {
|
||||
[
|
||||
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) })
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AudioPlayerView: View {
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
@State var model: AudioPlayerModel
|
||||
|
||||
@State var eqSheetIsShown: Bool = false
|
||||
@State var addNewAudioIsShown: Bool = false
|
||||
|
||||
init(appModel: AppModel) {
|
||||
self._model = State(wrappedValue: AudioPlayerModel(audioPlayerService: appModel.audioPlayerService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(model.audioTracks) { section in
|
||||
Section {
|
||||
ForEach(section.tracks) { track in
|
||||
AudioTrackView(track: track) {
|
||||
model.play(track)
|
||||
}
|
||||
.id(track.id)
|
||||
}
|
||||
} header: {
|
||||
Text(section.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: model.audioTracks) { _, newValue in
|
||||
if let lastId = newValue.last?.tracks.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
AudioPlayerControls(appModel: appModel, currentTrack: $model.currentTrack)
|
||||
.background(
|
||||
.ultraThinMaterial.shadow(
|
||||
ShadowStyle.drop(color: .black.opacity(0.1), radius: 8, x: 0, y: -10)
|
||||
)
|
||||
)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.navigationTitle("Audio Player")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
let placement: ToolbarItemPlacement = .topBarTrailing
|
||||
#else
|
||||
let placement: ToolbarItemPlacement = .automatic
|
||||
#endif
|
||||
ToolbarItemGroup(placement: placement) {
|
||||
Button {
|
||||
eqSheetIsShown.toggle()
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
addNewAudioIsShown.toggle()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $eqSheetIsShown) {
|
||||
EqualizerView(appModel: appModel)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium])
|
||||
#elseif os(macOS)
|
||||
.frame(minWidth: 520, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $addNewAudioIsShown) {
|
||||
AddNewAudioURLView(
|
||||
onAddNewUrl: { url in
|
||||
model.addNewAudioTrack(url: url)
|
||||
}
|
||||
)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.height(150)])
|
||||
#elseif os(macOS)
|
||||
.frame(minWidth: 320, maxWidth: .infinity, minHeight: 140, maxHeight: .infinity)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AudioPlayerView(appModel: AppModel())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// CustomStreamSource.swift
|
||||
// AudioPlayer
|
||||
//
|
||||
// Created by Jackson Harper on 12/9/24.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
import AudioStreaming
|
||||
|
||||
// This is a basic example of playing a custom audio stream. We generate
|
||||
// a small audio data on load and then pass it off to AudioStreaming.
|
||||
final class CustomStreamAudioSource: NSObject, CoreAudioStreamSource {
|
||||
weak var delegate: AudioStreamSourceDelegate?
|
||||
|
||||
var underlyingQueue: DispatchQueue
|
||||
|
||||
var position = 0
|
||||
var length = 0
|
||||
|
||||
var audioFileHint: AudioFileTypeID {
|
||||
kAudioFileWAVEType
|
||||
}
|
||||
|
||||
init(underlyingQueue: DispatchQueue) {
|
||||
self.underlyingQueue = underlyingQueue
|
||||
}
|
||||
|
||||
// no-op
|
||||
func close() {}
|
||||
|
||||
// no-op
|
||||
func suspend() {}
|
||||
|
||||
func resume() {}
|
||||
|
||||
func seek(at _: Int) {
|
||||
// The streaming process is started by a seek(0) call from AudioStreaming
|
||||
generateData()
|
||||
}
|
||||
|
||||
private func generateData() {
|
||||
let frequency = 440.0
|
||||
let sampleRate = 44100
|
||||
let duration = 20.0
|
||||
|
||||
let lpcmData = generateSineWave(frequency: frequency, sampleRate: sampleRate, duration: duration)
|
||||
let waveFile = createWavFile(using: lpcmData)
|
||||
|
||||
// We enqueue this because during startup the seek call will be made, but the player
|
||||
// is not completely setup and ready to handle data yet, as its expected to be
|
||||
// generated asyncronously.
|
||||
underlyingQueue.asyncAfter(deadline: .now().advanced(by: .milliseconds(100))) {
|
||||
self.delegate?.dataAvailable(source: self, data: waveFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Functions for generating some sample data
|
||||
|
||||
// Function to generate a sine wave as Data
|
||||
func generateSineWave(frequency: Double, sampleRate: Int, duration: Double, amplitude: Double = 0.5) -> Data {
|
||||
let numberOfSamples = Int(Double(sampleRate) * duration)
|
||||
let twoPi = 2.0 * Double.pi
|
||||
var lpcmData = Data()
|
||||
|
||||
for sampleIndex in 0 ..< numberOfSamples {
|
||||
let time = Double(sampleIndex) / Double(sampleRate)
|
||||
let sampleValue = amplitude * sin(twoPi * frequency * time)
|
||||
|
||||
let pcmValue = Int16(sampleValue * Double(Int16.max))
|
||||
withUnsafeBytes(of: pcmValue.littleEndian) { lpcmData.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
return lpcmData
|
||||
}
|
||||
|
||||
func createWavFile(using rawData: Data) -> Data {
|
||||
let waveHeaderFormate = createWaveHeader(data: rawData) as Data
|
||||
let waveFileData = waveHeaderFormate + rawData
|
||||
return waveFileData
|
||||
}
|
||||
|
||||
// from: https://stackoverflow.com/questions/49399823/in-ios-how-to-create-audio-file-wav-mp3-file-from-data
|
||||
private func createWaveHeader(data: Data) -> NSData {
|
||||
let sampleRate: Int32 = 44100
|
||||
let chunkSize: Int32 = 36 + Int32(data.count)
|
||||
let subChunkSize: Int32 = 16
|
||||
let format: Int16 = 1
|
||||
let channels: Int16 = 2
|
||||
let bitsPerSample: Int16 = 16
|
||||
let byteRate: Int32 = sampleRate * Int32(channels * bitsPerSample / 8)
|
||||
let blockAlign: Int16 = channels * bitsPerSample / 8
|
||||
let dataSize = Int32(data.count)
|
||||
|
||||
let header = NSMutableData()
|
||||
|
||||
header.append([UInt8]("RIFF".utf8), length: 4)
|
||||
header.append(intToByteArray(chunkSize), length: 4)
|
||||
|
||||
// WAVE
|
||||
header.append([UInt8]("WAVE".utf8), length: 4)
|
||||
|
||||
// FMT
|
||||
header.append([UInt8]("fmt ".utf8), length: 4)
|
||||
|
||||
header.append(intToByteArray(subChunkSize), length: 4)
|
||||
header.append(shortToByteArray(format), length: 2)
|
||||
header.append(shortToByteArray(channels), length: 2)
|
||||
header.append(intToByteArray(sampleRate), length: 4)
|
||||
header.append(intToByteArray(byteRate), length: 4)
|
||||
header.append(shortToByteArray(blockAlign), length: 2)
|
||||
header.append(shortToByteArray(bitsPerSample), length: 2)
|
||||
|
||||
header.append([UInt8]("data".utf8), length: 4)
|
||||
header.append(intToByteArray(dataSize), length: 4)
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
private func intToByteArray(_ i: Int32) -> [UInt8] {
|
||||
return [
|
||||
// little endian
|
||||
UInt8(truncatingIfNeeded: i & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 16) & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 24) & 0xFF),
|
||||
]
|
||||
}
|
||||
|
||||
private func shortToByteArray(_ i: Int16) -> [UInt8] {
|
||||
return [
|
||||
// little endian
|
||||
UInt8(truncatingIfNeeded: i & 0xFF),
|
||||
UInt8(truncatingIfNeeded: (i >> 8) & 0xFF),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class EQBand: Identifiable {
|
||||
var frequency: String
|
||||
var min: Float
|
||||
var max: Float
|
||||
var value: Float
|
||||
|
||||
@ObservationIgnored
|
||||
let index: Int
|
||||
|
||||
init(index: Int, frequency: String, min: Float, max: Float, value: Float) {
|
||||
self.index = index
|
||||
self.frequency = frequency
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
struct EqualizerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
@State var model: Model
|
||||
|
||||
init(appModel: AppModel) {
|
||||
self._model = State(wrappedValue: Model(equalizerService: appModel.equalizerService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 16) {
|
||||
EQSliderView()
|
||||
.frame(height: 180)
|
||||
.padding(.horizontal, 16)
|
||||
.environment(model)
|
||||
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Button {
|
||||
withAnimation {
|
||||
model.isEnabled.toggle()
|
||||
model.enable()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: model.isEnabled ? "waveform.slash" : "waveform")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Text(model.isEnabled ? "Disable": "Enable")
|
||||
.font(.body)
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.background(model.isEnabled ? .red : .mint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
Button {
|
||||
model.reset()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Reset")
|
||||
.font(.body)
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.background(.mint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.task {
|
||||
Task {
|
||||
model.generateBands()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Equalizer")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
#else
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done", action: dismiss.callAsFunction)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EQSliderView: View {
|
||||
@Environment(EqualizerView.Model.self) var eqModel
|
||||
|
||||
@State private var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
|
||||
@State private var resetPoints: [Double] = Array(repeating: .zero, count: 6)
|
||||
|
||||
@State private var eqViewFrame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
// Draw labels for gain values
|
||||
VStack {
|
||||
Text("\(Int(eqModel.maxGain))db")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.black)
|
||||
Spacer()
|
||||
Text("0dB")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.black)
|
||||
Spacer()
|
||||
Text("\(Int(eqModel.minGain))db")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
GeometryReader { innerGeo in
|
||||
ZStack {
|
||||
LineShape(values: eqModel.shouldReset ? resetPoints : dragPointYLocations.map { Double($0) })
|
||||
.stroke(Color.mint, lineWidth: 2)
|
||||
.animation(.easeInOut(duration: 0.2), value: eqModel.shouldReset)
|
||||
.onAppear {
|
||||
resetPoints = resetPoints.map { _ in Double(gainToYPosition(at: 0, in: innerGeo.size)) }
|
||||
}
|
||||
|
||||
Path { path in
|
||||
for index in 0..<dragPointYLocations.count {
|
||||
let x = positionForDragPoint(at: index, size: innerGeo.size)
|
||||
path.move(to: CGPoint(x: x, y: 0))
|
||||
path.addLine(to: CGPoint(x: x, y: innerGeo.size.height))
|
||||
}
|
||||
path.move(to: CGPoint(x: 0, y: innerGeo.size.height / 2))
|
||||
path.addLine(to: CGPoint(x: innerGeo.size.width, y: innerGeo.size.height / 2))
|
||||
}
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
|
||||
ForEach(eqModel.bands) { band in
|
||||
Circle()
|
||||
.fill(Color.mint)
|
||||
.frame(width: 20, height: 20)
|
||||
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: dragPointYLocations[band.index])
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
let newY = min(max(value.location.y, 0), innerGeo.size.height)
|
||||
dragPointYLocations[band.index] = newY
|
||||
updateGainValue(at: band.index, in: innerGeo.size)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
dragPointYLocations[band.index] = gainToYPosition(at: band.value, in: innerGeo.size)
|
||||
}
|
||||
.onChange(of: eqModel.shouldReset) { _, reset in
|
||||
if reset {
|
||||
resetPositions(in: innerGeo.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(eqModel.bands) { band in
|
||||
Text(band.frequency)
|
||||
.position(x: positionForDragPoint(at: band.index, size: innerGeo.size), y: innerGeo.size.height + 8)
|
||||
.font(.caption)
|
||||
.foregroundColor(.black)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
|
||||
size.width / 12 * CGFloat(index * 2 + 1)
|
||||
}
|
||||
|
||||
func updateGainValue(at index: Int, in size: CGSize) {
|
||||
let percentage = dragPointYLocations[index] / size.height
|
||||
let gain = (1 - Float(percentage)) * (eqModel.maxGain - eqModel.minGain) + eqModel.minGain
|
||||
eqModel.update(gain: gain, index: index)
|
||||
}
|
||||
|
||||
func gainToYPosition(at gain: Float, in size: CGSize) -> CGFloat {
|
||||
let percentage = 1 - (gain - eqModel.minGain) / (eqModel.maxGain - eqModel.minGain)
|
||||
return CGFloat(percentage) * size.height
|
||||
}
|
||||
|
||||
func resetPositions(in size: CGSize) {
|
||||
let reset = dragPointYLocations.map { _ in gainToYPosition(at: 0, in: size) }
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dragPointYLocations = reset
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension EqualizerView {
|
||||
@Observable
|
||||
class Model {
|
||||
@ObservationIgnored
|
||||
private let equalizerService: EqualizerService
|
||||
|
||||
var dragPointYLocations: [CGFloat] = Array(repeating: .zero, count: 6)
|
||||
|
||||
var isEnabled: Bool = false
|
||||
|
||||
var bands: [EQBand] = []
|
||||
|
||||
let minGain: Float = -12
|
||||
let maxGain: Float = 12
|
||||
|
||||
var shouldReset: Bool = false
|
||||
|
||||
init(equalizerService: EqualizerService) {
|
||||
self.equalizerService = equalizerService
|
||||
isEnabled = equalizerService.isActivated
|
||||
}
|
||||
|
||||
func generateBands() {
|
||||
bands = equalizerService.bands.enumerated().map { index, item in
|
||||
var measurement = item.frequency
|
||||
var frequency = String(Int(measurement))
|
||||
if item.frequency >= 1000 {
|
||||
measurement = item.frequency / 1000
|
||||
frequency = "\(String(Int(measurement)))K"
|
||||
}
|
||||
return EQBand(index: index, frequency: frequency, min: minGain, max: maxGain, value: item.gain)
|
||||
}
|
||||
}
|
||||
|
||||
func enable() {
|
||||
if isEnabled {
|
||||
equalizerService.activate()
|
||||
} else {
|
||||
equalizerService.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
func update(gain: Float, index: Int) {
|
||||
shouldReset = false
|
||||
bands[index].value = gain
|
||||
equalizerService.update(gain: gain, for: index)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
guard !shouldReset else {
|
||||
return
|
||||
}
|
||||
shouldReset = true
|
||||
equalizerService.reset()
|
||||
for band in bands {
|
||||
band.value = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LineShape: Shape {
|
||||
var values: [Double]
|
||||
|
||||
var animatableData: AnimatableLine {
|
||||
get { AnimatableLine(values: values) }
|
||||
set { values = newValue.values }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.size.width / 12, y: values.first ?? 0))
|
||||
for index in 1..<values.count {
|
||||
let x = positionForDragPoint(at: index, size: rect.size)
|
||||
let y = values[index]
|
||||
path.addLine(to: CGPoint(x: x, y: y))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func positionForDragPoint(at index: Int, size: CGSize) -> CGFloat {
|
||||
size.width / 12 * CGFloat(index * 2 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimatableLine : VectorArithmetic {
|
||||
var values: [Double]
|
||||
|
||||
var magnitudeSquared: Double {
|
||||
return values.map { $0 * $0 }.reduce(0, +)
|
||||
}
|
||||
|
||||
mutating func scale(by rhs: Double) {
|
||||
values = values.map { $0 * rhs }
|
||||
}
|
||||
|
||||
static var zero: AnimatableLine {
|
||||
return AnimatableLine(values: [0.0])
|
||||
}
|
||||
|
||||
static func - (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine {
|
||||
return AnimatableLine(values: zip(lhs.values, rhs.values).map(-))
|
||||
}
|
||||
|
||||
static func -= (lhs: inout AnimatableLine, rhs: AnimatableLine) {
|
||||
lhs = lhs - rhs
|
||||
}
|
||||
|
||||
static func + (lhs: AnimatableLine, rhs: AnimatableLine) -> AnimatableLine {
|
||||
return AnimatableLine(values: zip(lhs.values, rhs.values).map(+))
|
||||
}
|
||||
|
||||
static func += (lhs: inout AnimatableLine, rhs: AnimatableLine) {
|
||||
lhs = lhs + rhs
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EqualizerView(appModel: AppModel())
|
||||
}
|
||||
+49
-23
@@ -1,26 +1,23 @@
|
||||
//
|
||||
// AudioPlayerService.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioStreaming
|
||||
import AVFoundation
|
||||
|
||||
protocol AudioPlayerServiceDelegate: AnyObject {
|
||||
func didStartPlaying()
|
||||
func didStopPlaying()
|
||||
func didStartPlaying(id: AudioEntryId)
|
||||
func didStopPlaying(id: AudioEntryId, reason: AudioPlayerStopReason)
|
||||
func statusChanged(status: AudioPlayerState)
|
||||
func errorOccurred(error: AudioPlayerError)
|
||||
func metadataReceived(metadata: [String: String])
|
||||
}
|
||||
|
||||
final class AudioPlayerService {
|
||||
var delegate = MulticastDelegate<AudioPlayerServiceDelegate>()
|
||||
weak var delegate: AudioPlayerServiceDelegate?
|
||||
|
||||
private var player: AudioPlayer
|
||||
var player: AudioPlayer
|
||||
private var audioSystemResetObserver: Any?
|
||||
|
||||
var duration: Double {
|
||||
@@ -43,8 +40,15 @@ final class AudioPlayerService {
|
||||
player.state
|
||||
}
|
||||
|
||||
init() {
|
||||
player = AudioPlayer(configuration: .init(enableLogs: true))
|
||||
var statusChangedNotifier = Notifier<AudioPlayerState>()
|
||||
var metadataReceivedNotifier = Notifier<[String: String]>()
|
||||
var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>()
|
||||
|
||||
private let audioPlayerProvider: () -> AudioPlayer
|
||||
|
||||
init(audioPlayerProvider: @escaping () -> AudioPlayer) {
|
||||
self.audioPlayerProvider = audioPlayerProvider
|
||||
player = audioPlayerProvider()
|
||||
player.delegate = self
|
||||
|
||||
configureAudioSession()
|
||||
@@ -56,6 +60,11 @@ final class AudioPlayerService {
|
||||
player.play(url: url)
|
||||
}
|
||||
|
||||
func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
|
||||
activateAudioSession()
|
||||
player.play(source: source, entryId: entryId, format: format)
|
||||
}
|
||||
|
||||
func queue(url: URL) {
|
||||
activateAudioSession()
|
||||
player.queue(url: url)
|
||||
@@ -82,6 +91,10 @@ final class AudioPlayerService {
|
||||
player.rate = rate
|
||||
}
|
||||
|
||||
func update(volume: Float) {
|
||||
player.volume = volume
|
||||
}
|
||||
|
||||
func add(_ node: AVAudioNode) {
|
||||
player.attach(node: node)
|
||||
}
|
||||
@@ -98,27 +111,31 @@ final class AudioPlayerService {
|
||||
}
|
||||
}
|
||||
|
||||
func seek(at time: Float) {
|
||||
player.seek(to: Double(time))
|
||||
func seek(at time: Double) {
|
||||
player.seek(to: time)
|
||||
}
|
||||
|
||||
private func recreatePlayer() {
|
||||
player = AudioPlayer(configuration: .init(enableLogs: true))
|
||||
player = audioPlayerProvider()
|
||||
player.delegate = self
|
||||
}
|
||||
|
||||
private func registerSessionEvents() {
|
||||
// Note that a real app might need to observer other AVAudioSession notifications as well
|
||||
audioSystemResetObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil,
|
||||
queue: nil)
|
||||
{ [unowned self] _ in
|
||||
#if os(iOS)
|
||||
audioSystemResetObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [unowned self] _ in
|
||||
self.configureAudioSession()
|
||||
self.recreatePlayer()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
#if os(iOS)
|
||||
do {
|
||||
print("AudioSession category is AVAudioSessionCategoryPlayback")
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [])
|
||||
@@ -126,9 +143,11 @@ final class AudioPlayerService {
|
||||
} catch let error as NSError {
|
||||
print("Couldn't setup audio session category to Playback \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func activateAudioSession() {
|
||||
#if os(iOS)
|
||||
do {
|
||||
print("AudioSession is active")
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
@@ -136,29 +155,34 @@ final class AudioPlayerService {
|
||||
} catch let error as NSError {
|
||||
print("Couldn't set audio session to active: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func deactivateAudioSession() {
|
||||
#if os(iOS)
|
||||
do {
|
||||
print("AudioSession is deactivated")
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
} catch let error as NSError {
|
||||
print("Couldn't deactivate audio session: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioPlayerService: AudioPlayerDelegate {
|
||||
func audioPlayerDidStartPlaying(player _: AudioPlayer, with id: AudioEntryId) {
|
||||
print("audioPlayerDidStartPlaying entryId: \(id)")
|
||||
delegate.invoke(invocation: { $0.didStartPlaying() })
|
||||
delegate?.didStartPlaying(id: id)
|
||||
Task { await playingStartedStopped.send((true, id, nil)) }
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishBuffering(player _: AudioPlayer, with _: AudioEntryId) {}
|
||||
|
||||
func audioPlayerStateChanged(player _: AudioPlayer, with newState: AudioPlayerState, previous _: AudioPlayerState) {
|
||||
print("audioPlayerDidStartPlaying newState: \(newState)")
|
||||
delegate.invoke(invocation: { $0.statusChanged(status: newState) })
|
||||
Task { await statusChangedNotifier.send(newState) }
|
||||
delegate?.statusChanged(status: newState)
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(player _: AudioPlayer,
|
||||
@@ -168,16 +192,18 @@ extension AudioPlayerService: AudioPlayerDelegate {
|
||||
duration _: Double)
|
||||
{
|
||||
print("audioPlayerDidFinishPlaying entryId: \(id), reason: \(reason)")
|
||||
delegate.invoke(invocation: { $0.didStopPlaying() })
|
||||
Task { await playingStartedStopped.send((false, id, reason)) }
|
||||
delegate?.didStopPlaying(id: id, reason: reason)
|
||||
}
|
||||
|
||||
func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) {
|
||||
delegate.invoke(invocation: { $0.errorOccurred(error: error) })
|
||||
delegate?.errorOccurred(error: error)
|
||||
}
|
||||
|
||||
func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {}
|
||||
|
||||
func audioPlayerDidReadMetadata(player _: AudioPlayer, metadata: [String: String]) {
|
||||
delegate.invoke(invocation: { $0.metadataReceived(metadata: metadata) })
|
||||
Task { await metadataReceivedNotifier.send(metadata) }
|
||||
delegate?.metadataReceived(metadata: metadata)
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -1,16 +1,13 @@
|
||||
//
|
||||
// EqualizerService.swift
|
||||
// AudioExample
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
|
||||
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
final class EqualizerService {
|
||||
private let playerService: AudioPlayerService
|
||||
private let _freqs = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
||||
private let _freqs = [60, 150, 400, 1000, 2400, 15000]
|
||||
private let eqUnit: AVAudioUnitEQ
|
||||
|
||||
var bands: [AVAudioUnitEQFilterParameters] {
|
||||
@@ -0,0 +1,138 @@
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
final class DisplayLink {
|
||||
|
||||
private var displayLink: DisplayLinkPlatform?
|
||||
|
||||
var isPaused: Bool = true {
|
||||
didSet {
|
||||
displayLink?.isPaused = isPaused
|
||||
}
|
||||
}
|
||||
|
||||
init(onTick: @escaping (DisplayLinkFrame) -> Void) {
|
||||
displayLink = DisplayLinkPlatform()
|
||||
|
||||
displayLink?.onTick = onTick
|
||||
}
|
||||
|
||||
deinit {
|
||||
deactivate()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
displayLink?.activate()
|
||||
self.isPaused = false
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
displayLink?.deactivate()
|
||||
isPaused = true
|
||||
}
|
||||
}
|
||||
|
||||
struct DisplayLinkFrame {
|
||||
var timestamp: TimeInterval
|
||||
var duration: TimeInterval
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
final class DisplayLinkPlatform {
|
||||
private final class DisplayLinkTarget {
|
||||
var onTick: ((DisplayLinkFrame) -> Void)?
|
||||
|
||||
@objc func tick(_ link: CADisplayLink) {
|
||||
onTick?(DisplayLinkFrame(timestamp: link.timestamp, duration: link.duration))
|
||||
}
|
||||
}
|
||||
|
||||
var onTick: ((DisplayLinkFrame) -> Void)?
|
||||
private var target = DisplayLinkTarget()
|
||||
var displayLink: CADisplayLink?
|
||||
|
||||
var isPaused: Bool {
|
||||
get { displayLink?.isPaused ?? false }
|
||||
set { displayLink?.isPaused = newValue }
|
||||
}
|
||||
|
||||
init() {
|
||||
displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:)))
|
||||
target.onTick = { [weak self] value in
|
||||
self?.onTick?(value)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
displayLink?.invalidate()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
displayLink = CADisplayLink(target: target, selector: #selector(DisplayLinkTarget.tick(_:)))
|
||||
displayLink?.preferredFrameRateRange = .init(minimum: 6, maximum: 10)
|
||||
displayLink?.add(to: .current, forMode: .common)
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
}
|
||||
#else
|
||||
final class DisplayLinkPlatform {
|
||||
|
||||
var onTick: ((DisplayLinkFrame) -> Void)?
|
||||
var isPaused: Bool = true {
|
||||
didSet {
|
||||
guard isPaused != oldValue else { return }
|
||||
if isPaused == true {
|
||||
CVDisplayLinkStop(self.displayLink)
|
||||
} else {
|
||||
CVDisplayLinkStart(self.displayLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The CVDisplayLink that powers this DisplayLink instance.
|
||||
var displayLink: CVDisplayLink = {
|
||||
var dl: CVDisplayLink? = nil
|
||||
CVDisplayLinkCreateWithActiveCGDisplays(&dl)
|
||||
return dl!
|
||||
}()
|
||||
|
||||
init() {
|
||||
CVDisplayLinkSetOutputHandler(self.displayLink, { [weak self] (displayLink, inNow, inOutputTime, flageIn, flagsOut) -> CVReturn in
|
||||
let frame = DisplayLinkFrame(
|
||||
timestamp: inNow.pointee.timeInterval,
|
||||
duration: inOutputTime.pointee.timeInterval - inNow.pointee.timeInterval)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self?.isPaused == false else { return }
|
||||
self?.onTick?(frame)
|
||||
}
|
||||
|
||||
return kCVReturnSuccess
|
||||
})
|
||||
}
|
||||
|
||||
func activate() {
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
isPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
extension CVTimeStamp {
|
||||
fileprivate var timeInterval: TimeInterval {
|
||||
return TimeInterval(videoTime) / TimeInterval(self.videoTimeScale)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Created by Dimitris Chatzieleftheriou on 25/04/2024.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
actor Notifier<Output> {
|
||||
private var continuations: [UUID: AsyncStream<Output>.Continuation] = [:]
|
||||
|
||||
func values(bufferingPolicy limit: AsyncStream<Output>.Continuation.BufferingPolicy = .bufferingNewest(1)) -> AsyncStream<Output> {
|
||||
AsyncStream<Output>(bufferingPolicy: limit) { continuation in
|
||||
let id = UUID()
|
||||
continuations[id] = continuation
|
||||
continuation.onTermination = { _ in
|
||||
Task { await self.cancel(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ value: Output) {
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(value)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel(_ id: UUID) {
|
||||
continuations[id] = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrefersStackNavigationEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: Bool = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var prefersStackNavigation: Bool {
|
||||
get { self[PrefersStackNavigationEnvironmentKey.self] }
|
||||
set { self[PrefersStackNavigationEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension PrefersStackNavigationEnvironmentKey: UITraitBridgedEnvironmentKey {
|
||||
static func read(from traitCollection: UITraitCollection) -> Bool {
|
||||
return traitCollection.userInterfaceIdiom == .phone || traitCollection.userInterfaceIdiom == .tv
|
||||
}
|
||||
|
||||
static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
|
||||
// Do not write.
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
@Environment(AppModel.self) var appModel
|
||||
@Environment(\.prefersStackNavigation) private var prefersStackNavigation
|
||||
|
||||
@State private var selection: NavigationContent?
|
||||
|
||||
var body: some View {
|
||||
if prefersStackNavigation {
|
||||
NavigationStack {
|
||||
ContentSidebar(selection: $selection)
|
||||
.navigationTitle("Home")
|
||||
}
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
ContentSidebar(selection: $selection)
|
||||
.navigationTitle("Home")
|
||||
} detail: {
|
||||
if let selection {
|
||||
DetailView(selection: selection)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selection = .audioPlayer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DetailView: View {
|
||||
@Environment(AppModel.self) var appModel
|
||||
|
||||
var selection: NavigationContent
|
||||
|
||||
var body: some View {
|
||||
switch selection {
|
||||
case .audioPlayer:
|
||||
AudioPlayerView(appModel: appModel)
|
||||
case .audioQueue: // TODO
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Created by Dimitris C.
|
||||
// Copyright © 2024 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum NavigationContent: Hashable {
|
||||
case audioPlayer
|
||||
case audioQueue
|
||||
}
|
||||
|
||||
struct ContentSidebar: View {
|
||||
@Binding var selection: NavigationContent?
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
NavigationLink(value: NavigationContent.audioPlayer) {
|
||||
Label("Audio Player", systemImage: "play")
|
||||
}
|
||||
|
||||
NavigationLink(value: NavigationContent.audioQueue) {
|
||||
Label("Audio Queue", systemImage: "play.square.stack")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(item: $selection, destination: { selection in
|
||||
DetailView(selection: selection)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Sidebar_Previews: PreviewProvider {
|
||||
struct Preview: View {
|
||||
@State private var selection: NavigationContent? = NavigationContent.audioPlayer
|
||||
var body: some View {
|
||||
ContentSidebar(selection: $selection)
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
NavigationSplitView {
|
||||
Preview()
|
||||
} detail: {
|
||||
Text("Detail!")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'AudioStreaming'
|
||||
s.version = '1.1.0'
|
||||
s.license = 'MIT'
|
||||
s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.'
|
||||
s.homepage = 'https://github.com/dimitris-c/AudioStreaming'
|
||||
s.authors = { 'Dimitris C.' => 'dimmdesign@gmail.com' }
|
||||
s.source = { :git => 'https://github.com/dimitris-c/AudioStreaming.git', :tag => s.version }
|
||||
|
||||
s.ios.deployment_target = '12.0'
|
||||
|
||||
s.swift_versions = ['5.1', '5.2', '5.3']
|
||||
|
||||
s.source_files = 'AudioStreaming/**/*.swift'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
|
||||
}
|
||||
end
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 52;
|
||||
objectVersion = 55;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -54,7 +54,7 @@
|
||||
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */; };
|
||||
B59DF10424916FD50043C498 /* DispatchQueue+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF10324916FD50043C498 /* DispatchQueue+Helpers.swift */; };
|
||||
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DF1A22493E90C0043C498 /* AudioFileStream+Helpers.swift */; };
|
||||
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
|
||||
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; platformFilters = (ios, tvos, ); };
|
||||
B5AEDBBF24744153007D8101 /* AudioStreaming.h in Headers */ = {isa = PBXBuildFile; fileRef = B5AEDBB124744153007D8101 /* AudioStreaming.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B36E422655A32200DC96F5 /* FrameFilterProcessor.swift */; };
|
||||
B5B3B7CC248647ED00656828 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */; };
|
||||
@@ -528,8 +528,9 @@
|
||||
B5AEDBA524744153007D8101 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1140;
|
||||
LastUpgradeCheck = 1200;
|
||||
LastUpgradeCheck = 1620;
|
||||
ORGANIZATIONNAME = Decimal;
|
||||
TargetAttributes = {
|
||||
B5AEDBAD24744153007D8101 = {
|
||||
@@ -587,6 +588,7 @@
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
B583864B2545858E0087A712 /* SwiftLint */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@@ -683,6 +685,10 @@
|
||||
/* Begin PBXTargetDependency section */
|
||||
B5AEDBBA24744153007D8101 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilters = (
|
||||
ios,
|
||||
tvos,
|
||||
);
|
||||
target = B5AEDBAD24744153007D8101 /* AudioStreaming */;
|
||||
targetProxy = B5AEDBB924744153007D8101 /* PBXContainerItemProxy */;
|
||||
};
|
||||
@@ -727,6 +733,7 @@
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -742,7 +749,8 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.2.7;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -792,6 +800,7 @@
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -801,7 +810,8 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.2.7;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -817,12 +827,14 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
ENABLE_MODULE_VERIFIER = YES;
|
||||
INFOPLIST_FILE = AudioStreaming/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
@@ -831,16 +843,18 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MARKETING_VERSION = 1.2.7;
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -848,12 +862,14 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
DYLIB_INSTALL_NAME_BASE = "@rpath";
|
||||
ENABLE_MODULE_VERIFIER = YES;
|
||||
INFOPLIST_FILE = AudioStreaming/Info.plist;
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
@@ -862,22 +878,23 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MARKETING_VERSION = 1.2.7;
|
||||
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14";
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B5AEDBC624744153007D8101 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
|
||||
@@ -889,16 +906,17 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B5AEDBC724744153007D8101 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = AudioStreamingTests/Info.plist;
|
||||
@@ -910,8 +928,10 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreamingTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,3";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1200"
|
||||
LastUpgradeVersion = "1620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:AudioExample/AudioExample.xcodeproj">
|
||||
location = "group:AudioPlayer/AudioPlayer.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "container:AudioStreaming.xcodeproj">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
protocol Lock {
|
||||
func lock()
|
||||
@@ -14,24 +15,96 @@ protocol Lock {
|
||||
|
||||
// Execute a closure while acquiring a lock
|
||||
func withLock(body: () -> Void)
|
||||
|
||||
func deallocate()
|
||||
}
|
||||
|
||||
/// A wrapper for `os_unfair_lock`
|
||||
/// - Tag: UnfairLock
|
||||
final class UnfairLock: Lock {
|
||||
@usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock>
|
||||
|
||||
var unfairLock: Lock
|
||||
|
||||
init() {
|
||||
if #available(iOS 16.0, *), #available(macOS 13.0, *) {
|
||||
unfairLock = OSStorageLock()
|
||||
} else {
|
||||
unfairLock = UnfairStorageLock()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
deallocate()
|
||||
}
|
||||
|
||||
func deallocate() {
|
||||
unfairLock.deallocate()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
|
||||
try unfairLock.withLock(body: body)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func withLock(body: () -> Void) {
|
||||
unfairLock.withLock(body: body)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func lock() {
|
||||
unfairLock.lock()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func unlock() {
|
||||
unfairLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@available(macOS 13, *)
|
||||
private class OSStorageLock: Lock {
|
||||
@usableFromInline
|
||||
let osLock = OSAllocatedUnfairLock()
|
||||
|
||||
@inlinable
|
||||
func lock() {
|
||||
osLock.lock()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func unlock() {
|
||||
osLock.unlock()
|
||||
}
|
||||
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
|
||||
try osLock.withLockUnchecked(body)
|
||||
}
|
||||
|
||||
func withLock(body: () -> Void) {
|
||||
osLock.withLockUnchecked(body)
|
||||
}
|
||||
|
||||
func deallocate() {} // no-op
|
||||
}
|
||||
|
||||
private class UnfairStorageLock: Lock {
|
||||
|
||||
@usableFromInline
|
||||
let unfairLock: UnsafeMutablePointer<os_unfair_lock>
|
||||
|
||||
init() {
|
||||
unfairLock = .allocate(capacity: 1)
|
||||
unfairLock.initialize(to: os_unfair_lock())
|
||||
}
|
||||
|
||||
deinit {
|
||||
func deallocate() {
|
||||
unfairLock.deinitialize(count: 1)
|
||||
unfairLock.deallocate()
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func withLock<Result>(body: () throws -> Result) rethrows -> Result {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
defer { os_unfair_lock_unlock(unfairLock) }
|
||||
@@ -39,7 +112,6 @@ final class UnfairLock: Lock {
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func withLock(body: () -> Void) {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
defer { os_unfair_lock_unlock(unfairLock) }
|
||||
@@ -47,13 +119,11 @@ final class UnfairLock: Lock {
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func lock() {
|
||||
os_unfair_lock_lock(unfairLock)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
@inline(__always)
|
||||
func unlock() {
|
||||
os_unfair_lock_unlock(unfairLock)
|
||||
}
|
||||
|
||||
@@ -25,13 +25,15 @@
|
||||
+---+ +---+
|
||||
```
|
||||
*/
|
||||
final class Queue<Element>: Sequence, CustomDebugStringConvertible {
|
||||
final class Queue<Element: Equatable>: Sequence, CustomDebugStringConvertible {
|
||||
private var _storage: [Element] = []
|
||||
|
||||
var isEmpty: Bool { _storage.isEmpty }
|
||||
|
||||
var count: Int { _storage.count }
|
||||
|
||||
var items: [Element] { _storage }
|
||||
|
||||
/// Inserts an item at the end of the queue
|
||||
func enqueue(item: Element) {
|
||||
_storage.insert(item, at: 0)
|
||||
@@ -55,6 +57,30 @@ final class Queue<Element>: Sequence, CustomDebugStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts an item at a specific index in the queue
|
||||
func insert(item: Element, at index: Int) {
|
||||
guard index >= 0 && index <= count else {
|
||||
fatalError("Index out of range")
|
||||
}
|
||||
_storage.insert(item, at: index)
|
||||
}
|
||||
|
||||
func remove(item: Element) {
|
||||
guard let index = _storage.firstIndex(of: item) else {
|
||||
return
|
||||
}
|
||||
_storage.remove(at: index)
|
||||
}
|
||||
|
||||
/// Removes the item at the specified index in the queue
|
||||
@discardableResult
|
||||
func remove(at index: Int) -> Element? {
|
||||
guard index >= 0 && index < count else {
|
||||
return nil
|
||||
}
|
||||
return _storage.remove(at: index)
|
||||
}
|
||||
|
||||
/// Retrieves the last item
|
||||
func peek() -> Element? {
|
||||
_storage.last
|
||||
|
||||
@@ -92,7 +92,9 @@ class AudioEntry {
|
||||
|
||||
func reset() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
framesState = EntryFramesState()
|
||||
framesState.played = 0
|
||||
framesState.queued = 0
|
||||
framesState.lastFrameQueued = -1
|
||||
}
|
||||
|
||||
func has(same source: CoreAudioStreamSource) -> Bool {
|
||||
@@ -119,15 +121,21 @@ class AudioEntry {
|
||||
}
|
||||
|
||||
func duration() -> Double {
|
||||
guard sampleRate > 0 else { return 0 }
|
||||
lock.lock()
|
||||
guard sampleRate > 0 else {
|
||||
lock.unlock()
|
||||
return 0
|
||||
}
|
||||
|
||||
if let audioDataPacketOffset = audioStreamState.dataPacketOffset {
|
||||
let framesPerPacket = UInt64(audioStreamFormat.mFramesPerPacket)
|
||||
if audioDataPacketOffset > 0, framesPerPacket > 0 {
|
||||
return Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
|
||||
let duration = Double(audioDataPacketOffset * framesPerPacket) / audioStreamFormat.mSampleRate
|
||||
lock.unlock()
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
lock.unlock()
|
||||
let calculatedBitrate = self.calculatedBitrate()
|
||||
if calculatedBitrate < 1.0 || source.length == 0 {
|
||||
return 0
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
|
||||
protocol AudioStreamSourceDelegate: AnyObject {
|
||||
public protocol AudioStreamSourceDelegate: AnyObject {
|
||||
/// Indicates that there's data available
|
||||
func dataAvailable(source: CoreAudioStreamSource, data: Data)
|
||||
/// Indicates an error occurred
|
||||
@@ -17,7 +17,7 @@ protocol AudioStreamSourceDelegate: AnyObject {
|
||||
func metadataReceived(data: [String: String])
|
||||
}
|
||||
|
||||
protocol CoreAudioStreamSource: AnyObject {
|
||||
public protocol CoreAudioStreamSource: AnyObject {
|
||||
/// An `Int` that represents the position of the audio
|
||||
var position: Int { get }
|
||||
/// The length of the audio in bytes
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Copyright © 2020 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
@@ -17,6 +18,12 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
audioFileType(fileExtension: url.pathExtension)
|
||||
}
|
||||
|
||||
private var isMp4: Bool {
|
||||
audioFileHint == kAudioFileM4AType || audioFileHint == kAudioFileMPEG4Type
|
||||
}
|
||||
|
||||
private var mp4IsAlreadyOptimized: Bool = false
|
||||
|
||||
private var seekOffset: Int
|
||||
|
||||
private let url: URL
|
||||
@@ -26,6 +33,8 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
private var buffer: UnsafeMutablePointer<UInt8>
|
||||
private var inputStream: InputStream?
|
||||
|
||||
private var mp4Restructure: Mp4Restructure
|
||||
|
||||
init(url: URL,
|
||||
fileManager: FileManager = .default,
|
||||
underlyingQueue: DispatchQueue,
|
||||
@@ -35,6 +44,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
self.underlyingQueue = underlyingQueue
|
||||
self.fileManager = fileManager
|
||||
self.readSize = readSize
|
||||
self.mp4Restructure = Mp4Restructure()
|
||||
buffer = UnsafeMutablePointer.uint8pointer(of: readSize)
|
||||
seekOffset = 0
|
||||
position = 0
|
||||
@@ -43,6 +53,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
|
||||
deinit {
|
||||
buffer.deallocate()
|
||||
mp4Restructure.clear()
|
||||
}
|
||||
|
||||
func close() {
|
||||
@@ -73,18 +84,32 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
}
|
||||
|
||||
private func performOpen(seek seekOffset: Int) throws {
|
||||
close()
|
||||
try open()
|
||||
|
||||
guard let inputStream = inputStream else {
|
||||
return
|
||||
var reopened = false
|
||||
let status = inputStream?.streamStatus ?? .closed
|
||||
if status == .atEnd || status == .closed || status == .error {
|
||||
reopened = true
|
||||
close()
|
||||
try open()
|
||||
}
|
||||
|
||||
if inputStream.setProperty(seekOffset, forKey: .fileCurrentOffsetKey) {
|
||||
position = seekOffset
|
||||
var offset = seekOffset
|
||||
if isMp4, mp4Restructure.dataOptimized {
|
||||
offset = mp4Restructure.seekAdjusted(offset: seekOffset)
|
||||
}
|
||||
|
||||
if inputStream?.setProperty(offset, forKey: .fileCurrentOffsetKey) == true {
|
||||
position = offset
|
||||
} else {
|
||||
position = 0
|
||||
}
|
||||
|
||||
if !reopened {
|
||||
underlyingQueue.async { [weak self] in
|
||||
if self?.inputStream?.hasBytesAvailable == true {
|
||||
self?.dataAvailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dataAvailable() {
|
||||
@@ -92,13 +117,51 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
let read = inputStream.read(buffer, maxLength: readSize)
|
||||
if read > 0 {
|
||||
let data = Data(bytes: buffer, count: read)
|
||||
delegate?.dataAvailable(source: self, data: data)
|
||||
if isMp4, !mp4IsAlreadyOptimized {
|
||||
if !mp4Restructure.dataOptimized {
|
||||
do {
|
||||
if let mp4OptimizeInfo = try mp4Restructure.checkIsOptimized(data: data) {
|
||||
try performMp4Restructure(inputStream: inputStream, mp4OptimizeInfo: mp4OptimizeInfo)
|
||||
} else {
|
||||
mp4IsAlreadyOptimized = true
|
||||
delegate?.dataAvailable(source: self, data: data)
|
||||
}
|
||||
} catch {
|
||||
delegate?.errorOccurred(source: self, error: error)
|
||||
}
|
||||
} else {
|
||||
delegate?.dataAvailable(source: self, data: data)
|
||||
}
|
||||
} else {
|
||||
delegate?.dataAvailable(source: self, data: data)
|
||||
}
|
||||
position += read
|
||||
} else {
|
||||
position += getCurrentOffsetFromStream()
|
||||
}
|
||||
}
|
||||
|
||||
func performMp4Restructure(inputStream: InputStream, mp4OptimizeInfo: Mp4OptimizeInfo) throws {
|
||||
let offsetAccepted = inputStream.setProperty(mp4OptimizeInfo.moovOffset, forKey: .fileCurrentOffsetKey)
|
||||
if offsetAccepted {
|
||||
let moovDataBuffer = UnsafeMutablePointer.uint8pointer(of: mp4OptimizeInfo.moovSize)
|
||||
defer { moovDataBuffer.deallocate() }
|
||||
let moovRead = inputStream.read(moovDataBuffer, maxLength: mp4OptimizeInfo.moovSize)
|
||||
if moovRead > 0 {
|
||||
let data = Data(bytes: moovDataBuffer, count: moovRead)
|
||||
let moovData = try mp4Restructure.restructureMoov(data: data)
|
||||
delegate?.dataAvailable(source: self, data: moovData.initialData)
|
||||
if !inputStream.setProperty(moovData.mdatOffset, forKey: .fileCurrentOffsetKey) {
|
||||
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
|
||||
}
|
||||
} else {
|
||||
delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError)
|
||||
}
|
||||
} else {
|
||||
delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError)
|
||||
}
|
||||
}
|
||||
|
||||
private func open() throws {
|
||||
guard let inputStream = InputStream(url: url) else {
|
||||
throw AudioSystemError.playerStartError
|
||||
|
||||
@@ -11,17 +11,28 @@ struct MP4Atom: Equatable, CustomDebugStringConvertible {
|
||||
let offset: Int
|
||||
var data: Data?
|
||||
|
||||
var isFreeSpaceAtom: Bool {
|
||||
type == Atoms.free || type == Atoms.skip || type == Atoms.wide
|
||||
}
|
||||
|
||||
var debugDescription: String {
|
||||
"[Atom][size: \(size))][type: \(Atoms.integerToFourCC(type) ?? "")][offset: \(offset)]"
|
||||
}
|
||||
}
|
||||
|
||||
struct Mp4OptimizeInfo: Equatable {
|
||||
let moovOffset: Int
|
||||
let moovSize: Int
|
||||
}
|
||||
|
||||
/// These are some atoms, helpful for audio mp4
|
||||
enum Atoms {
|
||||
static var ftyp: Int { fourCcToInt("ftyp") }
|
||||
static var moov: Int { fourCcToInt("moov") }
|
||||
static var mdat: Int { fourCcToInt("mdat") }
|
||||
static var free: Int { fourCcToInt("free") }
|
||||
static var skip: Int { fourCcToInt("skip") }
|
||||
static var wide: Int { fourCcToInt("wide") }
|
||||
|
||||
static var cmov: Int { fourCcToInt("cmov") }
|
||||
static var stco: Int { fourCcToInt("stco") }
|
||||
@@ -104,14 +115,25 @@ final class Mp4Restructure {
|
||||
partialResult.append(data)
|
||||
}
|
||||
let initialData = accumulatedInitialData + atomData
|
||||
let mdatOffset = mdatAtom.offset - Atoms.atomPreampleSize
|
||||
let mdatOffset: Int
|
||||
if let ftyp = ftyp {
|
||||
mdatOffset = ftyp.offset + ftyp.size
|
||||
} else {
|
||||
let freeSpaceAtoms = atoms.filter(\.isFreeSpaceAtom)
|
||||
let freeSpaceSize = freeSpaceAtoms.reduce(into: 0) { partialResult, atom in
|
||||
partialResult += atom.size
|
||||
}
|
||||
mdatOffset = mdatAtom.offset - freeSpaceSize
|
||||
}
|
||||
dataOptimized = true
|
||||
return (initialData, mdatOffset)
|
||||
}
|
||||
|
||||
func checkIsOptimized(data: Data) -> (optimized: Bool, offset: Int?)? {
|
||||
/// Returns `nil` if the data is optimized otherwise `Mp4OptimizeInfo`
|
||||
func checkIsOptimized(data: Data) throws -> Mp4OptimizeInfo? {
|
||||
while atomOffset < UInt64(data.count) {
|
||||
let atomSize = Int(readUInt32FromData(data: data, offset: atomOffset))
|
||||
let atomType = Int(readUInt32FromData(data: data, offset: atomOffset + 4))
|
||||
var atomSize = try Int(getInteger(data: data, offset: atomOffset) as UInt32)
|
||||
let atomType = try Int(getInteger(data: data, offset: atomOffset + 4) as UInt32)
|
||||
switch atomType {
|
||||
case Atoms.ftyp:
|
||||
let ftypData = data[Int(atomOffset) ..< atomSize]
|
||||
@@ -119,6 +141,12 @@ final class Mp4Restructure {
|
||||
self.ftyp = ftyp
|
||||
atoms.append(ftyp)
|
||||
case Atoms.mdat:
|
||||
// ref: https://developer.apple.com/documentation/quicktime-file-format/movie_data_atom
|
||||
// This atom can be quite large, and may exceed 2^32 bytes, in which case the size field will be set to 1,
|
||||
// and the header will contain a 64-bit extended size field.
|
||||
if atomSize == 1 {
|
||||
atomSize = Int(try getInteger(data: data, offset: atomOffset + 8) as UInt64)
|
||||
}
|
||||
let mdat = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
|
||||
atoms.append(mdat)
|
||||
foundMdat = true
|
||||
@@ -133,11 +161,11 @@ final class Mp4Restructure {
|
||||
if ftyp != nil {
|
||||
if foundMoov && !foundMdat {
|
||||
Logger.debug("🕵️ detected an optimized mp4", category: .generic)
|
||||
return (true, nil)
|
||||
return nil
|
||||
} else if !foundMoov && foundMdat {
|
||||
Logger.debug("🕵️ detected an non-optimized mp4", category: .generic)
|
||||
let possibleMoovOffset = Int(atomOffset) + atomSize
|
||||
return (false, possibleMoovOffset)
|
||||
return Mp4OptimizeInfo(moovOffset: possibleMoovOffset, moovSize: atomSize)
|
||||
}
|
||||
}
|
||||
atomOffset += atomSize
|
||||
@@ -241,8 +269,12 @@ final class Mp4Restructure {
|
||||
return (moovAtom.storage, moovAtomSize)
|
||||
}
|
||||
|
||||
private func readUInt32FromData(data: Data, offset: Int) -> UInt32 {
|
||||
let valueData = data.subdata(in: offset ..< offset + 4)
|
||||
return UInt32(bigEndian: valueData.withUnsafeBytes { $0.load(as: UInt32.self) })
|
||||
func getInteger<T: FixedWidthInteger>(data: Data, offset: Int) throws -> T {
|
||||
let sizeOfInteger = MemoryLayout<T>.size
|
||||
guard sizeOfInteger <= data.count else {
|
||||
throw ByteBuffer.Error.eof
|
||||
}
|
||||
let _offset = offset + sizeOfInteger
|
||||
return T(data: data[_offset - sizeOfInteger ..< _offset]).bigEndian
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ final class RemoteMp4Restructure {
|
||||
return
|
||||
}
|
||||
self.audioData.append(data)
|
||||
let value = self.mp4Restructure.checkIsOptimized(data: self.audioData)
|
||||
if let value {
|
||||
if let offset = value.offset, !value.optimized {
|
||||
do {
|
||||
let value = try self.mp4Restructure.checkIsOptimized(data: self.audioData)
|
||||
if let value {
|
||||
guard response.response?.statusCode == 206 else {
|
||||
Logger.error("⛔️ mp4 error: no moov before mdat and the stream is not seekable", category: .networking)
|
||||
completion(.failure(Mp4RestructureError.nonOptimizedMp4AndServerCannotSeek))
|
||||
@@ -86,7 +86,7 @@ final class RemoteMp4Restructure {
|
||||
self.audioData = Data()
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.fetchAndRestructureMoovAtom(offset: offset) { result in
|
||||
self.fetchAndRestructureMoovAtom(offset: value.moovOffset) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
let data = value.data
|
||||
@@ -103,6 +103,8 @@ final class RemoteMp4Restructure {
|
||||
self.task = nil
|
||||
completion(.success(nil))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(Mp4RestructureError.invalidAtomSize))
|
||||
}
|
||||
case let .stream(.failure(error)):
|
||||
completion(.failure(Mp4RestructureError.networkError(error)))
|
||||
|
||||
@@ -13,13 +13,13 @@ enum RemoteAudioSourceError: Error {
|
||||
}
|
||||
|
||||
public class RemoteAudioSource: AudioStreamSource {
|
||||
weak var delegate: AudioStreamSourceDelegate?
|
||||
public weak var delegate: AudioStreamSourceDelegate?
|
||||
|
||||
var position: Int {
|
||||
public var position: Int {
|
||||
return seekOffset + relativePosition
|
||||
}
|
||||
|
||||
var length: Int {
|
||||
public var length: Int {
|
||||
guard let parsedHeader = parsedHeaderOutput else { return 0 }
|
||||
return parsedHeader.fileLength
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private var shouldTryParsingIcycastHeaders: Bool = false
|
||||
private let icycastHeadersProcessor: IcycastHeadersProcessor
|
||||
|
||||
var audioFileHint: AudioFileTypeID {
|
||||
public var audioFileHint: AudioFileTypeID {
|
||||
guard let output = parsedHeaderOutput, output.typeId != 0 else {
|
||||
return audioFileType(fileExtension: url.pathExtension)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
|
||||
private let mp4Restructure: RemoteMp4Restructure
|
||||
|
||||
let underlyingQueue: DispatchQueue
|
||||
public let underlyingQueue: DispatchQueue
|
||||
let streamOperationQueue: OperationQueue
|
||||
let netStatusService: NetStatusProvider
|
||||
var waitingForNetwork = false
|
||||
@@ -114,7 +114,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
httpHeaders: [:])
|
||||
}
|
||||
|
||||
func close() {
|
||||
public func close() {
|
||||
retrierTimeout.cancel()
|
||||
streamOperationQueue.isSuspended = false
|
||||
streamOperationQueue.cancelAllOperations()
|
||||
@@ -125,7 +125,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
streamRequest = nil
|
||||
}
|
||||
|
||||
func seek(at offset: Int) {
|
||||
public func seek(at offset: Int) {
|
||||
close()
|
||||
|
||||
relativePosition = 0
|
||||
@@ -144,11 +144,11 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
performOpen(seek: offset)
|
||||
}
|
||||
|
||||
func suspend() {
|
||||
public func suspend() {
|
||||
streamOperationQueue.isSuspended = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
public func resume() {
|
||||
streamOperationQueue.isSuspended = false
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ open class AudioPlayer {
|
||||
private let frameFilterProcessor: FrameFilterProcessor
|
||||
|
||||
private let serializationQueue: DispatchQueue
|
||||
private let sourceQueue: DispatchQueue
|
||||
public let sourceQueue: DispatchQueue
|
||||
|
||||
private let entryProvider: AudioEntryProviding
|
||||
|
||||
@@ -190,6 +190,20 @@ open class AudioPlayer {
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func play(url: URL, headers: [String: String]) {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
play(audioEntry: audioEntry)
|
||||
}
|
||||
|
||||
/// Starts the audio playback for the supplied stream
|
||||
///
|
||||
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
|
||||
/// - parameter entryId: A `String` that provides a unique id for this item
|
||||
/// - parameter format: An `AVAudioFormat` the format of this audio source
|
||||
public func play(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
|
||||
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
|
||||
play(audioEntry: audioEntry)
|
||||
}
|
||||
|
||||
private func play(audioEntry: AudioEntry) {
|
||||
audioEntry.delegate = self
|
||||
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
@@ -210,6 +224,25 @@ open class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
public func playNextInQueue() {
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
serializationQueue.sync {
|
||||
if entriesQueue.count(for: .upcoming) > 0 {
|
||||
playerContext.setInternalState(to: .pendingNext)
|
||||
}
|
||||
do {
|
||||
try self.startEngineIfNeeded()
|
||||
} catch {
|
||||
self.raiseUnexpected(error: .audioSystemError(.engineFailure))
|
||||
}
|
||||
}
|
||||
|
||||
sourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.processSource()
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues the specified URL
|
||||
///
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
@@ -224,15 +257,29 @@ open class AudioPlayer {
|
||||
queue(urls: urls, headers: [:])
|
||||
}
|
||||
|
||||
/// Queues the specified URL
|
||||
public func queue(url: URL, after afterUrl: URL) {
|
||||
queue(url: url, headers: [:], after: afterUrl)
|
||||
}
|
||||
|
||||
/// Queues the specified audio stream
|
||||
///
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func queue(url: URL, headers: [String: String]) {
|
||||
/// - parameter source: A `CoreAudioStreamSource` that will providing streaming data
|
||||
/// - parameter entryId: A `String` that provides a unique id for this item
|
||||
/// - parameter format: An `AVAudioFormat` the format of this audio source
|
||||
public func queue(source: CoreAudioStreamSource, entryId: String, format: AVAudioFormat) {
|
||||
let audioEntry = AudioEntry(source: source, entryId: AudioEntryId(id: entryId), outputAudioFormat: format)
|
||||
queue(audioEntry: audioEntry)
|
||||
}
|
||||
|
||||
public func removeFromQueue(url: URL) {
|
||||
serializationQueue.sync {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
audioEntry.delegate = self
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
if let item = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == url.absoluteString }) {
|
||||
entriesQueue.remove(item: item, type: .upcoming)
|
||||
|
||||
if playerContext.audioPlayingEntry?.id.id == item.id.id {
|
||||
stop(clearQueue: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
@@ -240,6 +287,15 @@ open class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues the specified URL
|
||||
///
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
queue(audioEntry: audioEntry, after: afterUrl)
|
||||
}
|
||||
|
||||
/// Queues the specified URLs
|
||||
///
|
||||
/// - Parameter url: A array of `URL`s specifying the audio content to be played.
|
||||
@@ -258,8 +314,25 @@ open class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private func queue(audioEntry: AudioEntry, after afterUrl: URL? = nil) {
|
||||
serializationQueue.sync {
|
||||
audioEntry.delegate = self
|
||||
if let afterUrl = afterUrl {
|
||||
if let afterUrlEntry = entriesQueue.items(type: .upcoming).first(where: { $0.id.id == afterUrl.absoluteString }) {
|
||||
entriesQueue.insert(item: audioEntry, type: .upcoming, after: afterUrlEntry)
|
||||
}
|
||||
} else {
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
}
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the audio playback
|
||||
public func stop() {
|
||||
public func stop(clearQueue: Bool = true) {
|
||||
guard playerContext.internalState != .stopped else { return }
|
||||
|
||||
serializationQueue.sync {
|
||||
@@ -274,7 +347,9 @@ open class AudioPlayer {
|
||||
self.processFinishPlaying(entry: playingEntry, with: nil)
|
||||
}
|
||||
|
||||
self.clearQueue()
|
||||
if clearQueue {
|
||||
self.clearQueue()
|
||||
}
|
||||
self.playerContext.entriesLock.lock()
|
||||
self.playerContext.audioReadingEntry = nil
|
||||
self.playerContext.audioPlayingEntry = nil
|
||||
@@ -749,7 +824,6 @@ open class AudioPlayer {
|
||||
|
||||
private func raiseUnexpected(error: AudioPlayerError) {
|
||||
playerContext.setInternalState(to: .error)
|
||||
// todo raise on main thread from playback thread
|
||||
asyncOnMain { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.audioPlayerUnexpectedError(player: self, error: error)
|
||||
@@ -759,7 +833,7 @@ open class AudioPlayer {
|
||||
}
|
||||
|
||||
extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
func dataAvailable(source: CoreAudioStreamSource, data: Data) {
|
||||
public func dataAvailable(source: CoreAudioStreamSource, data: Data) {
|
||||
guard let readingEntry = playerContext.audioReadingEntry, readingEntry.has(same: source) else {
|
||||
return
|
||||
}
|
||||
@@ -789,12 +863,12 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func errorOccurred(source: CoreAudioStreamSource, error: Error) {
|
||||
public func errorOccurred(source: CoreAudioStreamSource, error: Error) {
|
||||
guard let entry = playerContext.audioReadingEntry, entry.has(same: source) else { return }
|
||||
raiseUnexpected(error: .networkError(.failure(error)))
|
||||
}
|
||||
|
||||
func endOfFileOccurred(source: CoreAudioStreamSource) {
|
||||
public func endOfFileOccurred(source: CoreAudioStreamSource) {
|
||||
let hasSameSource = playerContext.audioReadingEntry?.has(same: source) ?? false
|
||||
guard playerContext.audioReadingEntry == nil || hasSameSource else {
|
||||
source.delegate = nil
|
||||
@@ -831,7 +905,7 @@ extension AudioPlayer: AudioStreamSourceDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func metadataReceived(data: [String: String]) {
|
||||
public func metadataReceived(data: [String: String]) {
|
||||
asyncOnMain { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.audioPlayerDidReadMetadata(player: self, metadata: data)
|
||||
|
||||
@@ -22,11 +22,11 @@ public struct AudioPlayerConfiguration: Equatable {
|
||||
/// Enables the internal logs
|
||||
let enableLogs: Bool
|
||||
|
||||
public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: true,
|
||||
public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: false,
|
||||
bufferSizeInSeconds: 10,
|
||||
secondsRequiredToStartPlaying: 1,
|
||||
gracePeriodAfterSeekInSeconds: 0.5,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: 1,
|
||||
secondsRequiredToStartPlayingAfterBufferUnderrun: 7,
|
||||
enableLogs: false)
|
||||
/// Initializes the configuration for the `AudioPlayer`
|
||||
///
|
||||
|
||||
@@ -13,11 +13,11 @@ extension AudioPlayer {
|
||||
|
||||
static let initial = InternalState([])
|
||||
static let running = InternalState(rawValue: 1)
|
||||
static let playing = InternalState(rawValue: 1 << 1 | InternalState.running.rawValue)
|
||||
static let rebuffering = InternalState(rawValue: 1 << 2 | InternalState.running.rawValue)
|
||||
static let waitingForData = InternalState(rawValue: 1 << 3 | InternalState.running.rawValue)
|
||||
static let waitingForDataAfterSeek = InternalState(rawValue: 1 << 4 | InternalState.running.rawValue)
|
||||
static let paused = InternalState(rawValue: 1 << 5 | InternalState.running.rawValue)
|
||||
static let playing = InternalState(rawValue: (1 << 1) | InternalState.running.rawValue)
|
||||
static let rebuffering = InternalState(rawValue: (1 << 2) | InternalState.running.rawValue)
|
||||
static let waitingForData = InternalState(rawValue: (1 << 3) | InternalState.running.rawValue)
|
||||
static let waitingForDataAfterSeek = InternalState(rawValue: (1 << 4) | InternalState.running.rawValue)
|
||||
static let paused = InternalState(rawValue: (1 << 5) | InternalState.running.rawValue)
|
||||
static let stopped = InternalState(rawValue: 1 << 9)
|
||||
static let pendingNext = InternalState(rawValue: 1 << 10)
|
||||
static let disposed = InternalState(rawValue: 1 << 30)
|
||||
|
||||
@@ -9,22 +9,22 @@ import CoreAudio
|
||||
var maxFramesPerSlice: AVAudioFrameCount = 8192
|
||||
|
||||
final class AudioRendererContext {
|
||||
var waiting = Atomic<Bool>(false)
|
||||
let waiting = Atomic<Bool>(false)
|
||||
|
||||
let lock = UnfairLock()
|
||||
|
||||
let bufferContext: BufferContext
|
||||
|
||||
var audioBuffer: AudioBuffer
|
||||
var inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
|
||||
let audioBuffer: AudioBuffer
|
||||
let inOutAudioBufferList: UnsafeMutablePointer<AudioBufferList>
|
||||
|
||||
let packetsSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
let framesRequiredToStartPlaying: UInt32
|
||||
let framesRequiredAfterRebuffering: UInt32
|
||||
let framesRequiredForDataAfterSeekPlaying: UInt32
|
||||
let framesRequiredToStartPlaying: Double
|
||||
let framesRequiredAfterRebuffering: Double
|
||||
let framesRequiredForDataAfterSeekPlaying: Double
|
||||
|
||||
var waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
|
||||
let waitingForDataAfterSeekFrameCount = Atomic<Int32>(0)
|
||||
|
||||
private let configuration: AudioPlayerConfiguration
|
||||
|
||||
@@ -33,9 +33,9 @@ final class AudioRendererContext {
|
||||
|
||||
let canonicalStream = outputAudioFormat.basicStreamDescription
|
||||
|
||||
framesRequiredToStartPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlaying)
|
||||
framesRequiredAfterRebuffering = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
|
||||
framesRequiredForDataAfterSeekPlaying = UInt32(canonicalStream.mSampleRate) * UInt32(configuration.gracePeriodAfterSeekInSeconds)
|
||||
framesRequiredToStartPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlaying)
|
||||
framesRequiredAfterRebuffering = Double(canonicalStream.mSampleRate) * Double(configuration.secondsRequiredToStartPlayingAfterBufferUnderrun)
|
||||
framesRequiredForDataAfterSeekPlaying = Double(canonicalStream.mSampleRate) * Double(configuration.gracePeriodAfterSeekInSeconds)
|
||||
|
||||
let dataByteSize = Int(canonicalStream.mSampleRate * configuration.bufferSizeInSeconds) * Int(canonicalStream.mBytesPerFrame)
|
||||
inOutAudioBufferList = allocateBufferList(dataByteSize: dataByteSize)
|
||||
|
||||
@@ -122,20 +122,13 @@ final class AudioFileStreamProcessor {
|
||||
let seekPacket = Int64(floor(readingEntry.seekRequest.time / readingEntry.packetDuration))
|
||||
|
||||
let seekStatus = AudioFileStreamSeek(stream, seekPacket, &packetsAlignedByteOffset, &ioFlags)
|
||||
guard seekStatus == noErr else {
|
||||
let streamError = AudioFileStreamError(status: seekStatus)
|
||||
Logger.error("seek failed %@", category: .generic, args: streamError.debugDescription)
|
||||
return
|
||||
}
|
||||
|
||||
let dataOffset = Int64(readingEntry.audioStreamState.dataOffset)
|
||||
if !ioFlags.contains(.offsetIsEstimated) {
|
||||
seekByteOffset = packetsAlignedByteOffset + dataOffset
|
||||
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / bitrate * 8
|
||||
|
||||
if seekStatus == noErr, !ioFlags.contains(.offsetIsEstimated) {
|
||||
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / (bitrate * 8)
|
||||
readingEntry.lock.lock()
|
||||
readingEntry.seekTime -= delta
|
||||
readingEntry.lock.unlock()
|
||||
seekByteOffset = packetsAlignedByteOffset + dataOffset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,22 +214,25 @@ final class AudioFileStreamProcessor {
|
||||
propertyId: AudioFileStreamPropertyID,
|
||||
flags _: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
|
||||
{
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
switch propertyId {
|
||||
case kAudioFileStreamProperty_DataOffset:
|
||||
processDataOffset(fileStream: fileStream)
|
||||
processDataOffset(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_FileFormat:
|
||||
processFileFormat(fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_DataFormat:
|
||||
processDataFormat(fileStream: fileStream)
|
||||
processDataFormat(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_AudioDataByteCount:
|
||||
processDataByteCount(fileStream: fileStream)
|
||||
processDataByteCount(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_AudioDataPacketCount:
|
||||
processAudioDataPacketCount(fileStream: fileStream)
|
||||
processAudioDataPacketCount(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_ReadyToProducePackets:
|
||||
// check converter for discontinuous stream
|
||||
processReadyToProducePackets(fileStream: fileStream)
|
||||
assignMagicCookieToConverterIfNeeded()
|
||||
processPacketUpperBoundAndMaxPacketSize(entry: entry, fileStream: fileStream)
|
||||
processReadyToProducePackets(entry: entry, fileStream: fileStream)
|
||||
case kAudioFileStreamProperty_FormatList:
|
||||
processFormatList(fileStream: fileStream)
|
||||
processFormatList(entry: entry, fileStream: fileStream)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -244,19 +240,23 @@ final class AudioFileStreamProcessor {
|
||||
|
||||
// MARK: AudioFileStream properties Processing
|
||||
|
||||
private func processDataOffset(fileStream: AudioFileStreamID) {
|
||||
private func processDataOffset(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var offset: UInt64 = 0
|
||||
fileStreamGetProperty(value: &offset, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataOffset)
|
||||
playerContext.audioReadingEntry?.audioStreamState.processedDataFormat = true
|
||||
playerContext.audioReadingEntry?.audioStreamState.dataOffset = offset
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.processedDataFormat = true
|
||||
entry.audioStreamState.dataOffset = offset
|
||||
}
|
||||
|
||||
private func processReadyToProducePackets(fileStream: AudioFileStreamID) {
|
||||
private func processReadyToProducePackets(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var packetCount: UInt64 = 0
|
||||
var packetCountSize = UInt32(MemoryLayout.size(ofValue: packetCount))
|
||||
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_AudioDataPacketCount, &packetCountSize, &packetCount)
|
||||
playerContext.audioPlayingEntry?.audioStreamState.dataPacketCount = Double(packetCount)
|
||||
if playerContext.audioPlayingEntry?.audioStreamFormat.mFormatID != kAudioFormatLinearPCM {
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.dataPacketCount = Double(packetCount)
|
||||
let entryFormatID = entry.audioStreamFormat.mFormatID
|
||||
let isFLAC = entryFormatID == kAudioFormatFLAC
|
||||
if entryFormatID != kAudioFormatLinearPCM && !isFLAC {
|
||||
discontinuous = true
|
||||
}
|
||||
}
|
||||
@@ -270,9 +270,9 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private func processDataFormat(fileStream: AudioFileStreamID) {
|
||||
private func processDataFormat(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var audioStreamFormat = AudioStreamBasicDescription()
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
if !entry.audioStreamState.processedDataFormat {
|
||||
fileStreamGetProperty(value: &audioStreamFormat, fileStream: fileStream, propertyId: kAudioFileStreamProperty_DataFormat)
|
||||
|
||||
@@ -295,9 +295,8 @@ final class AudioFileStreamProcessor {
|
||||
packetBufferSize = 2048 // default value
|
||||
}
|
||||
}
|
||||
entry.lock.withLock {
|
||||
entry.processedPacketsState.bufferSize = packetBufferSize
|
||||
}
|
||||
|
||||
entry.processedPacketsState.bufferSize = packetBufferSize
|
||||
|
||||
if !fileFormatsForDelayedConverterCreation.contains(currentFileFormat) {
|
||||
createAudioConverter(from: entry.audioStreamFormat, to: outputAudioFormat)
|
||||
@@ -305,21 +304,40 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private func processDataByteCount(fileStream: AudioFileStreamID) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
private func processPacketUpperBoundAndMaxPacketSize(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var packetBufferSize: UInt32 = 0
|
||||
var status = fileStreamGetProperty(value: &packetBufferSize,
|
||||
fileStream: fileStream,
|
||||
propertyId: kAudioFileStreamProperty_PacketSizeUpperBound)
|
||||
if status != 0 || packetBufferSize == 0 {
|
||||
status = fileStreamGetProperty(value: &packetBufferSize,
|
||||
fileStream: fileStream,
|
||||
propertyId: kAudioFileStreamProperty_MaximumPacketSize)
|
||||
if status != 0 || packetBufferSize == 0 {
|
||||
packetBufferSize = 2048 // default value
|
||||
}
|
||||
}
|
||||
entry.lock.withLock {
|
||||
entry.processedPacketsState.bufferSize = packetBufferSize
|
||||
}
|
||||
}
|
||||
|
||||
private func processDataByteCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var audioDataByteCount: UInt64 = 0
|
||||
fileStreamGetProperty(value: &audioDataByteCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataByteCount)
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.dataByteCount = audioDataByteCount
|
||||
}
|
||||
|
||||
private func processAudioDataPacketCount(fileStream: AudioFileStreamID) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
private func processAudioDataPacketCount(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
var audioDataPacketCount: UInt64 = 0
|
||||
fileStreamGetProperty(value: &audioDataPacketCount, fileStream: fileStream, propertyId: kAudioFileStreamProperty_AudioDataPacketCount)
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
entry.audioStreamState.dataPacketOffset = audioDataPacketCount
|
||||
}
|
||||
|
||||
private func processFormatList(fileStream: AudioFileStreamID) {
|
||||
private func processFormatList(entry: AudioEntry, fileStream: AudioFileStreamID) {
|
||||
entry.lock.lock(); defer { entry.lock.unlock() }
|
||||
let info = fileStreamGetPropertyInfo(fileStream: fileStream, propertyId: kAudioFileStreamProperty_FormatList)
|
||||
guard info.status == noErr else { return }
|
||||
var list: [AudioFormatListItem] = Array(repeating: AudioFormatListItem(), count: Int(info.size))
|
||||
@@ -346,14 +364,20 @@ final class AudioFileStreamProcessor {
|
||||
|
||||
// MARK: Packets Proc
|
||||
|
||||
func propertyPacketsProc(inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
|
||||
{
|
||||
func propertyPacketsProc(
|
||||
inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
|
||||
) {
|
||||
guard let entry = playerContext.audioReadingEntry else { return }
|
||||
guard entry.audioStreamState.processedDataFormat else { return }
|
||||
|
||||
guard let converter = audioConverter else {
|
||||
Logger.error("Couldn't find audio converter", category: .audioRendering)
|
||||
return
|
||||
}
|
||||
|
||||
if let playingEntry = playerContext.audioPlayingEntry,
|
||||
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
|
||||
{
|
||||
@@ -364,25 +388,24 @@ final class AudioFileStreamProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
guard let converter = audioConverter else {
|
||||
Logger.error("Couldn't find audio converter", category: .audioRendering)
|
||||
return
|
||||
}
|
||||
|
||||
// reset discontinuity
|
||||
discontinuous = false
|
||||
|
||||
var convertInfo = AudioConvertInfo(done: false,
|
||||
numberOfPackets: inNumberPackets,
|
||||
packDescription: inPacketDescriptions)
|
||||
var convertInfo = AudioConvertInfo(
|
||||
done: false,
|
||||
numberOfPackets: inNumberPackets,
|
||||
packDescription: inPacketDescriptions
|
||||
)
|
||||
convertInfo.audioBuffer.mData = UnsafeMutableRawPointer(mutating: inInputData)
|
||||
convertInfo.audioBuffer.mDataByteSize = inNumberBytes
|
||||
if let playingAudioStreamFormat = playerContext.audioPlayingEntry?.audioStreamFormat {
|
||||
convertInfo.audioBuffer.mNumberChannels = playingAudioStreamFormat.mChannelsPerFrame
|
||||
}
|
||||
|
||||
updateProcessedPackets(inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets)
|
||||
updateProcessedPackets(
|
||||
inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets
|
||||
)
|
||||
|
||||
var status: OSStatus = noErr
|
||||
packetProcess: while status == noErr {
|
||||
@@ -390,7 +413,7 @@ final class AudioFileStreamProcessor {
|
||||
let bufferContext = rendererContext.bufferContext
|
||||
var used = bufferContext.frameUsedCount
|
||||
var start = bufferContext.frameStartIndex
|
||||
var end = bufferContext.end
|
||||
var end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
|
||||
|
||||
var framesLeftInBuffer = bufferContext.totalFrameCount - used
|
||||
rendererContext.lock.unlock()
|
||||
@@ -438,16 +461,20 @@ final class AudioFileStreamProcessor {
|
||||
var framesToDecode: UInt32 = rendererContext.bufferContext.totalFrameCount - end
|
||||
|
||||
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode)
|
||||
prefillLocalBufferList(
|
||||
bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode
|
||||
)
|
||||
|
||||
status = AudioConverterFillComplexBuffer(converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil)
|
||||
status = AudioConverterFillComplexBuffer(
|
||||
converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil
|
||||
)
|
||||
|
||||
framesAdded = framesToDecode
|
||||
|
||||
@@ -464,16 +491,20 @@ final class AudioFileStreamProcessor {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProcess
|
||||
}
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: 0,
|
||||
framesToDecode: framesToDecode)
|
||||
prefillLocalBufferList(
|
||||
bufferList: localBufferList,
|
||||
dataOffset: 0,
|
||||
framesToDecode: framesToDecode
|
||||
)
|
||||
|
||||
status = AudioConverterFillComplexBuffer(converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil)
|
||||
status = AudioConverterFillComplexBuffer(
|
||||
converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil
|
||||
)
|
||||
|
||||
framesAdded += framesToDecode
|
||||
|
||||
@@ -492,16 +523,20 @@ final class AudioFileStreamProcessor {
|
||||
var framesToDecode: UInt32 = start - end
|
||||
|
||||
let offset = Int(end * rendererContext.bufferContext.sizeInBytes)
|
||||
prefillLocalBufferList(bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode)
|
||||
prefillLocalBufferList(
|
||||
bufferList: localBufferList,
|
||||
dataOffset: offset,
|
||||
framesToDecode: framesToDecode
|
||||
)
|
||||
|
||||
status = AudioConverterFillComplexBuffer(converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil)
|
||||
status = AudioConverterFillComplexBuffer(
|
||||
converter,
|
||||
_converterCallback,
|
||||
&convertInfo,
|
||||
&framesToDecode,
|
||||
localBufferList.unsafeMutablePointer,
|
||||
nil
|
||||
)
|
||||
|
||||
framesAdded = framesToDecode
|
||||
if status == AudioConvertStatus.done.rawValue {
|
||||
@@ -524,10 +559,11 @@ final class AudioFileStreamProcessor {
|
||||
/// - parameter dataOffset: An `Int` value indicating any offset to be applied to the buffer data
|
||||
/// - parameter framesToDecode: An `UInt32` value indicating the frames to be decoded, used in calculating the data size of the buffer.
|
||||
@inline(__always)
|
||||
private func prefillLocalBufferList(bufferList: UnsafeMutableAudioBufferListPointer,
|
||||
dataOffset: Int,
|
||||
framesToDecode: UInt32)
|
||||
{
|
||||
private func prefillLocalBufferList(
|
||||
bufferList: UnsafeMutableAudioBufferListPointer,
|
||||
dataOffset: Int,
|
||||
framesToDecode: UInt32
|
||||
) {
|
||||
if let mData = rendererContext.audioBuffer.mData {
|
||||
bufferList[0].mData = dataOffset > 0 ? mData + dataOffset : mData
|
||||
}
|
||||
@@ -550,9 +586,10 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func updateProcessedPackets(inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?,
|
||||
inNumberPackets: UInt32)
|
||||
{
|
||||
private func updateProcessedPackets(
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?,
|
||||
inNumberPackets: UInt32
|
||||
) {
|
||||
guard let inPacketDescriptions = inPacketDescriptions else { return }
|
||||
guard let readingEntry = playerContext.audioReadingEntry else { return }
|
||||
let processedPackCount = readingEntry.processedPacketsState.count
|
||||
@@ -572,23 +609,25 @@ final class AudioFileStreamProcessor {
|
||||
|
||||
// MARK: - AudioFileStream proc method
|
||||
|
||||
private func _propertyListenerProc(clientData: UnsafeMutableRawPointer,
|
||||
fileStream: AudioFileStreamID,
|
||||
propertyId: AudioFileStreamPropertyID,
|
||||
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>)
|
||||
{
|
||||
private func _propertyListenerProc(
|
||||
clientData: UnsafeMutableRawPointer,
|
||||
fileStream: AudioFileStreamID,
|
||||
propertyId: AudioFileStreamPropertyID,
|
||||
flags: UnsafeMutablePointer<AudioFileStreamPropertyFlags>
|
||||
) {
|
||||
let processor = clientData.to(type: AudioFileStreamProcessor.self)
|
||||
processor.propertyListenerProc(fileStream: fileStream,
|
||||
propertyId: propertyId,
|
||||
flags: flags)
|
||||
}
|
||||
|
||||
private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
|
||||
inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?)
|
||||
{
|
||||
private func _propertyPacketsProc(
|
||||
clientData: UnsafeMutableRawPointer,
|
||||
inNumberBytes: UInt32,
|
||||
inNumberPackets: UInt32,
|
||||
inInputData: UnsafeRawPointer,
|
||||
inPacketDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
|
||||
) {
|
||||
let processor = clientData.to(type: AudioFileStreamProcessor.self)
|
||||
processor.propertyPacketsProc(inNumberBytes: inNumberBytes,
|
||||
inNumberPackets: inNumberPackets,
|
||||
@@ -598,12 +637,13 @@ private func _propertyPacketsProc(clientData: UnsafeMutableRawPointer,
|
||||
|
||||
// MARK: - AudioConverterFillComplexBuffer callback method
|
||||
|
||||
private func _converterCallback(inAudioConverter _: AudioConverterRef,
|
||||
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
|
||||
inUserData: UnsafeMutableRawPointer?) -> OSStatus
|
||||
{
|
||||
private func _converterCallback(
|
||||
inAudioConverter _: AudioConverterRef,
|
||||
ioNumberDataPackets: UnsafeMutablePointer<UInt32>,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
outDataPacketDescription: UnsafeMutablePointer<UnsafeMutablePointer<AudioStreamPacketDescription>?>?,
|
||||
inUserData: UnsafeMutableRawPointer?
|
||||
) -> OSStatus {
|
||||
guard let convertInfo = inUserData?.assumingMemoryBound(to: AudioConvertInfo.self) else { return 0 }
|
||||
|
||||
// we need to tell the converter to stop converting after it should stop converting
|
||||
|
||||
@@ -64,29 +64,30 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
let frameSizeInBytes = bufferContext.sizeInBytes
|
||||
let used = bufferContext.frameUsedCount
|
||||
let start = bufferContext.frameStartIndex
|
||||
let end = bufferContext.end
|
||||
let end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
|
||||
let signal = rendererContext.waiting.value && used < bufferContext.totalFrameCount / 2
|
||||
|
||||
if let playingEntry = playingEntry {
|
||||
playingEntry.lock.lock()
|
||||
let framesState = playingEntry.framesState
|
||||
playingEntry.lock.unlock()
|
||||
|
||||
if state == .waitingForData {
|
||||
var requiredFramesToStart = rendererContext.framesRequiredToStartPlaying
|
||||
if framesState.lastFrameQueued >= 0 {
|
||||
requiredFramesToStart = min(requiredFramesToStart, UInt32(playingEntry.framesState.lastFrameQueued))
|
||||
requiredFramesToStart = min(requiredFramesToStart, Double(playingEntry.framesState.lastFrameQueued))
|
||||
}
|
||||
if let readingEntry = readingEntry, readingEntry === playingEntry,
|
||||
framesState.queued < requiredFramesToStart
|
||||
|
||||
if readingEntry === playingEntry, framesState.queued < Int(requiredFramesToStart)
|
||||
{
|
||||
waitForBuffer = true
|
||||
}
|
||||
} else if state == .rebuffering {
|
||||
var requiredFramesToStart = rendererContext.framesRequiredAfterRebuffering
|
||||
if framesState.lastFrameQueued >= 0 {
|
||||
requiredFramesToStart = min(requiredFramesToStart, UInt32(framesState.lastFrameQueued - framesState.queued))
|
||||
requiredFramesToStart = min(requiredFramesToStart, Double(framesState.lastFrameQueued - framesState.queued))
|
||||
}
|
||||
if used < requiredFramesToStart {
|
||||
if used < Int(requiredFramesToStart) {
|
||||
waitForBuffer = true
|
||||
}
|
||||
} else if state == .waitingForDataAfterSeek {
|
||||
@@ -102,21 +103,19 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
rendererContext.lock.unlock()
|
||||
|
||||
var totalFramesCopied: UInt32 = 0
|
||||
if used > 0 && !waitForBuffer && state.contains(.running) && state != .paused {
|
||||
if used > 0 && !waitForBuffer && playingEntry != nil && state.contains(.running) && state != .paused {
|
||||
if end > start {
|
||||
let framesToCopy = min(inNumberFrames, used)
|
||||
bufferList.mBuffers.mNumberChannels = 2
|
||||
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * framesToCopy
|
||||
|
||||
if isMuted {
|
||||
writeSilence(outputBuffer: &bufferList.mBuffers,
|
||||
outputBufferSize: 0,
|
||||
offset: Int(bufferList.mBuffers.mDataByteSize))
|
||||
if let mData = bufferList.mBuffers.mData {
|
||||
memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
|
||||
}
|
||||
} else {
|
||||
if let mDataBuffer = audioBuffer.mData {
|
||||
memcpy(bufferList.mBuffers.mData,
|
||||
mDataBuffer + Int(start * frameSizeInBytes),
|
||||
Int(bufferList.mBuffers.mDataByteSize))
|
||||
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
|
||||
}
|
||||
}
|
||||
totalFramesCopied = framesToCopy
|
||||
@@ -132,14 +131,12 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * frameToCopy
|
||||
|
||||
if isMuted {
|
||||
writeSilence(outputBuffer: &bufferList.mBuffers,
|
||||
outputBufferSize: 0,
|
||||
offset: Int(bufferList.mBuffers.mDataByteSize))
|
||||
if let mData = bufferList.mBuffers.mData {
|
||||
memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
|
||||
}
|
||||
} else {
|
||||
if let mDataBuffer = audioBuffer.mData {
|
||||
memcpy(bufferList.mBuffers.mData,
|
||||
mDataBuffer + Int(start * frameSizeInBytes),
|
||||
Int(bufferList.mBuffers.mDataByteSize))
|
||||
memcpy(bufferList.mBuffers.mData, mDataBuffer + Int(start * frameSizeInBytes), Int(bufferList.mBuffers.mDataByteSize))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,14 +148,10 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
bufferList.mBuffers.mDataByteSize += frameSizeInBytes * moreFramesToCopy
|
||||
if let ioBufferData = bufferList.mBuffers.mData {
|
||||
if isMuted {
|
||||
writeSilence(outputBuffer: &bufferList.mBuffers,
|
||||
outputBufferSize: Int(frameSizeInBytes * moreFramesToCopy),
|
||||
offset: Int(frameToCopy * frameSizeInBytes))
|
||||
memset(ioBufferData + Int(frameToCopy * frameSizeInBytes), 0, Int(frameSizeInBytes * moreFramesToCopy))
|
||||
} else {
|
||||
if let mDataBuffer = audioBuffer.mData {
|
||||
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes),
|
||||
mDataBuffer,
|
||||
Int(frameSizeInBytes * moreFramesToCopy))
|
||||
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes), mDataBuffer, Int(frameSizeInBytes * moreFramesToCopy))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +163,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
bufferContext.frameUsedCount -= totalFramesCopied
|
||||
rendererContext.lock.unlock()
|
||||
}
|
||||
|
||||
if playerContext.internalState != .playing {
|
||||
playerContext.setInternalState(to: .playing, when: { state -> Bool in
|
||||
state.contains(.running) && state != .paused
|
||||
@@ -179,11 +173,11 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
|
||||
if totalFramesCopied < inNumberFrames {
|
||||
let delta = inNumberFrames - totalFramesCopied
|
||||
writeSilence(outputBuffer: &bufferList.mBuffers,
|
||||
outputBufferSize: Int(delta * frameSizeInBytes),
|
||||
offset: Int(totalFramesCopied * frameSizeInBytes))
|
||||
if let mData = bufferList.mBuffers.mData {
|
||||
memset(mData + Int(totalFramesCopied * frameSizeInBytes), 0, Int(delta * frameSizeInBytes))
|
||||
}
|
||||
|
||||
if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) {
|
||||
if !(playingEntry == nil || state == .waitingForDataAfterSeek || state == .waitingForData || state == .rebuffering) {
|
||||
if playerContext.internalState != .rebuffering {
|
||||
playerContext.setInternalState(to: .rebuffering, when: { state -> Bool in
|
||||
state.contains(.running) && state != .paused
|
||||
@@ -192,7 +186,7 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
} else if state == .waitingForDataAfterSeek {
|
||||
if totalFramesCopied == 0 {
|
||||
rendererContext.waitingForDataAfterSeekFrameCount.write { $0 += Int32(inNumberFrames - totalFramesCopied) }
|
||||
if rendererContext.waitingForDataAfterSeekFrameCount.value > rendererContext.framesRequiredForDataAfterSeekPlaying {
|
||||
if rendererContext.waitingForDataAfterSeekFrameCount.value > Int(rendererContext.framesRequiredForDataAfterSeekPlaying) {
|
||||
if playerContext.internalState != .playing {
|
||||
playerContext.setInternalState(to: .playing) { state -> Bool in
|
||||
state.contains(.running) && state != .playing
|
||||
@@ -272,10 +266,11 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
return UnsafePointer(rendererContext.inOutAudioBufferList)
|
||||
}
|
||||
|
||||
func render(inNumberFrames: UInt32,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>) -> OSStatus
|
||||
{
|
||||
func render(
|
||||
inNumberFrames: UInt32,
|
||||
ioData: UnsafeMutablePointer<AudioBufferList>,
|
||||
flags _: UnsafeMutablePointer<AudioUnitRenderActionFlags>
|
||||
) -> OSStatus {
|
||||
var status = noErr
|
||||
|
||||
rendererContext.inOutAudioBufferList[0].mBuffers.mData = ioData.pointee.mBuffers.mData
|
||||
@@ -310,24 +305,18 @@ final class AudioPlayerRenderProcessor: NSObject {
|
||||
return status
|
||||
}
|
||||
|
||||
func renderProvider(flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
|
||||
timeStamp _: UnsafePointer<AudioTimeStamp>,
|
||||
inNumberFrames: AUAudioFrameCount,
|
||||
inputBusNumber: Int,
|
||||
inputData: UnsafeMutablePointer<AudioBufferList>) -> AUAudioUnitStatus
|
||||
{
|
||||
func renderProvider(
|
||||
flags: UnsafeMutablePointer<AudioUnitRenderActionFlags>,
|
||||
timeStamp _: UnsafePointer<AudioTimeStamp>,
|
||||
inNumberFrames: AUAudioFrameCount,
|
||||
inputBusNumber: Int,
|
||||
inputData: UnsafeMutablePointer<AudioBufferList>
|
||||
) -> AUAudioUnitStatus {
|
||||
guard inputBusNumber == 0 else { return noErr }
|
||||
return render(inNumberFrames: inNumberFrames, ioData: inputData, flags: flags)
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func writeSilence(outputBuffer: inout AudioBuffer,
|
||||
outputBufferSize: Int,
|
||||
offset: Int)
|
||||
{
|
||||
guard let mData = outputBuffer.mData else { return }
|
||||
memset(mData + offset, 0, outputBufferSize)
|
||||
outputBuffer.mDataByteSize = UInt32(outputBufferSize)
|
||||
outputBuffer.mNumberChannels = outputAudioFormat.mChannelsPerFrame
|
||||
return render(
|
||||
inNumberFrames: inNumberFrames,
|
||||
ioData: inputData,
|
||||
flags: flags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
|
||||
import AVFoundation
|
||||
|
||||
private let outputChannels: UInt32 = 2
|
||||
|
||||
enum UnitDescriptions {
|
||||
static var output: AudioComponentDescription = {
|
||||
static let output: AudioComponentDescription = {
|
||||
var desc = AudioComponentDescription()
|
||||
desc.componentType = kAudioUnitType_Output
|
||||
#if os(iOS)
|
||||
#if os(iOS) || os(tvOS)
|
||||
desc.componentSubType = kAudioUnitSubType_RemoteIO
|
||||
#else
|
||||
desc.componentSubType = kAudioUnitSubType_DefaultOutput
|
||||
|
||||
@@ -33,6 +33,7 @@ let fileTypesFromMimeType: [String: AudioFileTypeID] =
|
||||
"video/3gpp": kAudioFile3GPType,
|
||||
"audio/3gp2": kAudioFile3GP2Type,
|
||||
"video/3gp2": kAudioFile3GP2Type,
|
||||
"audio/flac": kAudioFileFLACType
|
||||
]
|
||||
|
||||
/// Method that converts mime type to AudioFileTypeID
|
||||
|
||||
@@ -34,6 +34,17 @@ final class PlayerQueueEntries {
|
||||
upcoming = Queue<AudioEntry>()
|
||||
}
|
||||
|
||||
/// Returns an array containing all items in the queue for the specified `type`.
|
||||
///
|
||||
/// - Note: This method returns the items in the queue without removing them.
|
||||
///
|
||||
/// - Parameter type: A `PlayerQueueType` specifying the type of the queue.
|
||||
/// - Returns: An array of `AudioEntry` objects representing the items in the queue.
|
||||
func items(type: PlayerQueueType) -> [AudioEntry] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return queue(for: type).items
|
||||
}
|
||||
|
||||
/// Adds the `item` to the underlying queue for the specified `type`
|
||||
/// - parameter item: An `AudioEntry` object to be added
|
||||
/// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType`
|
||||
@@ -51,6 +62,32 @@ final class PlayerQueueEntries {
|
||||
return queue(for: type).dequeue()
|
||||
}
|
||||
|
||||
func insert(item: AudioEntry, type: PlayerQueueType, after afterItem: AudioEntry) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
if let indexForAfterItem = queue(for: type).items.firstIndex(of: afterItem) {
|
||||
queue(for: .upcoming).insert(item: item, at: indexForAfterItem)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts the `item` at the specified index in the underlying queue for the specified `type`.
|
||||
/// - Parameters:
|
||||
/// - item: An `AudioEntry` object to be added.
|
||||
/// - type: The type of the underlying queue as expressed by `PlayerQueueType`.
|
||||
/// - index: The index at which to insert the item.
|
||||
func insert(item: AudioEntry, type: PlayerQueueType, at index: Int) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
queue(for: type).insert(item: item, at: index)
|
||||
}
|
||||
|
||||
/// Removes the item at the specified index from the underlying queue for the specified `type`.
|
||||
/// - Parameters:
|
||||
/// - type: The type of the underlying queue as expressed by `PlayerQueueType`.
|
||||
/// - index: The index of the item to remove.
|
||||
func remove(item: AudioEntry, type: PlayerQueueType) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
queue(for: type).remove(item: item)
|
||||
}
|
||||
|
||||
/// Appends (skips) the `items` to the underlying queue for the specified `type`
|
||||
/// - parameter item: An `AudioEntry` object to be added
|
||||
/// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType`
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "A1B13C01-AF5C-46DD-990A-A369639F2AD3",
|
||||
"name" : "Configuration 1",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"environmentVariableEntries" : [
|
||||
{
|
||||
"enabled" : false,
|
||||
"key" : "OS_ACTIVITY_MODE",
|
||||
"value" : "disable"
|
||||
}
|
||||
],
|
||||
"targetForVariableExpansion" : {
|
||||
"containerPath" : "container:AudioExample.xcodeproj",
|
||||
"identifier" : "B5AEDBD02475274C007D8101",
|
||||
"name" : "AudioExample"
|
||||
},
|
||||
"testExecutionOrdering" : "random",
|
||||
"threadSanitizerEnabled" : true
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"parallelizable" : true,
|
||||
"skippedTests" : [
|
||||
"ProtectedTests"
|
||||
],
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/AudioStreaming.xcodeproj",
|
||||
"identifier" : "B5AEDBB624744153007D8101",
|
||||
"name" : "AudioStreamingTests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
@@ -83,4 +83,27 @@ class QueueTests: XCTestCase {
|
||||
queue.removeAll()
|
||||
XCTAssertTrue(queue.isEmpty)
|
||||
}
|
||||
|
||||
func testInsertingAtSpecificIndex() {
|
||||
let queue = Queue<Int>()
|
||||
queue.enqueue(item: 1)
|
||||
queue.enqueue(item: 2)
|
||||
queue.enqueue(item: 3)
|
||||
|
||||
queue.insert(item: 6, at: 1)
|
||||
|
||||
XCTAssertEqual(queue.count, 4)
|
||||
XCTAssertEqual(queue.remove(at: 1), 6)
|
||||
}
|
||||
|
||||
func testRemovingAtSpecificIndex() {
|
||||
let queue = Queue<Int>()
|
||||
queue.enqueue(item: 1)
|
||||
queue.enqueue(item: 2)
|
||||
queue.enqueue(item: 3)
|
||||
|
||||
XCTAssertEqual(queue.remove(at: 1), 2)
|
||||
|
||||
XCTAssertEqual(queue.count, 2)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -13,6 +13,8 @@ import XCTest
|
||||
class MetadataStreamProcessorTests: XCTestCase {
|
||||
var metadataDelegateSpy = MetadataDelegateSpy()
|
||||
|
||||
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
|
||||
|
||||
func test_Processor_SendsCorrectValues_IfItCanProcessMetadata() throws {
|
||||
let parser = MetadataParser()
|
||||
let processor = MetadataStreamProcessor(parser: parser.eraseToAnyParser())
|
||||
@@ -34,7 +36,6 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func test_Processor_Outputs_Correct_Metadata_ForStep_WithEmptyMetadata() throws {
|
||||
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
|
||||
let url = bundle.url(forResource: "raw-stream-audio-empty-metadata", withExtension: nil)!
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -53,7 +54,6 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata() throws {
|
||||
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
|
||||
let url = bundle.url(forResource: "raw-stream-audio-normal-metadata", withExtension: nil)!
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -72,7 +72,6 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata_Alt() throws {
|
||||
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
|
||||
let url = bundle.url(forResource: "raw-stream-audio-normal-metadata-alt", withExtension: nil)!
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -95,7 +94,6 @@ class MetadataStreamProcessorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func test_Processor_Outputs_Correct_Metadata_ForStep_NoMetadata() throws {
|
||||
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
|
||||
let url = bundle.url(forResource: "raw-stream-audio-no-metadata", withExtension: nil)!
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
|
||||
+17
-3
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.3
|
||||
// swift-tools-version:5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
@@ -6,6 +6,8 @@ let package = Package(
|
||||
name: "AudioStreaming",
|
||||
platforms: [
|
||||
.iOS(.v12),
|
||||
.macOS(.v13),
|
||||
.tvOS(.v16)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
@@ -18,6 +20,18 @@ let package = Package(
|
||||
name: "AudioStreaming",
|
||||
path: "AudioStreaming"
|
||||
),
|
||||
],
|
||||
swiftLanguageVersions: [.v5]
|
||||
.testTarget(
|
||||
name: "AudioStreamingTests",
|
||||
dependencies: [
|
||||
"AudioStreaming"
|
||||
],
|
||||
path: "AudioStreamingTests",
|
||||
resources: [
|
||||
.copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-empty-metadata"),
|
||||
.copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-no-metadata"),
|
||||
.copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-normal-metadata"),
|
||||
.copy("Streaming/Metadata Stream Processor/raw-audio-streams/raw-stream-audio-normal-metadata-alt")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||
[](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml)
|
||||
|
||||
# AudioStreaming
|
||||
An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing.
|
||||
@@ -8,14 +8,18 @@ Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playbac
|
||||
#### Supported audio
|
||||
- Online streaming (Shoutcast/ICY streams) with metadata parsing
|
||||
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
|
||||
- M4A (_Optimized files only_)
|
||||
- M4A
|
||||
|
||||
As of 1.2.0 version, there's support for non-optimized M4A, please report any issues
|
||||
|
||||
Known limitations:
|
||||
- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)
|
||||
~~- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)~~
|
||||
|
||||
|
||||
# Requirements
|
||||
- iOS 12.0+
|
||||
- iOS 13.0+
|
||||
- macOS 13.0+
|
||||
- tvOS 16.0+
|
||||
- Swift 5.x
|
||||
|
||||
# Using AudioStreaming
|
||||
@@ -161,39 +165,11 @@ Under the hood the concrete class for frame filters, `FrameFilterProcessor` inst
|
||||
|
||||
# Installation
|
||||
|
||||
### Cocoapods
|
||||
|
||||
[Cocoapods](https://cocoapods.org/) is a dependency manager for Cocoa projects. You can install it with the following command:
|
||||
```
|
||||
$ gem install cocoapods
|
||||
```
|
||||
|
||||
To intergrate AudioStreaming with [Cocoapods](https://cocoapods.org/) to your Xcode project add the following to your `Podfile`:
|
||||
```
|
||||
pod 'AudioStreaming'
|
||||
```
|
||||
|
||||
### Swift Package Manager
|
||||
|
||||
On Xcode 11.0+ you can add a new dependency by going to **File / Swift Packages / Add Package Dependency...**
|
||||
and enter package repository URL https://github.com/dimitris-c/AudioStreaming.git, then follow the instructions.
|
||||
|
||||
### Carthage
|
||||
|
||||
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with frameworks.
|
||||
|
||||
You can install Carthage with Homebrew using the following command:
|
||||
```
|
||||
$ brew update
|
||||
$ brew install carthage
|
||||
```
|
||||
|
||||
To integrate AudioStreaming into your Xcode project using Carthage, add the following to your `Cartfile`:
|
||||
```
|
||||
github "dimitris-c/AudioStreaming"
|
||||
```
|
||||
Visit [installation instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) on Carthage to install the framework
|
||||
|
||||
# Licence
|
||||
|
||||
AudioStreaming is available under the MIT license. See the LICENSE file for more info.
|
||||
|
||||
Reference in New Issue
Block a user