Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64677ad6ce | |||
| 3894309706 | |||
| e44f16258f | |||
| 1e3cf35b7b | |||
| 4bfb3f1774 | |||
| e056336955 | |||
| 64d2959a27 | |||
| eb1675d4fd | |||
| ca7e48cbe7 | |||
| 653f2817bc | |||
| edff806647 | |||
| c47d623118 | |||
| b270cf86ab | |||
| 5c2fd7dc97 | |||
| d21ef34392 | |||
| e6d54b0c33 | |||
| 7a1e5bca74 | |||
| 1996812c90 | |||
| 6e1f8f12d4 | |||
| 625e1ab169 | |||
| 52c33518ad | |||
| 3f6fc327ff | |||
| e3e4e4dd46 | |||
| b60e567a83 | |||
| 17e0ee5dd8 | |||
| 97909bacce | |||
| 30b0189f61 | |||
| 5bde849bf0 | |||
| b3b519ab4c | |||
| f3b62cc756 | |||
| a56d3314ad | |||
| f75d743cd9 | |||
| f8876d821e | |||
| bca8fde2de | |||
| efbaa465b2 | |||
| 20f1d72058 | |||
| 6c3b1efe97 | |||
| a98f090b6a | |||
| 542f65f044 | |||
| f4a1141f65 | |||
| a034c7dc6f | |||
| d9c6d18921 | |||
| 7eb3d601fa | |||
| 9b375b99dc | |||
| ee80976e92 | |||
| 10aea39cae | |||
| 431fdc6428 | |||
| eda60a3c3d | |||
| d7b90f1f58 | |||
| 08b30307aa | |||
| 751ca765d5 | |||
| 68ea5a9468 | |||
| 46ab845c8e | |||
| b597704115 | |||
| 889e2257ab | |||
| e962008b4c | |||
| d6c1d13d7d | |||
| 922a794d09 | |||
| 96092a208c | |||
| b71729035d | |||
| 2abba6f0cc | |||
| f081b7549d | |||
| 55fbae7b4a | |||
| 2acbde2efa | |||
| acbdf05d4f | |||
| c325caa914 | |||
| dd54d81573 | |||
| ebc282d5c2 | |||
| 80ce253f92 | |||
| fe2395066f | |||
| 3e66b4b4d4 | |||
| 58bbc97a1b | |||
| 8d9e9d92f4 | |||
| 03392c21e0 | |||
| 924170d159 | |||
| b355eb4e09 | |||
| 1373a816a6 | |||
| 196b04a703 | |||
| ac971e65a6 | |||
| 2c50502b28 | |||
| c222b5a745 | |||
| 2e86a6503c | |||
| 9ebd7fa7fe | |||
| 5197a16023 | |||
| 159627c63e | |||
| 07230cce1a | |||
| a33aee80d1 | |||
| e1d3da1ddb | |||
| 8c2524d990 | |||
| be1b7aa05f | |||
| 4b57fee75c | |||
| fc9c43a23c | |||
| fd4e4e3b77 | |||
| f1200252be | |||
| 046e64b2b8 | |||
| ad9e40ad1c | |||
| f19eaf7ec9 | |||
| 012291c1c9 |
+41
-12
@@ -14,12 +14,13 @@
|
||||
79D8DF73FA7CDD6E266BAE71D46E035F /* Pods-SwiftAudioPlayer_Tests-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 50C71346CE708A211A5AFAC20BAE48CB /* Pods-SwiftAudioPlayer_Tests-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
831B263D357A5FA2DDC7B1AE4B374092 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A16F4CFC63FAC439D7A04994F579A03 /* Foundation.framework */; };
|
||||
8F93DB166237195ED222EE55B6404625 /* Pods-SwiftAudioPlayer_Example-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = 3B0B76CB1439F4D361322144E5A65C3A /* Pods-SwiftAudioPlayer_Example-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
A40DBE292391D9CA00F86146 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40DBE282391D9C900F86146 /* Data.swift */; };
|
||||
A411CE4625F9609D0039E1CD /* SAPlayerFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = A411CE4525F9609D0039E1CD /* SAPlayerFeatures.swift */; };
|
||||
A41AA0D2238BB9B600A467E1 /* SAPlayingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A41AA0D1238BB9B600A467E1 /* SAPlayingStatus.swift */; };
|
||||
A4681FC6220113880018AB51 /* SAPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F8D2200E00E0018AB51 /* SAPlayer.swift */; };
|
||||
A4681FC72201138B0018AB51 /* SAPlayerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F912200E1950018AB51 /* SAPlayerDelegate.swift */; };
|
||||
A4681FC82201138E0018AB51 /* SAPlayerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F8F2200E1450018AB51 /* SAPlayerPresenter.swift */; };
|
||||
A4681FC9220113920018AB51 /* LockScreenViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FBE22010ECF0018AB51 /* LockScreenViewProtocol.swift */; };
|
||||
A4681FCA220113940018AB51 /* AudioClockDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F852200DA8B0018AB51 /* AudioClockDirector.swift */; };
|
||||
A4681FCB220113980018AB51 /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F822200D9150018AB51 /* AudioEngine.swift */; };
|
||||
A4681FCC2201139B0018AB51 /* AudioDiskEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F942200E2220018AB51 /* AudioDiskEngine.swift */; };
|
||||
A4681FCD2201139E0018AB51 /* AudioStreamEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681FBC220100AB0018AB51 /* AudioStreamEngine.swift */; };
|
||||
@@ -43,8 +44,12 @@
|
||||
A4681FDF220113E20018AB51 /* DirectorThreadSafeClosures.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F872200DAD50018AB51 /* DirectorThreadSafeClosures.swift */; };
|
||||
A4681FE0220113E40018AB51 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F802200D0500018AB51 /* Log.swift */; };
|
||||
A4681FE1220113E70018AB51 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4681F8B2200DDD50018AB51 /* Constants.swift */; };
|
||||
A470FE0825F9ADF800F135FF /* AudioClockDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A470FE0625F9ADF800F135FF /* AudioClockDirector.swift */; };
|
||||
A470FE0925F9ADF800F135FF /* DownloadProgressDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A470FE0725F9ADF800F135FF /* DownloadProgressDirector.swift */; };
|
||||
A470FE1C25F9AEB900F135FF /* AudioQueueDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A470FE1B25F9AEB900F135FF /* AudioQueueDirector.swift */; };
|
||||
A470FE2125F9AF1400F135FF /* AudioQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A470FE2025F9AF1400F135FF /* AudioQueue.swift */; };
|
||||
A4827771262A216C00B6918A /* StreamingDownloadDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4827770262A216C00B6918A /* StreamingDownloadDirector.swift */; };
|
||||
A4B4CC122223ED2A0045554B /* SAPlayerDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B4CC112223ED2A0045554B /* SAPlayerDownloader.swift */; };
|
||||
A4FBA6B2221B538E00D5A353 /* DownloadProgressDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49B78C3221A78DE00BBA862 /* DownloadProgressDirector.swift */; };
|
||||
A4FBA6B5221B74C900D5A353 /* SALockScreenInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FBA6B3221B74C900D5A353 /* SALockScreenInfo.swift */; };
|
||||
A4FBA6B7221BAC3D00D5A353 /* SAPlayerUpdateSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FBA6B6221BAC3D00D5A353 /* SAPlayerUpdateSubscription.swift */; };
|
||||
A4FBA6B9221BAF8700D5A353 /* SAAudioAvailabilityRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FBA6B8221BAF8700D5A353 /* SAAudioAvailabilityRange.swift */; };
|
||||
@@ -96,11 +101,12 @@
|
||||
99925F09FC9C6EA4B9C0508F4E2D1FE2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A19C8F889C787C19BE4123C1896AF501 /* Pods-SwiftAudioPlayer_Example-resources.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "Pods-SwiftAudioPlayer_Example-resources.sh"; sourceTree = "<group>"; };
|
||||
A39F2A138CF40C1051CA9E227429A86D /* SwiftAudioPlayer.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = SwiftAudioPlayer.modulemap; sourceTree = "<group>"; };
|
||||
A40DBE282391D9C900F86146 /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = "<group>"; };
|
||||
A411CE4525F9609D0039E1CD /* SAPlayerFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayerFeatures.swift; sourceTree = "<group>"; };
|
||||
A41AA0D1238BB9B600A467E1 /* SAPlayingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayingStatus.swift; sourceTree = "<group>"; };
|
||||
A4523BC8220A0B3C0079C4BC /* Credited_LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = Credited_LICENSE; sourceTree = "<group>"; };
|
||||
A4681F802200D0500018AB51 /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
|
||||
A4681F822200D9150018AB51 /* AudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngine.swift; sourceTree = "<group>"; };
|
||||
A4681F852200DA8B0018AB51 /* AudioClockDirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioClockDirector.swift; sourceTree = "<group>"; };
|
||||
A4681F872200DAD50018AB51 /* DirectorThreadSafeClosures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectorThreadSafeClosures.swift; sourceTree = "<group>"; };
|
||||
A4681F892200DB3C0018AB51 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
|
||||
A4681F8B2200DDD50018AB51 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
|
||||
@@ -126,7 +132,11 @@
|
||||
A4681FBA2201002F0018AB51 /* AudioConverterListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterListener.swift; sourceTree = "<group>"; };
|
||||
A4681FBC220100AB0018AB51 /* AudioStreamEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioStreamEngine.swift; sourceTree = "<group>"; };
|
||||
A4681FBE22010ECF0018AB51 /* LockScreenViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenViewProtocol.swift; sourceTree = "<group>"; };
|
||||
A49B78C3221A78DE00BBA862 /* DownloadProgressDirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgressDirector.swift; sourceTree = "<group>"; };
|
||||
A470FE0625F9ADF800F135FF /* AudioClockDirector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioClockDirector.swift; sourceTree = "<group>"; };
|
||||
A470FE0725F9ADF800F135FF /* DownloadProgressDirector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadProgressDirector.swift; sourceTree = "<group>"; };
|
||||
A470FE1B25F9AEB900F135FF /* AudioQueueDirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioQueueDirector.swift; sourceTree = "<group>"; };
|
||||
A470FE2025F9AF1400F135FF /* AudioQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioQueue.swift; sourceTree = "<group>"; };
|
||||
A4827770262A216C00B6918A /* StreamingDownloadDirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingDownloadDirector.swift; sourceTree = "<group>"; };
|
||||
A4B4CC112223ED2A0045554B /* SAPlayerDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayerDownloader.swift; sourceTree = "<group>"; };
|
||||
A4FBA6B3221B74C900D5A353 /* SALockScreenInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SALockScreenInfo.swift; sourceTree = "<group>"; };
|
||||
A4FBA6B6221BAC3D00D5A353 /* SAPlayerUpdateSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SAPlayerUpdateSubscription.swift; sourceTree = "<group>"; };
|
||||
@@ -273,6 +283,7 @@
|
||||
A4681F872200DAD50018AB51 /* DirectorThreadSafeClosures.swift */,
|
||||
A4681F892200DB3C0018AB51 /* Date.swift */,
|
||||
A4681F962200E2E20018AB51 /* URL.swift */,
|
||||
A40DBE282391D9C900F86146 /* Data.swift */,
|
||||
);
|
||||
path = Util;
|
||||
sourceTree = "<group>";
|
||||
@@ -295,6 +306,7 @@
|
||||
A4681F9B2200E4850018AB51 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A470FE2025F9AF1400F135FF /* AudioQueue.swift */,
|
||||
A4681F992200E3D90018AB51 /* AudioDataManager.swift */,
|
||||
A4681FA62200F0130018AB51 /* StreamProgressPTO.swift */,
|
||||
A4681FA02200E5F50018AB51 /* Streaming */,
|
||||
@@ -348,20 +360,31 @@
|
||||
children = (
|
||||
A4FBA6B3221B74C900D5A353 /* SALockScreenInfo.swift */,
|
||||
A4681F8D2200E00E0018AB51 /* SAPlayer.swift */,
|
||||
A411CE4525F9609D0039E1CD /* SAPlayerFeatures.swift */,
|
||||
A4FBA6B6221BAC3D00D5A353 /* SAPlayerUpdateSubscription.swift */,
|
||||
A4B4CC112223ED2A0045554B /* SAPlayerDownloader.swift */,
|
||||
A4681F912200E1950018AB51 /* SAPlayerDelegate.swift */,
|
||||
A4681F8F2200E1450018AB51 /* SAPlayerPresenter.swift */,
|
||||
A4681FBE22010ECF0018AB51 /* LockScreenViewProtocol.swift */,
|
||||
A4681F852200DA8B0018AB51 /* AudioClockDirector.swift */,
|
||||
A49B78C3221A78DE00BBA862 /* DownloadProgressDirector.swift */,
|
||||
A4681F932200E2020018AB51 /* Engine */,
|
||||
A470FE0D25F9AE1800F135FF /* Directors */,
|
||||
A4681F9B2200E4850018AB51 /* Model */,
|
||||
A4681F842200D91D0018AB51 /* Util */,
|
||||
);
|
||||
path = Source;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A470FE0D25F9AE1800F135FF /* Directors */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A470FE0725F9ADF800F135FF /* DownloadProgressDirector.swift */,
|
||||
A470FE0625F9ADF800F135FF /* AudioClockDirector.swift */,
|
||||
A470FE1B25F9AEB900F135FF /* AudioQueueDirector.swift */,
|
||||
A4827770262A216C00B6918A /* StreamingDownloadDirector.swift */,
|
||||
);
|
||||
path = Directors;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BC3CA7F9E30CC8F7E2DD044DD34432FC /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -488,16 +511,17 @@
|
||||
LastSwiftMigration = 1010;
|
||||
};
|
||||
E50DAD13FFD3FC8036073A58BF8423D4 = {
|
||||
LastSwiftMigration = 1010;
|
||||
LastSwiftMigration = 1120;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 2D8E8EC45A3A1A1D94AE762CB5028504 /* Build configuration list for PBXProject "Pods" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
developmentRegion = English;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 7DB346D0F39D3F0E887471402A8071AB;
|
||||
productRefGroup = 21D946895A4F57F51246F3EBCF330719 /* Products */;
|
||||
@@ -516,19 +540,23 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A470FE0925F9ADF800F135FF /* DownloadProgressDirector.swift in Sources */,
|
||||
A41AA0D2238BB9B600A467E1 /* SAPlayingStatus.swift in Sources */,
|
||||
A470FE1C25F9AEB900F135FF /* AudioQueueDirector.swift in Sources */,
|
||||
A4681FDC220113D70018AB51 /* AudioDownloadWorker.swift in Sources */,
|
||||
A4681FD8220113C60018AB51 /* AudioDataManager.swift in Sources */,
|
||||
A4681FD1220113AF0018AB51 /* AudioParsable.swift in Sources */,
|
||||
A4681FD2220113B20018AB51 /* AudioParser.swift in Sources */,
|
||||
A4681FCF220113A40018AB51 /* AudioConverterListener.swift in Sources */,
|
||||
A4681FE1220113E70018AB51 /* Constants.swift in Sources */,
|
||||
A40DBE292391D9CA00F86146 /* Data.swift in Sources */,
|
||||
A4FBA6B5221B74C900D5A353 /* SALockScreenInfo.swift in Sources */,
|
||||
A4681FC6220113880018AB51 /* SAPlayer.swift in Sources */,
|
||||
A4FBA6B7221BAC3D00D5A353 /* SAPlayerUpdateSubscription.swift in Sources */,
|
||||
A4681FC72201138B0018AB51 /* SAPlayerDelegate.swift in Sources */,
|
||||
A4681FD5220113BD0018AB51 /* AudioParserErrors.swift in Sources */,
|
||||
A4681FC9220113920018AB51 /* LockScreenViewProtocol.swift in Sources */,
|
||||
A4827771262A216C00B6918A /* StreamingDownloadDirector.swift in Sources */,
|
||||
A4681FD6220113BF0018AB51 /* AudioThrottler.swift in Sources */,
|
||||
A4681FCC2201139B0018AB51 /* AudioDiskEngine.swift in Sources */,
|
||||
A4681FDE220113DE0018AB51 /* Date.swift in Sources */,
|
||||
@@ -538,16 +566,17 @@
|
||||
E08AD6157EF688FE832F866CBCDA3532 /* SwiftAudioPlayer-dummy.m in Sources */,
|
||||
A4681FDD220113DC0018AB51 /* URL.swift in Sources */,
|
||||
A4681FC82201138E0018AB51 /* SAPlayerPresenter.swift in Sources */,
|
||||
A470FE2125F9AF1400F135FF /* AudioQueue.swift in Sources */,
|
||||
A4681FD3220113B60018AB51 /* AudioParserPropertyListener.swift in Sources */,
|
||||
A4681FCA220113940018AB51 /* AudioClockDirector.swift in Sources */,
|
||||
A4B4CC122223ED2A0045554B /* SAPlayerDownloader.swift in Sources */,
|
||||
A4681FD0220113A70018AB51 /* AudioConverterErrors.swift in Sources */,
|
||||
A4FBA6B2221B538E00D5A353 /* DownloadProgressDirector.swift in Sources */,
|
||||
A4681FD7220113C30018AB51 /* StreamProgressPTO.swift in Sources */,
|
||||
A4681FE0220113E40018AB51 /* Log.swift in Sources */,
|
||||
A4681FCE220113A20018AB51 /* AudioConverter.swift in Sources */,
|
||||
A470FE0825F9ADF800F135FF /* AudioClockDirector.swift in Sources */,
|
||||
A4FBA6B9221BAF8700D5A353 /* SAAudioAvailabilityRange.swift in Sources */,
|
||||
A4681FCD2201139E0018AB51 /* AudioStreamEngine.swift in Sources */,
|
||||
A411CE4625F9609D0039E1CD /* SAPlayerFeatures.swift in Sources */,
|
||||
A4681FD9220113CD0018AB51 /* AudioStreamWorker.swift in Sources */,
|
||||
A4681FDF220113E20018AB51 /* DirectorThreadSafeClosures.swift in Sources */,
|
||||
A4681FCB220113980018AB51 /* AudioEngine.swift in Sources */,
|
||||
@@ -676,7 +705,7 @@
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 4.2;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
VERSION_INFO_PREFIX = "";
|
||||
@@ -708,7 +737,7 @@
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
SWIFT_VERSION = 4.2;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?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>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -14,6 +14,7 @@
|
||||
607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; };
|
||||
607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; };
|
||||
607FACEC1AFB9204008FA782 /* Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACEB1AFB9204008FA782 /* Tests.swift */; };
|
||||
A470FEE2260303DA00F135FF /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = A470FEE1260303DA00F135FF /* Model.swift */; };
|
||||
E5808EC0557FB2395AA56468 /* Pods_SwiftAudioPlayer_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1E5C0E3F3235B6FFE85EF425 /* Pods_SwiftAudioPlayer_Example.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -43,6 +44,7 @@
|
||||
607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
607FACEB1AFB9204008FA782 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = "<group>"; };
|
||||
65A66AB4C3016E8BB53FF3E0 /* Pods-SwiftAudioPlayer_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftAudioPlayer_Example.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SwiftAudioPlayer_Example/Pods-SwiftAudioPlayer_Example.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
A470FEE1260303DA00F135FF /* Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = "<group>"; };
|
||||
AF6A2C6BF79C291056D27D5D /* SwiftAudioPlayer.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = SwiftAudioPlayer.podspec; path = ../SwiftAudioPlayer.podspec; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
|
||||
BBD877782CC67FBCC7BF7532 /* Pods-SwiftAudioPlayer_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftAudioPlayer_Tests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SwiftAudioPlayer_Tests/Pods-SwiftAudioPlayer_Tests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
DA80DEA33D13EC91EB531881 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
|
||||
@@ -115,6 +117,7 @@
|
||||
children = (
|
||||
607FACD51AFB9204008FA782 /* AppDelegate.swift */,
|
||||
607FACD71AFB9204008FA782 /* ViewController.swift */,
|
||||
A470FEE1260303DA00F135FF /* Model.swift */,
|
||||
607FACD91AFB9204008FA782 /* Main.storyboard */,
|
||||
607FACDC1AFB9204008FA782 /* Images.xcassets */,
|
||||
607FACDE1AFB9204008FA782 /* LaunchScreen.xib */,
|
||||
@@ -212,20 +215,20 @@
|
||||
TargetAttributes = {
|
||||
607FACCF1AFB9204008FA782 = {
|
||||
CreatedOnToolsVersion = 6.3.1;
|
||||
DevelopmentTeam = R2392A68YQ;
|
||||
LastSwiftMigration = 1010;
|
||||
DevelopmentTeam = H9Y26B6GZB;
|
||||
LastSwiftMigration = 1120;
|
||||
};
|
||||
607FACE41AFB9204008FA782 = {
|
||||
CreatedOnToolsVersion = 6.3.1;
|
||||
DevelopmentTeam = R2392A68YQ;
|
||||
LastSwiftMigration = 1010;
|
||||
DevelopmentTeam = H9Y26B6GZB;
|
||||
LastSwiftMigration = 1120;
|
||||
TestTargetID = 607FACCF1AFB9204008FA782;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 607FACCB1AFB9204008FA782 /* Build configuration list for PBXProject "SwiftAudioPlayer" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
developmentRegion = English;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
@@ -324,6 +327,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A470FEE2260303DA00F135FF /* Model.swift in Sources */,
|
||||
607FACD81AFB9204008FA782 /* ViewController.swift in Sources */,
|
||||
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */,
|
||||
);
|
||||
@@ -475,13 +479,13 @@
|
||||
baseConfigurationReference = 65A66AB4C3016E8BB53FF3E0 /* Pods-SwiftAudioPlayer_Example.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
DEVELOPMENT_TEAM = H9Y26B6GZB;
|
||||
INFOPLIST_FILE = SwiftAudioPlayer/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MODULE_NAME = ExampleApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo-test.SwiftAudioPlayer-Example";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 4.2;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -490,13 +494,13 @@
|
||||
baseConfigurationReference = 4B5DD2AE0B23A759D18926DC /* Pods-SwiftAudioPlayer_Example.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
DEVELOPMENT_TEAM = H9Y26B6GZB;
|
||||
INFOPLIST_FILE = SwiftAudioPlayer/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MODULE_NAME = ExampleApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo-test.SwiftAudioPlayer-Example";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 4.2;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -504,7 +508,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = BBD877782CC67FBCC7BF7532 /* Pods-SwiftAudioPlayer_Tests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
DEVELOPMENT_TEAM = H9Y26B6GZB;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/Developer/Library/Frameworks",
|
||||
"$(inherited)",
|
||||
@@ -517,7 +521,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 4.2;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftAudioPlayer_Example.app/SwiftAudioPlayer_Example";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -526,7 +530,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 0B7D1E6C00E83B4AF8AA1781 /* Pods-SwiftAudioPlayer_Tests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
DEVELOPMENT_TEAM = R2392A68YQ;
|
||||
DEVELOPMENT_TEAM = H9Y26B6GZB;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(SDKROOT)/Developer/Library/Frameworks",
|
||||
"$(inherited)",
|
||||
@@ -535,7 +539,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 4.2;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftAudioPlayer_Example.app/SwiftAudioPlayer_Example";
|
||||
};
|
||||
name = Release;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="vXZ-lx-hvc">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="vXZ-lx-hvc">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@@ -22,13 +20,13 @@
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="lTK-Hd-Tl2">
|
||||
<rect key="frame" x="16" y="320" width="343" height="2"/>
|
||||
<rect key="frame" x="16" y="303" width="343" height="4"/>
|
||||
<color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="progressTintColor" red="0.46202266219999999" green="0.83828371759999998" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="trackTintColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</progressView>
|
||||
<slider opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="749" contentHorizontalAlignment="center" contentVerticalAlignment="center" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="w2a-RA-zmI">
|
||||
<rect key="frame" x="14" y="305" width="347" height="31"/>
|
||||
<rect key="frame" x="14" y="289" width="347" height="31"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="maximumTrackTintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<connections>
|
||||
@@ -37,94 +35,129 @@
|
||||
<action selector="scrubberStartedSeeking:" destination="vXZ-lx-hvc" eventType="touchDown" id="UXg-Wf-fKv"/>
|
||||
</connections>
|
||||
</slider>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="jUc-tP-CC5">
|
||||
<rect key="frame" x="172.5" y="250" width="30" height="30"/>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="jUc-tP-CC5">
|
||||
<rect key="frame" x="172.5" y="233" width="30" height="30"/>
|
||||
<state key="normal" title="play"/>
|
||||
<connections>
|
||||
<action selector="playPauseTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="Avk-K3-EZ7"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tFH-sY-Xu9">
|
||||
<rect key="frame" x="62.5" y="250" width="30" height="30"/>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tFH-sY-Xu9">
|
||||
<rect key="frame" x="62.5" y="233" width="30" height="30"/>
|
||||
<state key="normal" title="-15"/>
|
||||
<connections>
|
||||
<action selector="skipBackwardTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="PCT-BE-udf"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="0QE-3F-a4G">
|
||||
<rect key="frame" x="282.5" y="250" width="30" height="30"/>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="0QE-3F-a4G">
|
||||
<rect key="frame" x="282.5" y="233" width="30" height="30"/>
|
||||
<state key="normal" title="+30"/>
|
||||
<connections>
|
||||
<action selector="skipForwardTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="uXv-bz-tnt"/>
|
||||
</connections>
|
||||
</button>
|
||||
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" value="1" minValue="0.10000000000000001" maxValue="32" translatesAutoresizingMaskIntoConstraints="NO" id="vfk-OJ-S3T">
|
||||
<rect key="frame" x="14" y="464" width="347" height="31"/>
|
||||
<rect key="frame" x="14" y="448" width="347" height="31"/>
|
||||
<connections>
|
||||
<action selector="rateChanged:" destination="vXZ-lx-hvc" eventType="valueChanged" id="FDJ-jA-bm8"/>
|
||||
</connections>
|
||||
</slider>
|
||||
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" value="300" minValue="0.10000000149011612" maxValue="1000" translatesAutoresizingMaskIntoConstraints="NO" id="nsl-df-P21">
|
||||
<rect key="frame" x="14" y="397" width="347" height="31"/>
|
||||
<rect key="frame" x="14" y="381" width="347" height="31"/>
|
||||
<connections>
|
||||
<action selector="reverbChanged:" destination="vXZ-lx-hvc" eventType="valueChanged" id="J8Q-be-35q"/>
|
||||
</connections>
|
||||
</slider>
|
||||
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="joK-xi-MCo">
|
||||
<rect key="frame" x="16" y="80" width="343" height="29"/>
|
||||
<rect key="frame" x="16" y="60" width="343" height="32"/>
|
||||
<segments>
|
||||
<segment title="Soundbite"/>
|
||||
<segment title="Acquired"/>
|
||||
<segment title="Y Combinator"/>
|
||||
<segment title="Podcast"/>
|
||||
<segment title="Radio"/>
|
||||
</segments>
|
||||
<connections>
|
||||
<action selector="audioSelected:" destination="vXZ-lx-hvc" eventType="valueChanged" id="oYE-yq-348"/>
|
||||
</connections>
|
||||
</segmentedControl>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="KDu-ea-kF8">
|
||||
<rect key="frame" x="78" y="140" width="69" height="30"/>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="KDu-ea-kF8">
|
||||
<rect key="frame" x="43" y="123" width="69" height="30"/>
|
||||
<state key="normal" title="Download"/>
|
||||
<connections>
|
||||
<action selector="downloadTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="8Jg-1C-0Ms"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="6d9-Bc-hIz">
|
||||
<rect key="frame" x="244" y="140" width="49" height="30"/>
|
||||
<state key="normal" title="Stream"/>
|
||||
<connections>
|
||||
<action selector="streamTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="AXY-N7-87Y"/>
|
||||
</connections>
|
||||
</button>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="rate: 1.0x" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yUQ-mI-ozK">
|
||||
<rect key="frame" x="153" y="435" width="69" height="21"/>
|
||||
<rect key="frame" x="153" y="419" width="69" height="21"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="0:00" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="j3w-gr-HzF">
|
||||
<rect key="frame" x="16" y="297" width="27" height="15"/>
|
||||
<rect key="frame" x="16" y="280" width="27" height="15"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="12"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="100:00" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Urj-Dv-41y">
|
||||
<rect key="frame" x="319" y="297" width="40" height="15"/>
|
||||
<rect key="frame" x="319" y="280" width="40" height="15"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="12"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="remote url: " textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1IX-z5-wWx">
|
||||
<rect key="frame" x="16" y="207" width="343" height="16"/>
|
||||
<rect key="frame" x="16" y="190" width="343" height="16"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="reverb: 300.0" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="y5i-MZ-Qat">
|
||||
<rect key="frame" x="136" y="368" width="103" height="21"/>
|
||||
<rect key="frame" x="136.5" y="352" width="102" height="21"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="Skip Silences" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="M2y-FP-H1D">
|
||||
<rect key="frame" x="89" y="504" width="101" height="21"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="2cn-E5-TeQ">
|
||||
<rect key="frame" x="226" y="499" width="49" height="31"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<connections>
|
||||
<action selector="skipSilencesSwitched:" destination="vXZ-lx-hvc" eventType="valueChanged" id="p7X-Y8-7hO"/>
|
||||
</connections>
|
||||
</switch>
|
||||
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="IGe-aU-Y6D">
|
||||
<rect key="frame" x="226" y="540" width="49" height="31"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<connections>
|
||||
<action selector="sleepSwitched:" destination="vXZ-lx-hvc" eventType="valueChanged" id="noa-m8-VHy"/>
|
||||
</connections>
|
||||
</switch>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="Sleep After 5 s" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vf6-kr-yWa">
|
||||
<rect key="frame" x="83" y="545" width="112" height="21"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="pVf-cJ-9ca">
|
||||
<rect key="frame" x="164.5" y="123" width="46" height="30"/>
|
||||
<state key="normal" title="Queue"/>
|
||||
<connections>
|
||||
<action selector="queueTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="qRj-oT-AV1"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="6d9-Bc-hIz">
|
||||
<rect key="frame" x="282" y="123" width="49" height="30"/>
|
||||
<state key="normal" title="Stream"/>
|
||||
<connections>
|
||||
<action selector="streamTouched:" destination="vXZ-lx-hvc" eventType="touchUpInside" id="AXY-N7-87Y"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
@@ -137,13 +170,14 @@
|
||||
<constraint firstItem="vfk-OJ-S3T" firstAttribute="trailing" secondItem="lTK-Hd-Tl2" secondAttribute="trailing" id="8PP-Pp-1Hc"/>
|
||||
<constraint firstItem="joK-xi-MCo" firstAttribute="trailing" secondItem="lTK-Hd-Tl2" secondAttribute="trailing" id="AH1-Uu-eLB"/>
|
||||
<constraint firstItem="joK-xi-MCo" firstAttribute="top" secondItem="jyV-Pf-zRb" secondAttribute="bottom" constant="60" id="Ba7-nd-oCD"/>
|
||||
<constraint firstItem="pVf-cJ-9ca" firstAttribute="centerY" secondItem="KDu-ea-kF8" secondAttribute="centerY" id="Cma-VU-v2t"/>
|
||||
<constraint firstItem="Urj-Dv-41y" firstAttribute="centerY" secondItem="j3w-gr-HzF" secondAttribute="centerY" id="Fvd-7V-Rr8"/>
|
||||
<constraint firstItem="1IX-z5-wWx" firstAttribute="leading" secondItem="joK-xi-MCo" secondAttribute="leading" id="GeX-7f-jzu"/>
|
||||
<constraint firstItem="0QE-3F-a4G" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="jUc-tP-CC5" secondAttribute="trailing" constant="8" symbolic="YES" id="JP5-yW-eVB"/>
|
||||
<constraint firstItem="yUQ-mI-ozK" firstAttribute="top" secondItem="w2a-RA-zmI" secondAttribute="bottom" constant="100" id="K1K-8N-SpD"/>
|
||||
<constraint firstItem="vfk-OJ-S3T" firstAttribute="leading" secondItem="lTK-Hd-Tl2" secondAttribute="leading" id="NOY-IO-NIJ"/>
|
||||
<constraint firstItem="tFH-sY-Xu9" firstAttribute="centerY" secondItem="jUc-tP-CC5" secondAttribute="centerY" id="Rre-EY-kVY"/>
|
||||
<constraint firstItem="KDu-ea-kF8" firstAttribute="leading" secondItem="kh9-bI-dsS" secondAttribute="leading" constant="78" id="SRU-sX-z5b"/>
|
||||
<constraint firstItem="KDu-ea-kF8" firstAttribute="leading" secondItem="kh9-bI-dsS" secondAttribute="leading" constant="43" id="SRU-sX-z5b"/>
|
||||
<constraint firstItem="w2a-RA-zmI" firstAttribute="trailing" secondItem="lTK-Hd-Tl2" secondAttribute="trailing" id="Vki-IZ-AdN"/>
|
||||
<constraint firstItem="lTK-Hd-Tl2" firstAttribute="top" secondItem="j3w-gr-HzF" secondAttribute="bottom" constant="8" id="Wwx-Uo-yIC"/>
|
||||
<constraint firstItem="nsl-df-P21" firstAttribute="leading" secondItem="vfk-OJ-S3T" secondAttribute="leading" id="a5C-nZ-8Jc"/>
|
||||
@@ -159,12 +193,13 @@
|
||||
<constraint firstItem="Urj-Dv-41y" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="j3w-gr-HzF" secondAttribute="trailing" constant="8" symbolic="YES" id="fu0-ZZ-rj9"/>
|
||||
<constraint firstAttribute="trailing" secondItem="lTK-Hd-Tl2" secondAttribute="trailing" constant="16" id="gdg-7Y-7la"/>
|
||||
<constraint firstAttribute="trailing" secondItem="1IX-z5-wWx" secondAttribute="trailing" constant="16" id="hHM-jO-RZd"/>
|
||||
<constraint firstItem="pVf-cJ-9ca" firstAttribute="centerX" secondItem="joK-xi-MCo" secondAttribute="centerX" id="lOM-Fa-KdR"/>
|
||||
<constraint firstItem="6d9-Bc-hIz" firstAttribute="top" secondItem="joK-xi-MCo" secondAttribute="bottom" constant="32" id="m9s-An-IWV"/>
|
||||
<constraint firstItem="vfk-OJ-S3T" firstAttribute="top" secondItem="yUQ-mI-ozK" secondAttribute="bottom" constant="8" id="oaW-rr-UVN"/>
|
||||
<constraint firstItem="nsl-df-P21" firstAttribute="trailing" secondItem="vfk-OJ-S3T" secondAttribute="trailing" id="r5e-Wq-dqV"/>
|
||||
<constraint firstItem="y5i-MZ-Qat" firstAttribute="centerX" secondItem="nsl-df-P21" secondAttribute="centerX" id="reC-GA-ZgT"/>
|
||||
<constraint firstAttribute="trailing" secondItem="0QE-3F-a4G" secondAttribute="trailing" constant="62.5" id="tg1-gr-hdd"/>
|
||||
<constraint firstAttribute="trailing" secondItem="6d9-Bc-hIz" secondAttribute="trailing" constant="82" id="vtN-y4-iqp"/>
|
||||
<constraint firstAttribute="trailing" secondItem="6d9-Bc-hIz" secondAttribute="trailing" constant="44" id="vtN-y4-iqp"/>
|
||||
<constraint firstItem="0QE-3F-a4G" firstAttribute="centerY" secondItem="jUc-tP-CC5" secondAttribute="centerY" id="xDi-tj-bBF"/>
|
||||
<constraint firstItem="lTK-Hd-Tl2" firstAttribute="top" secondItem="jUc-tP-CC5" secondAttribute="bottom" constant="40" id="ytQ-s4-kJm"/>
|
||||
<constraint firstItem="w2a-RA-zmI" firstAttribute="centerY" secondItem="lTK-Hd-Tl2" secondAttribute="centerY" constant="-1" id="zHt-h3-4ig"/>
|
||||
@@ -185,6 +220,8 @@
|
||||
<outlet property="scrubberSlider" destination="w2a-RA-zmI" id="VbI-tT-lbc"/>
|
||||
<outlet property="skipBackwardButton" destination="tFH-sY-Xu9" id="LwM-2S-m6F"/>
|
||||
<outlet property="skipForwardButton" destination="0QE-3F-a4G" id="cQ7-b7-pW7"/>
|
||||
<outlet property="skipSilencesSwitch" destination="2cn-E5-TeQ" id="TRI-IT-YJT"/>
|
||||
<outlet property="sleepSwitch" destination="IGe-aU-Y6D" id="BZn-9C-hOk"/>
|
||||
<outlet property="streamButton" destination="6d9-Bc-hIz" id="DZe-ga-3RV"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// Model.swift
|
||||
// SwiftAudioPlayer_Example
|
||||
//
|
||||
// Created by Tanha Kabir on 3/17/21.
|
||||
// Copyright © 2021 CocoaPods. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct AudioInfo: Hashable {
|
||||
var index: Int = 0
|
||||
|
||||
var urls: [URL] = [URL(string: "https://www.fesliyanstudios.com/musicfiles/2019-04-23_-_Trusted_Advertising_-_www.fesliyanstudios.com/15SecVersion2019-04-23_-_Trusted_Advertising_-_www.fesliyanstudios.com.mp3")!,
|
||||
URL(string: "https://chtbl.com/track/18338/traffic.libsyn.com/secure/acquired/acquired_-_armrev_2.mp3?dest-id=376122")!,
|
||||
URL(string: "https://ice6.somafm.com/groovesalad-256-mp3")!]
|
||||
|
||||
var url: URL {
|
||||
switch index {
|
||||
case 0:
|
||||
return urls[0]
|
||||
case 1:
|
||||
return urls[1]
|
||||
case 2:
|
||||
return urls[2]
|
||||
default:
|
||||
return urls[0]
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch index {
|
||||
case 0:
|
||||
return "Soundbite"
|
||||
case 1:
|
||||
return "Podcast"
|
||||
case 2:
|
||||
return "Radio"
|
||||
default:
|
||||
return "Soundbite"
|
||||
}
|
||||
}
|
||||
|
||||
let artist: String = "SwiftAudioPlayer Sample App"
|
||||
let releaseDate: Int = 1550790640
|
||||
|
||||
var savedUrl: URL? {
|
||||
get {
|
||||
return savedUrls[index]
|
||||
}
|
||||
}
|
||||
|
||||
var savedUrls: [URL?] = [nil, nil, nil]
|
||||
|
||||
mutating func addSavedUrl(_ url: URL) {
|
||||
savedUrls[index] = url
|
||||
}
|
||||
|
||||
mutating func deleteSavedUrl() {
|
||||
savedUrls[index] = nil
|
||||
}
|
||||
|
||||
mutating func addSavedUrl(_ url: URL, atIndex i: Int) {
|
||||
savedUrls[i] = url
|
||||
}
|
||||
|
||||
mutating func deleteSavedUrl(atIndex i: Int) {
|
||||
savedUrls[i] = nil
|
||||
}
|
||||
|
||||
func getUrl(atIndex i: Int) -> URL {
|
||||
return urls[i]
|
||||
}
|
||||
|
||||
mutating func setIndex(_ i: Int) {
|
||||
index = i
|
||||
}
|
||||
|
||||
func getIndex(forURL url: URL) -> Int? {
|
||||
return urls.firstIndex(of: url) ?? savedUrls.firstIndex(of: url)
|
||||
}
|
||||
}
|
||||
@@ -11,55 +11,9 @@ import SwiftAudioPlayer
|
||||
import AVFoundation
|
||||
|
||||
class ViewController: UIViewController {
|
||||
struct AudioInfo: Hashable {
|
||||
let index: Int
|
||||
|
||||
var url: URL {
|
||||
switch index {
|
||||
case 0:
|
||||
return URL(string: "https://cdn.fastlearner.media/bensound-rumble.mp3")!
|
||||
case 1:
|
||||
return URL(string: "https://chtbl.com/track/18338/traffic.libsyn.com/secure/acquired/acquired_-_armrev_2.mp3?dest-id=376122")!
|
||||
case 2:
|
||||
return URL(string: "https://backtracks.fm/ycombinator/pr/0f685f72-29b1-11e9-9bcf-0ece7a7d2472/111---jake-klamka-and-kevin-hale---y-combinator.mp3?s=1&sd=1&u=1549423185")!
|
||||
default:
|
||||
return URL(string: "https://cdn.fastlearner.media/bensound-rumble.mp3")!
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch index {
|
||||
case 0:
|
||||
return "Soundbite"
|
||||
case 1:
|
||||
return "Acquired"
|
||||
case 2:
|
||||
return "Y Combinator"
|
||||
default:
|
||||
return "Soundbite"
|
||||
}
|
||||
}
|
||||
|
||||
let artist: String = "SwiftAudioPlayer Sample App"
|
||||
let releaseDate: Int = 1550790640
|
||||
}
|
||||
|
||||
var savedUrls: [AudioInfo: URL] = [:]
|
||||
|
||||
var selectedAudio: AudioInfo = AudioInfo(index: 0) {
|
||||
didSet {
|
||||
if SAPlayer.Downloader.isDownloaded(withRemoteUrl: selectedAudio.url) {
|
||||
downloadButton.setTitle("Delete downloaded", for: .normal)
|
||||
streamButton.isEnabled = false
|
||||
} else {
|
||||
downloadButton.setTitle("Download", for: .normal)
|
||||
streamButton.isEnabled = true
|
||||
}
|
||||
|
||||
self.currentUrlLocationLabel.text = "remote url: \(selectedAudio.url.absoluteString)"
|
||||
}
|
||||
}
|
||||
var selectedAudio: AudioInfo = AudioInfo(index: 0)
|
||||
|
||||
var freq:[Int] = [0,0,0,0,0,0,0,0,0,0]
|
||||
@IBOutlet weak var currentUrlLocationLabel: UILabel!
|
||||
@IBOutlet weak var bufferProgress: UIProgressView!
|
||||
@IBOutlet weak var scrubberSlider: UISlider!
|
||||
@@ -84,7 +38,18 @@ class ViewController: UIViewController {
|
||||
var isStreaming: Bool = false
|
||||
var beingSeeked: Bool = false
|
||||
|
||||
|
||||
var downloadId: UInt?
|
||||
var durationId: UInt?
|
||||
var bufferId: UInt?
|
||||
var playingStatusId: UInt?
|
||||
var queueId: UInt?
|
||||
var elapsedId: UInt?
|
||||
|
||||
var duration: Double = 0.0
|
||||
var playbackStatus: SAPlayingStatus = .paused
|
||||
|
||||
var lastPlayedAudioIndex: Int?
|
||||
|
||||
var isPlayable: Bool = false {
|
||||
didSet {
|
||||
@@ -103,22 +68,92 @@ class ViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
SAPlayer.Downloader.allowUsingCellularData = true
|
||||
|
||||
// SAPlayer.shared.DEBUG_MODE = true
|
||||
|
||||
isPlayable = false
|
||||
selectedAudio = AudioInfo(index: 0)
|
||||
checkIfAudioDownloaded()
|
||||
selectAudio(atIndex: 0)
|
||||
|
||||
addRandomModifiers()
|
||||
// addRandomModifiers()
|
||||
|
||||
_ = SAPlayer.Updates.Duration.subscribe { [weak self] (url, duration) in
|
||||
subscribeToChanges()
|
||||
}
|
||||
|
||||
func addRandomModifiers() {
|
||||
let node = AVAudioUnitReverb()
|
||||
SAPlayer.shared.audioModifiers.append(node)
|
||||
node.wetDryMix = 300
|
||||
let frequency:[Int] = [60,170,310,600,1000,3000,6000,12000,14000,16000]
|
||||
let node2 = AVAudioUnitEQ(numberOfBands:frequency.count)
|
||||
node2.globalGain = 1
|
||||
for i in 0...(node2.bands.count-1) {
|
||||
node2.bands[i].frequency = Float(frequency[i])
|
||||
node2.bands[i].gain = 0
|
||||
node2.bands[i].bypass = false
|
||||
node2.bands[i].filterType = .parametric
|
||||
}
|
||||
SAPlayer.shared.audioModifiers.append(node2)
|
||||
}
|
||||
|
||||
override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
// Dispose of any resources that can be recreated.
|
||||
}
|
||||
|
||||
@IBAction func audioSelected(_ sender: Any) {
|
||||
let selected = audioSelector.selectedSegmentIndex
|
||||
|
||||
selectAudio(atIndex: selected)
|
||||
}
|
||||
|
||||
func selectAudio(atIndex i: Int) {
|
||||
selectedAudio.setIndex(i)
|
||||
|
||||
if selectedAudio.savedUrl != nil {
|
||||
downloadButton.setTitle("Delete downloaded", for: .normal)
|
||||
streamButton.isEnabled = false
|
||||
} else {
|
||||
downloadButton.setTitle("Download", for: .normal)
|
||||
streamButton.isEnabled = true
|
||||
}
|
||||
|
||||
if let savedUrl = selectedAudio.savedUrl {
|
||||
self.currentUrlLocationLabel.text = "saved url: \(savedUrl.absoluteString)"
|
||||
} else {
|
||||
self.currentUrlLocationLabel.text = "remote url: \(selectedAudio.url.absoluteString)"
|
||||
}
|
||||
|
||||
// if let savedUrl = savedUrls[selectedAudio] {}
|
||||
scrubberSlider.value = 0
|
||||
bufferProgress.progress = 0
|
||||
|
||||
// unsubscribeFromChanges()
|
||||
// subscribeToChanges()
|
||||
|
||||
SAPlayer.shared.mediaInfo = SALockScreenInfo(title: selectedAudio.title, artist: selectedAudio.artist, artwork: UIImage(), releaseDate: selectedAudio.releaseDate)
|
||||
}
|
||||
|
||||
func checkIfAudioDownloaded() {
|
||||
for i in 0...2 {
|
||||
if let savedUrl = SAPlayer.Downloader.getSavedUrl(forRemoteUrl: selectedAudio.getUrl(atIndex: i)) {
|
||||
selectedAudio.addSavedUrl(savedUrl, atIndex: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeToChanges() {
|
||||
durationId = SAPlayer.Updates.Duration.subscribe { [weak self] (url, duration) in
|
||||
guard let self = self else { return }
|
||||
guard url == self.selectedAudio.url || url == self.savedUrls[self.selectedAudio] else { return }
|
||||
guard url == self.selectedAudio.savedUrl || url == self.selectedAudio.url else { return }
|
||||
self.durationLabel.text = SAPlayer.prettifyTimestamp(duration)
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
_ = SAPlayer.Updates.ElapsedTime.subscribe { [weak self] (url, position) in
|
||||
elapsedId = SAPlayer.Updates.ElapsedTime.subscribe { [weak self] (url, position) in
|
||||
guard let self = self else { return }
|
||||
guard self.beingSeeked == false else { return }
|
||||
guard url == self.selectedAudio.url || url == self.savedUrls[self.selectedAudio] else { return }
|
||||
guard url == self.selectedAudio.savedUrl || url == self.selectedAudio.url else { return }
|
||||
|
||||
self.currentTimestampLabel.text = SAPlayer.prettifyTimestamp(position)
|
||||
|
||||
@@ -127,7 +162,7 @@ class ViewController: UIViewController {
|
||||
self.scrubberSlider.value = Float(position/self.duration)
|
||||
}
|
||||
|
||||
_ = SAPlayer.Updates.AudioDownloading.subscribe { [weak self] (url, progress) in
|
||||
downloadId = SAPlayer.Updates.AudioDownloading.subscribe { [weak self] (url, progress) in
|
||||
guard let self = self else { return }
|
||||
guard url == self.selectedAudio.url else { return }
|
||||
|
||||
@@ -140,26 +175,28 @@ class ViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
_ = SAPlayer.Updates.StreamingBuffer.subscribe{ [weak self] (url, buffer) in
|
||||
bufferId = SAPlayer.Updates.StreamingBuffer.subscribe{ [weak self] (url, buffer) in
|
||||
guard let self = self else { return }
|
||||
guard url == self.selectedAudio.url || url == self.savedUrls[self.selectedAudio] else { return }
|
||||
guard url == self.selectedAudio.savedUrl || url == self.selectedAudio.url else { return }
|
||||
|
||||
if self.duration == 0.0 { return }
|
||||
|
||||
let progress = Float((buffer.totalDurationBuffered + buffer.startingBufferTimePositon) / self.duration)
|
||||
self.bufferProgress.progress = Float(buffer.bufferingProgress)
|
||||
|
||||
self.bufferProgress.progress = progress
|
||||
|
||||
if progress >= 0.99 {
|
||||
if buffer.bufferingProgress >= 0.99 {
|
||||
self.streamButton.isEnabled = false
|
||||
} else {
|
||||
self.streamButton.isEnabled = true
|
||||
}
|
||||
|
||||
self.isPlayable = buffer.isReadyForPlaying
|
||||
}
|
||||
|
||||
_ = SAPlayer.Updates.PlayingStatus.subscribe { [weak self] (url, playing) in
|
||||
playingStatusId = SAPlayer.Updates.PlayingStatus.subscribe { [weak self] (url, playing) in
|
||||
guard let self = self else { return }
|
||||
guard url == self.selectedAudio.url || url == self.savedUrls[self.selectedAudio] else { return }
|
||||
guard url == self.selectedAudio.savedUrl || url == self.selectedAudio.url else { return }
|
||||
|
||||
self.playbackStatus = playing
|
||||
|
||||
switch playing {
|
||||
case .playing:
|
||||
@@ -174,30 +211,40 @@ class ViewController: UIViewController {
|
||||
self.isPlayable = false
|
||||
self.playPauseButton.setTitle("Loading", for: .normal)
|
||||
return
|
||||
case .ended:
|
||||
self.isPlayable = false
|
||||
self.playPauseButton.setTitle("Done", for: .normal)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
queueId = SAPlayer.Updates.AudioQueue.subscribe { [weak self] key, forthcomingPlaybackUrl in
|
||||
guard let self = self else { return }
|
||||
/// we update the selected audio. this is a little contrived, but allows us to update outlets
|
||||
if let indexFound = self.selectedAudio.getIndex(forURL: forthcomingPlaybackUrl) {
|
||||
self.selectAudio(atIndex: indexFound)
|
||||
}
|
||||
print("💥 Received queue update 💥")
|
||||
}
|
||||
}
|
||||
|
||||
func addRandomModifiers() {
|
||||
let node = AVAudioUnitReverb()
|
||||
SAPlayer.shared.audioModifiers.append(node)
|
||||
node.wetDryMix = 300
|
||||
}
|
||||
|
||||
override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
// Dispose of any resources that can be recreated.
|
||||
func unsubscribeFromChanges() {
|
||||
guard let durationId = self.durationId,
|
||||
let elapsedId = self.elapsedId,
|
||||
let downloadId = self.downloadId,
|
||||
let queueId = self.queueId,
|
||||
let bufferId = self.bufferId,
|
||||
let playingStatusId = self.playingStatusId else { return }
|
||||
|
||||
SAPlayer.Updates.Duration.unsubscribe(durationId)
|
||||
SAPlayer.Updates.ElapsedTime.unsubscribe(elapsedId)
|
||||
SAPlayer.Updates.AudioDownloading.unsubscribe(downloadId)
|
||||
SAPlayer.Updates.AudioQueue.unsubscribe(queueId)
|
||||
SAPlayer.Updates.StreamingBuffer.unsubscribe(bufferId)
|
||||
SAPlayer.Updates.PlayingStatus.unsubscribe(playingStatusId)
|
||||
}
|
||||
|
||||
@IBAction func audioSelected(_ sender: Any) {
|
||||
let selected = audioSelector.selectedSegmentIndex
|
||||
|
||||
selectedAudio = AudioInfo(index: selected)
|
||||
|
||||
SAPlayer.shared.mediaInfo = SALockScreenInfo(title: selectedAudio.title, artist: selectedAudio.artist, artwork: UIImage(), releaseDate: selectedAudio.releaseDate)
|
||||
|
||||
// if let savedUrl = savedUrls[selectedAudio] {}
|
||||
}
|
||||
|
||||
@IBAction func scrubberStartedSeeking(_ sender: UISlider) {
|
||||
beingSeeked = true
|
||||
}
|
||||
@@ -212,10 +259,7 @@ class ViewController: UIViewController {
|
||||
@IBAction func rateChanged(_ sender: Any) {
|
||||
let speed = rateSlider.value
|
||||
rateLabel.text = "rate: \(speed)x"
|
||||
if let node = SAPlayer.shared.audioModifiers[0] as? AVAudioUnitTimePitch {
|
||||
node.rate = speed
|
||||
SAPlayer.shared.playbackRateOfAudioChanged(rate: speed)
|
||||
}
|
||||
SAPlayer.shared.rate = speed
|
||||
}
|
||||
@IBAction func reverbChanged(_ sender: Any) {
|
||||
let reverb = reverbSlider.value
|
||||
@@ -224,11 +268,21 @@ class ViewController: UIViewController {
|
||||
node.wetDryMix = reverb
|
||||
}
|
||||
}
|
||||
@IBAction func queueTouched(_ sender: Any) {
|
||||
if let savedUrl = selectedAudio.savedUrl {
|
||||
SAPlayer.shared.queueSavedAudio(withSavedUrl: savedUrl)
|
||||
} else {
|
||||
SAPlayer.shared.queueRemoteAudio(withRemoteUrl: selectedAudio.url)
|
||||
}
|
||||
|
||||
print("queue: \(SAPlayer.shared.audioQueued)")
|
||||
}
|
||||
|
||||
@IBAction func downloadTouched(_ sender: Any) {
|
||||
if !isDownloading {
|
||||
if let savedUrl = SAPlayer.Downloader.getSavedUrl(forRemoteUrl: selectedAudio.url) {
|
||||
SAPlayer.Downloader.deleteDownloaded(withSavedUrl: savedUrl)
|
||||
selectedAudio.deleteSavedUrl()
|
||||
downloadButton.setTitle("Download", for: .normal)
|
||||
streamButton.isEnabled = true
|
||||
isDownloading = false
|
||||
@@ -239,9 +293,10 @@ class ViewController: UIViewController {
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.currentUrlLocationLabel.text = "saved to: \(url.lastPathComponent)"
|
||||
self.savedUrls[self.selectedAudio] = url
|
||||
self.selectedAudio.addSavedUrl(url)
|
||||
|
||||
SAPlayer.shared.initializeSavedAudio(withSavedUrl: url)
|
||||
SAPlayer.shared.startSavedAudio(withSavedUrl: url)
|
||||
self.lastPlayedAudioIndex = self.selectedAudio.index
|
||||
}
|
||||
})
|
||||
streamButton.isEnabled = false
|
||||
@@ -256,15 +311,35 @@ class ViewController: UIViewController {
|
||||
|
||||
@IBAction func streamTouched(_ sender: Any) {
|
||||
if !isStreaming {
|
||||
SAPlayer.shared.initializeRemoteAudio(withRemoteUrl: selectedAudio.url)
|
||||
if selectedAudio.index == 2 { // radio
|
||||
SAPlayer.shared.startRemoteAudio(withRemoteUrl: selectedAudio.url, bitrate: .low)
|
||||
} else {
|
||||
SAPlayer.shared.startRemoteAudio(withRemoteUrl: selectedAudio.url)
|
||||
}
|
||||
|
||||
lastPlayedAudioIndex = selectedAudio.index
|
||||
streamButton.setTitle("Cancel streaming", for: .normal)
|
||||
downloadButton.isEnabled = false
|
||||
isStreaming = true
|
||||
} else {
|
||||
// TODO
|
||||
SAPlayer.shared.stopStreamingRemoteAudio()
|
||||
streamButton.setTitle("Stream", for: .normal)
|
||||
downloadButton.isEnabled = true
|
||||
isStreaming = false
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func playPauseTouched(_ sender: Any) {
|
||||
// if lastPlayedAudioIndex != selectedAudio.index {
|
||||
// if let savedUrl = selectedAudio.savedUrl {
|
||||
// SAPlayer.shared.startSavedAudio(withSavedUrl: savedUrl)
|
||||
// } else {
|
||||
// SAPlayer.shared.startRemoteAudio(withRemoteUrl: selectedAudio.url)
|
||||
// }
|
||||
//
|
||||
// return
|
||||
// }
|
||||
|
||||
SAPlayer.shared.togglePlayAndPause()
|
||||
}
|
||||
|
||||
@@ -275,6 +350,37 @@ class ViewController: UIViewController {
|
||||
@IBAction func skipForwardTouched(_ sender: Any) {
|
||||
SAPlayer.shared.skipForward()
|
||||
}
|
||||
@IBAction func setEqualizerValue(_ sender: Any) {
|
||||
if let slider = sender as? UISlider{
|
||||
print("slider of index:", slider.tag, "is changed to", slider.value)
|
||||
freq[slider.tag] = Int(slider.value)
|
||||
print("current frequency : ",freq)
|
||||
if let node = SAPlayer.shared.audioModifiers[2] as? AVAudioUnitEQ{
|
||||
for i in 0...(node.bands.count - 1){
|
||||
node.bands[i].gain = Float(freq[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@IBOutlet weak var skipSilencesSwitch: UISwitch!
|
||||
|
||||
@IBAction func skipSilencesSwitched(_ sender: Any) {
|
||||
if skipSilencesSwitch.isOn {
|
||||
_ = SAPlayer.Features.SkipSilences.enable()
|
||||
} else {
|
||||
_ = SAPlayer.Features.SkipSilences.disable()
|
||||
}
|
||||
}
|
||||
@IBOutlet weak var sleepSwitch: UISwitch!
|
||||
|
||||
@IBAction func sleepSwitched(_ sender: Any) {
|
||||
if sleepSwitch.isOn {
|
||||
_ = SAPlayer.Features.SleepTimer.enable(afterDelay: 5.0)
|
||||
} else {
|
||||
_ = SAPlayer.Features.SleepTimer.disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,21 +4,39 @@
|
||||
[](https://cocoapods.org/pods/SwiftAudioPlayer)
|
||||
[](https://cocoapods.org/pods/SwiftAudioPlayer)
|
||||
|
||||
Swift based audio player that is able to both stream remote audio and play locally saved audio, while performing audio manipulations in real-time. Underlying using AVAudioEngine, and you can change the rate of audio (up to 32x), change pitch, and [other audio enhancements](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements).
|
||||
Swift-based audio player with AVAudioEngine as its base. Allows for: streaming online audio, playing local file, changing audio speed (3.5X, 4X, 32X), pitch, and real-time audio manipulation using custom [audio enhancements](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements).
|
||||
|
||||
This player was originally developed to be used in a [podcast player](https://chameleonpodcast.com/). We had originally used AVPlayer for playing audio but we wanted to manipulate audio that was being streamed. We set up AVAudioEngine at first just to play a file saved on the phone and it worked great, but AVAudioEngine on its own doesn't support streaming audio as easily as AVPlayer.
|
||||
This player was built for [podcasting](https://chameleonpodcast.com/). We originally used AVPlayer for playing audio but we wanted to manipulate audio that was being streamed. We set up AVAudioEngine at first just to play a file saved on the phone and it worked great, but AVAudioEngine on its own doesn't support streaming audio as easily as AVPlayer.
|
||||
|
||||
Thus, using [AudioToolbox](https://developer.apple.com/documentation/audiotoolbox), we are able to stream audio and convert the downloaded data into usable data for the AVAudioEngine to play. For an overview of our solution check out our [blog post](https://medium.com/chameleon-podcast/creating-an-advanced-streaming-audio-engine-for-ios-9fbc7aef4115).
|
||||
|
||||
### Basic Features
|
||||
|
||||
1. Realtime audio manipulation that includes going up to 10x speed, using [equalizers and other manipulations](https://developer.apple.com/documentation/avfaudio/avaudiouniteq)
|
||||
1. Stream online audio using AVAudioEngine
|
||||
1. Play locally saved audio with the same API
|
||||
1. Download audio
|
||||
1. Queue up downloaded and streamed audio for autoplay
|
||||
1. Uses only 1-2% CPU for optimal performance for the rest of your app
|
||||
1. You're able to install taps and any other AVAudioEngine features to do cool things like skipping silences
|
||||
|
||||
### Special Features
|
||||
These are community supported audio manipulation features using this audio engine. You can implement your own version of these features and you can look at [SAPlayerFeatures](https://github.com/tanhakabir/SwiftAudioPlayer/blob/master/Source/SAPlayerFeatures.swift) to learn how they were implemented using the library.
|
||||
1. Skip silences in audio
|
||||
1. Sleep timer to stop playing audio after a delay
|
||||
|
||||
### Requirements
|
||||
|
||||
SwiftAudioPlayer is only available for iOS 10.0 and higher.
|
||||
iOS 10.0 and higher.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Example Project
|
||||
### Running the Example Project
|
||||
|
||||
To run the example project, clone the repo, and run `pod install` from the Example directory first.
|
||||
1. Clone repo
|
||||
2. CD to the `Example` folder where the Example app lives
|
||||
3. Run `pod install` in terminal
|
||||
4. Build and run
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -31,12 +49,17 @@ pod 'SwiftAudioPlayer'
|
||||
|
||||
### Usage
|
||||
|
||||
Import the player at the top:
|
||||
```swift
|
||||
import SwiftAudioPlayer
|
||||
```
|
||||
|
||||
**Important:** For app in background downloading please refer to [note](#important-step-for-background-downloads).
|
||||
|
||||
To play remote audio:
|
||||
```swift
|
||||
let url = URL(string: "https://randomwebsite.com/audio.mp3")!
|
||||
SAPlayer.shared.initializeAudio(withRemoteUrl: url)
|
||||
SAPlayer.shared.startRemoteAudio(withRemoteUrl: url)
|
||||
SAPlayer.shared.play()
|
||||
```
|
||||
|
||||
@@ -46,7 +69,7 @@ let info = SALockScreenInfo(title: "Random audio", artist: "Foo", artwork: UIIma
|
||||
SAPlayer.shared.mediaInfo = info
|
||||
```
|
||||
|
||||
To receive streaming progress:
|
||||
To receive streaming progress (for buffer progress %):
|
||||
```swift
|
||||
@IBOutlet weak var bufferProgress: UIProgressView!
|
||||
|
||||
@@ -115,21 +138,21 @@ SwiftAudioPlayer is available under the MIT license. See the LICENSE file for mo
|
||||
|
||||
Access the player and all of its fields and functions through `SAPlayer.shared`.
|
||||
|
||||
### Playing Audio
|
||||
### Supported file types
|
||||
|
||||
Known supported file types are `.mp3` and `.wav`.
|
||||
|
||||
### Playing Audio (Basic Commands)
|
||||
|
||||
To set up player with audio to play, use either:
|
||||
* `initializeSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo?)` to play audio that is saved on the device.
|
||||
* `initializeRemoteAudio(withRemoteUrl url: URL, mediaInfo: SALockScreenInfo?)` to play audio streamed from a remote location.
|
||||
* `startSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo?)` to play audio that is saved on the device.
|
||||
* `startRemoteAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate, mediaInfo: SALockScreenInfo?)` to play audio streamed from a remote location.
|
||||
|
||||
Both of these expect a URL of the location of the audio and an optional media information to display on the lockscreen.
|
||||
Both of these expect a URL of the location of the audio and an optional media information to display on the lockscreen. For streamed audio you can optionally set the bitrate to be `.high` or `.low`. High is more performant but won't work well for radio streams; for radio streams you should use low. The default bitrate if you don't set it is `.high`.
|
||||
|
||||
For streaming remote audio, subscribe to `SAPlayer.Updates.StreamingBuffer` for updates on streaming progress.
|
||||
|
||||
#### Important
|
||||
|
||||
Any audio manipulation intended to on the audio must have the nodes anticipated to use finalized before initialize is called. Look at [audio manipulation documentation](#realtime-audio-manipulation) for more information.
|
||||
|
||||
All other basic controls are available:
|
||||
Basic controls available:
|
||||
```swift
|
||||
play()
|
||||
pause()
|
||||
@@ -139,32 +162,23 @@ skipForward()
|
||||
skipBackwards()
|
||||
```
|
||||
|
||||
### Realtime Audio Manipulation
|
||||
### Queuing Audio for Autoplay
|
||||
|
||||
All audio effects on the player is done through [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/avaudiounit) nodes. These include adding reverb, changing pitch and playback rate, and adding distortion. Full list of effects available [here](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements).
|
||||
|
||||
The effects intended to use are stored in `audioModifiers` as a list of nodes. These nodes are in the order that the engine will attach them to one another.
|
||||
|
||||
**Note:** By default `SAPlayer` starts off with one node, an [AVAudioUnitTimePitch](https://developer.apple.com/documentation/avfoundation/avaudiounittimepitch) node, that is set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word).
|
||||
|
||||
#### Important
|
||||
All the nodes intended to be used on the playing audio must be finalized before calling `initializeSavedAudio(...)` or `initializeRemoteAudio(...)`. Any changes to list of nodes after initialize is called for a given audio file will not be reflected in playback.
|
||||
|
||||
Once all nodes are added to `audioModifiers` and the player has been initialized, any manipulations done with the nodes are performed in realtime. The example app shows manipulating the playback rate in realtime:
|
||||
You can queue either remote or locally saved audio to be played automatically next.
|
||||
|
||||
To queue:
|
||||
```swift
|
||||
let speed = rateSlider.value
|
||||
if let node = SAPlayer.shared.audioModifiers[0] as? AVAudioUnitTimePitch {
|
||||
node.rate = speed
|
||||
SAPlayer.shared.playbackRateOfAudioChanged(rate: speed)
|
||||
}
|
||||
SAPlayer.shared.queueSavedAudio(withSavedUrl: C://random_folder/audio.mp3) // or
|
||||
SAPlayer.shared.queueRemoteAudio(withRemoteUrl: https://randomwebsite.com/audio.mp3)
|
||||
```
|
||||
|
||||
**Note:** if the rate of the audio is changed, `playbackRateOfAudioChanged` should also be called to update the lockscreen's media player.
|
||||
#### Important
|
||||
|
||||
The engine can handle audio manipulations like speed, pitch, effects, etc. To do this, nodes for effects must be finalized before initialize is called. Look at [audio manipulation documentation](#realtime-audio-manipulation) for more information.
|
||||
|
||||
### Lockscreen Media Player
|
||||
|
||||
Update and set what displays on the lockscreen's media player when the player is active.
|
||||
Update and set what displays on the lockscreen's media player when the player is active.
|
||||
|
||||
`skipForwardSeconds` and `skipBackwardSeconds` for the intervals to skip forward and back with.
|
||||
|
||||
@@ -196,7 +210,7 @@ func application(_ application: UIApplication, handleEventsForBackgroundURLSessi
|
||||
|
||||
### Downloading
|
||||
|
||||
Downloads will be held on pause when active stream is started, and will resume downloads when streaming is done.
|
||||
All downloads will be paused when audio is streamed from a URL. They will automatically resume when streaming is done.
|
||||
|
||||
Use the following to start downloading audio in the background:
|
||||
|
||||
@@ -214,6 +228,12 @@ And use the following to stop any active or prevent future downloads of the corr
|
||||
func cancelDownload(withRemoteUrl url: URL)
|
||||
```
|
||||
|
||||
By default downloading will be allowed on cellular data. If you would like to turn this off set:
|
||||
```swift
|
||||
SAPlayer.Downloader.allowUsingCellularData = false
|
||||
```
|
||||
You can also retrieve what preference you have set for cellular downloads through `allowUsingCellularData`.
|
||||
|
||||
### Manage Downloaded
|
||||
|
||||
Use the following to manage downloaded audio files.
|
||||
@@ -233,6 +253,8 @@ Delete downloaded audio if it exists:
|
||||
func deleteDownloaded(withSavedUrl url: URL)
|
||||
```
|
||||
|
||||
**NOTE:** You're in charge or clearing downloads when your don't need them anymore
|
||||
|
||||
## SAPlayer.Updates
|
||||
|
||||
Receive updates for changing values from the player, such as the duration, elapsed time of playing audio, download progress, and etc.
|
||||
@@ -265,12 +287,12 @@ Subscribe to this to update views on changes in position of which part of audio
|
||||
### Duration
|
||||
Payload = `Double`
|
||||
|
||||
Changes in the duration of the current initialized audio. Especially helpful for audio that is being streamed and can change with more data.
|
||||
Changes in the duration of the current initialized audio. Especially helpful for audio that is being streamed and can change with more data. The engine makes a best effort guess as to the duration of the audio. The guess gets better with more bytes streamed from the web.
|
||||
|
||||
### PlayingStatus
|
||||
Payload = `SAPlayingStatus`
|
||||
|
||||
Changes in the playing status of the player. Can be one of the following 3: `playing`, `paused`, `buffering`.
|
||||
Changes in the playing status of the player. Can be one of the following 4: `playing`, `paused`, `buffering`, `ended` (audio ended).
|
||||
|
||||
### StreamingBuffer
|
||||
Payload = `SAAudioAvailabilityRange`
|
||||
@@ -283,3 +305,29 @@ For progress of downloading audio that saves to the phone for playback later, lo
|
||||
Payload = `Double`
|
||||
|
||||
Changes in the progress of downloading audio in the background. This does not correspond to progress in streaming downloads, look at StreamingBuffer for streaming progress.
|
||||
|
||||
## Audio Effects
|
||||
|
||||
### Realtime Audio Manipulation
|
||||
|
||||
All audio effects on the player is done through [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/avaudiounit) nodes. These include adding reverb, changing pitch and playback rate, and adding distortion. Full list of effects available [here](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements).
|
||||
|
||||
The effects intended to use are stored in `audioModifiers` as a list of nodes. These nodes are in the order that the engine will attach them to one another.
|
||||
|
||||
**Note:** By default `SAPlayer` starts off with one node, an [AVAudioUnitTimePitch](https://developer.apple.com/documentation/avfoundation/avaudiounittimepitch) node, that is set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word).
|
||||
|
||||
#### Important
|
||||
All the nodes intended to be used on the playing audio must be finalized before calling `initializeSavedAudio(...)` or `initializeRemoteAudio(...)`. Any changes to list of nodes after initialize is called for a given audio file will not be reflected in playback.
|
||||
|
||||
Once all nodes are added to `audioModifiers` and the player has been initialized, any manipulations done with the nodes are performed in realtime. The example app shows manipulating the playback rate in realtime:
|
||||
|
||||
```swift
|
||||
let speed = rateSlider.value
|
||||
if let node = SAPlayer.shared.audioModifiers[0] as? AVAudioUnitTimePitch {
|
||||
node.rate = speed
|
||||
SAPlayer.shared.playbackRateOfAudioChanged(rate: speed)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** if the rate of the audio is changed, `playbackRateOfAudioChanged` should also be called to update the lockscreen's media player.
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// AudioQueueDirector.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Joe Williams on 3/10/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AudioQueueDirector {
|
||||
static let shared = AudioQueueDirector()
|
||||
var closures: DirectorThreadSafeClosures<URL> = DirectorThreadSafeClosures()
|
||||
private init() {}
|
||||
|
||||
func create() {}
|
||||
|
||||
func clear() {
|
||||
closures.clear()
|
||||
}
|
||||
|
||||
func attach(closure: @escaping (Key, URL) throws -> Void) -> UInt {
|
||||
return closures.attach(closure: closure)
|
||||
}
|
||||
|
||||
func detach(withID id: UInt) {
|
||||
closures.detach(id: id)
|
||||
}
|
||||
|
||||
func changeInQueue(_ key: Key, url: URL) {
|
||||
closures.broadcast(key: key, payload: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// StreamingDownloadDirector.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 4/16/21.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
import Foundation
|
||||
|
||||
class StreamingDownloadDirector {
|
||||
static let shared = StreamingDownloadDirector()
|
||||
|
||||
var closures: DirectorThreadSafeClosures<Double> = DirectorThreadSafeClosures()
|
||||
|
||||
private init() {}
|
||||
|
||||
func create() {}
|
||||
|
||||
func clear() {
|
||||
closures.clear()
|
||||
}
|
||||
|
||||
func attach(closure: @escaping (Key, Double) throws -> Void) -> UInt {
|
||||
return closures.attach(closure: closure)
|
||||
}
|
||||
|
||||
func detach(withID id: UInt) {
|
||||
closures.detach(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamingDownloadDirector {
|
||||
func didUpdate(_ key: Key, networkStreamProgress: Double) {
|
||||
closures.broadcast(key: key, payload: networkStreamProgress)
|
||||
}
|
||||
}
|
||||
@@ -64,17 +64,17 @@ class AudioDiskEngine: AudioEngine {
|
||||
audioSampleRate = Float(audioFormat?.sampleRate ?? 44100)
|
||||
audioLengthSeconds = Float(audioLengthSamples) / audioSampleRate
|
||||
duration = Duration(audioLengthSeconds)
|
||||
bufferedSeconds = SAAudioAvailabilityRange(startingNeedle: 0, durationLoadedByNetwork: duration, isPlayable: true)
|
||||
bufferedSeconds = SAAudioAvailabilityRange(startingNeedle: 0, durationLoadedByNetwork: duration, predictedDurationToLoad: duration, isPlayable: true)
|
||||
} else {
|
||||
Log.monitor("Could not load downloaded file with url: \(url)")
|
||||
}
|
||||
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] (timer: Timer) in
|
||||
guard let _ = self else { return }
|
||||
self?.timer = timer
|
||||
self?.updateIsPlaying()
|
||||
self?.updateNeedle()
|
||||
doRepeatedly(timeInterval: 0.2) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.playingStatus != .ended else { return }
|
||||
|
||||
self.updateIsPlaying()
|
||||
self.updateNeedle()
|
||||
}
|
||||
|
||||
scheduleAudioFile()
|
||||
@@ -98,7 +98,7 @@ class AudioDiskEngine: AudioEngine {
|
||||
if state == .resumed {
|
||||
state = .suspended
|
||||
}
|
||||
delegate?.didEndPlaying()
|
||||
playingStatus = .ended
|
||||
}
|
||||
|
||||
guard audioSampleRate != 0 else {
|
||||
@@ -136,6 +136,7 @@ class AudioDiskEngine: AudioEngine {
|
||||
}
|
||||
|
||||
override func invalidate() {
|
||||
super.invalidate()
|
||||
//Nothing to invalidate for disk
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
protocol AudioEngineProtocol {
|
||||
var key: Key { get }
|
||||
var engine: AVAudioEngine! { get }
|
||||
func play()
|
||||
func pause()
|
||||
func seek(toNeedle needle: Needle)
|
||||
@@ -34,18 +36,15 @@ protocol AudioEngineProtocol {
|
||||
}
|
||||
|
||||
protocol AudioEngineDelegate: AnyObject {
|
||||
func didEndPlaying() //for auto play
|
||||
func didError()
|
||||
}
|
||||
|
||||
class AudioEngine: AudioEngineProtocol {
|
||||
weak var delegate:AudioEngineDelegate?
|
||||
let key:Key
|
||||
var key:Key
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
let playerNode = AVAudioPlayerNode()
|
||||
|
||||
var timer: Timer?
|
||||
var engine: AVAudioEngine!
|
||||
var playerNode: AVAudioPlayerNode!
|
||||
|
||||
static let defaultEngineAudioFormat: AVAudioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false)!
|
||||
|
||||
@@ -76,13 +75,13 @@ class AudioEngine: AudioEngineProtocol {
|
||||
guard playingStatus != oldValue, let status = playingStatus else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
AudioClockDirector.shared.audioPlayingStatusWasChanged(key, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
var bufferedSecondsDebouncer: SAAudioAvailabilityRange = SAAudioAvailabilityRange(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, isPlayable: false)
|
||||
var bufferedSeconds: SAAudioAvailabilityRange = SAAudioAvailabilityRange(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, isPlayable: false) {
|
||||
var bufferedSecondsDebouncer: SAAudioAvailabilityRange = SAAudioAvailabilityRange(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, predictedDurationToLoad: Double.greatestFiniteMagnitude, isPlayable: false)
|
||||
var bufferedSeconds: SAAudioAvailabilityRange = SAAudioAvailabilityRange(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, predictedDurationToLoad: Double.greatestFiniteMagnitude, isPlayable: false) {
|
||||
didSet {
|
||||
if bufferedSeconds.startingNeedle == 0.0 && bufferedSeconds.durationLoadedByNetwork == 0.0 {
|
||||
bufferedSecondsDebouncer = bufferedSeconds
|
||||
@@ -108,6 +107,13 @@ class AudioEngine: AudioEngineProtocol {
|
||||
self.key = url.key
|
||||
self.delegate = delegate
|
||||
|
||||
engine = AVAudioEngine()
|
||||
playerNode = AVAudioPlayerNode()
|
||||
|
||||
initHelper(engineAudioFormat)
|
||||
}
|
||||
|
||||
func initHelper(_ engineAudioFormat: AVAudioFormat) {
|
||||
engine.attach(playerNode)
|
||||
|
||||
for node in SAPlayer.shared.audioModifiers {
|
||||
@@ -141,25 +147,59 @@ class AudioEngine: AudioEngineProtocol {
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
if state == .resumed {
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
engine.disconnectNodeInput(self.playerNode)
|
||||
engine.detach(self.playerNode)
|
||||
|
||||
engine = nil
|
||||
playerNode = nil
|
||||
Log.info("deinit AVAudioEngine for \(key)")
|
||||
}
|
||||
|
||||
func doRepeatedly(timeInterval: Double, _ closure: @escaping () -> ()) {
|
||||
// A common error in AVAudioEngine is 'required condition is false: nil == owningEngine || GetEngine() == owningEngine'
|
||||
// where there can only be one instance of engine running at a time and if there is already one when trying to start
|
||||
// a new one then this error will be thrown.
|
||||
|
||||
// To handle this error we need to make sure we properly dispose of the engine when done using. In the case of timers, a
|
||||
// repeating timer will maintain a strong reference to the body even if you state that you wanted a weak reference to self
|
||||
// to mitigate this for repeating timers, you can either call timer.invalidate() properly or don't use repeat block timers.
|
||||
// To be in better control of references and to mitigate any unforeseen issues, I decided to implement a recurisive version
|
||||
// of the repeat block timer so I'm in full control of when to invalidate.
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] (timer: Timer) in
|
||||
guard let self = self else { return }
|
||||
guard self.playingStatus != .ended else {
|
||||
self.delegate = nil
|
||||
return
|
||||
}
|
||||
closure()
|
||||
self.doRepeatedly(timeInterval: timeInterval, closure)
|
||||
}
|
||||
}
|
||||
|
||||
func updateIsPlaying() {
|
||||
if !bufferedSeconds.isPlayable {
|
||||
playingStatus = .buffering
|
||||
if bufferedSeconds.reachedEndOfAudio(needle: needle) {
|
||||
playingStatus = .ended
|
||||
} else {
|
||||
playingStatus = .buffering
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let isPlaying = engine.isRunning && playerNode.isPlaying
|
||||
playingStatus = isPlaying ? .playing : .paused
|
||||
|
||||
// playingStatus = .paused
|
||||
}
|
||||
|
||||
func play() {
|
||||
// https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button
|
||||
if !engine.isRunning {
|
||||
if !(engine.isRunning) {
|
||||
do {
|
||||
try engine.start()
|
||||
|
||||
|
||||
@@ -59,15 +59,16 @@ class AudioStreamEngine: AudioEngine {
|
||||
//Constants
|
||||
private let MAX_POLL_BUFFER_COUNT = 300 //Having one buffer in engine at a time is choppy.
|
||||
private let MIN_BUFFERS_TO_BE_PLAYABLE = 1
|
||||
private let PCM_BUFFER_SIZE: AVAudioFrameCount = 8192
|
||||
private var PCM_BUFFER_SIZE: AVAudioFrameCount = 8192
|
||||
|
||||
private let queue = DispatchQueue(label: "SwiftAudioPlayer.engine", qos: .userInitiated)
|
||||
private let queue = DispatchQueue(label: "SwiftAudioPlayer.StreamEngine", qos: .userInitiated)
|
||||
|
||||
//From init
|
||||
private var converter: AudioConvertable!
|
||||
|
||||
//Fields
|
||||
private var currentTimeOffset: TimeInterval = 0
|
||||
private var streamChangeListenerId: UInt?
|
||||
|
||||
private var numberOfBuffersScheduledInTotal = 0 {
|
||||
didSet {
|
||||
@@ -133,28 +134,56 @@ class AudioStreamEngine: AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
init(withRemoteUrl url: AudioURL, delegate:AudioEngineDelegate?) {
|
||||
init(withRemoteUrl url: AudioURL, delegate:AudioEngineDelegate?, bitrate: SAPlayerBitrate) {
|
||||
Log.info(url)
|
||||
super.init(url: url, delegate: delegate, engineAudioFormat: AudioEngine.defaultEngineAudioFormat)
|
||||
|
||||
switch bitrate {
|
||||
case .high:
|
||||
PCM_BUFFER_SIZE = 8192
|
||||
case .low:
|
||||
PCM_BUFFER_SIZE = 4096
|
||||
}
|
||||
|
||||
do {
|
||||
converter = try AudioConverter(withRemoteUrl: url, toEngineAudioFormat: AudioEngine.defaultEngineAudioFormat)
|
||||
converter = try AudioConverter(withRemoteUrl: url, toEngineAudioFormat: AudioEngine.defaultEngineAudioFormat, withPCMBufferSize: PCM_BUFFER_SIZE)
|
||||
} catch {
|
||||
delegate?.didError()
|
||||
}
|
||||
|
||||
streamChangeListenerId = StreamingDownloadDirector.shared.attach { [weak self] (key, progress) in
|
||||
guard let self = self else { return }
|
||||
guard key == url.key else { return }
|
||||
|
||||
// polling for buffers when we receive data. This won't be throttled on fresh new audio or seeked audio but in all other cases it most likely will be throttled
|
||||
self.pollForNextBuffer() // no buffer updates because thread issues if I try to update buffer status in streaming listener
|
||||
}
|
||||
|
||||
|
||||
let timeInterval = 1 / (converter.engineAudioFormat.sampleRate / Double(PCM_BUFFER_SIZE))
|
||||
|
||||
Timer.scheduledTimer(withTimeInterval: timeInterval / 32, repeats: true) { [weak self] (timer: Timer) in
|
||||
self?.timer = timer
|
||||
self?.pollForNextBuffer()
|
||||
self?.updateNetworkBufferRange()
|
||||
self?.updateNeedle()
|
||||
self?.updateIsPlaying()
|
||||
self?.updateDuration()
|
||||
doRepeatedly(timeInterval: timeInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.playingStatus != .ended else { return }
|
||||
|
||||
self.repeatedUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let id = streamChangeListenerId {
|
||||
StreamingDownloadDirector.shared.detach(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func repeatedUpdates() {
|
||||
self.pollForNextBuffer()
|
||||
self.updateNetworkBufferRange() // thread issues if I try to update buffer status in streaming listener
|
||||
self.updateNeedle()
|
||||
self.updateIsPlaying()
|
||||
self.updateDuration()
|
||||
}
|
||||
|
||||
//MARK:- Timer loop
|
||||
|
||||
//Called when
|
||||
@@ -163,16 +192,30 @@ class AudioStreamEngine: AudioEngine {
|
||||
private func pollForNextBuffer() {
|
||||
guard shouldPollForNextBuffer else { return }
|
||||
|
||||
pollForNextBufferRecursive()
|
||||
}
|
||||
|
||||
private func pollForNextBufferRecursive() {
|
||||
do {
|
||||
let nextScheduledBuffer = try converter.pullBuffer(withSize: PCM_BUFFER_SIZE)
|
||||
var nextScheduledBuffer: AVAudioPCMBuffer! = try converter.pullBuffer()
|
||||
numberOfBuffersScheduledFromPoll += 1
|
||||
numberOfBuffersScheduledInTotal += 1
|
||||
|
||||
Log.debug("processed buffer for engine of frame length \(nextScheduledBuffer.frameLength)")
|
||||
queue.async { [weak self] in
|
||||
self?.playerNode.scheduleBuffer(nextScheduledBuffer) {
|
||||
self?.numberOfBuffersScheduledInTotal -= 1
|
||||
self?.pollForNextBufferRecursionHelper()
|
||||
if #available(iOS 11.0, *) {
|
||||
// to make sure the pcm buffers are properly free'd from memory we need to nil them after the player has used them
|
||||
self?.playerNode.scheduleBuffer(nextScheduledBuffer, completionCallbackType: .dataConsumed, completionHandler: { (_) in
|
||||
nextScheduledBuffer = nil
|
||||
self?.numberOfBuffersScheduledInTotal -= 1
|
||||
self?.pollForNextBufferRecursive()
|
||||
})
|
||||
} else {
|
||||
self?.playerNode.scheduleBuffer(nextScheduledBuffer) {
|
||||
nextScheduledBuffer = nil
|
||||
self?.numberOfBuffersScheduledInTotal -= 1
|
||||
self?.pollForNextBufferRecursive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,37 +231,11 @@ class AudioStreamEngine: AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private func pollForNextBufferRecursionHelper() {
|
||||
do {
|
||||
let nextScheduledBuffer = try converter.pullBuffer(withSize: PCM_BUFFER_SIZE)
|
||||
Log.debug("processed buffer for engine of frame lengthL \(nextScheduledBuffer.frameLength)")
|
||||
numberOfBuffersScheduledInTotal += 1
|
||||
|
||||
queue.async { [weak self] in
|
||||
self?.playerNode.scheduleBuffer(nextScheduledBuffer) {
|
||||
self?.numberOfBuffersScheduledInTotal -= 1
|
||||
self?.pollForNextBufferRecursionHelper()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch ConverterError.reachedEndOfFile {
|
||||
Log.info(ConverterError.reachedEndOfFile.localizedDescription)
|
||||
} catch ConverterError.notEnoughData {
|
||||
shouldPollForNextBuffer = true
|
||||
Log.debug(ConverterError.notEnoughData.localizedDescription)
|
||||
} catch ConverterError.superConcerningShouldNeverHappen {
|
||||
Log.error(ConverterError.superConcerningShouldNeverHappen.localizedDescription)
|
||||
} catch {
|
||||
Log.debug(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNetworkBufferRange() { //for ui
|
||||
let range = converter.pollNetworkAudioAvailabilityRange()
|
||||
isPlayable = (numberOfBuffersScheduledInTotal >= MIN_BUFFERS_TO_BE_PLAYABLE && range.1 > 0) && predictedStreamDuration > 0
|
||||
Log.debug("loaded \(range), numberOfBuffersScheduledInTotal: \(numberOfBuffersScheduledInTotal), isPlayable: \(isPlayable)")
|
||||
bufferedSeconds = SAAudioAvailabilityRange(startingNeedle: range.0, durationLoadedByNetwork: range.1, isPlayable: isPlayable)
|
||||
bufferedSeconds = SAAudioAvailabilityRange(startingNeedle: range.0, durationLoadedByNetwork: range.1, predictedDurationToLoad: predictedStreamDuration, isPlayable: isPlayable)
|
||||
}
|
||||
|
||||
private func updateNeedle() {
|
||||
@@ -236,12 +253,6 @@ class AudioStreamEngine: AudioEngine {
|
||||
var currentTime = TimeInterval(playerTime.sampleTime) / playerTime.sampleRate
|
||||
currentTime = currentTime > 0 ? currentTime : 0
|
||||
|
||||
if currentTime > predictedStreamDuration {
|
||||
Log.info("reached end of audio")
|
||||
seek(toNeedle: 0)
|
||||
pause()
|
||||
delegate?.didEndPlaying()
|
||||
}
|
||||
needle = (currentTime + currentTimeOffset)
|
||||
}
|
||||
|
||||
@@ -296,7 +307,28 @@ class AudioStreamEngine: AudioEngine {
|
||||
updateNetworkBufferRange()
|
||||
}
|
||||
|
||||
override func pause() {
|
||||
queue.async { [weak self] in
|
||||
self?.pauseHelperDispatchQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseHelperDispatchQueue() {
|
||||
super.pause()
|
||||
}
|
||||
|
||||
override func play() {
|
||||
queue.async { [weak self] in
|
||||
self?.playHelperDispatchQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func playHelperDispatchQueue() {
|
||||
super.play()
|
||||
}
|
||||
|
||||
override func invalidate() {
|
||||
super.invalidate()
|
||||
converter.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,77 +27,41 @@ import Foundation
|
||||
|
||||
protocol AudioThrottleDelegate: AnyObject {
|
||||
func didUpdate(totalBytesExpected bytes: Int64)
|
||||
func didUpdate(networkStreamProgress progress: Double)
|
||||
func shouldProcess(networkData data: Data)
|
||||
}
|
||||
|
||||
protocol AudioThrottleable {
|
||||
init(withRemoteUrl url: AudioURL, withDelegate delegate: AudioThrottleDelegate)
|
||||
func tellAudioFormatFound()
|
||||
func tellByteOffset(offset: UInt64)
|
||||
func pullNextDataPacket(_ callback: @escaping (Data?) -> ())
|
||||
func tellSeek(offset: UInt64)
|
||||
func tellBytesPerAudioPacket(count: UInt64)
|
||||
func pollRangeOfBytesAvailable() -> (UInt64, UInt64)
|
||||
func invalidate()
|
||||
}
|
||||
|
||||
class AudioThrottler: AudioThrottleable {
|
||||
private class NetworkDataWrapper: NSObject {
|
||||
let startOffset: UInt
|
||||
var data: Data
|
||||
var alreadySent: Bool
|
||||
var next: NetworkDataWrapper?
|
||||
|
||||
var byteCount: UInt {
|
||||
return UInt(data.count)
|
||||
}
|
||||
|
||||
var endOffset: UInt {
|
||||
return startOffset + UInt(data.count) - 1
|
||||
}
|
||||
|
||||
init(startingOffset: UInt, data: Data) {
|
||||
self.startOffset = startingOffset
|
||||
self.data = data
|
||||
self.alreadySent = false
|
||||
}
|
||||
|
||||
func containsOffset(_ offset: UInt) -> Bool {
|
||||
return startOffset <= offset && offset <= endOffset
|
||||
}
|
||||
|
||||
func isNextSent() -> Bool {
|
||||
return next?.alreadySent ?? false
|
||||
}
|
||||
|
||||
//FIXME: what is the offset was at the edge of the split? We will have empty data
|
||||
func splitToRight(atOffset offset: UInt) -> NetworkDataWrapper {
|
||||
let splitPoint:Int = Int(offset - startOffset)
|
||||
let leftData = data.subdata(in: 0..<splitPoint)
|
||||
let rightData = data.subdata(in: splitPoint..<data.count)
|
||||
|
||||
data = leftData
|
||||
|
||||
let rightWrapper:NetworkDataWrapper = NetworkDataWrapper(startingOffset: offset, data: rightData)
|
||||
rightWrapper.next = next
|
||||
next = rightWrapper
|
||||
|
||||
return rightWrapper
|
||||
}
|
||||
|
||||
override var description: String {
|
||||
return "startOffset:\(startOffset), endOffset:\(endOffset), dataCount:\(data.count), sent:\(alreadySent), next:\(next != nil ?"hasNext":"noNext")"
|
||||
}
|
||||
}
|
||||
private let queue = DispatchQueue(label: "SwiftAudioPlayer.Throttler", qos: .userInitiated)
|
||||
|
||||
//Init
|
||||
let url: AudioURL
|
||||
weak var delegate: AudioThrottleDelegate?
|
||||
|
||||
private var networkData: [NetworkDataWrapper] = []
|
||||
private var networkData: [Data] = [] {
|
||||
didSet {
|
||||
// Log.test("NETWORK DATA \(networkData.count)")
|
||||
}
|
||||
}
|
||||
private var lastSentDataPacketIndex = -1
|
||||
|
||||
var shouldThrottle = false
|
||||
var byteOffsetBecauseOfSeek: UInt = 0
|
||||
var totalBytesExpected: Int64? //this got sent up twice. Once at beginning of stream and second from network seek. We honor the first send
|
||||
|
||||
//This will be sent once at beginning of stream and every network seek
|
||||
var totalBytesExpected: Int64? {
|
||||
didSet {
|
||||
if let bytes = totalBytesExpected {
|
||||
delegate?.didUpdate(totalBytesExpected: Int64(byteOffsetBecauseOfSeek) + bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var largestPollingOffsetDifference: UInt64 = 1
|
||||
|
||||
@@ -108,128 +72,103 @@ class AudioThrottler: AudioThrottleable {
|
||||
AudioDataManager.shared.startStream(withRemoteURL: url) { [weak self] (pto: StreamProgressPTO) in
|
||||
guard let self = self else {return}
|
||||
Log.debug("received stream data of size \(pto.getData().count) and progress: \(pto.getProgress())")
|
||||
self.delegate?.didUpdate(networkStreamProgress: pto.getProgress())
|
||||
|
||||
if self.totalBytesExpected == nil, let totalBytesExpected = pto.getTotalBytesExpected() {
|
||||
|
||||
if let totalBytesExpected = pto.getTotalBytesExpected() {
|
||||
self.totalBytesExpected = totalBytesExpected
|
||||
self.delegate?.didUpdate(totalBytesExpected: totalBytesExpected)
|
||||
}
|
||||
|
||||
let lastItem = self.networkData.last
|
||||
let startoffset = lastItem == nil ? self.byteOffsetBecauseOfSeek : lastItem!.endOffset + 1
|
||||
let wrappedNetworkData = NetworkDataWrapper(startingOffset: startoffset, data: pto.getData())
|
||||
lastItem?.next = wrappedNetworkData
|
||||
self.networkData.append(wrappedNetworkData)
|
||||
|
||||
if !self.shouldThrottle {
|
||||
Log.debug("sending up packet from stream untrottled at start: \(wrappedNetworkData.startOffset)")
|
||||
//NOTE: the order here matters.
|
||||
//We have to set to true before sending up to be processed because
|
||||
//tellByteOffset() is ran in a separate thread than this one
|
||||
//We got in a state where 10% of the time an episode will keep polling because
|
||||
//the first 30 buffers have not been filled
|
||||
wrappedNetworkData.alreadySent = true
|
||||
delegate.shouldProcess(networkData: wrappedNetworkData.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tellAudioFormatFound() {
|
||||
shouldThrottle = true //the above layer has enough info that we can throttle
|
||||
}
|
||||
|
||||
func tellBytesPerAudioPacket(count: UInt64) {
|
||||
if count > largestPollingOffsetDifference {
|
||||
largestPollingOffsetDifference = count
|
||||
}
|
||||
}
|
||||
|
||||
func tellByteOffset(offset: UInt64) {
|
||||
Log.debug("offset \(offset)")
|
||||
|
||||
for wrappedNetworkData in networkData {
|
||||
if wrappedNetworkData.containsOffset(UInt(offset)) {
|
||||
Log.debug("offset: \(offset) within network packet of range: \(wrappedNetworkData.startOffset) to \(wrappedNetworkData.endOffset) is next sent: \(wrappedNetworkData.isNextSent())")
|
||||
|
||||
if wrappedNetworkData.alreadySent {
|
||||
if !wrappedNetworkData.isNextSent() {
|
||||
var bytesSent: UInt = 0
|
||||
var current = wrappedNetworkData
|
||||
|
||||
// Sometimes the next data packet is smaller than a full audio chunk size, so we need to ensure we send up enough packets for the audio chunk. This prevented Issue #4 where tsreaming would randomly get stuck in a state needing more data up the chain.
|
||||
// https://github.com/tanhakabir/SwiftAudioPlayer/issues/4
|
||||
while bytesSent < largestPollingOffsetDifference {
|
||||
if let next = current.next {
|
||||
Log.debug("Sending next network packet with range: \(next.startOffset) to \(next.endOffset)")
|
||||
next.alreadySent = true
|
||||
delegate?.shouldProcess(networkData: next.data)
|
||||
bytesSent += next.byteCount
|
||||
current = next
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Log.debug("Found network packet to send with range: \(wrappedNetworkData.startOffset) to \(wrappedNetworkData.endOffset)")
|
||||
delegate?.shouldProcess(networkData: wrappedNetworkData.data)
|
||||
wrappedNetworkData.alreadySent = true
|
||||
return
|
||||
self.queue.async { [weak self] in
|
||||
self?.networkData.append(pto.getData())
|
||||
StreamingDownloadDirector.shared.didUpdate(url.key, networkStreamProgress: pto.getProgress())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func tellSeek(offset: UInt64) {
|
||||
Log.info("seek with offset: \(offset)")
|
||||
|
||||
self.queue.async { [weak self] in
|
||||
self?.seekQueueHelper(offset)
|
||||
}
|
||||
}
|
||||
|
||||
func seekQueueHelper(_ offset: UInt64) {
|
||||
let offsetToFind = Int(offset) - Int(byteOffsetBecauseOfSeek)
|
||||
|
||||
var shouldStartNewStream: Bool = false
|
||||
|
||||
// if we have no data start a new stream after seek
|
||||
if networkData.count == 0 {
|
||||
shouldStartNewStream = true
|
||||
}
|
||||
|
||||
// if what we're looking for is outside of available data, start a new stream
|
||||
if offset < byteOffsetBecauseOfSeek || offsetToFind > networkData.sum {
|
||||
shouldStartNewStream = true
|
||||
}
|
||||
|
||||
// we should have the data within our cache. find it and save the index for the next pull
|
||||
if let indexOfDataContainingOffset = networkData.getIndexContainingByteOffset(offsetToFind) {
|
||||
lastSentDataPacketIndex = indexOfDataContainingOffset - 1
|
||||
}
|
||||
|
||||
if shouldStartNewStream {
|
||||
byteOffsetBecauseOfSeek = UInt(offset)
|
||||
lastSentDataPacketIndex = -1
|
||||
AudioDataManager.shared.seekStream(withRemoteURL: url, toByteOffset: offset)
|
||||
|
||||
networkData = []
|
||||
return
|
||||
}
|
||||
|
||||
if let finalOffset = networkData.last?.endOffset, let firstOffset = networkData.first?.startOffset {
|
||||
if offset < firstOffset || offset > finalOffset {
|
||||
byteOffsetBecauseOfSeek = UInt(offset)
|
||||
AudioDataManager.shared.seekStream(withRemoteURL: url, toByteOffset: offset)
|
||||
|
||||
networkData = []
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (i, d) in networkData.enumerated() {
|
||||
if offset > d.endOffset {
|
||||
d.alreadySent = false
|
||||
continue
|
||||
}
|
||||
|
||||
if d.containsOffset(UInt(offset)) {
|
||||
let wrappedData = d.splitToRight(atOffset: UInt(offset))
|
||||
networkData.insert(wrappedData, at: i+1)
|
||||
|
||||
d.alreadySent = false
|
||||
wrappedData.alreadySent = true
|
||||
Log.info("\(d) ::: \(wrappedData)")
|
||||
|
||||
delegate?.shouldProcess(networkData: wrappedData.data)
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.error("83672 Should not get here")
|
||||
}
|
||||
|
||||
func pollRangeOfBytesAvailable() -> (UInt64, UInt64) {
|
||||
let start = networkData.first?.startOffset ?? 0
|
||||
let end = networkData.last?.endOffset ?? 0
|
||||
let start = byteOffsetBecauseOfSeek
|
||||
let end = networkData.sum + Int(byteOffsetBecauseOfSeek)
|
||||
|
||||
return (UInt64(start), UInt64(end))
|
||||
}
|
||||
|
||||
func pullNextDataPacket(_ callback: @escaping (Data?) -> ()) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.lastSentDataPacketIndex < self.networkData.count - 1 else {
|
||||
callback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.lastSentDataPacketIndex += 1
|
||||
|
||||
callback(self.networkData[self.lastSentDataPacketIndex])
|
||||
}
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
AudioDataManager.shared.deleteStream(withRemoteURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Data {
|
||||
var sum: Int {
|
||||
get {
|
||||
return self.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
}
|
||||
|
||||
func getIndexContainingByteOffset(_ offset: Int) -> Int? {
|
||||
var dataCount = 0
|
||||
|
||||
for (i, data) in self.enumerated() {
|
||||
if offset >= dataCount && offset <= dataCount + data.count {
|
||||
return i
|
||||
}
|
||||
|
||||
dataCount += data.count
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ import AudioToolbox
|
||||
protocol AudioConvertable {
|
||||
var engineAudioFormat: AVAudioFormat {get}
|
||||
|
||||
init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat) throws
|
||||
func pullBuffer(withSize size: AVAudioFrameCount) throws -> AVAudioPCMBuffer
|
||||
init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat, withPCMBufferSize size: AVAudioFrameCount) throws
|
||||
func pullBuffer() throws -> AVAudioPCMBuffer
|
||||
func pollPredictedDuration() -> Duration?
|
||||
func pollNetworkAudioAvailabilityRange() -> (Needle, Duration)
|
||||
func seek(_ needle: Needle)
|
||||
@@ -70,15 +70,22 @@ class AudioConverter: AudioConvertable {
|
||||
|
||||
//From protocol
|
||||
public var engineAudioFormat: AVAudioFormat
|
||||
let pcmBufferSize: AVAudioFrameCount
|
||||
|
||||
//Field
|
||||
var converter: AudioConverterRef? //set by AudioConverterNew
|
||||
var currentAudioPacketIndex: AVAudioPacketCount = 0
|
||||
|
||||
required init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat) throws {
|
||||
// use to store reference to the allocated buffers from the converter to properly deallocate them before the next packet is being converted
|
||||
var converterBuffer: UnsafeMutableRawPointer?
|
||||
var converterDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?
|
||||
|
||||
required init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat, withPCMBufferSize size: AVAudioFrameCount) throws {
|
||||
self.engineAudioFormat = toEngineAudioFormat
|
||||
self.pcmBufferSize = size
|
||||
|
||||
do {
|
||||
parser = try AudioParser(withRemoteUrl: url, parsedFileAudioFormatCallback: {
|
||||
parser = try AudioParser(withRemoteUrl: url, bufferSize: Int(size), parsedFileAudioFormatCallback: {
|
||||
[weak self] (fileAudioFormat: AVAudioFormat) in
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
@@ -108,17 +115,17 @@ class AudioConverter: AudioConvertable {
|
||||
}
|
||||
}
|
||||
|
||||
func pullBuffer(withSize size: AVAudioFrameCount) throws -> AVAudioPCMBuffer {
|
||||
func pullBuffer() throws -> AVAudioPCMBuffer {
|
||||
guard let converter = converter else {
|
||||
Log.debug("reader_error trying to read before converter has been created")
|
||||
throw ConverterError.cannotCreatePCMBufferWithoutConverter
|
||||
}
|
||||
|
||||
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: engineAudioFormat, frameCapacity: size) else {
|
||||
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: engineAudioFormat, frameCapacity: pcmBufferSize) else {
|
||||
Log.monitor(ConverterError.failedToCreatePCMBuffer.errorDescription as Any)
|
||||
throw ConverterError.failedToCreatePCMBuffer
|
||||
}
|
||||
pcmBuffer.frameLength = size
|
||||
pcmBuffer.frameLength = pcmBufferSize
|
||||
|
||||
/**
|
||||
The whole thing is wrapped in queue.sync() because the converter listener
|
||||
@@ -127,7 +134,7 @@ class AudioConverter: AudioConvertable {
|
||||
*/
|
||||
return try queue.sync { () -> AVAudioPCMBuffer in
|
||||
let framesPerPacket = engineAudioFormat.streamDescription.pointee.mFramesPerPacket
|
||||
var numberOfPacketsWeWantTheBufferToFill = size / framesPerPacket
|
||||
var numberOfPacketsWeWantTheBufferToFill = pcmBuffer.frameLength / framesPerPacket
|
||||
|
||||
let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
|
||||
let status = AudioConverterFillComplexBuffer(converter, ConverterListener, context, &numberOfPacketsWeWantTheBufferToFill, pcmBuffer.mutableAudioBufferList, nil)
|
||||
|
||||
@@ -57,28 +57,39 @@ public enum ConverterError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .cannotLockQueue:
|
||||
Log.warn("Failed to lock queue")
|
||||
return "Failed to lock queue"
|
||||
case .converterFailed(let status):
|
||||
Log.warn(localizedDescriptionFromConverterError(status))
|
||||
return localizedDescriptionFromConverterError(status)
|
||||
case .failedToCreateDestinationFormat:
|
||||
Log.warn("Failed to create a destination (processing) format")
|
||||
return "Failed to create a destination (processing) format"
|
||||
case .failedToCreatePCMBuffer:
|
||||
Log.warn("Failed to create PCM buffer for reading data")
|
||||
return "Failed to create PCM buffer for reading data"
|
||||
case .notEnoughData:
|
||||
Log.warn("Not enough data for read-conversion operation")
|
||||
return "Not enough data for read-conversion operation"
|
||||
case .parserMissingDataFormat:
|
||||
Log.warn("Parser is missing a valid data format")
|
||||
return "Parser is missing a valid data format"
|
||||
case .reachedEndOfFile:
|
||||
Log.warn("Reached the end of the file")
|
||||
return "Reached the end of the file"
|
||||
case .unableToCreateConverter(let status):
|
||||
return localizedDescriptionFromConverterError(status)
|
||||
case .superConcerningShouldNeverHappen:
|
||||
Log.warn("Weird unexpected reader error. Should not have happened")
|
||||
return "Weird unexpected reader error. Should not have happened"
|
||||
case .cannotCreatePCMBufferWithoutConverter:
|
||||
Log.debug("Could not create a PCM Buffer because reader does not have a converter yet")
|
||||
return "Could not create a PCM Buffer because reader does not have a converter yet"
|
||||
case .throttleParsingBuffersForEngine:
|
||||
Log.warn("Preventing the reader from creating more PCM buffers since the player has more than 60 seconds of audio already to play")
|
||||
return "Preventing the reader from creating more PCM buffers since the player has more than 60 seconds of audio already to play"
|
||||
case .failedToCreateParser:
|
||||
Log.warn("Could not create a parser")
|
||||
return "Could not create a parser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +65,26 @@ func ConverterListener(_ converter: AudioConverterRef, _ packetCount: UnsafeMuta
|
||||
return ReaderShouldNotHappenError
|
||||
}
|
||||
|
||||
if let lastBuffer = selfAudioConverter.converterBuffer {
|
||||
lastBuffer.deallocate()
|
||||
}
|
||||
|
||||
// Copy data over (note we've only processing a single packet of data at a time)
|
||||
var packet = audioPacket.1
|
||||
let packetByteCount = packet.count //this is not the count of an array
|
||||
ioData.pointee.mNumberBuffers = 1
|
||||
ioData.pointee.mBuffers.mData = UnsafeMutableRawPointer.allocate(byteCount: packetByteCount, alignment: 0)
|
||||
_ = packet.withUnsafeMutableBytes({ (bytes: UnsafeMutablePointer<UInt8>) in
|
||||
_ = packet.accessMutableBytes({ (bytes: UnsafeMutablePointer<UInt8>) in
|
||||
memcpy((ioData.pointee.mBuffers.mData?.assumingMemoryBound(to: UInt8.self))!, bytes, packetByteCount)
|
||||
})
|
||||
ioData.pointee.mBuffers.mDataByteSize = UInt32(packetByteCount)
|
||||
|
||||
selfAudioConverter.converterBuffer = ioData.pointee.mBuffers.mData
|
||||
|
||||
if let lastDescription = selfAudioConverter.converterDescriptions {
|
||||
lastDescription.deallocate()
|
||||
}
|
||||
|
||||
// Handle packet descriptions for compressed formats (MP3, AAC, etc)
|
||||
let fileFormatDescription = fileAudioFormat.streamDescription.pointee
|
||||
if fileFormatDescription.mFormatID != kAudioFormatLinearPCM {
|
||||
@@ -86,6 +96,8 @@ func ConverterListener(_ converter: AudioConverterRef, _ packetCount: UnsafeMuta
|
||||
outPacketDescriptions?.pointee?.pointee.mVariableFramesInPacket = 0
|
||||
}
|
||||
|
||||
selfAudioConverter.converterDescriptions = outPacketDescriptions?.pointee
|
||||
|
||||
packetCount.pointee = 1
|
||||
|
||||
//we've successfully given a packet to the LPCM buffer now we can process the next audio packet
|
||||
|
||||
@@ -53,6 +53,9 @@ import AVFoundation
|
||||
//TODO: what if user seeks beyond the data we have? What if we're done but user seeks even further than what we have
|
||||
|
||||
class AudioParser: AudioParsable {
|
||||
private var MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING = 8192 // this will be modified when we know the file format to be just enough packets to fill up 1 pcm buffer
|
||||
private var framesPerBuffer: Int = 1
|
||||
|
||||
//MARK:- For OS parser class
|
||||
var parsedAudioHeaderPacketCount: UInt64 = 0
|
||||
var parsedAudioPacketDataSize: UInt64 = 0
|
||||
@@ -61,8 +64,8 @@ class AudioParser: AudioParsable {
|
||||
public var fileAudioFormat: AVAudioFormat? {
|
||||
didSet {
|
||||
if let format = fileAudioFormat, oldValue == nil {
|
||||
MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING = framesPerBuffer/Int(format.streamDescription.pointee.mFramesPerPacket)
|
||||
parsedFileAudioFormatCallback(format)
|
||||
throttler.tellAudioFormatFound()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +88,9 @@ class AudioParser: AudioParsable {
|
||||
return max(AVAudioPacketCount(parsedAudioHeaderPacketCount), AVAudioPacketCount(audioPackets.count))
|
||||
}
|
||||
|
||||
guard let sizeOfFileInBytes = expectedFileSizeInBytes, let bytesPerPacket = averageBytesPerPacket else {
|
||||
let sizeOfFileInBytes: UInt64 = expectedFileSizeInBytes != nil ? expectedFileSizeInBytes! : 0
|
||||
|
||||
guard let bytesPerPacket = averageBytesPerPacket else {
|
||||
return AVAudioPacketCount(0)
|
||||
}
|
||||
|
||||
@@ -98,25 +103,23 @@ class AudioParser: AudioParsable {
|
||||
return predictedCount
|
||||
}
|
||||
|
||||
var sumOfParsedAudioBytes:UInt32 = 0 {
|
||||
didSet {
|
||||
if let byteCount = averageBytesPerPacket {
|
||||
throttler.tellBytesPerAudioPacket(count: UInt64(byteCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sumOfParsedAudioBytes:UInt32 = 0
|
||||
var numberOfPacketsParsed:UInt32 = 0
|
||||
var audioPackets: [(AudioStreamPacketDescription?,Data)] = [] {
|
||||
var audioPackets: [(AudioStreamPacketDescription?,Data)] = [] {
|
||||
didSet {
|
||||
if let audioPacketByteSize = audioPackets.last?.0?.mDataByteSize {
|
||||
sumOfParsedAudioBytes += audioPacketByteSize
|
||||
numberOfPacketsParsed += 1
|
||||
} else if let audioPacketByteSize = audioPackets.last?.1.count { // for uncompressed audio there are no descriptors to say how many bytes of audio are in this packet so we approximate by data size
|
||||
sumOfParsedAudioBytes += UInt32(audioPacketByteSize)
|
||||
}
|
||||
|
||||
//TODO: duration will not work with WAV or AIFF
|
||||
numberOfPacketsParsed += 1
|
||||
|
||||
//TODO: duration will not be accurate with WAV or AIFF
|
||||
}
|
||||
}
|
||||
private let lockQueue = DispatchQueue(label: "SwiftAudioPlayer.Parser.packets.lock")
|
||||
var lastSentAudioPacketIndex = -1
|
||||
|
||||
/**
|
||||
Audio packets varry in size. The first one parsed in a batch of audio
|
||||
@@ -143,12 +146,30 @@ class AudioParser: AudioParsable {
|
||||
return audioPackets.count == totalPredictedPacketCount
|
||||
}
|
||||
|
||||
var streamChangeListenerId: UInt?
|
||||
|
||||
init(withRemoteUrl url: AudioURL, parsedFileAudioFormatCallback: @escaping(AVAudioFormat) -> ()) throws {
|
||||
init(withRemoteUrl url: AudioURL, bufferSize: Int, parsedFileAudioFormatCallback: @escaping(AVAudioFormat) -> ()) throws {
|
||||
self.url = url
|
||||
self.framesPerBuffer = bufferSize
|
||||
self.parsedFileAudioFormatCallback = parsedFileAudioFormatCallback
|
||||
|
||||
self.throttler = AudioThrottler(withRemoteUrl: url, withDelegate: self)
|
||||
|
||||
streamChangeListenerId = StreamingDownloadDirector.shared.attach { [weak self] (key, progress) in
|
||||
guard let self = self else { return }
|
||||
guard key == url.key else { return }
|
||||
self.networkProgress = progress
|
||||
|
||||
// initially parse a bunch of packets
|
||||
self.lockQueue.sync {
|
||||
if self.fileAudioFormat == nil {
|
||||
self.processNextDataPacket()
|
||||
} else if self.audioPackets.count - self.lastSentAudioPacketIndex < self.MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING {
|
||||
self.processNextDataPacket()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self)
|
||||
//Open the stream and when we call parse data is fed into this stream
|
||||
guard AudioFileStreamOpen(context, ParserPropertyListener, ParserPacketListener, kAudioFileMP3Type, &streamID) == noErr else {
|
||||
@@ -156,33 +177,61 @@ class AudioParser: AudioParsable {
|
||||
}
|
||||
}
|
||||
|
||||
func pullPacket(atIndex index: AVAudioPacketCount) throws -> (AudioStreamPacketDescription?, Data) {
|
||||
if let offset = getOffset(fromPacketIndex: index) {
|
||||
throttler.tellByteOffset(offset: offset)
|
||||
deinit {
|
||||
if let id = streamChangeListenerId {
|
||||
StreamingDownloadDirector.shared.detach(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
func pullPacket(atIndex index: AVAudioPacketCount) throws -> (AudioStreamPacketDescription?, Data) {
|
||||
determineIfMoreDataNeedsToBeParsed(index: index)
|
||||
|
||||
// Check if we've reached the end of the packets. We have two scenarios:
|
||||
// 1. We've reached the end of the packet data and the file has been completely parsed
|
||||
// 2. We've reached the end of the data we currently have downloaded, but not the file
|
||||
let packetIndex = index - indexSeekOffset
|
||||
let isEndOfData = packetIndex >= audioPackets.count
|
||||
if isEndOfData {
|
||||
if isParsingComplete {
|
||||
throw ParserError.readerAskingBeyondEndOfFile
|
||||
} else {
|
||||
Log.debug("Tried to pull packet at index: \(packetIndex) when only have: \(audioPackets.count), we predict \(totalPredictedPacketCount) in total")
|
||||
throw ParserError.notEnoughDataForReader
|
||||
|
||||
var exception: ParserError? = nil
|
||||
var packet: (AudioStreamPacketDescription?, Data) = (nil, Data())
|
||||
lockQueue.sync {
|
||||
if packetIndex >= self.audioPackets.count {
|
||||
if isParsingComplete {
|
||||
exception = ParserError.readerAskingBeyondEndOfFile
|
||||
return
|
||||
} else {
|
||||
Log.debug("Tried to pull packet at index: \(packetIndex) when only have: \(self.audioPackets.count), we predict \(self.totalPredictedPacketCount) in total")
|
||||
exception = ParserError.notEnoughDataForReader
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lastSentAudioPacketIndex = Int(packetIndex)
|
||||
packet = audioPackets[Int(packetIndex)]
|
||||
}
|
||||
if let exception = exception {
|
||||
throw exception
|
||||
} else {
|
||||
return packet
|
||||
}
|
||||
}
|
||||
|
||||
private func determineIfMoreDataNeedsToBeParsed(index: AVAudioPacketCount) {
|
||||
lockQueue.sync {
|
||||
if index > self.audioPackets.count - self.MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING {
|
||||
self.processNextDataPacket()
|
||||
}
|
||||
}
|
||||
|
||||
return audioPackets[Int(packetIndex)]
|
||||
}
|
||||
|
||||
func tellSeek(toIndex index: AVAudioPacketCount) {
|
||||
//Already within the processed audio packets. Ignore
|
||||
if indexSeekOffset <= index && index < audioPackets.count + Int(indexSeekOffset) {
|
||||
return
|
||||
var isIndexValid: Bool = true
|
||||
lockQueue.sync {
|
||||
if self.indexSeekOffset <= index && index < self.audioPackets.count + Int(self.indexSeekOffset) {
|
||||
isIndexValid = false
|
||||
}
|
||||
}
|
||||
guard isIndexValid else { return }
|
||||
|
||||
guard let byteOffset = getOffset(fromPacketIndex: index) else {
|
||||
return
|
||||
@@ -194,15 +243,18 @@ class AudioParser: AudioParsable {
|
||||
// NOTE: Order matters. Need to prevent appending to the array before we clean it. Just in case
|
||||
// then we tell the throttler to send us appropriate packet
|
||||
shouldPreventPacketFromFillingUp = true
|
||||
audioPackets = []
|
||||
lockQueue.sync {
|
||||
self.audioPackets = []
|
||||
}
|
||||
|
||||
throttler.tellSeek(offset: byteOffset)
|
||||
self.processNextDataPacket()
|
||||
}
|
||||
|
||||
private func getOffset(fromPacketIndex index: AVAudioPacketCount) -> UInt64? {
|
||||
//Clear current buffer if we have audio format
|
||||
guard fileAudioFormat != nil, let bytesPerPacket = self.averageBytesPerPacket else {
|
||||
Log.error("should not get here")
|
||||
Log.error("should not get here \(String(describing: fileAudioFormat)) and \(String(describing: self.averageBytesPerPacket))")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,6 +301,12 @@ class AudioParser: AudioParsable {
|
||||
return Needle(TimeInterval(frame)/TimeInterval(frameCount)*duration)
|
||||
}
|
||||
|
||||
func append(description: AudioStreamPacketDescription?, data: Data) {
|
||||
lockQueue.sync {
|
||||
self.audioPackets.append((description, data))
|
||||
}
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
throttler.invalidate()
|
||||
|
||||
@@ -276,6 +334,32 @@ class AudioParser: AudioParsable {
|
||||
|
||||
}
|
||||
|
||||
private func processNextDataPacket() {
|
||||
throttler.pullNextDataPacket { [weak self] (d) in
|
||||
guard let self = self else { return }
|
||||
guard let data = d else { return }
|
||||
|
||||
self.lockQueue.sync {
|
||||
Log.debug("processing data count: \(data.count) :: already had \(self.audioPackets.count) audio packets")
|
||||
}
|
||||
self.shouldPreventPacketFromFillingUp = false
|
||||
do {
|
||||
let sID = self.streamID!
|
||||
let dataSize = data.count
|
||||
|
||||
_ = try data.accessBytes({ (bytes: UnsafePointer<UInt8>) in
|
||||
let result:OSStatus = AudioFileStreamParseBytes(sID, UInt32(dataSize), bytes, [])
|
||||
guard result == noErr else {
|
||||
Log.monitor(ParserError.failedToParseBytes(result).errorDescription as Any)
|
||||
throw ParserError.failedToParseBytes(result)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
Log.monitor(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//MARK:- AudioThrottleDelegate
|
||||
@@ -283,27 +367,4 @@ extension AudioParser: AudioThrottleDelegate {
|
||||
func didUpdate(totalBytesExpected bytes: Int64) {
|
||||
expectedFileSizeInBytes = UInt64(bytes)
|
||||
}
|
||||
|
||||
func didUpdate(networkStreamProgress progress: Double) {
|
||||
networkProgress = progress
|
||||
}
|
||||
|
||||
func shouldProcess(networkData data: Data) {
|
||||
Log.debug("processing data count: \(data.count) :: already had \(audioPackets.count) audio packets")
|
||||
self.shouldPreventPacketFromFillingUp = false
|
||||
do {
|
||||
let sID = self.streamID!
|
||||
let dataSize = data.count
|
||||
|
||||
let _ = try data.withUnsafeBytes({ (bytes:UnsafePointer<UInt8>) in
|
||||
let result:OSStatus = AudioFileStreamParseBytes(sID, UInt32(dataSize), bytes, [])
|
||||
guard result == noErr else {
|
||||
Log.monitor(ParserError.failedToParseBytes(result).errorDescription as Any)
|
||||
throw ParserError.failedToParseBytes(result)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
Log.monitor(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-01-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer, Moy Inzunza
|
||||
//
|
||||
// This file was modified and adapted from https://github.com/syedhali/AudioStreamer
|
||||
// which was released under Apache License 2.0. Apache License 2.0 requires explicit
|
||||
@@ -32,15 +32,23 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
func ParserPacketListener(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>) {
|
||||
#if swift(>=5.3)
|
||||
func ParserPacketListener (_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?) {
|
||||
parserPacket(context, byteCount, packetCount, streamData, packetDescriptions)
|
||||
}
|
||||
|
||||
#else
|
||||
func ParserPacketListener (_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>) {
|
||||
parserPacket(context, byteCount, packetCount, streamData, packetDescriptions)
|
||||
}
|
||||
#endif
|
||||
|
||||
func parserPacket(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer<AudioStreamPacketDescription>?){
|
||||
|
||||
let selfAudioParser = Unmanaged<AudioParser>.fromOpaque(context).takeUnretainedValue()
|
||||
|
||||
//bug in core audio where this could be nil
|
||||
let packetDescriptionOrNil: UnsafeMutablePointer<AudioStreamPacketDescription>? = packetDescriptions
|
||||
let isCompressed = packetDescriptionOrNil != nil
|
||||
|
||||
guard let fileAudioFormat = selfAudioParser.fileAudioFormat else {
|
||||
Log.monitor("shouldnot have reached packet listener without a data format")
|
||||
Log.monitor("should not have reached packet listener without a data format")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,22 +58,25 @@ func ParserPacketListener(_ context: UnsafeMutableRawPointer, _ byteCount: UInt3
|
||||
}
|
||||
|
||||
//TODO refactor this after we get it working
|
||||
if isCompressed {
|
||||
if let compressedPacketDescriptions = packetDescriptions { // is compressed audio (.mp3)
|
||||
Log.debug("compressed audio")
|
||||
for i in 0 ..< Int(packetCount) {
|
||||
let audioPacketDescription = packetDescriptions[i]
|
||||
let audioPacketDescription = compressedPacketDescriptions[i]
|
||||
let audioPacketStart = Int(audioPacketDescription.mStartOffset)
|
||||
let audioPacketSize = Int(audioPacketDescription.mDataByteSize)
|
||||
let audioPacketData = Data(bytes: streamData.advanced(by: audioPacketStart), count: audioPacketSize)
|
||||
selfAudioParser.audioPackets.append((audioPacketDescription,audioPacketData))
|
||||
selfAudioParser.append(description: audioPacketDescription, data: audioPacketData)
|
||||
}
|
||||
} else {
|
||||
} else { // not compressed audio (.wav)
|
||||
Log.debug("uncompressed audio")
|
||||
let format = fileAudioFormat.streamDescription.pointee
|
||||
let bytesPerAudioPacket = Int(format.mBytesPerPacket)
|
||||
for i in 0 ..< Int(packetCount) {
|
||||
let audioPacketStart = i * bytesPerAudioPacket
|
||||
let audioPacketSize = bytesPerAudioPacket
|
||||
let audioPacketData = Data(bytes: streamData.advanced(by: audioPacketStart), count: audioPacketSize)
|
||||
selfAudioParser.audioPackets.append((nil, audioPacketData))
|
||||
selfAudioParser.append(description: nil, data: audioPacketData)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -29,8 +29,15 @@ import Foundation
|
||||
public struct SAAudioAvailabilityRange {
|
||||
let startingNeedle: Needle
|
||||
let durationLoadedByNetwork: Duration
|
||||
let predictedDurationToLoad: Duration
|
||||
let isPlayable: Bool
|
||||
|
||||
public var bufferingProgress: Double {
|
||||
get {
|
||||
return (startingNeedle + durationLoadedByNetwork) / predictedDurationToLoad
|
||||
}
|
||||
}
|
||||
|
||||
public var startingBufferTimePositon: Double {
|
||||
get {
|
||||
return startingNeedle
|
||||
@@ -49,7 +56,31 @@ public struct SAAudioAvailabilityRange {
|
||||
}
|
||||
}
|
||||
|
||||
var secondsLeftToBuffer: Double {
|
||||
get {
|
||||
return predictedDurationToLoad - (startingNeedle + durationLoadedByNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
public func contains(_ needle: Double) -> Bool {
|
||||
return needle >= startingNeedle && (needle - startingNeedle) < durationLoadedByNetwork
|
||||
}
|
||||
|
||||
public func reachedEndOfAudio(needle: Double) -> Bool {
|
||||
var needleAtEnd = false
|
||||
|
||||
if(totalDurationBuffered > 0 && needle > 0) {
|
||||
needleAtEnd = needle >= totalDurationBuffered - 1
|
||||
}
|
||||
|
||||
// if most of the audio is buffered for long audio or in short audio there isn't many seconds left to buffer it means wwe've reached the end of the audio
|
||||
|
||||
let isBuffered = (bufferingProgress > 0.99 || secondsLeftToBuffer < 5)
|
||||
|
||||
return isBuffered && needleAtEnd
|
||||
}
|
||||
|
||||
public func isCompletelyBuffered() -> Bool {
|
||||
return startingNeedle + durationLoadedByNetwork >= predictedDurationToLoad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,5 @@ public enum SAPlayingStatus {
|
||||
case playing
|
||||
case paused
|
||||
case buffering
|
||||
case ended
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ protocol LockScreenViewProtocol {
|
||||
}
|
||||
|
||||
extension LockScreenViewProtocol {
|
||||
func clearLockScreenInfo() {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
||||
}
|
||||
|
||||
@available(iOS 10.0, *)
|
||||
func setLockScreenInfo(withMediaInfo info: SALockScreenInfo?, duration: Duration) {
|
||||
var nowPlayingInfo:[String : Any] = [:]
|
||||
|
||||
@@ -32,6 +32,7 @@ protocol AudioDataManagable {
|
||||
var allowCellular: Bool { get set }
|
||||
|
||||
func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ())
|
||||
func setAllowCellularDownloadPreference(_ preference: Bool)
|
||||
|
||||
func clear()
|
||||
|
||||
@@ -51,7 +52,7 @@ protocol AudioDataManagable {
|
||||
}
|
||||
|
||||
class AudioDataManager: AudioDataManagable {
|
||||
var allowCellular: Bool = false
|
||||
var allowCellular: Bool = true
|
||||
|
||||
static let shared: AudioDataManagable = AudioDataManager()
|
||||
|
||||
@@ -99,6 +100,10 @@ class AudioDataManager: AudioDataManagable {
|
||||
backgroundCompletion = completionHandler
|
||||
}
|
||||
|
||||
func setAllowCellularDownloadPreference(_ preference: Bool) {
|
||||
allowCellular = preference
|
||||
}
|
||||
|
||||
func attach(callback: @escaping (_ id: ID, _ progress: Double)->()) {
|
||||
globalDownloadProgressCallback = callback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// AudioQueue.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Joe Williams on 3/10/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// wrapper for array of urls
|
||||
struct AudioQueue<T> {
|
||||
private var audioUrls: [T] = []
|
||||
|
||||
var isQueueEmpty: Bool {
|
||||
return audioUrls.isEmpty
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
return audioUrls.count
|
||||
}
|
||||
|
||||
var front: T? {
|
||||
return audioUrls.first
|
||||
}
|
||||
|
||||
mutating func append(item: T) {
|
||||
audioUrls.append(item)
|
||||
}
|
||||
|
||||
mutating func dequeue() -> T? {
|
||||
guard !isQueueEmpty else { return nil }
|
||||
return audioUrls.removeFirst()
|
||||
}
|
||||
}
|
||||
@@ -271,21 +271,18 @@ extension AudioDownloadWorker {
|
||||
return lhs.id == rhs.id && lhs.remoteUrl == rhs.remoteUrl
|
||||
}
|
||||
|
||||
var hashValue: Int {
|
||||
return id.hashValue ^ remoteUrl.hashValue
|
||||
}
|
||||
|
||||
let id: ID
|
||||
let remoteUrl: URL
|
||||
let rank: Int
|
||||
var completionHandlers: [(URL) -> ()]
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
hasher.combine(remoteUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private class ActiveDownload: Hashable {
|
||||
var hashValue: Int {
|
||||
return info.id.hashValue ^ task.hashValue
|
||||
}
|
||||
|
||||
static func == (lhs: AudioDownloadWorker.ActiveDownload, rhs: AudioDownloadWorker.ActiveDownload) -> Bool {
|
||||
return lhs.info.id == rhs.info.id
|
||||
}
|
||||
@@ -299,6 +296,11 @@ extension AudioDownloadWorker {
|
||||
self.info = info
|
||||
self.task = task
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(info.id)
|
||||
hasher.combine(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,12 +103,15 @@ extension FileStorage {
|
||||
}
|
||||
|
||||
static func locate(_ id: ID) -> URL? {
|
||||
let urls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
|
||||
|
||||
for url in urls {
|
||||
if url.absoluteString.contains(id) && url.pathExtension != "" {
|
||||
_ = getUrl(givenId: id, andFileExtension: url.pathExtension)
|
||||
return url
|
||||
let folderUrls = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
|
||||
guard folderUrls.count != 0 else { return nil }
|
||||
|
||||
if let urls = try? FileManager.default.contentsOfDirectory(at: folderUrls[0], includingPropertiesForKeys: nil) {
|
||||
for url in urls {
|
||||
if url.absoluteString.contains(id) && url.pathExtension != "" {
|
||||
_ = getUrl(givenId: id, andFileExtension: url.pathExtension)
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -243,15 +243,19 @@ extension AudioStreamWorker: URLSessionDataDelegate {
|
||||
}
|
||||
|
||||
guard self.task == dataTask else {
|
||||
Log.error("stream_error not the same task") //Probably because of seek
|
||||
Log.error("stream_error not the same task 638283") //Probably because of seek
|
||||
return
|
||||
}
|
||||
|
||||
guard let totalBytesExpected = totalBytesExpectedForCurrentStream, totalBytesExpected > 0 else {
|
||||
guard var totalBytesExpected = totalBytesExpectedForCurrentStream else {
|
||||
Log.monitor("should not be called 223r2")
|
||||
return
|
||||
}
|
||||
|
||||
if totalBytesExpected <= 0 {
|
||||
totalBytesExpected = totalBytesReceived
|
||||
}
|
||||
|
||||
totalBytesReceived = totalBytesReceived + Int64(data.count)
|
||||
let progress = Double(totalBytesReceived)/Double(totalBytesExpected)
|
||||
|
||||
@@ -267,7 +271,7 @@ extension AudioStreamWorker: URLSessionDataDelegate {
|
||||
}
|
||||
|
||||
guard self.task == dataTask else {
|
||||
Log.error("stream_error not the same task")
|
||||
Log.error("stream_error not the same task 517253")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -289,8 +293,8 @@ extension AudioStreamWorker: URLSessionDataDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard self.task == task else {
|
||||
Log.error("stream_error not the same task")
|
||||
if self.task != task && self.task != nil {
|
||||
Log.error("stream_error not the same task 3901833")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+257
-16
@@ -27,6 +27,16 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
public class SAPlayer {
|
||||
public var DEBUG_MODE: Bool = false {
|
||||
didSet {
|
||||
if(DEBUG_MODE) {
|
||||
logLevel = LogLevel.EXTERNAL_DEBUG
|
||||
} else {
|
||||
logLevel = LogLevel.MONITOR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Access to the player.
|
||||
*/
|
||||
@@ -35,6 +45,94 @@ public class SAPlayer {
|
||||
private var presenter: SAPlayerPresenter!
|
||||
private var player: AudioEngine?
|
||||
|
||||
/**
|
||||
Access the engine of the player. Engine is nil if player has not been initialized with audio.
|
||||
|
||||
- Important: Changes to the engine are not safe guarded, thus unknown behaviour can arise from changing the engine. Just be wary and read [documentation of AVAudioEngine](https://developer.apple.com/documentation/avfoundation/avaudioengine) well when modifying,
|
||||
*/
|
||||
public var engine: AVAudioEngine? {
|
||||
get {
|
||||
return player?.engine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Unique ID for the current engine. This will be nil if no audio has been initialized which means no engine exists.
|
||||
*/
|
||||
public var engineUID: String? {
|
||||
get {
|
||||
return player?.key
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Access the player node of the engine. Node is nil if player has not been initialized with audio.
|
||||
|
||||
- Important: Changes to the engine and this node are not safe guarded, thus unknown behaviour can arise from changing the engine or this node. Just be wary and read [documentation of AVAudioEngine](https://developer.apple.com/documentation/avfoundation/avaudioengine) well when modifying,
|
||||
*/
|
||||
public var playerNode: AVAudioPlayerNode? {
|
||||
get {
|
||||
return player?.playerNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Corresponding to the overall volume of the player. Volume's default value is 1.0 and the range of valid values is 0.0 to 1.0. Volume is nil if no audio has been initialized yet.
|
||||
*/
|
||||
public var volume: Float? {
|
||||
get {
|
||||
return player?.playerNode.volume
|
||||
}
|
||||
|
||||
set {
|
||||
guard let value = newValue else { return }
|
||||
guard value >= 0.0 && value <= 1.0 else { return }
|
||||
|
||||
player?.playerNode.volume = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Corresponding to the rate of audio playback. This rate assumes use of the default rate modifier at the first index of `audioModifiers`; if you removed that modifier than this will be nil. If no audio has been initialized then this will also be nil.
|
||||
|
||||
- Note: By default this engine has added a pitch modifier node to change the pitch so that on playback rate changes of spoken word the pitch isn't shifted.
|
||||
|
||||
The component description of this node is:
|
||||
````
|
||||
var componentDescription: AudioComponentDescription {
|
||||
get {
|
||||
var ret = AudioComponentDescription()
|
||||
ret.componentType = kAudioUnitType_FormatConverter
|
||||
ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther
|
||||
return ret
|
||||
}
|
||||
}
|
||||
````
|
||||
Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details.
|
||||
|
||||
For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050).
|
||||
*/
|
||||
public var rate: Float? {
|
||||
get {
|
||||
return (audioModifiers.first as? AVAudioUnitTimePitch)?.rate
|
||||
}
|
||||
|
||||
set {
|
||||
guard let value = newValue else { return }
|
||||
guard let node = audioModifiers.first as? AVAudioUnitTimePitch else { return }
|
||||
|
||||
node.rate = value
|
||||
playbackRateOfAudioChanged(rate: value)
|
||||
|
||||
// if skip silences was on, reset it to have the new rate
|
||||
// TODO fix this to rate being broadcasted and handled in only Features.SkipSilences https://github.com/tanhakabir/SwiftAudioPlayer/issues/77
|
||||
// if Features.SkipSilences.enabled && !(value == rate ?? 1.0 - 0.5 || value == rate ?? 1.0 + 0.5) {
|
||||
// _ = Features.SkipSilences.disable()
|
||||
// _ = Features.SkipSilences.enable()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Corresponding to the skipping forward button on the media player on the lockscreen. Default is set to 30 seconds.
|
||||
*/
|
||||
@@ -72,11 +170,28 @@ public class SAPlayer {
|
||||
}
|
||||
````
|
||||
Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details.
|
||||
|
||||
For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050).
|
||||
|
||||
To remove this default pitch modifier for playback rate changes, remove the node by calling `SAPlayer.shared.clearAudioModifiers()`.
|
||||
*/
|
||||
public var audioModifiers: [AVAudioUnit] = []
|
||||
|
||||
/**
|
||||
List of audio URLs queued for playback.
|
||||
*/
|
||||
public var audioQueued: [URL] {
|
||||
get {
|
||||
return presenter.audioQueue.map { (queued) -> URL in
|
||||
return queued.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Total duration of current audio initialized. Returns nil if no audio is initialized in player.
|
||||
|
||||
- Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, this value will be constantly updating to best known value at the time.
|
||||
*/
|
||||
public var duration: Double? {
|
||||
get {
|
||||
@@ -142,6 +257,22 @@ public class SAPlayer {
|
||||
audioModifiers.append(AVAudioUnitTimePitch(audioComponentDescription: componentDescription))
|
||||
}
|
||||
|
||||
/**
|
||||
Clears all [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) modifiers intended to be used for realtime audio manipulation.
|
||||
*/
|
||||
public func clearAudioModifiers() {
|
||||
audioModifiers.removeAll()
|
||||
}
|
||||
|
||||
/**
|
||||
Append an [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) modifier to the list of modifiers used for realtime audio manipulation. The modifier will be added to the end of the list.
|
||||
|
||||
- Parameter modifier: The modifier to append.
|
||||
*/
|
||||
public func addAudioModifier(_ modifer: AVAudioUnit) {
|
||||
audioModifiers.append(modifer)
|
||||
}
|
||||
|
||||
/**
|
||||
Formats a textual representation of a given timestamp for display in hh:MM:SS format, that is hours:minutes:seconds.
|
||||
|
||||
@@ -150,9 +281,8 @@ public class SAPlayer {
|
||||
*/
|
||||
public static func prettifyTimestamp(_ timestamp: Double) -> String {
|
||||
let hours = Int(timestamp / 60 / 60)
|
||||
let minutes = Int((timestamp - Double(hours * 60)) / 60)
|
||||
|
||||
let secondsLeft = Int(timestamp) - (minutes * 60)
|
||||
let minutes = Int((timestamp - Double(hours * 60 * 60)) / 60)
|
||||
let secondsLeft = Int(timestamp - Double(hours * 60 * 60) - Double(minutes * 60))
|
||||
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secondsLeft))"
|
||||
}
|
||||
@@ -166,24 +296,38 @@ public class SAPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SAPlayerBitrate {
|
||||
/// This bitrate is good for radio streams that are passing ittle amounts of audio data at a time. This will allow the player to process the audio data in a fast enough rate to not pause or get stuck playing. This rate however ends up using more CPU and is worse for your battery-life and performance of your app.
|
||||
case low
|
||||
|
||||
/// This bitrate is good for streaming saved audio files like podcasts where most of the audio data will be received from the remote server at the beginning in a short time. This rate is more performant by using much less CPU and being better for your battery-life and app performance.
|
||||
case high // go for audio files being streamed. This is uses less CPU and
|
||||
}
|
||||
|
||||
//MARK: - External Player Controls
|
||||
extension SAPlayer {
|
||||
/**
|
||||
Toggles between the play and pause state of the player if the player is not buffering (thus is playable).
|
||||
Toggles between the play and pause state of the player. If nothing is playable (aka still in buffering state or no audio is initialized) no action will be taken. Please call `startSavedAudio` or `startRemoteAudio` to set up the player with audio before this.
|
||||
|
||||
- Note: If you are streaming, wait till the status from `SAPlayer.Updates.PlayingStatus` is not `.buffering`.
|
||||
*/
|
||||
public func togglePlayAndPause() {
|
||||
presenter.handleTogglePlayingAndPausing()
|
||||
}
|
||||
|
||||
/**
|
||||
Attempts to play the player even if nothing playable is loaded (aka still in buffering state or no audio is initialized).
|
||||
Attempts to play the player. If nothing is playable (aka still in buffering state or no audio is initialized) no action will be taken. Please call `startSavedAudio` or `startRemoteAudio` to set up the player with audio before this.
|
||||
|
||||
- Note: If you are streaming, wait till the status from `SAPlayer.Updates.PlayingStatus` is not `.buffering`.
|
||||
*/
|
||||
public func play() {
|
||||
presenter.handlePlay()
|
||||
}
|
||||
|
||||
/**
|
||||
Attempts to pause the player even if nothing playable is loaded (aka still in buffering state or no audio is initialized).
|
||||
Attempts to pause the player. If nothing is playable (aka still in buffering state or no audio is initialized) no action will be taken. Please call `startSavedAudio` or `startRemoteAudio` to set up the player with audio before this.
|
||||
|
||||
- Note:If you are streaming, wait till the status from `SAPlayer.Updates.PlayingStatus` is not `.buffering`.
|
||||
*/
|
||||
public func pause() {
|
||||
presenter.handlePause()
|
||||
@@ -221,6 +365,23 @@ extension SAPlayer {
|
||||
/**
|
||||
If using an AVAudioUnitTimePitch, it's important to notify the player that the rate at which the audio playing has changed to keep the media player in the lockscreen up to date. This is only important for playback rate changes.
|
||||
|
||||
- Note: By default this engine has added a pitch modifier node to change the pitch so that on playback rate changes of spoken word the pitch isn't shifted.
|
||||
|
||||
The component description of this node is:
|
||||
````
|
||||
var componentDescription: AudioComponentDescription {
|
||||
get {
|
||||
var ret = AudioComponentDescription()
|
||||
ret.componentType = kAudioUnitType_FormatConverter
|
||||
ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther
|
||||
return ret
|
||||
}
|
||||
}
|
||||
````
|
||||
Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details.
|
||||
|
||||
For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050).
|
||||
|
||||
- Parameter rate: The current rate at which the audio is playing.
|
||||
*/
|
||||
public func playbackRateOfAudioChanged(rate: Float) {
|
||||
@@ -232,27 +393,106 @@ extension SAPlayer {
|
||||
|
||||
- Important: If intending to use [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) audio modifiers during playback, the list of audio modifiers under `SAPlayer.shared.audioModifiers` must be finalized before calling this function. After all realtime audio manipulations within the this will be effective.
|
||||
|
||||
- Note: The default list already has an AVAudioUnitTimePitch node first in the list. This node is specifically set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word).
|
||||
|
||||
The component description of this node is:
|
||||
````
|
||||
var componentDescription: AudioComponentDescription {
|
||||
get {
|
||||
var ret = AudioComponentDescription()
|
||||
ret.componentType = kAudioUnitType_FormatConverter
|
||||
ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther
|
||||
return ret
|
||||
}
|
||||
}
|
||||
````
|
||||
Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details.
|
||||
|
||||
To remove this default pitch modifier for playback rate changes, remove the node by calling `SAPlayer.shared.clearAudioModifiers()`.
|
||||
|
||||
- Parameter withSavedUrl: The URL of the audio saved on the device.
|
||||
- Parameter mediaInfo: The media information of the audio to show on the lockscreen media player (optional).
|
||||
*/
|
||||
public func startSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) {
|
||||
self.mediaInfo = mediaInfo
|
||||
presenter.handlePlaySavedAudio(withSavedUrl: url)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed: "startSavedAudio")
|
||||
public func initializeSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) {
|
||||
self.mediaInfo = mediaInfo
|
||||
presenter.handlePlaySavedAudio(withSavedUrl: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Sets up player to play audio that will be streamed from a remote location.
|
||||
Sets up player to play audio that will be streamed from a remote location. After this is called, it will connect to the server and start to receive and process data. The player is not playable the SAAudioAvailabilityRange notifies that player is ready for playing (you can subscribe to these updates through `SAPlayer.Updates.StreamingBuffer`). You can alternatively see when the player is available to play by subscribing to `SAPlayer.Updates.PlayingStatus` and waiting for a status that isn't `.buffering`.
|
||||
|
||||
- Important: If intending to use [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) audio modifiers during playback, the list of audio modifiers under `SAPlayer.shared.audioModifiers` must be finalized before calling this function. After all realtime audio manipulations within the this will be effective.
|
||||
|
||||
- Note: The default list already has an AVAudioUnitTimePitch node first in the list. This node is specifically set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word).
|
||||
|
||||
The component description of this node is:
|
||||
````
|
||||
var componentDescription: AudioComponentDescription {
|
||||
get {
|
||||
var ret = AudioComponentDescription()
|
||||
ret.componentType = kAudioUnitType_FormatConverter
|
||||
ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther
|
||||
return ret
|
||||
}
|
||||
}
|
||||
````
|
||||
Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details.
|
||||
|
||||
To remove this default pitch modifier for playback rate changes, remove the node by calling `SAPlayer.shared.clearAudioModifiers()`.
|
||||
|
||||
- Note: Subscribe to `SAPlayer.Updates.StreamingBuffer` to see updates in streaming progress.
|
||||
|
||||
- Parameter withRemoteUrl: The URL of the remote audio.
|
||||
- Parameter bitrate: The bitrate of the streamed audio. By default the bitrate is set to high for streaming saved audio files. If you want to stream radios then you should use the `low` bitrate option.
|
||||
- Parameter mediaInfo: The media information of the audio to show on the lockscreen media player (optional).
|
||||
*/
|
||||
public func startRemoteAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate = .high, mediaInfo: SALockScreenInfo? = nil) {
|
||||
self.mediaInfo = mediaInfo
|
||||
presenter.handlePlayStreamedAudio(withRemoteUrl: url, bitrate: bitrate)
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed: "startRemoteAudio")
|
||||
public func initializeRemoteAudio(withRemoteUrl url: URL, mediaInfo: SALockScreenInfo? = nil) {
|
||||
self.mediaInfo = mediaInfo
|
||||
presenter.handlePlayStreamedAudio(withRemoteUrl: url)
|
||||
presenter.handlePlayStreamedAudio(withRemoteUrl: url, bitrate: .high)
|
||||
}
|
||||
|
||||
/**
|
||||
Stops any streaming in progress.
|
||||
*/
|
||||
public func stopStreamingRemoteAudio() {
|
||||
presenter.handleStopStreamingAudio()
|
||||
}
|
||||
|
||||
/**
|
||||
Queues remote audio to be played next. The URLs in the queue can be both remote or on disk but once the queued audio starts playing it will start buffering and loading then. This means no guarantee for a 'gapless' playback where there might be several moments in between one audio ending and another starting due to buffering remote audio.
|
||||
|
||||
- Parameter withRemoteUrl: The URL of the remote audio.
|
||||
*/
|
||||
public func queueRemoteAudio(withRemoteUrl url: URL) {
|
||||
presenter.handleQueueStreamedAudio(withRemoteUrl: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Queues saved audio to be played next. The URLs in the queuecan be both remote or on disk but once the queued audio starts playing it will start buffering and loading then. This means no guarantee for a 'gapless' playback where there might be several moments in between one audio ending and another starting due to buffering remote audio.
|
||||
|
||||
- Parameter withSavedUrl: The URL of the audio saved on the device.
|
||||
*/
|
||||
public func queueSavedAudio(withSavedUrl url: URL) {
|
||||
presenter.handleQueueSavedAudio(withSavedUrl: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Resets the player to the state before initializing audio and setting media info.
|
||||
*/
|
||||
public func clear() {
|
||||
presenter.handleClear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,15 +500,18 @@ extension SAPlayer {
|
||||
//MARK: - Internal implementation of delegate
|
||||
extension SAPlayer: SAPlayerDelegate {
|
||||
func startAudioDownloaded(withSavedUrl url: AudioURL) {
|
||||
player?.pause()
|
||||
player?.invalidate()
|
||||
player = AudioDiskEngine(withSavedUrl: url, delegate: presenter)
|
||||
}
|
||||
|
||||
func startAudioStreamed(withRemoteUrl url: AudioURL) {
|
||||
func startAudioStreamed(withRemoteUrl url: AudioURL, bitrate: SAPlayerBitrate) {
|
||||
player = AudioStreamEngine(withRemoteUrl: url, delegate: presenter, bitrate: bitrate)
|
||||
}
|
||||
|
||||
func clearEngine() {
|
||||
player?.pause()
|
||||
player?.invalidate()
|
||||
player = AudioStreamEngine(withRemoteUrl: url, delegate: presenter)
|
||||
player = nil
|
||||
Log.info("cleared engine")
|
||||
}
|
||||
|
||||
func playEngine() {
|
||||
@@ -280,12 +523,10 @@ extension SAPlayer: SAPlayerDelegate {
|
||||
private func becomeDeviceAudioPlayer() {
|
||||
do {
|
||||
if #available(iOS 11.0, *) {
|
||||
// try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, policy: .longForm, options: [])
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, policy: .longFormAudio, options: [])
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: AVAudioSession.Mode(rawValue: convertFromAVAudioSessionMode(AVAudioSession.Mode.default)), options: .allowAirPlay)
|
||||
}
|
||||
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: AVAudioSession.Mode(rawValue: convertFromAVAudioSessionMode(AVAudioSession.Mode.default)), options: .allowAirPlay)
|
||||
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
Log.monitor("Problem setting up AVAudioSession to play in:: \(error.localizedDescription)")
|
||||
|
||||
@@ -31,7 +31,8 @@ protocol SAPlayerDelegate: AnyObject, LockScreenViewProtocol {
|
||||
var skipBackwardSeconds: Double { get set }
|
||||
|
||||
func startAudioDownloaded(withSavedUrl url: AudioURL)
|
||||
func startAudioStreamed(withRemoteUrl url: AudioURL)
|
||||
func startAudioStreamed(withRemoteUrl url: AudioURL, bitrate: SAPlayerBitrate)
|
||||
func clearEngine()
|
||||
func playEngine()
|
||||
func pauseEngine()
|
||||
func seekEngine(toNeedle needle: Needle) //TODO ensure that engine cleans up out of bounds
|
||||
|
||||
@@ -100,5 +100,14 @@ extension SAPlayer {
|
||||
public static func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ()) {
|
||||
AudioDataManager.shared.setBackgroundCompletionHandler(completionHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
Whether downloading audio on cellular data is allowed. By default this is set to `true`.
|
||||
*/
|
||||
public static var allowUsingCellularData = true {
|
||||
didSet {
|
||||
AudioDataManager.shared.setAllowCellularDownloadPreference(allowUsingCellularData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// SAPlayerFeature.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 3/10/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
extension SAPlayer {
|
||||
/**
|
||||
Special features for audio manipulation. These are examples of manipulations you can do with the player outside of this library. This is just an aggregation of community contibuted ones.
|
||||
|
||||
- Note: These features assume default state of the player and `audioModifiers` meaning some expect the first audio modifier to be the default `AVAudioUnitTimePitch` that comes with the SAPlayer.
|
||||
*/
|
||||
public struct Features {
|
||||
|
||||
/**
|
||||
Feature to skip silences in spoken word audio. The player will speed up the rate of audio playback when silence is detected.
|
||||
|
||||
- Important: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work.
|
||||
*/
|
||||
public struct SkipSilences {
|
||||
|
||||
static var enabled: Bool = false
|
||||
static var originalRate: Float = 1.0
|
||||
|
||||
/**
|
||||
Enable feature to skip silences in spoken word audio. The player will speed up the rate of audio playback when silence is detected. This can be called at any point of audio playback.
|
||||
|
||||
- Important: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work.
|
||||
*/
|
||||
public static func enable() -> Bool {
|
||||
guard let engine = SAPlayer.shared.engine else { return false }
|
||||
|
||||
Log.info("enabling skip silences feature")
|
||||
enabled = true
|
||||
originalRate = SAPlayer.shared.rate ?? 1.0
|
||||
let format = engine.mainMixerNode.outputFormat(forBus: 0)
|
||||
|
||||
|
||||
// look at documentation here to get an understanding of what is happening here: https://www.raywenderlich.com/5154-avaudioengine-tutorial-for-ios-getting-started#toc-anchor-005
|
||||
engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, when in
|
||||
guard let channelData = buffer.floatChannelData else {
|
||||
return
|
||||
}
|
||||
|
||||
let channelDataValue = channelData.pointee
|
||||
let channelDataValueArray = stride(from: 0,
|
||||
to: Int(buffer.frameLength),
|
||||
by: buffer.stride).map { channelDataValue[$0] }
|
||||
|
||||
let rms = sqrt(channelDataValueArray.map { $0 * $0 }.reduce(0, +) / Float(buffer.frameLength))
|
||||
|
||||
let avgPower = 20 * log10(rms)
|
||||
|
||||
let meterLevel = self.scaledPower(power: avgPower)
|
||||
Log.debug("meterLevel: \(meterLevel)")
|
||||
if meterLevel < 0.6 { // below 0.6 decibels is below audible audio
|
||||
SAPlayer.shared.rate = originalRate + 0.5
|
||||
Log.debug("speed up rate to \(String(describing: SAPlayer.shared.rate))")
|
||||
} else {
|
||||
SAPlayer.shared.rate = originalRate
|
||||
Log.debug("slow down rate to \(String(describing: SAPlayer.shared.rate))")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
Disable feature to skip silences in spoken word audio. The player will speed up the rate of audio playback when silence is detected. This can be called at any point of audio playback.
|
||||
|
||||
- Important: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work.
|
||||
*/
|
||||
public static func disable() -> Bool {
|
||||
guard let engine = SAPlayer.shared.engine else { return false }
|
||||
Log.info("disabling skip silences feature")
|
||||
engine.mainMixerNode.removeTap(onBus: 0)
|
||||
SAPlayer.shared.rate = originalRate
|
||||
enabled = false
|
||||
return true
|
||||
}
|
||||
|
||||
private static func scaledPower(power: Float) -> Float {
|
||||
guard power.isFinite else { return 0.0 }
|
||||
let minDb: Float = -80.0
|
||||
if power < minDb {
|
||||
return 0.0
|
||||
} else if power >= 1.0 {
|
||||
return 1.0
|
||||
} else {
|
||||
return (abs(minDb) - abs(power)) / abs(minDb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Feature to pause the player after a delay. This will happen regardless of if another audio clip has started.
|
||||
*/
|
||||
public struct SleepTimer {
|
||||
static var timer: Timer?
|
||||
|
||||
/**
|
||||
Enable feature to pause the player after a delay. This will happen regardless of if another audio clip has started.
|
||||
|
||||
- Parameter afterDelay: The number of seconds to wait before pausing the audio
|
||||
*/
|
||||
public static func enable(afterDelay delay: Double) {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false, block: { _ in
|
||||
SAPlayer.shared.pause()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
Disable feature to pause the player after a delay.
|
||||
*/
|
||||
public static func disable() {
|
||||
timer?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ import AVFoundation
|
||||
import MediaPlayer
|
||||
|
||||
class SAPlayerPresenter {
|
||||
enum Location {
|
||||
case remote
|
||||
case disk
|
||||
}
|
||||
|
||||
weak var delegate: SAPlayerDelegate?
|
||||
var shouldPlayImmediately = false //for auto-play
|
||||
|
||||
@@ -43,13 +48,12 @@ class SAPlayerPresenter {
|
||||
var durationRef:UInt = 0
|
||||
var needleRef:UInt = 0
|
||||
var playingStatusRef:UInt = 0
|
||||
var audioQueue: [(Location, URL)] = []
|
||||
|
||||
init(delegate: SAPlayerDelegate?) {
|
||||
self.delegate = delegate
|
||||
|
||||
delegate?.setLockScreenControls(presenter: self)
|
||||
|
||||
prepareNextEpisodeToPlay()
|
||||
}
|
||||
|
||||
func getUrl(forKey key: Key) -> URL? {
|
||||
@@ -60,20 +64,48 @@ class SAPlayerPresenter {
|
||||
urlKeyMap[url.key] = url
|
||||
}
|
||||
|
||||
func handleClear() {
|
||||
delegate?.clearEngine()
|
||||
|
||||
needle = nil
|
||||
duration = nil
|
||||
key = nil
|
||||
mediaInfo = nil
|
||||
delegate?.clearLockScreenInfo()
|
||||
|
||||
AudioClockDirector.shared.detachFromChangesInDuration(withID: durationRef)
|
||||
AudioClockDirector.shared.detachFromChangesInNeedle(withID: needleRef)
|
||||
AudioClockDirector.shared.detachFromChangesInPlayingStatus(withID: playingStatusRef)
|
||||
}
|
||||
|
||||
func handlePlaySavedAudio(withSavedUrl url: URL) {
|
||||
// Because we support queueing, we want to clear off any existing players.
|
||||
// Therefore, instantiate new player every time, destroy any existing ones.
|
||||
// This prevents a crash where an owning engine already exists.
|
||||
handleClear()
|
||||
attachForUpdates(url: url)
|
||||
delegate?.startAudioDownloaded(withSavedUrl: url)
|
||||
}
|
||||
|
||||
func handlePlayStreamedAudio(withRemoteUrl url: URL) {
|
||||
func handlePlayStreamedAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate) {
|
||||
// Because we support queueing, we want to clear off any existing players.
|
||||
// Therefore, instantiate new player every time, destroy any existing ones.
|
||||
// This prevents a crash where an owning engine already exists.
|
||||
handleClear()
|
||||
attachForUpdates(url: url)
|
||||
delegate?.startAudioStreamed(withRemoteUrl: url)
|
||||
delegate?.startAudioStreamed(withRemoteUrl: url, bitrate: bitrate)
|
||||
}
|
||||
|
||||
func handleQueueStreamedAudio(withRemoteUrl url: URL) {
|
||||
audioQueue.append((.remote, url))
|
||||
}
|
||||
|
||||
func handleQueueSavedAudio(withSavedUrl url: URL) {
|
||||
audioQueue.append((.disk, url))
|
||||
}
|
||||
|
||||
private func attachForUpdates(url: URL) {
|
||||
AudioClockDirector.shared.detachFromChangesInDuration(withID: durationRef)
|
||||
AudioClockDirector.shared.detachFromChangesInNeedle(withID: needleRef)
|
||||
AudioClockDirector.shared.detachFromChangesInPlayingStatus(withID: playingStatusRef)
|
||||
detachFromUpdates()
|
||||
|
||||
self.key = url.key
|
||||
urlKeyMap[url.key] = url
|
||||
@@ -110,9 +142,29 @@ class SAPlayerPresenter {
|
||||
}
|
||||
|
||||
self.isPlaying = isPlaying
|
||||
|
||||
if(self.isPlaying == .paused && self.shouldPlayImmediately) {
|
||||
self.shouldPlayImmediately = false
|
||||
self.handlePlay()
|
||||
}
|
||||
|
||||
if(self.isPlaying == .ended) {
|
||||
self.playNextAudioIfExists()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func detachFromUpdates() {
|
||||
AudioClockDirector.shared.detachFromChangesInDuration(withID: durationRef)
|
||||
AudioClockDirector.shared.detachFromChangesInNeedle(withID: needleRef)
|
||||
AudioClockDirector.shared.detachFromChangesInPlayingStatus(withID: playingStatusRef)
|
||||
}
|
||||
|
||||
func handleStopStreamingAudio() {
|
||||
delegate?.clearEngine()
|
||||
detachFromUpdates()
|
||||
}
|
||||
|
||||
@available(iOS 10.0, *)
|
||||
func handleLockscreenInfo(info: SALockScreenInfo?) {
|
||||
self.mediaInfo = info
|
||||
@@ -175,17 +227,38 @@ extension SAPlayerPresenter: AudioEngineDelegate {
|
||||
func didError() {
|
||||
Log.monitor("We should have handled engine error")
|
||||
}
|
||||
|
||||
func didEndPlaying() {
|
||||
// TODO
|
||||
// playNextEpisode()
|
||||
}
|
||||
}
|
||||
|
||||
//MARK:- Autoplay
|
||||
//FIXME: This needs to be refactored
|
||||
extension SAPlayerPresenter {
|
||||
func prepareNextEpisodeToPlay() {
|
||||
// TODO
|
||||
func playNextAudioIfExists() {
|
||||
Log.info("looking foor next audio in queue to play")
|
||||
guard audioQueue.count > 0 else {
|
||||
Log.info("no queued audio")
|
||||
return
|
||||
}
|
||||
let nextAudioURL = audioQueue.removeFirst()
|
||||
let key = nextAudioURL.1.key
|
||||
|
||||
|
||||
Log.info("getting ready to play \(nextAudioURL)")
|
||||
AudioQueueDirector.shared.changeInQueue(key, url: nextAudioURL.1)
|
||||
|
||||
handleClear()
|
||||
|
||||
// We need to give a second to clean up the previous engine properly. Deinit takes some time.
|
||||
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch nextAudioURL.0 {
|
||||
case .remote:
|
||||
self.handlePlayStreamedAudio(withRemoteUrl: nextAudioURL.1, bitrate: .high) // TODO fix to add option for low birate
|
||||
break
|
||||
case .disk:
|
||||
self.handlePlaySavedAudio(withSavedUrl: nextAudioURL.1)
|
||||
}
|
||||
|
||||
self.shouldPlayImmediately = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,16 @@ extension SAPlayer {
|
||||
|
||||
/**
|
||||
Updates to changes in the duration of the current initialized audio. Especially helpful for audio that is being streamed and can change with more data.
|
||||
|
||||
- Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, duration will be constantly updating to best known value at the time (which is the seconds buffered currently and not necessarily the actual total duration of audio).
|
||||
*/
|
||||
public struct Duration {
|
||||
|
||||
/**
|
||||
Subscribe to updates to changes in duration of the current audio initialized.
|
||||
|
||||
- Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, duration will be constantly updating to best known value at the time (which is the seconds buffered currently and not necessarily the actual total duration of audio).
|
||||
|
||||
- Note: It's recommended to have a weak reference to a class that uses this function
|
||||
|
||||
- Parameter closure: The closure that will receive the updates of the changes in duration.
|
||||
@@ -136,6 +140,8 @@ extension SAPlayer {
|
||||
/**
|
||||
Subscribe to updates to changes in the progress of downloading audio for streaming. Information about range of audio available and if the audio is playable. Look at SAAudioAvailabilityRange for more information. For progress of downloading audio that saves to the phone for playback later, look at AudioDownloading instead.
|
||||
|
||||
- Note: For live streams that don't have an expected audio length from the beginning of the stream; the duration is constantly changing and equal to the total seconds buffered from the SAAudioAvailabilityRange.
|
||||
|
||||
- Note: It's recommended to have a weak reference to a class that uses this function
|
||||
|
||||
- Parameter closure: The closure that will receive the updates of the changes in duration.
|
||||
@@ -191,6 +197,30 @@ extension SAPlayer {
|
||||
DownloadProgressDirector.shared.detach(withID: id)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AudioQueue {
|
||||
/**
|
||||
Subscribe to updates to changes in the progress of your audio queue. When streaming audio playback completes
|
||||
and continues onto the next track, the closure is invoked.
|
||||
- Note: It's recommended to have a weak reference to a class that uses this function
|
||||
- Parameter closure: The closure that will receive the updates of the changes in duration.
|
||||
- Parameter url: The corresponding remote URL for the forthcoming audio file.
|
||||
- Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure.
|
||||
*/
|
||||
public static func subscribe(_ closure: @escaping (_ key: String, _ newUrl: URL) -> ()) -> UInt {
|
||||
return AudioQueueDirector.shared.attach(closure: { (key, url) in
|
||||
closure(key, url)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
Stop recieving updates of changes in download progress.
|
||||
- Parameter id: The closure with this id will stop receiving updates.
|
||||
*/
|
||||
public static func unsubscribe(_ id: UInt) {
|
||||
AudioQueueDirector.shared.detach(withID: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Data.swift
|
||||
// SwiftAudioPlayer
|
||||
//
|
||||
// Created by Tanha Kabir on 2019-11-29.
|
||||
// Copyright © 2019 Tanha Kabir, Jon Mercer
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
// Introduced in Swift 5, withUnsafeBytes using UnsafePointers is deprecated
|
||||
// https://mjtsai.com/blog/2019/03/27/swift-5-released/
|
||||
func accessBytes<R>(_ body: (UnsafePointer<UInt8>) throws -> R) rethrows -> R {
|
||||
return try withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) -> R in
|
||||
let unsafeBufferPointer = rawBufferPointer.bindMemory(to: UInt8.self)
|
||||
guard let unsafePointer = unsafeBufferPointer.baseAddress else {
|
||||
Log.error("")
|
||||
var int: UInt8 = 0
|
||||
return try body(&int)
|
||||
}
|
||||
return try body(unsafePointer)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func accessMutableBytes<R>(_ body: (UnsafeMutablePointer<UInt8>) throws -> R) rethrows -> R {
|
||||
return try withUnsafeMutableBytes { (rawBufferPointer: UnsafeMutableRawBufferPointer) -> R in
|
||||
let unsafeMutableBufferPointer = rawBufferPointer.bindMemory(to: UInt8.self)
|
||||
guard let unsafeMutablePointer = unsafeMutableBufferPointer.baseAddress else {
|
||||
Log.error("")
|
||||
var int: UInt8 = 0
|
||||
return try body(&int)
|
||||
}
|
||||
return try body(unsafeMutablePointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-14
@@ -9,22 +9,23 @@
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
// Possible levels of log messages to log
|
||||
enum LogLevel: Int {
|
||||
case DEBUG = 1
|
||||
case INFO = 2
|
||||
case WARN = 3
|
||||
case ERROR = 4
|
||||
case EXTERNAL_DEBUG = 5
|
||||
case MONITOR = 6
|
||||
case TEST = 7
|
||||
}
|
||||
|
||||
// Specify which types of log messages to display. Default level is set to WARN, which means Log will print any log messages of type only WARN, ERROR, MONITOR, and TEST. To print DEBUG and INFO logs, set the level to a lower value.
|
||||
var logLevel: LogLevel = LogLevel.MONITOR
|
||||
|
||||
class Log {
|
||||
private init() {}
|
||||
|
||||
// Possible levels of log messages to log
|
||||
public enum LogLevel: Int {
|
||||
case DEBUG = 1
|
||||
case INFO = 2
|
||||
case WARN = 3
|
||||
case ERROR = 4
|
||||
case MONITOR = 5
|
||||
case TEST = 6
|
||||
}
|
||||
|
||||
// Specify which types of log messages to display. Default level is set to WARN, which means Log will print any log messages of type only WARN, ERROR, MONITOR, and TEST. To print DEBUG and INFO logs, set the level to a lower value.
|
||||
public static var logLevel: LogLevel = LogLevel.MONITOR
|
||||
|
||||
// Used for OSLog
|
||||
private static let SUBSYSTEM: String = "com.SwiftAudioPlayer"
|
||||
|
||||
@@ -68,6 +69,11 @@ class Log {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "ERROR 🛑🛑🛑🛑")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
|
||||
if logLevel.rawValue <= LogLevel.EXTERNAL_DEBUG.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "WARNING")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +92,7 @@ class Log {
|
||||
public static func monitor(_ logMessage: Any, classPath: String = #file, functionName: String = #function, lineNumber: Int = #line) {
|
||||
let fileName = URLUtil.getNameFromStringPath(classPath)
|
||||
if logLevel.rawValue <= LogLevel.ERROR.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "MONITOR 🔥🔥🔥🔥")
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "ERROR 🔥🔥🔥🔥")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
@@ -110,6 +116,11 @@ class Log {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "WARN ⚠️⚠️⚠️⚠️")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
|
||||
if logLevel.rawValue <= LogLevel.EXTERNAL_DEBUG.rawValue {
|
||||
let log = OSLog(subsystem: SUBSYSTEM, category: "DEBUG")
|
||||
os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'SwiftAudioPlayer'
|
||||
s.version = '1.3.0'
|
||||
s.version = '5.0.3'
|
||||
s.summary = 'SwiftAudioPlayer is a Swift based audio player that can handle streaming from a remote location and audio manipulation.'
|
||||
|
||||
# This description is used to generate tags and improve search results.
|
||||
@@ -31,7 +31,7 @@ SwiftAudioPlayer is a Swift based audio player that can handle streaming from a
|
||||
s.ios.deployment_target = '10.0'
|
||||
|
||||
s.source_files = 'Source/**/*'
|
||||
s.swift_version = '4.2'
|
||||
s.swift_version = '5.0'
|
||||
|
||||
# s.resource_bundles = {
|
||||
# 'SwiftAudioPlayer' => ['SwiftAudioPlayer/Assets/*.png']
|
||||
|
||||
Reference in New Issue
Block a user