Compare commits

...

27 Commits

Author SHA1 Message Date
Dimitris C e1801e5065 Update README.md 2024-05-16 13:00:27 +03:00
dimitris-c d041f17e76 Merge branch 'update-example' of https://github.com/dimitris-c/AudioStreaming into update-example 2024-05-16 12:56:36 +03:00
dimitris-c 5db7c2921b fix tests 2024-05-16 12:56:25 +03:00
Dimitris C dec732a597 Update swift.yml 2024-05-16 12:52:42 +03:00
Dimitris C 51af91f4d4 Update swift.yml 2024-05-16 12:47:46 +03:00
dimitris-c 02ba7012da Merge branch 'main' into update-example 2024-05-16 12:41:11 +03:00
Dimitris C 94bc48c7f1 Update swift.yml 2024-05-16 12:39:25 +03:00
dimitris-c 473afdaa50 Updates example, fixes noise output on mute 2024-05-16 12:34:42 +03:00
dimitris-c 8a13074c21 progress 2024-05-15 13:23:46 +03:00
dimitris-c 1b7558a4f1 refactor 2024-05-15 13:23:46 +03:00
dimitris-c 7d91e318d1 progress 2024-05-15 13:23:46 +03:00
dimitris-c 4e2cd36677 adds animations to eq view 2024-05-15 13:23:46 +03:00
dimitris-c f2adacb687 update comment on example project 2024-05-15 13:23:46 +03:00
dimitris-c 553132f486 progress 2024-05-15 13:23:45 +03:00
dimitris-c 1f5f7c8be3 progress 2024-05-15 13:23:45 +03:00
dimitris-c 70a4dfd698 progress 2024-05-15 13:23:45 +03:00
dimitris-c 81bf90f5c4 example project progress 2024-05-15 13:23:45 +03:00
dimitris-c 8424611b34 Adds initial new example project 2024-05-15 13:23:44 +03:00
Dimitris C de2c04cdaf Update README.md 2024-05-15 10:13:58 +03:00
dimitris-c 738397c637 version bump 1.2.1 2024-05-15 00:33:47 +03:00
Dimitris C 1f70860473 feature(Mp4): Adds support for non-optimised mp4 audio for local files (#79)
* adds handling for non-optimized m4a for local files

* seek improvements

* small improvement in render processor

* improvements on seeking on local files

* improvements on seeking

* nit
2024-05-15 00:30:01 +03:00
Dimitris C a8865bb4d8 Mp4 Restructure account for large mdat box size (#78) 2024-05-09 14:45:02 +03:00
Dimitris C dd2e790ca6 Update README.md 2024-04-01 17:51:23 +03:00
Dimitris C c5bdbdd692 update readme.md (#71) 2024-04-01 16:46:23 +03:00
dimitris-c ffa5bf8f2c version bump to 1.2.0 2024-04-01 16:42:04 +03:00
dimitris-c 9d8973e971 update gitignore 2024-04-01 16:06:42 +03:00
Dimitris C cb72197f8e feature(Mp4): Support for non-optimised mp4 (#67)
* initial work for supporting non-optimised mp4

* Update AppCoordinator.swift

* some refactor and fixed seek for a restructured mp4

* nit

* nit

* nit

* runs swiftlint

* improvements

* improvements

* handles case where we the stream is not seekable for an mp4 file

* better check for mp4, seekable and moov atom

* nit

* fix an issue with seek

* some refactoring
2024-04-01 16:02:51 +03:00
87 changed files with 3153 additions and 2077 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
strategy: strategy:
matrix: matrix:
destination: ["OS=latest,name=iPhone 13 Pro"] destination: ["OS=latest,name=iPhone 15 Pro"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: iOS - ${{ matrix.destination }} - name: iOS - ${{ matrix.destination }}
+3
View File
@@ -10,6 +10,7 @@ xcuserdata/
*.xccheckout *.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
**/.DS_Store
build/ build/
DerivedData/ DerivedData/
*.moved-aside *.moved-aside
@@ -88,3 +89,5 @@ fastlane/test_output
# https://github.com/johnno1962/injectionforxcode # https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/ iOSInjectionProject/
/.DS_Store
/AudioExample/AudioExample/.DS_Store
@@ -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>
BIN
View File
Binary file not shown.
@@ -1,438 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objects = {
/* Begin PBXBuildFile section */
984808A028C0F549001160E6 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9848089F28C0F549001160E6 /* hipjazz.wav */; };
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>"; };
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 = (
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 */,
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 */;
}
@@ -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>
Binary file not shown.
@@ -1,62 +0,0 @@
//
// AppCoordinator.swift
// AudioExample
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
//
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 @@
//
// EqualzerViewModel.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,163 +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
}
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,108 +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)))
default:
break
}
}
func errorOccurred(error _: AudioPlayerError) {
currentPlayingItemIndex = nil
}
func metadataReceived(metadata _: [String: String]) {}
func didStopPlaying() {}
func didStartPlaying() {}
}
-52
View File
@@ -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>
@@ -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,95 +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
}
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,503 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
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 */; };
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 */
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>"; };
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 */,
);
path = Helpers;
sourceTree = "<group>";
};
984DE9532BDAE57F004B427A /* Dependencies */ = {
isa = PBXGroup;
children = (
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
);
path = Dependencies;
sourceTree = "<group>";
};
98E3921C2BD845E100B586E9 /* AudioPlayer */ = {
isa = PBXGroup;
children = (
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 */,
);
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)";
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)";
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 */;
}
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1200" LastUpgradeVersion = "1530"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -14,10 +15,10 @@
buildForAnalyzing = "YES"> buildForAnalyzing = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101" BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioExample.app" BuildableName = "AudioPlayer.app"
BlueprintName = "AudioExample" BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioExample.xcodeproj"> ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
@@ -27,64 +28,28 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
enableThreadSanitizer = "YES" shouldAutocreateTestPlan = "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>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableASanStackUseAfterReturn = "YES"
disableMainThreadChecker = "YES"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101" BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioExample.app" BuildableName = "AudioPlayer.app"
BlueprintName = "AudioExample" BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioExample.xcodeproj"> ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "disable"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -96,10 +61,10 @@
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "B5AEDBD02475274C007D8101" BlueprintIdentifier = "9806E8132BC5D12500757370"
BuildableName = "AudioExample.app" BuildableName = "AudioPlayer.app"
BlueprintName = "AudioExample" BlueprintName = "AudioPlayer"
ReferencedContainer = "container:AudioExample.xcodeproj"> ReferencedContainer = "container:AudioPlayer.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>
+55
View File
@@ -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(
audioPlayer: 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,76 @@
//
// 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")
})
.keyboardType(.URL)
.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)
}
.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")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.gray)
}
}
}
}
}
}
#Preview {
AddNewAudioURLView(onAddNewUrl: { _ in })
}
@@ -1,72 +1,89 @@
// //
// AudioContent.swift // Created by Dimitris C.
// AudioExample // Copyright © 2024 Decimal. All rights reserved.
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
// //
import Foundation import Foundation
enum AudioContent: Int, CaseIterable { enum AudioContent {
case offradio case offradio
case enlefko case enlefko
case pepper966 case pepper966
case kosmos case kosmos
case kosmosJazz
case radiox case radiox
case khruangbin case khruangbin
case piano case piano
case optimized
case nonOptimized
case remoteWave case remoteWave
case local case local
case localWave case localWave
case custom(String)
var title: String { var title: String {
switch self { switch self {
case .offradio: case .offradio:
return "Offradio (stream)" return "Offradio"
case .enlefko: case .enlefko:
return "Enlefko (stream)" return "Enlefko"
case .pepper966: case .pepper966:
return "Pepper 96.6 (stream)" return "Pepper 96.6"
case .kosmos: case .kosmos:
return "Kosmos 93.6 (stream)" return "Kosmos 93.6"
case .kosmosJazz:
return "Kosmos Jazz"
case .radiox: case .radiox:
return "Radio X (stream)" return "Radio X"
case .khruangbin: case .khruangbin:
return "Khruangbin (mp3 preview)" return "Khruangbin"
case .piano: case .piano:
return "Piano (mp3)" return "Piano"
case .remoteWave: case .remoteWave:
return "Sample remote (wave)" return "Sample remote"
case .local: case .local:
return "Jazzy Frenchy (local mp3)" return "Jazzy Frenchy"
case .localWave: case .localWave:
return "Local file (local wave)" return "Local file"
case .optimized:
return "Jazzy Frenchy"
case .nonOptimized:
return "Jazzy Frenchy"
case .custom(let url):
return url
} }
} }
var subtitle: String? { var subtitle: String? {
switch self { switch self {
case .offradio: case .offradio:
return nil return "Stream • offradio.gr"
case .enlefko: case .enlefko:
return nil return "Stream • enlefko.fm"
case .pepper966: case .pepper966:
return nil return "Stream • pepper966.gr"
case .kosmos: case .kosmos:
return nil return "Stream • ertecho.gr"
case .kosmosJazz:
return "Stream • ertecho.gr"
case .radiox: case .radiox:
return nil return "Stream • globalplayer.com"
case .khruangbin: case .khruangbin:
return nil return "Remote mp3"
case .piano: case .piano:
return nil return "Remote mp3"
case .remoteWave: case .remoteWave:
return nil return "wave"
case .local: case .local:
return "Music by: bensound.com" return "Music by: bensound.com"
case .localWave: case .localWave:
return "Music by: bensound.com" return "Music by: bensound.com"
case .optimized:
return "Music by: bensound.com - m4a optimized"
case .nonOptimized:
return "Music by: bensound.com - m4a non-optimized"
case .custom:
return ""
} }
} }
@@ -80,12 +97,18 @@ enum AudioContent: Int, CaseIterable {
return URL(string: "https://n04.radiojar.com/pepper.m4a?1662039818=&rj-tok=AAABgvlUaioALhdOXDt0mgajoA&rj-ttl=5")! return URL(string: "https://n04.radiojar.com/pepper.m4a?1662039818=&rj-tok=AAABgvlUaioALhdOXDt0mgajoA&rj-ttl=5")!
case .kosmos: case .kosmos:
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")! return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
case .kosmosJazz:
return URL(string: "https://radiostreaming.ert.gr/ert-webjazz")!
case .radiox: case .radiox:
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")! return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
case .khruangbin: case .khruangbin:
return URL(string: "https://p.scdn.co/mp3-preview/cab4b09c23ffc11774d879977131df9d150fcef4?cid=d8a5ed958d274c2e8ee717e6a4b0971d")! return URL(string: "https://p.scdn.co/mp3-preview/cab4b09c23ffc11774d879977131df9d150fcef4?cid=d8a5ed958d274c2e8ee717e6a4b0971d")!
case .piano: case .piano:
return URL(string: "https://www.kozco.com/tech/piano2-CoolEdit.mp3")! return URL(string: "https://www.kozco.com/tech/piano2-CoolEdit.mp3")!
case .optimized:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/bensound-jazzyfrenchy-optimized.m4a")!
case .nonOptimized:
return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/bensound-jazzyfrenchy.m4a")!
case .local: case .local:
let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")! let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")!
return URL(fileURLWithPath: path) return URL(fileURLWithPath: path)
@@ -93,7 +116,9 @@ enum AudioContent: Int, CaseIterable {
let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")! let path = Bundle.main.path(forResource: "hipjazz", ofType: "wav")!
return URL(fileURLWithPath: path) return URL(fileURLWithPath: path)
case .remoteWave: case .remoteWave:
return URL(string: "https://file-examples.com/storage/fe183d9197630fb5c969255/2017/11/file_example_WAV_5MG.wav")! return URL(string: "https://github.com/dimitris-c/sample-audio/raw/main/5-MB-WAV.wav")!
case .custom(let url):
return URL(string: url)!
} }
} }
} }
@@ -0,0 +1,118 @@
//
// 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)
}
}
}
}
#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,285 @@
//
// Created by Dimitris Chatzieleftheriou on 26/04/2024.
//
import SwiftUI
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.fill" : "play.fill")
.font(.title)
.imageScale(.small)
}
.contentTransition(.symbolEffect(.replace))
Button(action: {
model.stop()
currentTrack = nil
}) {
Image(systemName: "stop.fill")
.font(.title)
.imageScale(.small)
}
.padding(.leading, 8)
Spacer()
Button(action: { model.mute() }) {
Image(systemName: model.isMuted ? "speaker.slash.fill" : "speaker.fill")
.font(.title)
.imageScale(.small)
}
.frame(width: 20, height: 20)
.contentTransition(.symbolEffect(.replace))
}
.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 playbackRate: Double = 0.0
var currentTime: Double = 0
var totalTime: Double?
var scrubState: ScrubState = .idle
var formattedCurrentTime: String?
var formattedTotalTime: String?
var currentTrack: AudioTrack?
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 stop() {
isPlaying = false
audioPlayerService.stop()
currentTrack?.status = .idle
currentTrack = nil
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack?.status = .idle
audioPlayerService.play(url: track.url)
currentTrack = track
}
}
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,69 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import UIKit
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]
func audioTracksProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Radio", tracks: radioTracks.map { AudioTrack.init(from: $0) }),
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) })
]
}
func audioQueueTrackProvider() -> [AudioPlaylist] {
[
AudioPlaylist(title: "Tracks", tracks: audioTracks.map { AudioTrack.init(from:$0) })
]
}
@@ -0,0 +1,86 @@
//
// 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")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
eqSheetIsShown.toggle()
} label: {
Image(systemName: "slider.horizontal.3")
}
Button {
addNewAudioIsShown.toggle()
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $eqSheetIsShown) {
EqualizerView(appModel: appModel)
.presentationDetents([.medium])
}
.sheet(isPresented: $addNewAudioIsShown) {
AddNewAudioURLView(
onAddNewUrl: { url in
model.addNewAudioTrack(url: url)
}
)
.presentationDetents([.height(150)])
}
}
}
#Preview {
AudioPlayerView(appModel: AppModel())
}
@@ -0,0 +1,327 @@
//
// 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")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.gray)
}
}
}
}
}
}
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())
}
@@ -1,24 +1,21 @@
// //
// AudioPlayerService.swift // Created by Dimitris C.
// AudioExample // Copyright © 2024 Decimal. All rights reserved.
//
// Created by Dimitrios Chatzieleftheriou on 14/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
// //
import AudioStreaming import AudioStreaming
import AVFoundation import AVFoundation
protocol AudioPlayerServiceDelegate: AnyObject { protocol AudioPlayerServiceDelegate: AnyObject {
func didStartPlaying() func didStartPlaying(id: AudioEntryId)
func didStopPlaying() func didStopPlaying(id: AudioEntryId, reason: AudioPlayerStopReason)
func statusChanged(status: AudioPlayerState) func statusChanged(status: AudioPlayerState)
func errorOccurred(error: AudioPlayerError) func errorOccurred(error: AudioPlayerError)
func metadataReceived(metadata: [String: String]) func metadataReceived(metadata: [String: String])
} }
final class AudioPlayerService { final class AudioPlayerService {
var delegate = MulticastDelegate<AudioPlayerServiceDelegate>() weak var delegate: AudioPlayerServiceDelegate?
private var player: AudioPlayer private var player: AudioPlayer
private var audioSystemResetObserver: Any? private var audioSystemResetObserver: Any?
@@ -43,8 +40,12 @@ final class AudioPlayerService {
player.state player.state
} }
init() { var statusChangedNotifier = Notifier<AudioPlayerState>()
player = AudioPlayer(configuration: .init(enableLogs: true)) var metadataReceivedNotifier = Notifier<[String: String]>()
var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>()
init(audioPlayer: AudioPlayer) {
player = audioPlayer
player.delegate = self player.delegate = self
configureAudioSession() configureAudioSession()
@@ -98,8 +99,8 @@ final class AudioPlayerService {
} }
} }
func seek(at time: Float) { func seek(at time: Double) {
player.seek(to: Double(time)) player.seek(to: time)
} }
private func recreatePlayer() { private func recreatePlayer() {
@@ -109,9 +110,11 @@ final class AudioPlayerService {
private func registerSessionEvents() { private func registerSessionEvents() {
// Note that a real app might need to observer other AVAudioSession notifications as well // Note that a real app might need to observer other AVAudioSession notifications as well
audioSystemResetObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, audioSystemResetObserver = NotificationCenter.default.addObserver(
object: nil, forName: AVAudioSession.mediaServicesWereResetNotification,
queue: nil) { [unowned self] _ in object: nil,
queue: nil
) { [unowned self] _ in
self.configureAudioSession() self.configureAudioSession()
self.recreatePlayer() self.recreatePlayer()
} }
@@ -148,32 +151,40 @@ final class AudioPlayerService {
} }
extension AudioPlayerService: AudioPlayerDelegate { extension AudioPlayerService: AudioPlayerDelegate {
func audioPlayerDidStartPlaying(player _: AudioPlayer, with _: AudioEntryId) { func audioPlayerDidStartPlaying(player _: AudioPlayer, with id: AudioEntryId) {
delegate.invoke(invocation: { $0.didStartPlaying() }) print("audioPlayerDidStartPlaying entryId: \(id)")
delegate?.didStartPlaying(id: id)
Task { await playingStartedStopped.send((true, id, nil)) }
} }
func audioPlayerDidFinishBuffering(player _: AudioPlayer, with _: AudioEntryId) {} func audioPlayerDidFinishBuffering(player _: AudioPlayer, with _: AudioEntryId) {}
func audioPlayerStateChanged(player _: AudioPlayer, with newState: AudioPlayerState, previous _: AudioPlayerState) { func audioPlayerStateChanged(player _: AudioPlayer, with newState: AudioPlayerState, previous _: AudioPlayerState) {
delegate.invoke(invocation: { $0.statusChanged(status: newState) }) print("audioPlayerDidStartPlaying newState: \(newState)")
Task { await statusChangedNotifier.send(newState) }
delegate?.statusChanged(status: newState)
} }
func audioPlayerDidFinishPlaying(player _: AudioPlayer, func audioPlayerDidFinishPlaying(player _: AudioPlayer,
entryId _: AudioEntryId, entryId id: AudioEntryId,
stopReason _: AudioPlayerStopReason, stopReason reason: AudioPlayerStopReason,
progress _: Double, progress _: Double,
duration _: Double) duration _: Double)
{ {
delegate.invoke(invocation: { $0.didStopPlaying() }) print("audioPlayerDidFinishPlaying entryId: \(id), reason: \(reason)")
Task { await playingStartedStopped.send((false, id, reason)) }
delegate?.didStopPlaying(id: id, reason: reason)
} }
func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) { func audioPlayerUnexpectedError(player _: AudioPlayer, error: AudioPlayerError) {
delegate.invoke(invocation: { $0.errorOccurred(error: error) }) delegate?.errorOccurred(error: error)
} }
func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {} func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {}
func audioPlayerDidReadMetadata(player _: AudioPlayer, metadata: [String: String]) { func audioPlayerDidReadMetadata(player _: AudioPlayer, metadata: [String: String]) {
delegate.invoke(invocation: { $0.metadataReceived(metadata: metadata) }) Task { await metadataReceivedNotifier.send(metadata) }
delegate?.metadataReceived(metadata: metadata)
} }
} }
@@ -1,16 +1,13 @@
// //
// EqualizerService.swift // Created by Dimitris C.
// AudioExample // Copyright © 2024 Decimal. All rights reserved.
//
// Created by Dimitrios Chatzieleftheriou on 15/11/2020.
// Copyright © 2020 Dimitrios Chatzieleftheriou. All rights reserved.
// //
import AVFoundation import AVFoundation
final class EqualizerService { final class EqualizerService {
private let playerService: AudioPlayerService 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 private let eqUnit: AVAudioUnitEQ
var bands: [AVAudioUnitEQFilterParameters] { var bands: [AVAudioUnitEQFilterParameters] {
@@ -0,0 +1,49 @@
//
// Created by Dimitris C.
// Copyright © 2024 Decimal. All rights reserved.
//
import UIKit
final class DisplayLink {
private var displayLink: CADisplayLink?
private var target = DisplayLinkTarget()
var isPaused: Bool = true {
didSet {
displayLink?.isPaused = isPaused
}
}
init(onTick: @escaping (CADisplayLink) -> Void) {
target.onTick = onTick
}
deinit {
deactivate()
}
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)
self.isPaused = false
}
func deactivate() {
isPaused = true
displayLink?.invalidate()
displayLink = nil
}
}
private final class DisplayLinkTarget {
var onTick: ((CADisplayLink) -> Void)?
@objc func tick(_ link: CADisplayLink) {
onTick?(link)
}
}
@@ -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
}
}
+10
View File
@@ -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
@State private var selection: NavigationContent?
var body: some View {
NavigationStack {
List {
NavigationLink(value: NavigationContent.audioPlayer) {
Label("Audio Player", systemImage: "play")
}
NavigationLink(value: NavigationContent.audioQueue) {
Label("Audio Queue", systemImage: "play.square.stack")
}
}
.navigationTitle("Home")
.navigationDestination(for: NavigationContent.self) { content in
DetailView(selection: content)
}
}
}
}
#Preview {
ContentView()
}
@@ -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,45 @@
//
// 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")
}
}
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
}
}
+2 -2
View File
@@ -1,13 +1,13 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'AudioStreaming' s.name = 'AudioStreaming'
s.version = '1.1.0' s.version = '1.2.1'
s.license = 'MIT' s.license = 'MIT'
s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.' s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.'
s.homepage = 'https://github.com/dimitris-c/AudioStreaming' s.homepage = 'https://github.com/dimitris-c/AudioStreaming'
s.authors = { 'Dimitris C.' => 'dimmdesign@gmail.com' } s.authors = { 'Dimitris C.' => 'dimmdesign@gmail.com' }
s.source = { :git => 'https://github.com/dimitris-c/AudioStreaming.git', :tag => s.version } s.source = { :git => 'https://github.com/dimitris-c/AudioStreaming.git', :tag => s.version }
s.ios.deployment_target = '12.0' s.ios.deployment_target = '13.0'
s.swift_versions = ['5.1', '5.2', '5.3'] s.swift_versions = ['5.1', '5.2', '5.3']
+34 -12
View File
@@ -7,7 +7,11 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
98ABF69E2BAB07A20059C441 /* Mp4Restructure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ABF69D2BAB07A20059C441 /* Mp4Restructure.swift */; };
98C82AE62B8CA8BC00AED485 /* RemoteMp4Restructure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C82AE52B8CA8BC00AED485 /* RemoteMp4Restructure.swift */; };
98CC396E28BD651E006C9FF9 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CC396D28BD651E006C9FF9 /* Atomic.swift */; }; 98CC396E28BD651E006C9FF9 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CC396D28BD651E006C9FF9 /* Atomic.swift */; };
98DC00CC2B961F5E0068900A /* ByteBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC00CB2B961F5E0068900A /* ByteBuffer.swift */; };
98DC00CE2B9726380068900A /* ByteBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DC00CD2B9726380068900A /* ByteBufferTests.swift */; };
B500732024D00BAC00BB4475 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500731F24D00BAC00BB4475 /* Logger.swift */; }; B500732024D00BAC00BB4475 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500731F24D00BAC00BB4475 /* Logger.swift */; };
B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514657E248E3884005C03F7 /* DispatchTimerSource.swift */; }; B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B514657E248E3884005C03F7 /* DispatchTimerSource.swift */; };
B51B9F9A24DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */; }; B51B9F9A24DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */; };
@@ -34,7 +38,6 @@
B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */; }; B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */; };
B5667A922499063D00D93F85 /* AudioPlayerContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667A912499063D00D93F85 /* AudioPlayerContext.swift */; }; B5667A922499063D00D93F85 /* AudioPlayerContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667A912499063D00D93F85 /* AudioPlayerContext.swift */; };
B5667B3E249BC43100D93F85 /* AudioPlayerRenderProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */; }; B5667B3E249BC43100D93F85 /* AudioPlayerRenderProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */; };
B5737340254DE43E003DFBEC /* measure.swift in Sources */ = {isa = PBXBuildFile; fileRef = B573733F254DE43E003DFBEC /* measure.swift */; };
B57829CF2548B32B00C78D36 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57829CE2548B32B00C78D36 /* Lock.swift */; }; B57829CF2548B32B00C78D36 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57829CE2548B32B00C78D36 /* Lock.swift */; };
B58386382544A2C10087A712 /* EntryFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58386372544A2C10087A712 /* EntryFrames.swift */; }; B58386382544A2C10087A712 /* EntryFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58386372544A2C10087A712 /* EntryFrames.swift */; };
B5838640254584A50087A712 /* ProcessedPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B583863F254584A50087A712 /* ProcessedPackets.swift */; }; B5838640254584A50087A712 /* ProcessedPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B583863F254584A50087A712 /* ProcessedPackets.swift */; };
@@ -94,7 +97,11 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
98ABF69D2BAB07A20059C441 /* Mp4Restructure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mp4Restructure.swift; sourceTree = "<group>"; };
98C82AE52B8CA8BC00AED485 /* RemoteMp4Restructure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMp4Restructure.swift; sourceTree = "<group>"; };
98CC396D28BD651E006C9FF9 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; }; 98CC396D28BD651E006C9FF9 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
98DC00CB2B961F5E0068900A /* ByteBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteBuffer.swift; sourceTree = "<group>"; };
98DC00CD2B9726380068900A /* ByteBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteBufferTests.swift; sourceTree = "<group>"; };
B500731F24D00BAC00BB4475 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; B500731F24D00BAC00BB4475 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
B514657E248E3884005C03F7 /* DispatchTimerSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchTimerSource.swift; sourceTree = "<group>"; }; B514657E248E3884005C03F7 /* DispatchTimerSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchTimerSource.swift; sourceTree = "<group>"; };
B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVAudioFormat+Convenience.swift"; sourceTree = "<group>"; }; B51B9F9924DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVAudioFormat+Convenience.swift"; sourceTree = "<group>"; };
@@ -122,7 +129,6 @@
B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileStreamProcessor.swift; sourceTree = "<group>"; }; B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileStreamProcessor.swift; sourceTree = "<group>"; };
B5667A912499063D00D93F85 /* AudioPlayerContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerContext.swift; sourceTree = "<group>"; }; B5667A912499063D00D93F85 /* AudioPlayerContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerContext.swift; sourceTree = "<group>"; };
B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerRenderProcessor.swift; sourceTree = "<group>"; }; B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerRenderProcessor.swift; sourceTree = "<group>"; };
B573733F254DE43E003DFBEC /* measure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = measure.swift; sourceTree = "<group>"; };
B57829CE2548B32B00C78D36 /* Lock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lock.swift; sourceTree = "<group>"; }; B57829CE2548B32B00C78D36 /* Lock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lock.swift; sourceTree = "<group>"; };
B580CB1D25628CF4006D7DD8 /* AudioStreaming.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = AudioStreaming.podspec; sourceTree = "<group>"; }; B580CB1D25628CF4006D7DD8 /* AudioStreaming.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = AudioStreaming.podspec; sourceTree = "<group>"; };
B580CB1E25628CF4006D7DD8 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; B580CB1E25628CF4006D7DD8 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
@@ -184,6 +190,15 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
98C82AE42B8CA8AA00AED485 /* Mp4 */ = {
isa = PBXGroup;
children = (
98C82AE52B8CA8BC00AED485 /* RemoteMp4Restructure.swift */,
98ABF69D2BAB07A20059C441 /* Mp4Restructure.swift */,
);
path = Mp4;
sourceTree = "<group>";
};
B5276B70247D4D3D00D2F56A /* Network */ = { B5276B70247D4D3D00D2F56A /* Network */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -302,6 +317,7 @@
B58BD7FC255DB653005B756D /* Audio Source */ = { B58BD7FC255DB653005B756D /* Audio Source */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
98C82AE42B8CA8AA00AED485 /* Mp4 */,
B5EF9556247E9439003E8FF8 /* AudioStreamSource.swift */, B5EF9556247E9439003E8FF8 /* AudioStreamSource.swift */,
B5EF955C247ECBB1003E8FF8 /* RemoteAudioSource.swift */, B5EF955C247ECBB1003E8FF8 /* RemoteAudioSource.swift */,
B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */, B59D0B6E255C904900D6CCE5 /* FileAudioSource.swift */,
@@ -321,8 +337,8 @@
B592E13025460883008866FB /* Helpers */ = { B592E13025460883008866FB /* Helpers */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
98DC00CB2B961F5E0068900A /* ByteBuffer.swift */,
98CC396D28BD651E006C9FF9 /* Atomic.swift */, 98CC396D28BD651E006C9FF9 /* Atomic.swift */,
B573733F254DE43E003DFBEC /* measure.swift */,
B514657E248E3884005C03F7 /* DispatchTimerSource.swift */, B514657E248E3884005C03F7 /* DispatchTimerSource.swift */,
B57829CE2548B32B00C78D36 /* Lock.swift */, B57829CE2548B32B00C78D36 /* Lock.swift */,
B500731F24D00BAC00BB4475 /* Logger.swift */, B500731F24D00BAC00BB4475 /* Logger.swift */,
@@ -431,8 +447,8 @@
B5F883B42476DABE00D277C1 /* Core */ = { B5F883B42476DABE00D277C1 /* Core */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B592E11E2545FF33008866FB /* Structures */,
B55CE97624813BA10001C498 /* Extensions */, B55CE97624813BA10001C498 /* Extensions */,
B592E11E2545FF33008866FB /* Structures */,
B5276B70247D4D3D00D2F56A /* Network */, B5276B70247D4D3D00D2F56A /* Network */,
B592E13025460883008866FB /* Helpers */, B592E13025460883008866FB /* Helpers */,
); );
@@ -447,6 +463,7 @@
B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */, B51FE0C12488F96A00F2A4D2 /* QueueTests.swift */,
B592E12825460146008866FB /* BiMapTests.swift */, B592E12825460146008866FB /* BiMapTests.swift */,
B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */, B592E133254608B4008866FB /* DispatchTimerSourceTests.swift */,
98DC00CD2B9726380068900A /* ByteBufferTests.swift */,
); );
path = Core; path = Core;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -584,7 +601,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; shellScript = "if [[ \"$(uname -m)\" == arm64 ]]; then\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
@@ -608,7 +625,6 @@
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */, B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */,
B54D876D2490E4A000C361A0 /* UnitDescriptions.swift in Sources */, B54D876D2490E4A000C361A0 /* UnitDescriptions.swift in Sources */,
B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */, B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */,
B5737340254DE43E003DFBEC /* measure.swift in Sources */,
B55CEABC24853CD20001C498 /* AudioPlayer.swift in Sources */, B55CEABC24853CD20001C498 /* AudioPlayer.swift in Sources */,
B5667B3E249BC43100D93F85 /* AudioPlayerRenderProcessor.swift in Sources */, B5667B3E249BC43100D93F85 /* AudioPlayerRenderProcessor.swift in Sources */,
B5276B6F247D21A000D2F56A /* NetworkingClient.swift in Sources */, B5276B6F247D21A000D2F56A /* NetworkingClient.swift in Sources */,
@@ -618,6 +634,7 @@
B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */, B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */,
B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */, B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */,
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */, B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */,
98DC00CC2B961F5E0068900A /* ByteBuffer.swift in Sources */,
B5EF9555247E9393003E8FF8 /* AudioEntry.swift in Sources */, B5EF9555247E9393003E8FF8 /* AudioEntry.swift in Sources */,
B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */, B5B36E432655A32200DC96F5 /* FrameFilterProcessor.swift in Sources */,
B51FE0C02488F67C00F2A4D2 /* Queue.swift in Sources */, B51FE0C02488F67C00F2A4D2 /* Queue.swift in Sources */,
@@ -634,11 +651,13 @@
B55CE96E248058B60001C498 /* MetadataParser.swift in Sources */, B55CE96E248058B60001C498 /* MetadataParser.swift in Sources */,
B5838644254584BE0087A712 /* AudioStreamState.swift in Sources */, B5838644254584BE0087A712 /* AudioStreamState.swift in Sources */,
B500732024D00BAC00BB4475 /* Logger.swift in Sources */, B500732024D00BAC00BB4475 /* Logger.swift in Sources */,
98C82AE62B8CA8BC00AED485 /* RemoteMp4Restructure.swift in Sources */,
B5276B74247D4D9F00D2F56A /* NetworkSessionDelegate.swift in Sources */, B5276B74247D4D9F00D2F56A /* NetworkSessionDelegate.swift in Sources */,
B55F77D624DACE140057F431 /* BufferContext.swift in Sources */, B55F77D624DACE140057F431 /* BufferContext.swift in Sources */,
B5838648254584D90087A712 /* SeekRequest.swift in Sources */, B5838648254584D90087A712 /* SeekRequest.swift in Sources */,
B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */, B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */,
B55CE97824813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift in Sources */, B55CE97824813BCA0001C498 /* UnsafeMutablePointer+Helpers.swift in Sources */,
98ABF69E2BAB07A20059C441 /* Mp4Restructure.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -655,6 +674,7 @@
B592E134254608B4008866FB /* DispatchTimerSourceTests.swift in Sources */, B592E134254608B4008866FB /* DispatchTimerSourceTests.swift in Sources */,
B55CEAB82485172D0001C498 /* HTTPHeaderParserTests.swift in Sources */, B55CEAB82485172D0001C498 /* HTTPHeaderParserTests.swift in Sources */,
B592E12925460146008866FB /* BiMapTests.swift in Sources */, B592E12925460146008866FB /* BiMapTests.swift in Sources */,
98DC00CE2B9726380068900A /* ByteBufferTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -721,7 +741,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.1.0; MARKETING_VERSION = 1.1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
@@ -780,7 +800,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.1.0; MARKETING_VERSION = 1.1.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
@@ -805,13 +825,13 @@
DYLIB_INSTALL_NAME_BASE = "@rpath"; DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = AudioStreaming/Info.plist; INFOPLIST_FILE = AudioStreaming/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@loader_path/Frameworks", "@loader_path/Frameworks",
); );
MARKETING_VERSION = 1.1.0; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = "-ObjC"; OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming; PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
@@ -836,13 +856,13 @@
DYLIB_INSTALL_NAME_BASE = "@rpath"; DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = AudioStreaming/Info.plist; INFOPLIST_FILE = AudioStreaming/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@loader_path/Frameworks", "@loader_path/Frameworks",
); );
MARKETING_VERSION = 1.1.0; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = "-ObjC"; OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming; PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
@@ -861,6 +881,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = AudioStreamingTests/Info.plist; INFOPLIST_FILE = AudioStreamingTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -881,6 +902,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = AudioStreamingTests/Info.plist; INFOPLIST_FILE = AudioStreamingTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
+1 -1
View File
@@ -2,7 +2,7 @@
<Workspace <Workspace
version = "1.0"> version = "1.0">
<FileRef <FileRef
location = "group:AudioExample/AudioExample.xcodeproj"> location = "group:AudioPlayer/AudioPlayer.xcodeproj">
</FileRef> </FileRef>
<FileRef <FileRef
location = "container:AudioStreaming.xcodeproj"> location = "container:AudioStreaming.xcodeproj">
@@ -8,11 +8,13 @@ import AVFoundation
@discardableResult @discardableResult
func fileStreamGetProperty<Value>(value: inout Value, fileStream streamId: AudioFileStreamID, propertyId: AudioFileStreamPropertyID) -> OSStatus { func fileStreamGetProperty<Value>(value: inout Value, fileStream streamId: AudioFileStreamID, propertyId: AudioFileStreamPropertyID) -> OSStatus {
var (size, _) = fileStreamGetPropertyInfo(fileStream: streamId, propertyId: propertyId) var (size, _) = fileStreamGetPropertyInfo(fileStream: streamId, propertyId: propertyId)
let status = AudioFileStreamGetProperty(streamId, propertyId, &size, &value) return withUnsafeMutablePointer(to: &value) { pointer in
guard status == noErr else { let status = AudioFileStreamGetProperty(streamId, propertyId, &size, pointer)
guard status == noErr else {
return status
}
return status return status
} }
return status
} }
func fileStreamGetPropertyInfo(fileStream streamId: AudioFileStreamID, propertyId: AudioFileStreamPropertyID) -> (size: UInt32, status: OSStatus) { func fileStreamGetPropertyInfo(fileStream streamId: AudioFileStreamID, propertyId: AudioFileStreamPropertyID) -> (size: UInt32, status: OSStatus) {
@@ -112,3 +114,50 @@ public enum AudioFileStreamError: CustomDebugStringConvertible {
} }
} }
} }
public extension AudioFileStreamPropertyID {
var description: String {
switch self {
case kAudioFileStreamProperty_ReadyToProducePackets:
return "Ready to produce packets"
case kAudioFileStreamProperty_FileFormat:
return "File format"
case kAudioFileStreamProperty_DataFormat:
return "Data format"
case kAudioFileStreamProperty_AudioDataByteCount:
return "Byte count"
case kAudioFileStreamProperty_AudioDataPacketCount:
return "Packet count"
case kAudioFileStreamProperty_DataOffset:
return "Data offset"
case kAudioFileStreamProperty_BitRate:
return "Bit rate"
case kAudioFileStreamProperty_FormatList:
return "Format list"
case kAudioFileStreamProperty_MagicCookieData:
return "Magic cookie"
case kAudioFileStreamProperty_MaximumPacketSize:
return "Max packet size"
case kAudioFileStreamProperty_ChannelLayout:
return "Channel layout"
case kAudioFileStreamProperty_PacketToFrame:
return "Packet to frame"
case kAudioFileStreamProperty_FrameToPacket:
return "Frame to packet"
case kAudioFileStreamProperty_PacketToByte:
return "Packet to byte"
case kAudioFileStreamProperty_ByteToPacket:
return "Byte to packet"
case kAudioFileStreamProperty_PacketTableInfo:
return "Packet table"
case kAudioFileStreamProperty_PacketSizeUpperBound:
return "Packet size upper bound"
case kAudioFileStreamProperty_AverageBytesPerPacket:
return "Average bytes per packet"
case kAudioFileStreamProperty_InfoDictionary:
return "Info dictionary"
default:
return "Unknown"
}
}
}
@@ -0,0 +1,187 @@
//
// Created by Dimitrios Chatzieleftheriou on 4/03/2024.
// Copyright © 2024 Decimal. All rights reserved.
//
import Foundation
// Struct representing a buffer for handling binary data
struct ByteBuffer {
// Custom errors for ByteBuffer
enum Error: Swift.Error {
case eof // End of file
case parse // Parsing error
}
// Data storage for the buffer
private(set) var storage = Data()
// Current offset in the buffer
var offset: Int = 0
// Calculated property for the number of bytes available for reading
var bytesAvailable: Int {
storage.count - offset
}
// Calculated property for the length of the buffer
var length: Int {
get {
storage.count
}
set {
// Adjusting the length of the buffer
switch true {
case storage.count < newValue:
storage.append(Data(count: newValue - storage.count))
case newValue < storage.count:
storage = storage.subdata(in: 0 ..< newValue)
default:
break
}
}
}
// Subscript for accessing individual bytes in the buffer
subscript(i: Int) -> UInt8 {
get { storage[i] }
set { storage[i] = newValue }
}
// Initialize the buffer with given data
init(data: Data) {
storage = data
offset = 0
}
// Initialize the buffer with a specified size, filling it with zeros
init(size: Int) {
storage = Data(repeating: 0x00, count: size)
offset = 0
}
// Clear the buffer (reset offset to zero)
@discardableResult
mutating func clear() -> Self {
offset = 0
return self
}
// Rewind the buffer (reset offset to zero)
mutating func rewind() {
offset = 0
}
// Read a specified number of bytes from the buffer
mutating func readBytes(_ length: Int) throws -> Data {
guard length <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += length
return storage.subdata(in: offset - length ..< offset)
}
// Write data into the buffer
@discardableResult
mutating func writeBytes(_ value: Data) -> Self {
// If the offset is at the end, append the value to the data
if offset == storage.count {
storage.append(value)
offset = storage.count
return self
}
// Otherwise, write the value into the buffer at the current offset
let length: Int = min(storage.count, value.count)
storage[offset ..< offset + length] = value[0 ..< length]
// If the value is longer than the remaining space, append the rest to the data
if length == storage.count {
storage.append(value[length ..< value.count])
}
offset += value.count
return self
}
// Write integer value into the buffer
@discardableResult
mutating func put<T: FixedWidthInteger>(_ value: T) -> ByteBuffer {
writeBytes(value.data)
}
// Write float value into the buffer
@discardableResult
mutating func put(_ value: Float) -> ByteBuffer {
writeBytes(Data(value.data.reversed()))
}
// Write double value into the buffer
@discardableResult
mutating func put(_ value: Double) -> ByteBuffer {
writeBytes(Data(value.data.reversed()))
}
// Read an integer value from the buffer
mutating func getInteger<T: FixedWidthInteger>() throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += sizeOfInteger
return T(data: storage[offset - sizeOfInteger ..< offset]).bigEndian
}
// Read an integer value from a specific index in the buffer
func getInteger<T: FixedWidthInteger>(_ index: Int) throws -> T {
let sizeOfInteger = MemoryLayout<T>.size
guard sizeOfInteger + index <= length else {
throw ByteBuffer.Error.eof
}
return T(data: storage[index ..< index + sizeOfInteger]).bigEndian
}
// Read a float value from the buffer
mutating func getFloat() throws -> Float {
let sizeOfFloat = MemoryLayout<UInt32>.size
guard sizeOfFloat <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += sizeOfFloat
return Float(data: Data(storage.subdata(in: offset - sizeOfFloat ..< offset).reversed()))
}
// Read a double value from the buffer
mutating func getDouble() throws -> Double {
let sizeOfDouble = MemoryLayout<UInt64>.size
guard sizeOfDouble <= bytesAvailable else {
throw ByteBuffer.Error.eof
}
offset += sizeOfDouble
return Double(data: Data(storage.subdata(in: offset - sizeOfDouble ..< offset).reversed()))
}
}
// Extension to provide conformance to ExpressibleByIntegerLiteral for easy conversion between integers and Data
extension ExpressibleByIntegerLiteral {
// Convert integer to Data
var data: Data {
return withUnsafePointer(to: self) { pointer in
Data(bytes: pointer, count: MemoryLayout<Self>.size)
}
}
// Initialize from Data
init(data: Data) {
let diff: Int = MemoryLayout<Self>.size - data.count
if diff > 0 {
var buffer = Data(repeating: 0, count: diff)
buffer.append(data)
self = buffer.withUnsafeBytes { $0.baseAddress!.assumingMemoryBound(to: Self.self).pointee }
return
}
self = data.withUnsafeBytes { $0.baseAddress!.assumingMemoryBound(to: Self.self).pointee }
}
// Initialize from Data slice
init(data: Slice<Data>) {
self.init(data: Data(data))
}
}
@@ -11,10 +11,10 @@ import Foundation
final class DispatchTimerSource { final class DispatchTimerSource {
private var handler: (() -> Void)? private var handler: (() -> Void)?
private let timer: DispatchSourceTimer private let timer: DispatchSourceTimer
internal var state: SourceState = .suspended var state: SourceState = .suspended
/// The state of the timer /// The state of the timer
internal enum SourceState { enum SourceState {
case activated case activated
case suspended case suspended
} }
+3 -3
View File
@@ -21,7 +21,7 @@ protocol Lock {
final class UnfairLock: Lock { final class UnfairLock: Lock {
@usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock> @usableFromInline let unfairLock: UnsafeMutablePointer<os_unfair_lock>
internal init() { init() {
unfairLock = .allocate(capacity: 1) unfairLock = .allocate(capacity: 1)
unfairLock.initialize(to: os_unfair_lock()) unfairLock.initialize(to: os_unfair_lock())
} }
@@ -48,13 +48,13 @@ final class UnfairLock: Lock {
@inlinable @inlinable
@inline(__always) @inline(__always)
internal func lock() { func lock() {
os_unfair_lock_lock(unfairLock) os_unfair_lock_lock(unfairLock)
} }
@inlinable @inlinable
@inline(__always) @inline(__always)
internal func unlock() { func unlock() {
os_unfair_lock_unlock(unfairLock) os_unfair_lock_unlock(unfairLock)
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import os
private let loggingSubsystem = "audio.streaming.log" private let loggingSubsystem = "audio.streaming.log"
internal enum Logger { enum Logger {
private static let audioRendering = OSLog(subsystem: loggingSubsystem, category: "audio.rendering") private static let audioRendering = OSLog(subsystem: loggingSubsystem, category: "audio.rendering")
private static let networking = OSLog(subsystem: loggingSubsystem, category: "audio.networking") private static let networking = OSLog(subsystem: loggingSubsystem, category: "audio.networking")
private static let generic = OSLog(subsystem: loggingSubsystem, category: "audio.streaming.generic") private static let generic = OSLog(subsystem: loggingSubsystem, category: "audio.streaming.generic")
@@ -5,7 +5,7 @@
import Foundation import Foundation
internal final class NetworkDataStream { final class NetworkDataStream {
typealias StreamResult = Result<Response, Error> typealias StreamResult = Result<Response, Error>
typealias StreamCompletion = (_ event: NetworkDataStream.ResponseEvent) -> Void typealias StreamCompletion = (_ event: NetworkDataStream.ResponseEvent) -> Void
@@ -52,7 +52,7 @@ internal final class NetworkDataStream {
task?.response as? HTTPURLResponse task?.response as? HTTPURLResponse
} }
internal init(id: UUID, underlyingQueue: DispatchQueue) { init(id: UUID, underlyingQueue: DispatchQueue) {
self.id = id self.id = id
self.underlyingQueue = underlyingQueue self.underlyingQueue = underlyingQueue
state = .initialised state = .initialised
@@ -94,7 +94,7 @@ internal final class NetworkDataStream {
// MARK: Internal // MARK: Internal
internal func didReceive(response: HTTPURLResponse?) { func didReceive(response: HTTPURLResponse?) {
underlyingQueue.async { [weak self] in underlyingQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard let streamCallback = self.streamCallback else { return } guard let streamCallback = self.streamCallback else { return }
@@ -102,7 +102,7 @@ internal final class NetworkDataStream {
} }
} }
internal func didReceive(data: Data, response: HTTPURLResponse?) { func didReceive(data: Data, response: HTTPURLResponse?) {
underlyingQueue.async { [weak self] in underlyingQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard let streamCallback = self.streamCallback else { return } guard let streamCallback = self.streamCallback else { return }
@@ -111,7 +111,7 @@ internal final class NetworkDataStream {
} }
} }
internal func didComplete(with error: Error?, response: HTTPURLResponse?) { func didComplete(with error: Error?, response: HTTPURLResponse?) {
underlyingQueue.async { [weak self] in underlyingQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard let stream = self.streamCallback else { return } guard let stream = self.streamCallback else { return }
@@ -5,10 +5,10 @@
import Foundation import Foundation
internal final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate { final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
weak var taskProvider: StreamTaskProvider? weak var taskProvider: StreamTaskProvider?
internal func stream(for task: URLSessionTask) -> NetworkDataStream? { func stream(for task: URLSessionTask) -> NetworkDataStream? {
guard let taskProvider = taskProvider else { guard let taskProvider = taskProvider else {
assertionFailure("couldn't found taskProvider") assertionFailure("couldn't found taskProvider")
return nil return nil
@@ -16,22 +16,22 @@ internal final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
return taskProvider.dataStream(for: task) return taskProvider.dataStream(for: task)
} }
internal func urlSession(_: URLSession, func urlSession(_: URLSession,
dataTask: URLSessionDataTask, dataTask: URLSessionDataTask,
didReceive data: Data) didReceive data: Data)
{ {
guard let stream = self.stream(for: dataTask) else { guard let stream = stream(for: dataTask) else {
return return
} }
stream.didReceive(data: data, stream.didReceive(data: data,
response: dataTask.response as? HTTPURLResponse) response: dataTask.response as? HTTPURLResponse)
} }
internal func urlSession(_: URLSession, func urlSession(_: URLSession,
task: URLSessionTask, task: URLSessionTask,
didCompleteWithError error: Error?) didCompleteWithError error: Error?)
{ {
guard let stream = self.stream(for: task) else { guard let stream = stream(for: task) else {
return return
} }
stream.didComplete(with: error, response: task.response as? HTTPURLResponse) stream.didComplete(with: error, response: task.response as? HTTPURLResponse)
@@ -42,7 +42,7 @@ internal final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate {
didReceive response: URLResponse, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)
{ {
guard let stream = self.stream(for: dataTask) else { guard let stream = stream(for: dataTask) else {
return return
} }
stream.didReceive(response: response as? HTTPURLResponse) stream.didReceive(response: response as? HTTPURLResponse)
@@ -44,7 +44,7 @@ extension URLSessionConfiguration {
} }
} }
internal final class NetworkingClient { final class NetworkingClient {
let session: URLSession let session: URLSession
weak var delegate: NetworkSessionDelegate? weak var delegate: NetworkSessionDelegate?
let networkQueue: DispatchQueue let networkQueue: DispatchQueue
@@ -52,9 +52,9 @@ internal final class NetworkingClient {
var tasksLock = UnfairLock() var tasksLock = UnfairLock()
var tasks = BiMap<URLSessionTask, NetworkDataStream>() var tasks = BiMap<URLSessionTask, NetworkDataStream>()
internal init(configuration: URLSessionConfiguration = .networkingConfiguration, init(configuration: URLSessionConfiguration = .networkingConfiguration,
delegate: NetworkSessionDelegate = NetworkSessionDelegate(), delegate: NetworkSessionDelegate = NetworkSessionDelegate(),
networkQueue: DispatchQueue = DispatchQueue(label: "audio.streaming.session.network.queue")) networkQueue: DispatchQueue = DispatchQueue(label: "audio.streaming.session.network.queue"))
{ {
let delegateQueue = operationQueue(underlyingQueue: networkQueue) let delegateQueue = operationQueue(underlyingQueue: networkQueue)
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue)
@@ -70,13 +70,13 @@ internal final class NetworkingClient {
/// Creates a data stream for the given `URLRequest` /// Creates a data stream for the given `URLRequest`
/// - parameter request: A `URLRequest` to be used for the data stream /// - parameter request: A `URLRequest` to be used for the data stream
internal func stream(request: URLRequest) -> NetworkDataStream { func stream(request: URLRequest) -> NetworkDataStream {
let stream = NetworkDataStream(id: UUID(), underlyingQueue: networkQueue) let stream = NetworkDataStream(id: UUID(), underlyingQueue: networkQueue)
setupRequest(stream, request: request) setupRequest(stream, request: request)
return stream return stream
} }
internal func remove(task: NetworkDataStream) { func remove(task: NetworkDataStream) {
tasksLock.withLock { tasksLock.withLock {
if !tasks.isEmpty { if !tasks.isEmpty {
tasks[task] = nil tasks[task] = nil
@@ -84,6 +84,21 @@ internal final class NetworkingClient {
} }
} }
@discardableResult
func task(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) -> URLSessionDataTask {
let task = session.dataTask(with: request) { data, _, error in
if let error {
completion(Result<Data, Error>.failure(error))
return
}
if let data {
completion(Result<Data, Error>.success(data))
}
}
task.resume()
return task
}
// MARK: Private // MARK: Private
/// Schedules the given `NetworkDataStream` to be performed immediately /// Schedules the given `NetworkDataStream` to be performed immediately
@@ -100,13 +115,13 @@ internal final class NetworkingClient {
// MARK: StreamTaskProvider conformance // MARK: StreamTaskProvider conformance
extension NetworkingClient: StreamTaskProvider { extension NetworkingClient: StreamTaskProvider {
internal func dataStream(for request: URLSessionTask) -> NetworkDataStream? { func dataStream(for request: URLSessionTask) -> NetworkDataStream? {
tasksLock.withLock { tasksLock.withLock {
tasks[request] ?? nil tasks[request] ?? nil
} }
} }
internal func sessionTask(for stream: NetworkDataStream) -> URLSessionTask? { func sessionTask(for stream: NetworkDataStream) -> URLSessionTask? {
tasksLock.withLock { tasksLock.withLock {
tasks[stream] ?? nil tasks[stream] ?? nil
} }
+27 -1
View File
@@ -25,13 +25,15 @@
+---+ +---+ +---+ +---+
``` ```
*/ */
final class Queue<Element>: Sequence, CustomDebugStringConvertible { final class Queue<Element: Equatable>: Sequence, CustomDebugStringConvertible {
private var _storage: [Element] = [] private var _storage: [Element] = []
var isEmpty: Bool { _storage.isEmpty } var isEmpty: Bool { _storage.isEmpty }
var count: Int { _storage.count } var count: Int { _storage.count }
var items: [Element] { _storage }
/// Inserts an item at the end of the queue /// Inserts an item at the end of the queue
func enqueue(item: Element) { func enqueue(item: Element) {
_storage.insert(item, at: 0) _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 /// Retrieves the last item
func peek() -> Element? { func peek() -> Element? {
_storage.last _storage.last
@@ -7,11 +7,11 @@ import AudioToolbox
import AVFoundation import AVFoundation
public struct AudioEntryId: Equatable { public struct AudioEntryId: Equatable {
internal var unique = UUID() var unique = UUID()
public var id: String public var id: String
} }
internal class AudioEntry { class AudioEntry {
private let estimationMinPackets = 2 private let estimationMinPackets = 2
private let estimationMinPacketsPreferred = 64 private let estimationMinPacketsPreferred = 64
@@ -92,7 +92,9 @@ internal class AudioEntry {
func reset() { func reset() {
lock.lock(); defer { lock.unlock() } lock.lock(); defer { lock.unlock() }
framesState = EntryFramesState() framesState.played = 0
framesState.queued = 0
framesState.lastFrameQueued = -1
} }
func has(same source: CoreAudioStreamSource) -> Bool { func has(same source: CoreAudioStreamSource) -> Bool {
@@ -3,6 +3,7 @@
// Copyright © 2020 Decimal. All rights reserved. // Copyright © 2020 Decimal. All rights reserved.
// //
import Foundation
import AVFoundation import AVFoundation
final class FileAudioSource: NSObject, CoreAudioStreamSource { final class FileAudioSource: NSObject, CoreAudioStreamSource {
@@ -17,6 +18,12 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
audioFileType(fileExtension: url.pathExtension) audioFileType(fileExtension: url.pathExtension)
} }
private var isMp4: Bool {
audioFileHint == kAudioFileM4AType || audioFileHint == kAudioFileMPEG4Type
}
private var mp4IsAlreadyOptimized: Bool = false
private var seekOffset: Int private var seekOffset: Int
private let url: URL private let url: URL
@@ -26,6 +33,8 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
private var buffer: UnsafeMutablePointer<UInt8> private var buffer: UnsafeMutablePointer<UInt8>
private var inputStream: InputStream? private var inputStream: InputStream?
private var mp4Restructure: Mp4Restructure
init(url: URL, init(url: URL,
fileManager: FileManager = .default, fileManager: FileManager = .default,
underlyingQueue: DispatchQueue, underlyingQueue: DispatchQueue,
@@ -35,6 +44,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
self.underlyingQueue = underlyingQueue self.underlyingQueue = underlyingQueue
self.fileManager = fileManager self.fileManager = fileManager
self.readSize = readSize self.readSize = readSize
self.mp4Restructure = Mp4Restructure()
buffer = UnsafeMutablePointer.uint8pointer(of: readSize) buffer = UnsafeMutablePointer.uint8pointer(of: readSize)
seekOffset = 0 seekOffset = 0
position = 0 position = 0
@@ -43,6 +53,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
deinit { deinit {
buffer.deallocate() buffer.deallocate()
mp4Restructure.clear()
} }
func close() { func close() {
@@ -55,7 +66,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
} }
// no-op // no-op
func suspend() { } func suspend() {}
func resume() { func resume() {
guard let inputStream = inputStream else { guard let inputStream = inputStream else {
@@ -73,18 +84,32 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
} }
private func performOpen(seek seekOffset: Int) throws { private func performOpen(seek seekOffset: Int) throws {
close() var reopened = false
try open() let status = inputStream?.streamStatus ?? .closed
if status == .atEnd || status == .closed || status == .error {
guard let inputStream = inputStream else { reopened = true
return close()
try open()
} }
if inputStream.setProperty(seekOffset, forKey: .fileCurrentOffsetKey) { var offset = seekOffset
position = seekOffset if isMp4, mp4Restructure.dataOptimized {
offset = mp4Restructure.seekAdjusted(offset: seekOffset)
}
if inputStream?.setProperty(offset, forKey: .fileCurrentOffsetKey) == true {
position = offset
} else { } else {
position = 0 position = 0
} }
if !reopened {
underlyingQueue.async { [weak self] in
if self?.inputStream?.hasBytesAvailable == true {
self?.dataAvailable()
}
}
}
} }
private func dataAvailable() { private func dataAvailable() {
@@ -92,13 +117,51 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
let read = inputStream.read(buffer, maxLength: readSize) let read = inputStream.read(buffer, maxLength: readSize)
if read > 0 { if read > 0 {
let data = Data(bytes: buffer, count: read) 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 position += read
} else { } else {
position += getCurrentOffsetFromStream() 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 { private func open() throws {
guard let inputStream = InputStream(url: url) else { guard let inputStream = InputStream(url: url) else {
throw AudioSystemError.playerStartError throw AudioSystemError.playerStartError
@@ -0,0 +1,280 @@
//
// Created by Dimitrios Chatzieleftheriou on 20/03/2024.
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
struct MP4Atom: Equatable, CustomDebugStringConvertible {
let type: Int
let size: Int
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") }
static var co64: Int { fourCcToInt("c064") }
static var atomPreampleSize: Int = 8
static func fourCcToInt(_ fourCc: String) -> Int {
let data = fourCc.data(using: .ascii)!
return Int(bigEndian: Int(data: data))
}
static func integerToFourCC(_ value: Int) -> String? {
guard value >= 0, value <= 0xFFFF_FFFF else {
return nil // Integer value out of range
}
var bytes: [UInt8] = []
bytes.append(UInt8((value >> 24) & 0xFF))
bytes.append(UInt8((value >> 16) & 0xFF))
bytes.append(UInt8((value >> 8) & 0xFF))
bytes.append(UInt8(value & 0xFF))
let data = Data(bytes)
return String(data: data, encoding: .ascii)
}
}
enum Mp4RestructureError: Error {
case unableToRestructureData
case missingMoovData
case invalidMoovAtom
case invalidAtomSize
case invalidAtomType
case invalidOffset
case missingMdatAtom
case missingMoovAtom
case compressedAtomNotSupported
case nonOptimizedMp4AndServerCannotSeek
case networkError(Error)
}
final class Mp4Restructure {
private var atomOffset: Int = 0
private var atoms: [MP4Atom] = []
private var ftyp: MP4Atom?
private var foundMoov = false
private var foundMdat = false
private(set) var dataOptimized: Bool = false
private var moovAtomSize: Int = 0
func clear() {
atomOffset = 0
atoms = []
ftyp = nil
foundMdat = false
foundMoov = false
}
/// Adjust the seekOffset of subtracting the moovAtomSize
/// - Parameter offset: A byte offset
/// - Returns: An adjusted byte offset
func seekAdjusted(offset: Int) -> Int {
offset - moovAtomSize
}
func restructureMoov(data: Data) throws -> (initialData: Data, mdatOffset: Int) {
let (atomData, moovSize) = try doRestructureMoov(data: data)
moovAtomSize = moovSize
guard let mdatIndex = atoms.firstIndex(where: { $0.type == Atoms.mdat }) else {
throw Mp4RestructureError.missingMdatAtom
}
let mdatAtom = atoms[mdatIndex]
let atoms = Array(atoms[..<mdatIndex])
let dataOfAtomsBefore = atoms.filter { $0.data != nil }.compactMap(\.data)
let accumulatedInitialData = dataOfAtomsBefore.reduce(into: Data()) { partialResult, data in
partialResult.append(data)
}
let initialData = accumulatedInitialData + atomData
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)
}
/// Returns `nil` if the data is optimized otherwise `Mp4OptimizeInfo`
func checkIsOptimized(data: Data) throws -> Mp4OptimizeInfo? {
while atomOffset < UInt64(data.count) {
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]
let ftyp = MP4Atom(type: atomType, size: atomSize, offset: atomOffset, data: ftypData)
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
case Atoms.moov:
let moov = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(moov)
foundMoov = true
default:
let atom = MP4Atom(type: atomType, size: atomSize, offset: atomOffset)
atoms.append(atom)
}
if ftyp != nil {
if foundMoov && !foundMdat {
Logger.debug("🕵️ detected an optimized mp4", category: .generic)
return nil
} else if !foundMoov && foundMdat {
Logger.debug("🕵️ detected an non-optimized mp4", category: .generic)
let possibleMoovOffset = Int(atomOffset) + atomSize
return Mp4OptimizeInfo(moovOffset: possibleMoovOffset, moovSize: atomSize)
}
}
atomOffset += atomSize
}
return nil
}
/// logic taken from qt-faststart.c over at ffmpeg
/// https://github.com/FFmpeg/FFmpeg/blob/b47b2c5b912558b639c8542993e1256f9c69e675/tools/qt-faststart.c
private func doRestructureMoov(data: Data) throws -> (Data, Int) {
var moovAtomSize: Int = 0
var moovAtomType: Int = 0
var originalData = ByteBuffer(data: data)
var offset: Int = 0
// do search for moov within the new data
while offset < originalData.length {
moovAtomSize = Int(try originalData.getInteger() as UInt32)
moovAtomType = Int(try originalData.getInteger() as UInt32)
if moovAtomType == Atoms.moov {
break
}
offset += moovAtomSize
}
// error if we couldn't find an moov type
guard moovAtomType == Atoms.moov else {
throw Mp4RestructureError.missingMoovAtom
}
originalData.offset = offset
var moovAtom = ByteBuffer(size: moovAtomSize)
let slicedData: Data = try originalData.readBytes(moovAtom.length)
moovAtom.writeBytes(slicedData)
moovAtom.rewind()
if try Int(moovAtom.getInteger(12) as UInt32) == Atoms.cmov {
Logger.debug("Compressed moov atom not supported", category: .generic)
throw Mp4RestructureError.compressedAtomNotSupported
}
var atomType: Int
var atomSize: Int
// crawl through the atom and restructure offsets
while moovAtom.bytesAvailable >= 8 {
let atomHead = moovAtom.offset
atomType = try Int(moovAtom.getInteger(atomHead + 4) as UInt32)
if !(atomType == Atoms.stco || atomType == Atoms.co64) {
moovAtom.offset += 1
continue
}
atomSize = try Int(moovAtom.getInteger(atomHead) as UInt32)
if atomSize > moovAtom.bytesAvailable {
Logger.debug("aborting due to a bad size on an atom", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
// we need to skip the offset by `12` which come from the bytes of [size/4][type/4][version/1][flags/3]
// more info https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom
moovAtom.offset = atomHead + 12
if moovAtom.bytesAvailable < 4 {
Logger.debug("aborting due to a malformed atom", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
// the next integer determines the `Number of entries`
// https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom/number_of_entries
let numberOfOffsetEntries = try Int(moovAtom.getInteger() as UInt32)
if atomType == Atoms.stco {
Logger.debug("🏗️ patching stco atom...", category: .generic)
if moovAtom.bytesAvailable < numberOfOffsetEntries * 4 {
Logger.debug("aborting due to bad atom..", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset = try Int(moovAtom.getInteger(moovAtom.offset) as UInt32)
// adjust the offset by adding the size of moov atom
let adjustOffset = currentOffset + moovAtomSize
if currentOffset < 0, adjustOffset >= 0 {
throw Mp4RestructureError.unableToRestructureData
}
moovAtom.put(UInt32(adjustOffset).bigEndian)
}
} else if atomType == Atoms.co64 {
Logger.debug("🏗️ patching co64 atom...", category: .generic)
if moovAtom.bytesAvailable < numberOfOffsetEntries * 8 {
Logger.debug("aborting due to bad atom..", category: .generic)
throw Mp4RestructureError.unableToRestructureData
}
for _ in 0 ..< numberOfOffsetEntries {
let currentOffset: Int = try moovAtom.getInteger(moovAtom.offset)
// adjust the offset by adding the size of moov atom
moovAtom.put(currentOffset + moovAtomSize)
}
}
}
return (moovAtom.storage, moovAtomSize)
}
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
}
}
@@ -0,0 +1,146 @@
//
// Created by Dimitrios Chatzieleftheriou on 10/03/2024.
// Copyright © 2020 Decimal. All rights reserved.
//
import Foundation
final class RemoteMp4Restructure {
struct RestructuredData {
var initialData: Data
var mdatOffset: Int
}
private var audioData: Data
private var atomOffset: Int = 0
private var atoms: [MP4Atom] = []
private var ftyp: MP4Atom?
private var foundMoov = false
private var foundMdat = false
private var task: NetworkDataStream?
private(set) var dataOptimized: Bool = false
private var moovAtomSize: Int = 0
private let url: URL
private let networking: NetworkingClient
private let mp4Restructure: Mp4Restructure
init(url: URL, networking: NetworkingClient, restructure: Mp4Restructure = Mp4Restructure()) {
self.url = url
self.networking = networking
self.audioData = Data()
self.mp4Restructure = restructure
}
func clear() {
mp4Restructure.clear()
audioData = Data()
task?.cancel()
task = nil
}
/// Adjust the seekOffset of subtracting the moovAtomSize
/// - Parameter offset: A byte offset
/// - Returns: An adjusted byte offset
func seekAdjusted(offset: Int) -> Int {
mp4Restructure.seekAdjusted(offset: offset)
}
///
/// Gather audio and parse along the way, if moov atom is found, continue as usual
/// if mdat is found before moov:
/// - Get mdat size and make a byte request Range: bytes=mdatAtomSize- for possible moov atom
/// - once the request is complete search for an moov atom and restructure it
/// - finally, make a byte request Range: bytes=mdatOffset- to get the mdat
/// Atoms needs to be as following for the AudioFileStreamParse to work
/// [ftyp][moov][mdat]
///
func optimizeIfNeeded(completion: @escaping (Result<RestructuredData?, Error>) -> Void) {
task = networking.stream(request: urlForPartialContent(with: url, offset: 0))
.responseStream { [weak self] event in
guard let self else { return }
switch event {
case .response:
break
case let .stream(.success(response)):
guard let data = response.data else {
self.audioData = Data()
completion(.failure(Mp4RestructureError.unableToRestructureData))
return
}
self.audioData.append(data)
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))
return
}
// stop request, fetch moov and restructure
self.audioData = Data()
self.task?.cancel()
self.task = nil
self.fetchAndRestructureMoovAtom(offset: value.moovOffset) { result in
switch result {
case let .success(value):
let data = value.data
let offset = value.offset
self.dataOptimized = true
completion(.success(RestructuredData(initialData: data, mdatOffset: offset)))
case let .failure(error):
completion(.failure(Mp4RestructureError.networkError(error)))
}
}
} else {
self.audioData = Data()
self.task?.cancel()
self.task = nil
completion(.success(nil))
}
} catch {
completion(.failure(Mp4RestructureError.invalidAtomSize))
}
case let .stream(.failure(error)):
completion(.failure(Mp4RestructureError.networkError(error)))
case .complete:
break
}
}
task?.resume()
}
func fetchAndRestructureMoovAtom(offset: Int, completion: @escaping (Result<(data: Data, offset: Int), Error>) -> Void) {
networking.task(request: urlForPartialContent(with: url, offset: offset)) { [weak self] result in
guard let self else { return }
switch result {
case let .success(data):
do {
let (initialData, mdatOffset) = try self.mp4Restructure.restructureMoov(data: data)
completion(.success((initialData, mdatOffset)))
} catch {
completion(.failure(error))
}
case let .failure(failure):
completion(.failure(Mp4RestructureError.networkError(failure)))
}
}
}
private func urlForPartialContent(with url: URL, offset: Int) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
urlRequest.addValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
return urlRequest
}
}
@@ -8,6 +8,10 @@ import AVFoundation
import Foundation import Foundation
import Network import Network
enum RemoteAudioSourceError: Error {
case mp4NotSeekable
}
public class RemoteAudioSource: AudioStreamSource { public class RemoteAudioSource: AudioStreamSource {
weak var delegate: AudioStreamSourceDelegate? weak var delegate: AudioStreamSourceDelegate?
@@ -31,23 +35,25 @@ public class RemoteAudioSource: AudioStreamSource {
private var seekOffset: Int private var seekOffset: Int
private var supportsSeek: Bool private var supportsSeek: Bool
internal var metadataStreamProcessor: MetadataStreamSource var metadataStreamProcessor: MetadataStreamSource
private var shouldTryParsingIcycastHeaders: Bool = false private var shouldTryParsingIcycastHeaders: Bool = false
private let icycastHeadersProcessor: IcycastHeadersProcessor private let icycastHeadersProcessor: IcycastHeadersProcessor
internal var audioFileHint: AudioFileTypeID { var audioFileHint: AudioFileTypeID {
guard let output = parsedHeaderOutput, output.typeId != 0 else { guard let output = parsedHeaderOutput, output.typeId != 0 else {
return audioFileType(fileExtension: url.pathExtension) return audioFileType(fileExtension: url.pathExtension)
} }
return output.typeId return output.typeId
} }
internal let underlyingQueue: DispatchQueue private let mp4Restructure: RemoteMp4Restructure
internal let streamOperationQueue: OperationQueue
internal let netStatusService: NetStatusProvider let underlyingQueue: DispatchQueue
internal var waitingForNetwork = false let streamOperationQueue: OperationQueue
internal let retrierTimeout: Retrier let netStatusService: NetStatusProvider
var waitingForNetwork = false
let retrierTimeout: Retrier
init(networking: NetworkingClient, init(networking: NetworkingClient,
metadataStreamSource: MetadataStreamSource, metadataStreamSource: MetadataStreamSource,
@@ -74,6 +80,7 @@ public class RemoteAudioSource: AudioStreamSource {
streamOperationQueue.isSuspended = true streamOperationQueue.isSuspended = true
streamOperationQueue.name = "remote.audio.source.data.stream.queue" streamOperationQueue.name = "remote.audio.source.data.stream.queue"
retrierTimeout = retrier retrierTimeout = retrier
mp4Restructure = RemoteMp4Restructure(url: url, networking: networkingClient)
startNetworkService() startNetworkService()
} }
@@ -128,6 +135,7 @@ public class RemoteAudioSource: AudioStreamSource {
return return
} }
mp4Restructure.clear()
retrierTimeout.cancel() retrierTimeout.cancel()
metadataStreamProcessor.reset() metadataStreamProcessor.reset()
icycastHeadersProcessor.reset() icycastHeadersProcessor.reset()
@@ -158,8 +166,27 @@ public class RemoteAudioSource: AudioStreamSource {
} }
private func performOpen(seek seekOffset: Int) { private func performOpen(seek seekOffset: Int) {
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset) if seekOffset == 0 {
initialRequest { [weak self] in
guard let self else { return }
if self.parsedHeaderOutput?.isMp4 == true {
self.handleMp4Files()
} else {
self.doPerfomOpen(seek: 0)
}
}
} else {
if mp4Restructure.dataOptimized {
let adjustedOffset = mp4Restructure.seekAdjusted(offset: seekOffset)
doPerfomOpen(seek: adjustedOffset)
} else {
doPerfomOpen(seek: seekOffset)
}
}
}
private func doPerfomOpen(seek seekOffset: Int) {
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
streamRequest = networkingClient.stream(request: urlRequest) streamRequest = networkingClient.stream(request: urlRequest)
.responseStream { [weak self] event in .responseStream { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
@@ -170,6 +197,41 @@ public class RemoteAudioSource: AudioStreamSource {
metadataStreamProcessor.delegate = self metadataStreamProcessor.delegate = self
} }
private func initialRequest(completion: @escaping () -> Void) {
let urlRequest = fetchUrlForPartialContent(with: url)
let task: NetworkDataStream = networkingClient.stream(request: urlRequest)
task.responseStream { [weak self] event in
switch event {
case let .response(urlResponse):
self?.parseResponseHeader(response: urlResponse)
task.cancel()
completion()
default:
break
}
}.resume()
}
private func handleMp4Files() {
mp4Restructure.optimizeIfNeeded { [weak self] result in
guard let self else { return }
switch result {
case let .success(value):
if let value {
self.addStreamOperation {
let audioCount = self.processAudio(data: value.initialData)
self.relativePosition += audioCount
}
self.doPerfomOpen(seek: value.mdatOffset)
} else {
self.doPerfomOpen(seek: 0)
}
case let .failure(failure):
self.delegate?.errorOccurred(source: self, error: failure)
}
}
}
// MARK: - Network Handle Methods // MARK: - Network Handle Methods
private func handleResponse(event: NetworkDataStream.ResponseEvent) { private func handleResponse(event: NetworkDataStream.ResponseEvent) {
@@ -195,7 +257,7 @@ public class RemoteAudioSource: AudioStreamSource {
private func handleSuccessfulStreamEvent(response: NetworkDataStream.Response) { private func handleSuccessfulStreamEvent(response: NetworkDataStream.Response) {
guard let audioData = response.data else { guard let audioData = response.data else {
self.delegate?.errorOccurred(source: self, error: NetworkError.missingData) delegate?.errorOccurred(source: self, error: NetworkError.missingData)
return return
} }
addStreamOperation { [weak self] in addStreamOperation { [weak self] in
@@ -219,7 +281,7 @@ public class RemoteAudioSource: AudioStreamSource {
} }
} }
private func handleFailedStreamEvent(error: Error) { private func handleFailedStreamEvent(error _: Error) {
if !netStatusService.isConnected { if !netStatusService.isConnected {
waitingForNetwork = true waitingForNetwork = true
return return
@@ -254,7 +316,9 @@ public class RemoteAudioSource: AudioStreamSource {
return return
} }
if let acceptRanges = parser.value(forHTTPHeaderField: HeaderField.acceptRanges, in: response) { if httpStatusCode == 206 {
supportsSeek = true
} else if let acceptRanges = parser.value(forHTTPHeaderField: HeaderField.acceptRanges, in: response) {
supportsSeek = acceptRanges != "none" supportsSeek = acceptRanges != "none"
} }
@@ -297,6 +361,22 @@ public class RemoteAudioSource: AudioStreamSource {
return urlRequest return urlRequest
} }
private func fetchUrlForPartialContent(with url: URL) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.networkServiceType = .avStreaming
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.timeoutInterval = 60
for header in additionalRequestHeaders {
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
}
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
urlRequest.addValue("bytes=0-1", forHTTPHeaderField: "Range")
return urlRequest
}
private func retryOnError() { private func retryOnError() {
retrierTimeout.retry { [weak self] in retrierTimeout.retry { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -311,6 +391,7 @@ public class RemoteAudioSource: AudioStreamSource {
/// - Parameter block: A closure to be executed /// - Parameter block: A closure to be executed
private func addStreamOperation(_ block: @escaping () -> Void) { private func addStreamOperation(_ block: @escaping () -> Void) {
let operation = BlockOperation(block: block) let operation = BlockOperation(block: block)
operation.qualityOfService = .userInitiated
streamOperationQueue.addOperation(operation) streamOperationQueue.addOperation(operation)
} }
@@ -100,9 +100,7 @@ open class AudioPlayer {
} }
/// An `AVAudioFormat` object for the canonical audio stream /// An `AVAudioFormat` object for the canonical audio stream
private var outputAudioFormat: AVAudioFormat = { private var outputAudioFormat: AVAudioFormat = .init(commonFormat: .pcmFormatFloat32, sampleRate: 44100.0, channels: 2, interleaved: true)!
AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100.0, channels: 2, interleaved: true)!
}()
/// Keeps track of the player's state before being paused. /// Keeps track of the player's state before being paused.
private var stateBeforePaused: InternalState = .initial private var stateBeforePaused: InternalState = .initial
@@ -135,30 +133,32 @@ open class AudioPlayer {
public init(configuration: AudioPlayerConfiguration = .default) { public init(configuration: AudioPlayerConfiguration = .default) {
self.configuration = configuration.normalizeValues() self.configuration = configuration.normalizeValues()
let engine = AVAudioEngine() let engine = AVAudioEngine()
self.audioEngine = engine audioEngine = engine
rendererContext = AudioRendererContext(configuration: configuration, outputAudioFormat: outputAudioFormat) rendererContext = AudioRendererContext(configuration: configuration, outputAudioFormat: outputAudioFormat)
playerContext = AudioPlayerContext() playerContext = AudioPlayerContext()
entriesQueue = PlayerQueueEntries() entriesQueue = PlayerQueueEntries()
serializationQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated) serializationQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated)
sourceQueue = DispatchQueue(label: "source.queue", qos: .default) sourceQueue = DispatchQueue(label: "source.queue", qos: .default)
entryProvider = AudioEntryProvider( entryProvider = AudioEntryProvider(
networkingClient: NetworkingClient(), networkingClient: NetworkingClient(),
underlyingQueue: sourceQueue, underlyingQueue: sourceQueue,
outputAudioFormat: outputAudioFormat outputAudioFormat: outputAudioFormat
) )
fileStreamProcessor = AudioFileStreamProcessor( fileStreamProcessor = AudioFileStreamProcessor(
playerContext: playerContext, playerContext: playerContext,
rendererContext: rendererContext, rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription) outputAudioFormat: outputAudioFormat.basicStreamDescription
)
playerRenderProcessor = AudioPlayerRenderProcessor( playerRenderProcessor = AudioPlayerRenderProcessor(
playerContext: playerContext, playerContext: playerContext,
rendererContext: rendererContext, rendererContext: rendererContext,
outputAudioFormat: outputAudioFormat.basicStreamDescription) outputAudioFormat: outputAudioFormat.basicStreamDescription
)
frameFilterProcessor = FrameFilterProcessor( frameFilterProcessor = FrameFilterProcessor(
mixerNodeProvider: { mixerNodeProvider: {
engine.mainMixerNode engine.mainMixerNode
@@ -210,6 +210,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 /// Queues the specified URL
/// ///
/// - Parameter url: A `URL` specifying the audio content to be played. /// - Parameter url: A `URL` specifying the audio content to be played.
@@ -224,15 +243,41 @@ open class AudioPlayer {
queue(urls: urls, headers: [:]) queue(urls: urls, headers: [:])
} }
public func queue(url: URL, after afterUrl: URL) {
queue(url: url, headers: [:], after: afterUrl)
}
public func removeFromQueue(url: URL) {
serializationQueue.sync {
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
self?.processSource()
}
}
/// Queues the specified URL /// Queues the specified URL
/// ///
/// - Parameter url: A `URL` specifying the audio content to be played. /// - 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. /// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
public func queue(url: URL, headers: [String: String]) { public func queue(url: URL, headers: [String: String], after afterUrl: URL? = nil) {
serializationQueue.sync { serializationQueue.sync {
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers) let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
audioEntry.delegate = self audioEntry.delegate = self
entriesQueue.enqueue(item: audioEntry, type: .upcoming) 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() checkRenderWaitingAndNotifyIfNeeded()
sourceQueue.async { [weak self] in sourceQueue.async { [weak self] in
@@ -259,7 +304,7 @@ open class AudioPlayer {
} }
/// Stops the audio playback /// Stops the audio playback
public func stop() { public func stop(clearQueue: Bool = true) {
guard playerContext.internalState != .stopped else { return } guard playerContext.internalState != .stopped else { return }
serializationQueue.sync { serializationQueue.sync {
@@ -274,7 +319,9 @@ open class AudioPlayer {
self.processFinishPlaying(entry: playingEntry, with: nil) self.processFinishPlaying(entry: playingEntry, with: nil)
} }
self.clearQueue() if clearQueue {
self.clearQueue()
}
self.playerContext.entriesLock.lock() self.playerContext.entriesLock.lock()
self.playerContext.audioReadingEntry = nil self.playerContext.audioReadingEntry = nil
self.playerContext.audioPlayingEntry = nil self.playerContext.audioPlayingEntry = nil
@@ -354,7 +401,7 @@ open class AudioPlayer {
/// - Note: The nodes will be added after the default rate node /// - Note: The nodes will be added after the default rate node
/// - Parameter node: An array of `AVAudioNode` instances /// - Parameter node: An array of `AVAudioNode` instances
public func attach(nodes: [AVAudioNode]) { public func attach(nodes: [AVAudioNode]) {
nodes.forEach { node in for node in nodes {
customAttachedNodes.append(node) customAttachedNodes.append(node)
} }
nodes.forEach(audioEngine.attach) nodes.forEach(audioEngine.attach)
@@ -376,7 +423,7 @@ open class AudioPlayer {
/// Detaches the given `AVAudioNode`s from the engine /// Detaches the given `AVAudioNode`s from the engine
/// - Parameter node: An array of `AVAudioNode` instances /// - Parameter node: An array of `AVAudioNode` instances
public func detachCustomAttachedNodes() { public func detachCustomAttachedNodes() {
customAttachedNodes.forEach { node in for node in customAttachedNodes {
audioEngine.detach(node) audioEngine.detach(node)
} }
attachAndConnectDefaultNodes() attachAndConnectDefaultNodes()
@@ -561,8 +608,8 @@ open class AudioPlayer {
setCurrentReading(entry: entry, startPlaying: true, shouldClearQueue: true) setCurrentReading(entry: entry, startPlaying: true, shouldClearQueue: true)
rendererContext.resetBuffers() rendererContext.resetBuffers()
} else if let playingEntry = playerContext.audioPlayingEntry, } else if let playingEntry = playerContext.audioPlayingEntry,
playingEntry.seekRequest.requested, playingEntry.seekRequest.requested,
playingEntry != playerContext.audioReadingEntry playingEntry != playerContext.audioReadingEntry
{ {
playingEntry.audioStreamState.processedDataFormat = false playingEntry.audioStreamState.processedDataFormat = false
playingEntry.reset() playingEntry.reset()
@@ -676,7 +723,7 @@ open class AudioPlayer {
if let entry = entry, !isPlayingSameItemProbablySeek { if let entry = entry, !isPlayingSameItemProbablySeek {
let entryId = entry.id let entryId = entry.id
let progressInFrames = entry.progressInFrames() let progressInFrames = entry.progressInFrames()
let progress = Double(progressInFrames) / self.outputAudioFormat.basicStreamDescription.mSampleRate let progress = Double(progressInFrames) / outputAudioFormat.basicStreamDescription.mSampleRate
let duration = entry.duration() let duration = entry.duration()
asyncOnMain { [weak self] in asyncOnMain { [weak self] in
guard let self else { return } guard let self else { return }
@@ -704,7 +751,7 @@ open class AudioPlayer {
if let entry = entry, !isPlayingSameItemProbablySeek { if let entry = entry, !isPlayingSameItemProbablySeek {
let entryId = entry.id let entryId = entry.id
let progressInFrames = entry.progressInFrames() let progressInFrames = entry.progressInFrames()
let progress = Double(progressInFrames) / self.outputAudioFormat.basicStreamDescription.mSampleRate let progress = Double(progressInFrames) / outputAudioFormat.basicStreamDescription.mSampleRate
let duration = entry.duration() let duration = entry.duration()
sourceQueue.async { [weak self] in sourceQueue.async { [weak self] in
@@ -749,7 +796,6 @@ open class AudioPlayer {
private func raiseUnexpected(error: AudioPlayerError) { private func raiseUnexpected(error: AudioPlayerError) {
playerContext.setInternalState(to: .error) playerContext.setInternalState(to: .error)
// todo raise on main thread from playback thread
asyncOnMain { [weak self] in asyncOnMain { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.delegate?.audioPlayerUnexpectedError(player: self, error: error) self.delegate?.audioPlayerUnexpectedError(player: self, error: error)
@@ -22,7 +22,7 @@ public struct AudioPlayerConfiguration: Equatable {
/// Enables the internal logs /// Enables the internal logs
let enableLogs: Bool let enableLogs: Bool
public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: true, public static let `default` = AudioPlayerConfiguration(flushQueueOnSeek: false,
bufferSizeInSeconds: 10, bufferSizeInSeconds: 10,
secondsRequiredToStartPlaying: 1, secondsRequiredToStartPlaying: 1,
gracePeriodAfterSeekInSeconds: 0.5, gracePeriodAfterSeekInSeconds: 0.5,
@@ -5,7 +5,7 @@
import Foundation import Foundation
internal final class AudioPlayerContext { final class AudioPlayerContext {
var stopReason: Atomic<AudioPlayerStopReason> var stopReason: Atomic<AudioPlayerStopReason>
var state: Atomic<AudioPlayerState> var state: Atomic<AudioPlayerState>
@@ -38,8 +38,8 @@ internal final class AudioPlayerContext {
/// - parameter state: The new `PlayerInternalState` /// - parameter state: The new `PlayerInternalState`
/// - parameter inState: If the `inState` expression is not nil, the internalState will be set if the evaluated expression is `true` /// - parameter inState: If the `inState` expression is not nil, the internalState will be set if the evaluated expression is `true`
/// - NOTE: This sets the underlying `__playerInternalState` variable /// - NOTE: This sets the underlying `__playerInternalState` variable
internal func setInternalState(to state: AudioPlayer.InternalState, func setInternalState(to state: AudioPlayer.InternalState,
when inState: ((AudioPlayer.InternalState) -> Bool)? = nil) when inState: ((AudioPlayer.InternalState) -> Bool)? = nil)
{ {
let newValues = playerStateAndStopReason(for: state) let newValues = playerStateAndStopReason(for: state)
if let stopReason = newValues.stopReason { if let stopReason = newValues.stopReason {
@@ -32,8 +32,7 @@ extension AudioPlayer {
/// - Returns: A tuple of `(AudioPlayerState, AudioPlayerStopReason)` /// - Returns: A tuple of `(AudioPlayerState, AudioPlayerStopReason)`
func playerStateAndStopReason( func playerStateAndStopReason(
for internalState: AudioPlayer.InternalState for internalState: AudioPlayer.InternalState
) -> (state: AudioPlayerState, stopReason: AudioPlayerStopReason?) ) -> (state: AudioPlayerState, stopReason: AudioPlayerStopReason?) {
{
switch internalState { switch internalState {
case .initial: case .initial:
return (.ready, AudioPlayerStopReason.none) return (.ready, AudioPlayerStopReason.none)
@@ -6,7 +6,7 @@
import AVFoundation import AVFoundation
import CoreAudio import CoreAudio
internal var maxFramesPerSlice: AVAudioFrameCount = 8192 var maxFramesPerSlice: AVAudioFrameCount = 8192
final class AudioRendererContext { final class AudioRendererContext {
var waiting = Atomic<Bool>(false) var waiting = Atomic<Bool>(false)
@@ -34,13 +34,13 @@ final class AudioFileStreamProcessor {
private let rendererContext: AudioRendererContext private let rendererContext: AudioRendererContext
private let outputAudioFormat: AudioStreamBasicDescription private let outputAudioFormat: AudioStreamBasicDescription
internal var audioFileStream: AudioFileStreamID? var audioFileStream: AudioFileStreamID?
internal var audioConverter: AudioConverterRef? var audioConverter: AudioConverterRef?
internal var discontinuous: Bool = false var discontinuous: Bool = false
internal var inputFormat = AudioStreamBasicDescription() var inputFormat = AudioStreamBasicDescription()
internal var currentFileFormat: String = "" var currentFileFormat: String = ""
internal let fileFormatsForDelayedConverterCreation: Set = ["fa4m", "f4pm"] let fileFormatsForDelayedConverterCreation: Set = ["fa4m", "f4pm"]
var isFileStreamOpen: Bool { var isFileStreamOpen: Bool {
audioFileStream != nil audioFileStream != nil
@@ -122,20 +122,13 @@ final class AudioFileStreamProcessor {
let seekPacket = Int64(floor(readingEntry.seekRequest.time / readingEntry.packetDuration)) let seekPacket = Int64(floor(readingEntry.seekRequest.time / readingEntry.packetDuration))
let seekStatus = AudioFileStreamSeek(stream, seekPacket, &packetsAlignedByteOffset, &ioFlags) 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) let dataOffset = Int64(readingEntry.audioStreamState.dataOffset)
if !ioFlags.contains(.offsetIsEstimated) { if seekStatus == noErr, !ioFlags.contains(.offsetIsEstimated) {
seekByteOffset = packetsAlignedByteOffset + dataOffset let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / (bitrate * 8)
let delta = Double((seekByteOffset - dataOffset) - packetsAlignedByteOffset) / bitrate * 8
readingEntry.lock.lock() readingEntry.lock.lock()
readingEntry.seekTime -= delta readingEntry.seekTime -= delta
readingEntry.lock.unlock() readingEntry.lock.unlock()
seekByteOffset = packetsAlignedByteOffset + dataOffset
} }
} }
@@ -235,9 +228,11 @@ final class AudioFileStreamProcessor {
case kAudioFileStreamProperty_ReadyToProducePackets: case kAudioFileStreamProperty_ReadyToProducePackets:
// check converter for discontinuous stream // check converter for discontinuous stream
processReadyToProducePackets(fileStream: fileStream) processReadyToProducePackets(fileStream: fileStream)
processPacketUpperBoundAndMaxPacketSize(fileStream: fileStream)
case kAudioFileStreamProperty_FormatList: case kAudioFileStreamProperty_FormatList:
processFormatList(fileStream: fileStream) processFormatList(fileStream: fileStream)
default: break default:
break
} }
} }
@@ -304,6 +299,25 @@ final class AudioFileStreamProcessor {
} }
} }
private func processPacketUpperBoundAndMaxPacketSize(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return }
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(fileStream: AudioFileStreamID) { private func processDataByteCount(fileStream: AudioFileStreamID) {
guard let entry = playerContext.audioReadingEntry else { return } guard let entry = playerContext.audioReadingEntry else { return }
var audioDataByteCount: UInt64 = 0 var audioDataByteCount: UInt64 = 0
@@ -90,7 +90,7 @@ final class AudioPlayerRenderProcessor: NSObject {
waitForBuffer = true waitForBuffer = true
} }
} else if state == .waitingForDataAfterSeek { } else if state == .waitingForDataAfterSeek {
var requiredFramesToStart: Int = 1024 var requiredFramesToStart = 1024
if framesState.lastFrameQueued >= 0 { if framesState.lastFrameQueued >= 0 {
requiredFramesToStart = min(requiredFramesToStart, framesState.lastFrameQueued - framesState.queued) requiredFramesToStart = min(requiredFramesToStart, framesState.lastFrameQueued - framesState.queued)
} }
@@ -109,9 +109,9 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * framesToCopy bufferList.mBuffers.mDataByteSize = frameSizeInBytes * framesToCopy
if isMuted { if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers, if let mData = bufferList.mBuffers.mData {
outputBufferSize: 0, memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
offset: Int(bufferList.mBuffers.mDataByteSize)) }
} else { } else {
if let mDataBuffer = audioBuffer.mData { if let mDataBuffer = audioBuffer.mData {
memcpy(bufferList.mBuffers.mData, memcpy(bufferList.mBuffers.mData,
@@ -132,9 +132,9 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize = frameSizeInBytes * frameToCopy bufferList.mBuffers.mDataByteSize = frameSizeInBytes * frameToCopy
if isMuted { if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers, if let mData = bufferList.mBuffers.mData {
outputBufferSize: 0, memset(mData, 0, Int(bufferList.mBuffers.mDataByteSize))
offset: Int(bufferList.mBuffers.mDataByteSize)) }
} else { } else {
if let mDataBuffer = audioBuffer.mData { if let mDataBuffer = audioBuffer.mData {
memcpy(bufferList.mBuffers.mData, memcpy(bufferList.mBuffers.mData,
@@ -151,9 +151,7 @@ final class AudioPlayerRenderProcessor: NSObject {
bufferList.mBuffers.mDataByteSize += frameSizeInBytes * moreFramesToCopy bufferList.mBuffers.mDataByteSize += frameSizeInBytes * moreFramesToCopy
if let ioBufferData = bufferList.mBuffers.mData { if let ioBufferData = bufferList.mBuffers.mData {
if isMuted { if isMuted {
writeSilence(outputBuffer: &bufferList.mBuffers, memset(ioBufferData + Int(frameToCopy * frameSizeInBytes), 0, Int(frameSizeInBytes * moreFramesToCopy))
outputBufferSize: Int(frameSizeInBytes * moreFramesToCopy),
offset: Int(frameToCopy * frameSizeInBytes))
} else { } else {
if let mDataBuffer = audioBuffer.mData { if let mDataBuffer = audioBuffer.mData {
memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes), memcpy(ioBufferData + Int(frameToCopy * frameSizeInBytes),
@@ -179,9 +177,9 @@ final class AudioPlayerRenderProcessor: NSObject {
if totalFramesCopied < inNumberFrames { if totalFramesCopied < inNumberFrames {
let delta = inNumberFrames - totalFramesCopied let delta = inNumberFrames - totalFramesCopied
writeSilence(outputBuffer: &bufferList.mBuffers, if let mData = bufferList.mBuffers.mData {
outputBufferSize: Int(delta * frameSizeInBytes), memset(mData + Int(totalFramesCopied * frameSizeInBytes), 0, Int(delta * frameSizeInBytes))
offset: Int(totalFramesCopied * frameSizeInBytes)) }
if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) { if playingEntry != nil || AudioPlayer.InternalState.waiting.contains(state) {
if playerContext.internalState != .rebuffering { if playerContext.internalState != .rebuffering {
@@ -210,7 +208,7 @@ final class AudioPlayerRenderProcessor: NSObject {
} }
currentPlayingEntry.lock.lock() currentPlayingEntry.lock.lock()
var extraFramesPlayedNotAssigned: Int = 0 var extraFramesPlayedNotAssigned = 0
var framesPlayedForCurrent = Int(totalFramesCopied) var framesPlayedForCurrent = Int(totalFramesCopied)
if currentPlayingEntry.framesState.lastFrameQueued >= 0 { if currentPlayingEntry.framesState.lastFrameQueued >= 0 {
@@ -319,15 +317,4 @@ final class AudioPlayerRenderProcessor: NSObject {
guard inputBusNumber == 0 else { return noErr } guard inputBusNumber == 0 else { return noErr }
return render(inNumberFrames: inNumberFrames, ioData: inputData, flags: flags) 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
}
} }
@@ -78,10 +78,8 @@ final class FrameFilterProcessor: NSObject, FrameFiltering {
} }
private let lock = UnfairLock() private let lock = UnfairLock()
private let mixerNodeProvider: (() -> AVAudioMixerNode) private let mixerNodeProvider: () -> AVAudioMixerNode
private lazy var mixerNode: AVAudioMixerNode = { private lazy var mixerNode: AVAudioMixerNode = mixerNodeProvider()
return mixerNodeProvider()
}()
private(set) var entries: [FilterEntry] = [] private(set) var entries: [FilterEntry] = []
@@ -7,7 +7,7 @@ import AudioToolbox
import Foundation import Foundation
/// mapping from mime types to `AudioFileTypeID` /// mapping from mime types to `AudioFileTypeID`
internal let fileTypesFromMimeType: [String: AudioFileTypeID] = let fileTypesFromMimeType: [String: AudioFileTypeID] =
[ [
"audio/mp3": kAudioFileMP3Type, "audio/mp3": kAudioFileMP3Type,
"audio/mpg": kAudioFileMP3Type, "audio/mpg": kAudioFileMP3Type,
@@ -44,7 +44,7 @@ func audioFileType(mimeType: String) -> AudioFileTypeID {
} }
/// mapping from file extension to `AudioFileTypeID` /// mapping from file extension to `AudioFileTypeID`
internal let fileTypesFromFileExtension: [String: AudioFileTypeID] = let fileTypesFromFileExtension: [String: AudioFileTypeID] =
[ [
"mp3": kAudioFileMP3Type, "mp3": kAudioFileMP3Type,
"wav": kAudioFileWAVEType, "wav": kAudioFileWAVEType,
@@ -34,6 +34,17 @@ final class PlayerQueueEntries {
upcoming = Queue<AudioEntry>() 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` /// Adds the `item` to the underlying queue for the specified `type`
/// - parameter item: An `AudioEntry` object to be added /// - parameter item: An `AudioEntry` object to be added
/// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType` /// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType`
@@ -51,6 +62,32 @@ final class PlayerQueueEntries {
return queue(for: type).dequeue() 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` /// Appends (skips) the `items` to the underlying queue for the specified `type`
/// - parameter item: An `AudioEntry` object to be added /// - parameter item: An `AudioEntry` object to be added
/// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType` /// - parameter type: The type fo the underlying queue as expressed by `PlayerQueueType`
@@ -6,7 +6,7 @@
import AudioToolbox.AudioFile import AudioToolbox.AudioFile
import Foundation import Foundation
struct HeaderField { enum HeaderField {
public static let acceptRanges = "Accept-Ranges" public static let acceptRanges = "Accept-Ranges"
public static let contentLength = "Content-Length" public static let contentLength = "Content-Length"
public static let contentType = "Content-Type" public static let contentType = "Content-Type"
@@ -22,6 +22,11 @@ struct HTTPHeaderParserOutput {
let typeId: AudioFileTypeID let typeId: AudioFileTypeID
// Metadata Support // Metadata Support
let metadataStep: Int let metadataStep: Int
let seekable: Bool
var isMp4: Bool {
(typeId == kAudioFileMPEG4Type || typeId == kAudioFileM4AType)
}
} }
protocol HTTPHeaderParsing: Parser { protocol HTTPHeaderParsing: Parser {
@@ -46,7 +51,7 @@ struct HTTPHeaderParser: HTTPHeaderParsing {
typeId = audioFileType(mimeType: contentType) typeId = audioFileType(mimeType: contentType)
} }
var fileLength: Int = 0 var fileLength = 0
if input.statusCode == 200 { if input.statusCode == 200 {
let contentLength = value(forHTTPHeaderField: HeaderField.contentLength, in: input) let contentLength = value(forHTTPHeaderField: HeaderField.contentLength, in: input)
if let contentLength = contentLength, let length = Int(contentLength) { if let contentLength = contentLength, let length = Int(contentLength) {
@@ -70,9 +75,12 @@ struct HTTPHeaderParser: HTTPHeaderParsing {
metadataStep = intValue metadataStep = intValue
} }
return HTTPHeaderParserOutput(fileLength: fileLength, return HTTPHeaderParserOutput(
typeId: typeId, fileLength: fileLength,
metadataStep: metadataStep) typeId: typeId,
metadataStep: metadataStep,
seekable: input.statusCode == 206
)
} }
} }
@@ -91,10 +99,8 @@ extension Parser where Self: HTTPHeaderParsing {
private func valueForCaseInsensitiveKey(_ key: String, fields: [String: String]) -> String? { private func valueForCaseInsensitiveKey(_ key: String, fields: [String: String]) -> String? {
let keyToBeFound = key.lowercased() let keyToBeFound = key.lowercased()
for (key, value) in fields { for (key, value) in fields where key.lowercased() == keyToBeFound {
if key.lowercased() == keyToBeFound { return value
return value
}
} }
return nil return nil
} }
@@ -25,8 +25,11 @@ struct IcycastHeaderParser: Parser {
let contentType = result[HeaderField.contentType.lowercased()] ?? "audio/mpeg" let contentType = result[HeaderField.contentType.lowercased()] ?? "audio/mpeg"
let typeId = audioFileType(mimeType: contentType) let typeId = audioFileType(mimeType: contentType)
return HTTPHeaderParserOutput(fileLength: 0, return HTTPHeaderParserOutput(
typeId: typeId, fileLength: 0,
metadataStep: metadataStep) typeId: typeId,
metadataStep: metadataStep,
seekable: false
)
} }
} }
@@ -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
}
+2 -2
View File
@@ -12,12 +12,12 @@ class AtomicTests: XCTestCase {
measure { measure {
let atomic = Atomic<Int>(0) let atomic = Atomic<Int>(0)
DispatchQueue.concurrentPerform(iterations: 100000) { _ in DispatchQueue.concurrentPerform(iterations: 100_000) { _ in
_ = atomic.value _ = atomic.value
atomic.write { $0 += 1 } atomic.write { $0 += 1 }
} }
XCTAssertEqual(atomic.value, 100000) XCTAssertEqual(atomic.value, 100_000)
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// //
// BiMap.swift // BiMapTests.swift
// AudioStreamingTests // AudioStreamingTests
// //
// Created by Dimitrios Chatzieleftheriou on 26/05/2020. // Created by Dimitrios Chatzieleftheriou on 26/05/2020.
@@ -0,0 +1,75 @@
//
// Copyright © Blockchain Luxembourg S.A. All rights reserved.
import XCTest
@testable import AudioStreaming
final class ByteBufferTests: XCTestCase {
func testWriteAndReadBytes() {
var buffer = ByteBuffer(size: 10)
// Write bytes to the buffer
let testData = Data([0x01, 0x02, 0x03, 0x04])
buffer.writeBytes(testData)
buffer.rewind()
// Read the written bytes
do {
let readData = try buffer.readBytes(4)
XCTAssertEqual(readData, testData)
} catch {
XCTFail("Error reading bytes: \(error)")
}
}
func testWriteAndReadInteger() {
var buffer = ByteBuffer(size: 8)
// Write integer to the buffer
let testInteger: UInt32 = 123_456_789
buffer.put(testInteger)
buffer.rewind()
// Read the written integer
do {
let readInteger: UInt32 = try buffer.getInteger()
XCTAssertEqual(readInteger, testInteger.bigEndian)
} catch {
XCTFail("Error reading integer: \(error)")
}
}
func testWriteAndReadFloat() {
var buffer = ByteBuffer(size: 8)
// Write float to the buffer
let testFloat: Float = 123.456
buffer.put(testFloat)
buffer.rewind()
// Read the written float
do {
let readFloat: Float = try buffer.getFloat()
XCTAssertEqual(readFloat, testFloat, accuracy: 0.001)
} catch {
XCTFail("Error reading float: \(error)")
}
}
func testWriteAndReadDouble() {
var buffer = ByteBuffer(size: 8)
// Write double to the buffer
let testDouble = 123.456
buffer.put(testDouble)
buffer.rewind()
// Read the written double
do {
let readDouble: Double = try buffer.getDouble()
XCTAssertEqual(readDouble, testDouble, accuracy: 0.001)
} catch {
XCTFail("Error reading double: \(error)")
}
}
}
@@ -1,5 +1,5 @@
// //
// DispatchReadSourceTests.swift // DispatchTimerSourceTests.swift
// AudioStreamingTests // AudioStreamingTests
// //
// Created by Dimitrios Chatzieleftheriou on 25/10/2020. // Created by Dimitrios Chatzieleftheriou on 25/10/2020.
@@ -4,7 +4,6 @@
// //
import XCTest import XCTest
@testable import AudioStreaming @testable import AudioStreaming
class NetworkingClientTests: XCTestCase { class NetworkingClientTests: XCTestCase {
+23
View File
@@ -83,4 +83,27 @@ class QueueTests: XCTestCase {
queue.removeAll() queue.removeAll()
XCTAssertTrue(queue.isEmpty) 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)
}
} }
@@ -13,6 +13,8 @@ import XCTest
class MetadataStreamProcessorTests: XCTestCase { class MetadataStreamProcessorTests: XCTestCase {
var metadataDelegateSpy = MetadataDelegateSpy() var metadataDelegateSpy = MetadataDelegateSpy()
let bundle = Bundle(for: MetadataStreamProcessorTests.self)
func test_Processor_SendsCorrectValues_IfItCanProcessMetadata() throws { func test_Processor_SendsCorrectValues_IfItCanProcessMetadata() throws {
let parser = MetadataParser() let parser = MetadataParser()
let processor = MetadataStreamProcessor(parser: parser.eraseToAnyParser()) let processor = MetadataStreamProcessor(parser: parser.eraseToAnyParser())
@@ -34,7 +36,6 @@ class MetadataStreamProcessorTests: XCTestCase {
} }
func test_Processor_Outputs_Correct_Metadata_ForStep_WithEmptyMetadata() throws { 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 url = bundle.url(forResource: "raw-stream-audio-empty-metadata", withExtension: nil)!
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -53,7 +54,6 @@ class MetadataStreamProcessorTests: XCTestCase {
} }
func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata() throws { 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 url = bundle.url(forResource: "raw-stream-audio-normal-metadata", withExtension: nil)!
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -72,7 +72,6 @@ class MetadataStreamProcessorTests: XCTestCase {
} }
func test_Processor_Outputs_Correct_Metadata_ForStep_WithMetadata_Alt() throws { 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 url = bundle.url(forResource: "raw-stream-audio-normal-metadata-alt", withExtension: nil)!
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
@@ -95,7 +94,6 @@ class MetadataStreamProcessorTests: XCTestCase {
} }
func test_Processor_Outputs_Correct_Metadata_ForStep_NoMetadata() throws { 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 url = bundle.url(forResource: "raw-stream-audio-no-metadata", withExtension: nil)!
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
+15 -3
View File
@@ -1,4 +1,4 @@
// swift-tools-version:5.3 // swift-tools-version:5.9
import PackageDescription import PackageDescription
@@ -18,6 +18,18 @@ let package = Package(
name: "AudioStreaming", name: "AudioStreaming",
path: "AudioStreaming" path: "AudioStreaming"
), ),
], .testTarget(
swiftLanguageVersions: [.v5] 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")
]
)
]
) )
+5 -3
View File
@@ -1,4 +1,4 @@
![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/workflows/AudioStreaming%20CI/badge.svg) [![AudioStreaming CI](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml/badge.svg)](https://github.com/dimitris-c/AudioStreaming/actions/workflows/swift.yml)
# AudioStreaming # AudioStreaming
An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing. An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing.
@@ -10,12 +10,14 @@ Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playbac
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats - AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
- M4A (_Optimized files only_) - M4A (_Optimized files only_)
As of 1.2.0 version, there's support for non-optimized M4A, please report any issues
Known limitations: 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 # Requirements
- iOS 12.0+ - iOS 13.0+
- Swift 5.x - Swift 5.x
# Using AudioStreaming # Using AudioStreaming